import { test } from '@japa/runner' import yencode from 'yencode' import { YencCrcError, YencError, YencFile, crc32Hex, decodeSegment, parseYencMeta, } from '#lib/yenc/index' /** Builds an article body as a yEnc poster would send it. */ function article( data: Buffer, opts: { name: string; fileSize?: number; part?: number; total?: number; begin?: number; fileCrc?: string } ) { const fileSize = opts.fileSize ?? data.length const encoded = yencode.encode(data).toString('latin1') const lines: string[] = [] if (opts.part) { lines.push(`=ybegin part=${opts.part} total=${opts.total} line=128 size=${fileSize} name=${opts.name}`) const begin = opts.begin ?? 1 lines.push(`=ypart begin=${begin} end=${begin + data.length - 1}`) lines.push(encoded) const fileCrc = opts.fileCrc ? ` crc32=${opts.fileCrc}` : '' lines.push(`=yend size=${data.length} part=${opts.part} pcrc32=${crc32Hex(data)}${fileCrc}`) } else { lines.push(`=ybegin line=128 size=${fileSize} name=${opts.name}`) lines.push(encoded) lines.push(`=yend size=${data.length} crc32=${crc32Hex(data)}`) } return Buffer.from(lines.join('\r\n'), 'latin1') } function randomBytes(length: number, seed = 1) { const buf = Buffer.alloc(length) let x = seed for (let i = 0; i < length; i++) { x = (x * 1103515245 + 12345) & 0x7fffffff buf[i] = x & 0xff } return buf } test.group('yenc / parseYencMeta', () => { test('parses a multi-part header as seen on alt.binaries.test', ({ assert }) => { const body = Buffer.from( '=ybegin part=56 line=128 size=104857600 name=obrMkrDeLmbhaouwYO34.part08.rar\r\n' + '=ypart begin=21120001 end=21504000\r\n' + 'xyz\r\n' + '=yend size=384000 part=56 pcrc32=B6339AFC', 'latin1' ) const meta = parseYencMeta(body) assert.deepEqual(meta.begin, { part: 56, total: undefined, line: 128, size: 104857600, name: 'obrMkrDeLmbhaouwYO34.part08.rar', }) assert.deepEqual(meta.part, { begin: 21120001, end: 21504000 }) assert.deepEqual(meta.end, { size: 384000, part: 56, pcrc32: 'b6339afc', crc32: undefined }) assert.equal(body.subarray(meta.dataStart, meta.dataEnd).toString(), 'xyz') }) test('keeps spaces and "=" in the filename', ({ assert }) => { const body = article(Buffer.from('hi'), { name: 'My File = v2 (final).nfo' }) assert.equal(parseYencMeta(body).begin.name, 'My File = v2 (final).nfo') }) test('ignores leading blank lines before =ybegin', ({ assert }) => { const body = Buffer.concat([Buffer.from('\r\n\r\n'), article(Buffer.from('hi'), { name: 'a.txt' })]) assert.equal(parseYencMeta(body).begin.name, 'a.txt') }) test('rejects bodies without yEnc markers', ({ assert }) => { assert.throws(() => parseYencMeta(Buffer.from('plain text\r\n')), /=ybegin/) assert.throws(() => parseYencMeta(Buffer.from('=ybegin line=128 size=2 name=a\r\nxx')), /=yend/) }) }) test.group('yenc / decodeSegment', () => { test('round-trips a single-part post and checks crc32', ({ assert }) => { const data = randomBytes(5000) const segment = decodeSegment(article(data, { name: 'single.bin' })) assert.isTrue(segment.data.equals(data)) assert.equal(segment.offset, 0) }) test('throws YencCrcError on a corrupted part', ({ assert }) => { const body = article(randomBytes(1000), { name: 'x.bin', part: 1, total: 1 }) const index = body.indexOf('\r\n=ypart') + 60 body[index] = body[index] === 0x41 ? 0x42 : 0x41 assert.throws(() => decodeSegment(body), YencCrcError as any) }) test('undoes NNTP dot-stuffing', ({ assert }) => { // Byte 4 encodes to "." (4 + 42 = 46). An unescaped leading "." is dot-stuffed to "..". const data = Buffer.from([4, 5, 6]) const body = Buffer.from( `=ybegin line=128 size=3 name=dot.bin\r\n../0\r\n=yend size=3 crc32=${crc32Hex(data)}`, 'latin1' ) assert.isTrue(decodeSegment(body).data.equals(data)) assert.throws(() => decodeSegment(body, { stripDots: false }), YencError as any) }) }) test.group('yenc / YencFile', () => { test('assembles parts added out of order and verifies the file crc', ({ assert }) => { const file = randomBytes(10_000, 7) const chunk = 3000 const total = Math.ceil(file.length / chunk) const bodies = Array.from({ length: total }, (_, i) => article(file.subarray(i * chunk, (i + 1) * chunk), { name: 'multi.rar', fileSize: file.length, part: i + 1, total, begin: i * chunk + 1, fileCrc: i === total - 1 ? crc32Hex(file) : undefined, }) ) const segments = bodies.map((body) => decodeSegment(body)).reverse() const yencFile = YencFile.fromSegment(segments[0]) assert.equal(yencFile.totalParts, 4) for (const segment of segments.slice(0, -1)) yencFile.add(segment) assert.isFalse(yencFile.isComplete) assert.deepEqual(yencFile.missingParts(), [1]) assert.throws(() => yencFile.getBuffer(), /missing parts: 1/) yencFile.add(segments.at(-1)!) yencFile.add(segments[0]) // duplicate is ignored assert.isTrue(yencFile.isComplete) assert.isTrue(yencFile.getBuffer().equals(file)) }) test('rejects segments of a file with another size', ({ assert }) => { const a = decodeSegment(article(Buffer.from('aa'), { name: 'a.bin' })) const b = decodeSegment(article(Buffer.from('bbb'), { name: 'b.bin' })) assert.throws(() => YencFile.fromSegment(a).add(b), /does not belong to "a.bin"/) }) test('accepts segments with per-article random names', ({ assert }) => { const file = randomBytes(4000, 3) const [x, y] = [0, 1].map((i) => decodeSegment( article(file.subarray(i * 2000, (i + 1) * 2000), { name: i === 0 ? 'Xq8r2Kd' : 'p0Lm3Zw', fileSize: 4000, part: i + 1, total: 2, begin: i * 2000 + 1, }) ) ) const assembled = YencFile.fromSegment(x) assembled.add(x) assembled.add(y) assert.isTrue(assembled.getBuffer().equals(file)) }) }) test.group('content / firstChunkMd5', () => { test('hashes the first 16 KiB of the decoded first part', async ({ assert }) => { const { firstChunkMd5 } = await import('#services/ContentService') const { createHash } = await import('node:crypto') const file = randomBytes(40_000, 5) const body = article(file.subarray(0, 20_000), { name: 'x.bin', fileSize: 40_000, part: 1, total: 2 }) assert.equal(firstChunkMd5(body, 40_000), createHash('md5').update(file.subarray(0, 16384)).digest('hex')) // A first part shorter than 16 KiB of a larger file cannot give the fingerprint. const short = article(file.subarray(0, 1000), { name: 'x.bin', fileSize: 40_000, part: 1, total: 40 }) assert.isUndefined(firstChunkMd5(short, 40_000)) }) }) test.group('yenc / YencFile without total=', () => { test('infers the part count when =ybegin has no total', ({ assert }) => { const file = randomBytes(5000, 11) const strip = (body: Buffer) => Buffer.from(body.toString('latin1').replace(/ total=\d+/, ''), 'latin1') const parts = [0, 1].map((i) => decodeSegment(strip(article(file.subarray(i * 3000, (i + 1) * 3000), { name: 'n.nzb', fileSize: 5000, part: i + 1, total: 2, begin: i * 3000 + 1 }))) ) const assembled = YencFile.fromSegment(parts[0]) assert.equal(assembled.totalParts, 2) parts.forEach((p) => assembled.add(p)) assert.isTrue(assembled.getBuffer().equals(file)) }) })