193 lines
7.4 KiB
TypeScript
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))
|
|
})
|
|
})
|