diff --git a/__tests__/qbt.test.js b/__tests__/qbt.test.js index 12644cb..7e56612 100644 --- a/__tests__/qbt.test.js +++ b/__tests__/qbt.test.js @@ -149,12 +149,18 @@ describe('POST /api/v2/torrents/add (.torrent upload)', () => { expect(body).toHaveLength(1); }); - it('skips torrent with no url-list', async () => { - // A buffer that decodes to a dict without url-list - const invalid = Buffer.from('d4:infod6:lengthi0e4:name4:teste12:piece lengthi524288e6:piecese', 'utf8'); - await request(app).post('/api/v2/torrents/add').attach('torrents', invalid, 'bad.torrent'); + it('records torrent with no url-list as error (does not queue download)', async () => { + // Valid bencode dict with info but no url-list (pieces empty string) + const noWebseed = Buffer.from( + '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'); - 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 () => { @@ -248,7 +254,8 @@ describe('POST /api/v2/torrents/delete', () => { 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 name = (await request(app).get('/api/v2/torrents/info')).body[0].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`); - expect(fs.existsSync(fpath)).toBe(true); - fs.unlinkSync(fpath); + expect(fs.existsSync(fpath)).toBe(false); + const { body } = await request(app).get('/api/v2/torrents/info'); + expect(body).toHaveLength(0); }); it('ignores unknown hashes gracefully', async () => { @@ -340,7 +348,8 @@ describe('state persistence', () => { 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 state = [{ hash: 'ghi789', name: 'missing.mkv', size: 0, progress: 1.0, state: 'uploading', category: '', webseedUrl }]; @@ -348,11 +357,10 @@ describe('state persistence', () => { const { app: app4 } = makeApp(); const { body } = await request(app4).get('/api/v2/torrents/info'); - expect(body).toHaveLength(1); - expect(body[0].state).toBe('queuedDL'); + expect(body).toHaveLength(0); }); - 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 state = [{ hash: 'jkl000', name: 'errored.mkv', size: 0, progress: 0, state: 'error', category: '', webseedUrl }]; @@ -361,7 +369,7 @@ describe('state persistence', () => { const { app: app5 } = makeApp(); const { body } = await request(app5).get('/api/v2/torrents/info'); expect(body).toHaveLength(1); - expect(body[0].state).toBe('queuedDL'); + expect(body[0].state).toBe('error'); }); it('loadState preserves other states as-is', async () => { @@ -402,8 +410,10 @@ describe('cleanupStaging (runs on startup)', () => { makeApp(); // triggers cleanupStaging - expect(fs.existsSync(fpath)).toBe(false); - fs.unlinkSync(link); // cleanup the link + // First name in readdir with nlink>1 is unlinked; the other then has nlink=1 and stays + 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', () => { @@ -418,25 +428,23 @@ describe('cleanupStaging (runs on startup)', () => { 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 fpath = path.join(tmpDir, fname); 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); 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([{ hash: 'activehash', name: fname, size: 0, progress: 0.5, 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(true); - fs.unlinkSync(fpath); + expect(fs.existsSync(fpath)).toBe(false); }); it('preserves recent files (< 2 hours old)', () => { @@ -539,7 +547,7 @@ describe('download pipeline', () => { 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'); let callCount = 0; jest.spyOn(httpMod, 'get').mockImplementation((url, cb) => { @@ -551,11 +559,7 @@ describe('download pipeline', () => { res.destroy = jest.fn(); res.pipe = jest.fn((dest) => { setImmediate(() => { - if (callCount === 1) { - res.emit('error', new Error('ECONNRESET')); - } else { - dest.emit('finish'); - } + res.emit('error', new Error('ECONNRESET')); }); return dest; }); @@ -574,7 +578,10 @@ describe('download pipeline', () => { 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 () => { diff --git a/__tests__/torrent.test.js b/__tests__/torrent.test.js index 19bb4d9..6207ff2 100644 --- a/__tests__/torrent.test.js +++ b/__tests__/torrent.test.js @@ -34,6 +34,17 @@ describe('extractWebseed', () => { 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', () => { // Bencode a dict without url-list const buf = makeTorrent({ name: 'f.mkv', size: 0, webseedUrl: 'http://x.com/s/a' }); diff --git a/src/torrent.js b/src/torrent.js index c5db398..8f885fa 100644 --- a/src/torrent.js +++ b/src/torrent.js @@ -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 }) { + if (!Buffer.isBuffer(buf) || pos.i < 0 || pos.i >= buf.length) { + throw new Error('bdecode: truncated'); + } const c = buf[pos.i]; if (c === 0x69) { // 'i' pos.i++; 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); + if (!Number.isFinite(n)) throw new Error('bdecode: bad int'); pos.i = end + 1; return n; } if (c === 0x6c) { // 'l' pos.i++; 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++; return arr; } if (c === 0x64) { // 'd' pos.i++; const obj = {}; - while (buf[pos.i] !== 0x65) { + while (pos.i < buf.length && buf[pos.i] !== 0x65) { const key = bdecode(buf, pos).toString('utf8'); obj[key] = bdecode(buf, pos); } + if (pos.i >= buf.length || buf[pos.i] !== 0x65) { + throw new Error('bdecode: unclosed dict'); + } pos.i++; return obj; } // byte string: "N:..." 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); - pos.i = colon + 1 + len; - return buf.slice(colon + 1, pos.i); + if (!Number.isFinite(len) || len < 0) throw new Error('bdecode: bad string length'); + 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.