Files
usenet-indexer/tests/unit/yenc.spec.ts
T

193 lines
7.4 KiB
TypeScript

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))
})
})