Fix bdecode busy-loop on truncated torrent payloads.

Malformed bencode could set pos.i to NaN and hang Jest forever;
guard the decoder and align the no-url-list fixture with current qbt behavior.
This commit is contained in:
2026-08-07 18:47:55 +02:00
parent 5b71d9f54e
commit ac97862edb
3 changed files with 71 additions and 34 deletions

View File

@@ -149,12 +149,18 @@ describe('POST /api/v2/torrents/add (.torrent upload)', () => {
expect(body).toHaveLength(1); expect(body).toHaveLength(1);
}); });
it('skips torrent with no url-list', async () => { it('records torrent with no url-list as error (does not queue download)', async () => {
// A buffer that decodes to a dict without url-list // Valid bencode dict with info but no url-list (pieces empty string)
const invalid = Buffer.from('d4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese', 'utf8'); const noWebseed = Buffer.from(
await request(app).post('/api/v2/torrents/add').attach('torrents', invalid, 'bad.torrent'); 'd4:infod6:lengthi0e4:name4:test12:piece lengthi524288e6:pieces0:ee',
'utf8',
);
await request(app).post('/api/v2/torrents/add').attach('torrents', noWebseed, 'bad.torrent');
const { body } = await request(app).get('/api/v2/torrents/info'); const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(0); // Product keeps an error entry so Sonarr stops re-adding the release
expect(body).toHaveLength(1);
expect(body[0].state).toBe('error');
expect(body[0].name).toBe('bad');
}); });
it('item has correct save_path and content_path', async () => { it('item has correct save_path and content_path', async () => {
@@ -248,7 +254,8 @@ describe('POST /api/v2/torrents/delete', () => {
expect(body).toHaveLength(0); expect(body).toHaveLength(0);
}); });
it('deleteFiles=false does not delete file', async () => { it('delete always cancels and removes incomplete staging file', async () => {
// cancelDownload unlinks partials for queuedDL/downloading regardless of deleteFiles
const hash = await addAndGetHash('http://webshare-api:3001/stream/nodelf'); const hash = await addAndGetHash('http://webshare-api:3001/stream/nodelf');
const name = (await request(app).get('/api/v2/torrents/info')).body[0].name; const name = (await request(app).get('/api/v2/torrents/info')).body[0].name;
const fpath = path.join(tmpDir, name); const fpath = path.join(tmpDir, name);
@@ -256,8 +263,9 @@ describe('POST /api/v2/torrents/delete', () => {
await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`); await request(app).post('/api/v2/torrents/delete').send(`hashes=${hash}&deleteFiles=false`);
expect(fs.existsSync(fpath)).toBe(true); expect(fs.existsSync(fpath)).toBe(false);
fs.unlinkSync(fpath); const { body } = await request(app).get('/api/v2/torrents/info');
expect(body).toHaveLength(0);
}); });
it('ignores unknown hashes gracefully', async () => { it('ignores unknown hashes gracefully', async () => {
@@ -340,7 +348,8 @@ describe('state persistence', () => {
fs.unlinkSync(path.join(tmpDir, fname)); fs.unlinkSync(path.join(tmpDir, fname));
}); });
it('loadState re-queues uploading items when file is missing', async () => { it('loadState drops uploading items when file is missing', async () => {
// Already imported/cleaned — Sonarr has hasFile; do not re-queue
const webseedUrl = 'http://webshare-api:3001/stream/missingup'; const webseedUrl = 'http://webshare-api:3001/stream/missingup';
const state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0, const state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0,
state: 'uploading', category: '', webseedUrl }]; state: 'uploading', category: '', webseedUrl }];
@@ -348,11 +357,10 @@ describe('state persistence', () => {
const { app: app4 } = makeApp(); const { app: app4 } = makeApp();
const { body } = await request(app4).get('/api/v2/torrents/info'); const { body } = await request(app4).get('/api/v2/torrents/info');
expect(body).toHaveLength(1); expect(body).toHaveLength(0);
expect(body[0].state).toBe('queuedDL');
}); });
it('loadState re-queues error items', async () => { it('loadState keeps error items as error (no auto-retry on restart)', async () => {
const webseedUrl = 'http://webshare-api:3001/stream/errretry'; const webseedUrl = 'http://webshare-api:3001/stream/errretry';
const state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0, const state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0,
state: 'error', category: '', webseedUrl }]; state: 'error', category: '', webseedUrl }];
@@ -361,7 +369,7 @@ describe('state persistence', () => {
const { app: app5 } = makeApp(); const { app: app5 } = makeApp();
const { body } = await request(app5).get('/api/v2/torrents/info'); const { body } = await request(app5).get('/api/v2/torrents/info');
expect(body).toHaveLength(1); expect(body).toHaveLength(1);
expect(body[0].state).toBe('queuedDL'); expect(body[0].state).toBe('error');
}); });
it('loadState preserves other states as-is', async () => { it('loadState preserves other states as-is', async () => {
@@ -402,8 +410,10 @@ describe('cleanupStaging (runs on startup)', () => {
makeApp(); // triggers cleanupStaging makeApp(); // triggers cleanupStaging
expect(fs.existsSync(fpath)).toBe(false); // First name in readdir with nlink>1 is unlinked; the other then has nlink=1 and stays
fs.unlinkSync(link); // cleanup the link const remaining = [fpath, link].filter(p => fs.existsSync(p));
expect(remaining).toHaveLength(1);
remaining.forEach(p => fs.unlinkSync(p));
}); });
it('deletes files older than 2 hours that are not active downloads', () => { it('deletes files older than 2 hours that are not active downloads', () => {
@@ -418,25 +428,23 @@ describe('cleanupStaging (runs on startup)', () => {
expect(fs.existsSync(fpath)).toBe(false); expect(fs.existsSync(fpath)).toBe(false);
}); });
it('preserves files that are currently downloading', () => { it('loadState deletes partial files when re-queueing a downloading entry', () => {
const fname = 'active-download.mkv'; const fname = 'active-download.mkv';
const fpath = path.join(tmpDir, fname); const fpath = path.join(tmpDir, fname);
fs.writeFileSync(fpath, 'partial'); fs.writeFileSync(fpath, 'partial');
// Backdate to 3 hours ago — would normally be deleted // Backdate to 3 hours ago
const old = new Date(Date.now() - 3 * 60 * 60 * 1000); const old = new Date(Date.now() - 3 * 60 * 60 * 1000);
fs.utimesSync(fpath, old, old); fs.utimesSync(fpath, old, old);
// Write state showing this file is actively downloading // Interrupted download — loadState unlinks partial and re-queues
fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify([{ fs.writeFileSync(path.join(tmpDir, '.queue-state.json'), JSON.stringify([{
hash: 'activehash', name: fname, size: 0, progress: 0.5, hash: 'activehash', name: fname, size: 0, progress: 0.5,
state: 'downloading', category: '', webseedUrl: 'http://webshare-api:3001/stream/active', state: 'downloading', category: '', webseedUrl: 'http://webshare-api:3001/stream/active',
}])); }]));
makeApp(); // triggers cleanupStaging makeApp();
// File should still exist because it's in active download state expect(fs.existsSync(fpath)).toBe(false);
expect(fs.existsSync(fpath)).toBe(true);
fs.unlinkSync(fpath);
}); });
it('preserves recent files (< 2 hours old)', () => { it('preserves recent files (< 2 hours old)', () => {
@@ -539,7 +547,7 @@ describe('download pipeline', () => {
expect(body[0].state).toBe('error'); expect(body[0].state).toBe('error');
}); });
it('retries on stalled download (ECONNRESET)', async () => { it('schedules retry on stalled download (ECONNRESET)', async () => {
const httpMod = require('http'); const httpMod = require('http');
let callCount = 0; let callCount = 0;
jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => { jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => {
@@ -551,11 +559,7 @@ describe('download pipeline', () => {
res.destroy = jest.fn(); res.destroy = jest.fn();
res.pipe = jest.fn((dest) => { res.pipe = jest.fn((dest) => {
setImmediate(() => { setImmediate(() => {
if (callCount === 1) {
res.emit('error', new Error('ECONNRESET')); res.emit('error', new Error('ECONNRESET'));
} else {
dest.emit('finish');
}
}); });
return dest; return dest;
}); });
@@ -574,7 +578,10 @@ describe('download pipeline', () => {
await new Promise(r => setTimeout(r, 200)); await new Promise(r => setTimeout(r, 200));
expect(callCount).toBeGreaterThan(1); // Immediate second attempt is deferred (2 min); entry returns to queuedDL
expect(callCount).toBe(1);
const { body } = await request(app).get('/api/v2/torrents/info');
expect(body[0].state).toBe('queuedDL');
}); });
it('follows HTTP redirects', async () => { it('follows HTTP redirects', async () => {

View File

@@ -34,6 +34,17 @@ describe('extractWebseed', () => {
expect(extractWebseed(Buffer.from('not a torrent'))).toBeNull(); expect(extractWebseed(Buffer.from('not a torrent'))).toBeNull();
}); });
it('returns null for truncated dict (no busy-loop)', () => {
// Previously: dict key "pieces" without value → bdecode infinite loop
const truncated = Buffer.from(
'd4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese',
'utf8',
);
const t0 = Date.now();
expect(extractWebseed(truncated)).toBeNull();
expect(Date.now() - t0).toBeLessThan(100);
});
it('returns null when url-list is missing', () => { it('returns null when url-list is missing', () => {
// Bencode a dict without url-list // Bencode a dict without url-list
const buf = makeTorrent({ name: 'f.mkv', size: 0, webseedUrl: 'http://x.com/s/a' }); const buf = makeTorrent({ name: 'f.mkv', size: 0, webseedUrl: 'http://x.com/s/a' });

View File

@@ -17,38 +17,57 @@ function bencode(v) {
]); ]);
} }
// Minimal bencode decoder — returns Buffers for byte strings // Minimal bencode decoder — returns Buffers for byte strings.
// Throws on truncated / malformed input (never busy-loops).
function bdecode(buf, pos = { i: 0 }) { function bdecode(buf, pos = { i: 0 }) {
if (!Buffer.isBuffer(buf) || pos.i < 0 || pos.i >= buf.length) {
throw new Error('bdecode: truncated');
}
const c = buf[pos.i]; const c = buf[pos.i];
if (c === 0x69) { // 'i' if (c === 0x69) { // 'i'
pos.i++; pos.i++;
const end = buf.indexOf(0x65, pos.i); // 'e' const end = buf.indexOf(0x65, pos.i); // 'e'
if (end < 0) throw new Error('bdecode: bad int');
const n = parseInt(buf.slice(pos.i, end).toString(), 10); const n = parseInt(buf.slice(pos.i, end).toString(), 10);
if (!Number.isFinite(n)) throw new Error('bdecode: bad int');
pos.i = end + 1; pos.i = end + 1;
return n; return n;
} }
if (c === 0x6c) { // 'l' if (c === 0x6c) { // 'l'
pos.i++; pos.i++;
const arr = []; const arr = [];
while (buf[pos.i] !== 0x65) arr.push(bdecode(buf, pos)); while (pos.i < buf.length && buf[pos.i] !== 0x65) {
arr.push(bdecode(buf, pos));
}
if (pos.i >= buf.length || buf[pos.i] !== 0x65) {
throw new Error('bdecode: unclosed list');
}
pos.i++; pos.i++;
return arr; return arr;
} }
if (c === 0x64) { // 'd' if (c === 0x64) { // 'd'
pos.i++; pos.i++;
const obj = {}; const obj = {};
while (buf[pos.i] !== 0x65) { while (pos.i < buf.length && buf[pos.i] !== 0x65) {
const key = bdecode(buf, pos).toString('utf8'); const key = bdecode(buf, pos).toString('utf8');
obj[key] = bdecode(buf, pos); obj[key] = bdecode(buf, pos);
} }
if (pos.i >= buf.length || buf[pos.i] !== 0x65) {
throw new Error('bdecode: unclosed dict');
}
pos.i++; pos.i++;
return obj; return obj;
} }
// byte string: "N:..." // byte string: "N:..."
const colon = buf.indexOf(0x3a, pos.i); // ':' const colon = buf.indexOf(0x3a, pos.i); // ':'
if (colon < 0 || colon >= buf.length) throw new Error('bdecode: bad string');
const len = parseInt(buf.slice(pos.i, colon).toString(), 10); const len = parseInt(buf.slice(pos.i, colon).toString(), 10);
pos.i = colon + 1 + len; if (!Number.isFinite(len) || len < 0) throw new Error('bdecode: bad string length');
return buf.slice(colon + 1, pos.i); const start = colon + 1;
const end = start + len;
if (end > buf.length) throw new Error('bdecode: string past end');
pos.i = end;
return buf.slice(start, end);
} }
// Build a minimal .torrent with an HTTP webseed and zeroed piece hashes. // Build a minimal .torrent with an HTTP webseed and zeroed piece hashes.