probe:run (compose service prober, PROBE_CONNECTIONS, default 6) claims waiting candidates of settled releases (lone, then par2, then archive), fetches each article with BODY over connections of its own (no compression) and inspects it: the yEnc name and size, the content type from the first bytes, a par2 set's files, a RAR volume's entries and whether they're encrypted. Results per release and kind in release_inspections (CHUNK_TABLESPACE, the HDD); on the release passworded, inner_name (the largest content name) and inspected_at. 430 = missing, not yEnc = error, both final; connection trouble puts the candidate back. Fetch and parse in one process: an article takes ~0.1 s per connection, parsing a few ms (no queue in between). Parsers from voyager 1 (lib/yenc, lib/rar, lib/par2, lib/content sniff) with their tests; XoverClient.body; yencode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
190 lines
8.0 KiB
TypeScript
190 lines
8.0 KiB
TypeScript
import { test } from '@japa/runner'
|
|
import { crc32 } from 'node:zlib'
|
|
import { decodeRar4Name, parseRarHeaders, rarFormat, storedEntryData, type RarEntry } from '#lib/rar/index'
|
|
|
|
/** Entries without the extraction fields, for comparing listings. */
|
|
const listing = (entries: RarEntry[]) => entries.map(({ stored, dataOffset, ...rest }) => rest)
|
|
|
|
// --- RAR 4 builders -----------------------------------------------------------
|
|
|
|
const RAR4 = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x00])
|
|
|
|
function rar4Block(type: number, flags: number, body: Buffer) {
|
|
const header = Buffer.alloc(7)
|
|
header.writeUInt8(type, 2)
|
|
header.writeUInt16LE(flags, 3)
|
|
header.writeUInt16LE(7 + body.length, 5)
|
|
const block = Buffer.concat([header, body])
|
|
block.writeUInt16LE(crc32(block.subarray(2)) & 0xffff, 0)
|
|
return block
|
|
}
|
|
|
|
function rar4Main(flags: number) {
|
|
return rar4Block(0x73, flags, Buffer.alloc(6))
|
|
}
|
|
|
|
function rar4File(name: Buffer, opts: { flags?: number; packed: number; unpacked: number; method?: number }) {
|
|
const body = Buffer.alloc(25)
|
|
body.writeUInt8(opts.method ?? 0x33, 18)
|
|
body.writeUInt32LE(opts.packed, 0)
|
|
body.writeUInt32LE(opts.unpacked, 4)
|
|
body.writeUInt16LE(name.length, 19)
|
|
// LONG_BLOCK (0x8000) is always set on file headers.
|
|
return Buffer.concat([rar4Block(0x74, 0x8000 | (opts.flags ?? 0), Buffer.concat([body, name])), Buffer.alloc(opts.packed)])
|
|
}
|
|
|
|
// --- RAR 5 builders -----------------------------------------------------------
|
|
|
|
const RAR5 = Buffer.from([0x52, 0x61, 0x72, 0x21, 0x1a, 0x07, 0x01, 0x00])
|
|
|
|
function vint(value: number) {
|
|
const bytes: number[] = []
|
|
do {
|
|
let byte = value % 128
|
|
value = Math.floor(value / 128)
|
|
if (value > 0) byte |= 0x80
|
|
bytes.push(byte)
|
|
} while (value > 0)
|
|
return Buffer.from(bytes)
|
|
}
|
|
|
|
function rar5Block(type: number, headerFlags: number, fields: Buffer, opts: { extra?: Buffer; data?: number } = {}) {
|
|
let flags = headerFlags
|
|
const parts = [vint(type)]
|
|
const sizes: Buffer[] = []
|
|
if (opts.extra) {
|
|
flags |= 0x0001
|
|
sizes.push(vint(opts.extra.length))
|
|
}
|
|
if (opts.data !== undefined) {
|
|
flags |= 0x0002
|
|
sizes.push(vint(opts.data))
|
|
}
|
|
const header = Buffer.concat([...parts, vint(flags), ...sizes, fields, opts.extra ?? Buffer.alloc(0)])
|
|
const sized = Buffer.concat([vint(header.length), header])
|
|
const crc = Buffer.alloc(4)
|
|
crc.writeUInt32LE(crc32(sized) >>> 0)
|
|
return Buffer.concat([crc, sized, Buffer.alloc(opts.data ?? 0)])
|
|
}
|
|
|
|
function rar5File(name: string, size: number, opts: { packed: number; headerFlags?: number; encrypted?: boolean; dir?: boolean }) {
|
|
const nameBuf = Buffer.from(name, 'utf8')
|
|
const fields = Buffer.concat([
|
|
vint(opts.dir ? 0x0001 : 0), // file flags
|
|
vint(size),
|
|
vint(0x20), // attributes
|
|
vint(0), // compression info
|
|
vint(0), // host OS
|
|
vint(nameBuf.length),
|
|
nameBuf,
|
|
])
|
|
// Encryption record: size, type 1, then its data (dummy here).
|
|
const extra = opts.encrypted ? Buffer.concat([vint(3), vint(0x01), Buffer.from([0, 0])]) : undefined
|
|
return rar5Block(2, opts.headerFlags ?? 0, fields, { extra, data: opts.packed })
|
|
}
|
|
|
|
test.group('rar / RAR 4', () => {
|
|
test('lists files of a first volume, including 64-bit sizes and flags', ({ assert }) => {
|
|
const data = Buffer.concat([
|
|
RAR4,
|
|
rar4Main(0x0001 | 0x0100), // volume, first volume
|
|
rar4File(Buffer.from('Some.Movie.2024/movie.mkv'), { packed: 10, unpacked: 5_000_000, flags: 0x0002 }),
|
|
])
|
|
assert.equal(rarFormat(data), 'rar4')
|
|
const info = parseRarHeaders(data)!
|
|
assert.include(info, { format: 'rar4', volume: true, firstVolume: true, headersEncrypted: false, truncated: true })
|
|
assert.deepEqual(listing(info.entries), [
|
|
{ name: 'Some.Movie.2024/movie.mkv', size: 5_000_000, packedSize: 10, directory: false, encrypted: false, splitBefore: false, splitAfter: true },
|
|
])
|
|
})
|
|
|
|
test('detects password-protected files and encrypted headers', ({ assert }) => {
|
|
const files = parseRarHeaders(
|
|
Buffer.concat([RAR4, rar4Main(0), rar4File(Buffer.from('secret.bin'), { packed: 4, unpacked: 4, flags: 0x0004 })])
|
|
)!
|
|
assert.isTrue(files.entries[0].encrypted)
|
|
const headers = parseRarHeaders(Buffer.concat([RAR4, rar4Main(0x0080)]))!
|
|
assert.isTrue(headers.headersEncrypted)
|
|
assert.lengthOf(headers.entries, 0)
|
|
})
|
|
|
|
test('copies out stored entries, not compressed ones', ({ assert }) => {
|
|
const data = Buffer.concat([
|
|
RAR4,
|
|
rar4Main(0),
|
|
rar4File(Buffer.from('a.nzb'), { packed: 5, unpacked: 5, method: 0x30 }),
|
|
rar4File(Buffer.from('b.nzb'), { packed: 5, unpacked: 9 }),
|
|
])
|
|
const [stored, packed] = parseRarHeaders(data)!.entries
|
|
data.write('<nzb>', stored.dataOffset)
|
|
assert.equal(storedEntryData(data, stored)?.toString(), '<nzb>')
|
|
assert.isFalse(packed.stored)
|
|
assert.isNull(storedEntryData(data, packed))
|
|
})
|
|
|
|
test('stops at a header with a bad CRC', ({ assert }) => {
|
|
const good = rar4File(Buffer.from('a.bin'), { packed: 1, unpacked: 1 })
|
|
const bad = rar4File(Buffer.from('b.bin'), { packed: 1, unpacked: 1 })
|
|
bad[10] ^= 0xff
|
|
const info = parseRarHeaders(Buffer.concat([RAR4, rar4Main(0), good, bad]))!
|
|
assert.deepEqual(info.entries.map((e) => e.name), ['a.bin'])
|
|
})
|
|
|
|
test('decodes compressed Unicode names', ({ assert }) => {
|
|
// "é.txt": ASCII fallback "?.txt", then highByte 0, flags 0b01_00_00_00 (é via high byte,
|
|
// then 3 plain bytes), followed by one more flag byte for the last byte.
|
|
const ascii = Buffer.from('?.txt', 'latin1')
|
|
const encoded = Buffer.from([0x00, 0b01000000, 0xe9, 0x2e, 0x74, 0x78, 0b00000000, 0x74])
|
|
assert.equal(decodeRar4Name(Buffer.concat([ascii, Buffer.from([0]), encoded])), 'é.txt')
|
|
assert.equal(decodeRar4Name(Buffer.from('plain.txt')), 'plain.txt')
|
|
})
|
|
})
|
|
|
|
test.group('rar / RAR 5', () => {
|
|
test('lists files and reads volume flags', ({ assert }) => {
|
|
const data = Buffer.concat([
|
|
RAR5,
|
|
rar5Block(1, 0, vint(0x0001)), // main: volume, no volume number → first volume
|
|
rar5File('Release/track01.flac', 30_000_000, { packed: 20, headerFlags: 0x0010 }),
|
|
])
|
|
assert.equal(rarFormat(data), 'rar5')
|
|
const info = parseRarHeaders(data)!
|
|
assert.include(info, { format: 'rar5', volume: true, firstVolume: true, headersEncrypted: false })
|
|
assert.deepEqual(listing(info.entries), [
|
|
{ name: 'Release/track01.flac', size: 30_000_000, packedSize: 20, directory: false, encrypted: false, splitBefore: false, splitAfter: true },
|
|
])
|
|
})
|
|
|
|
test('a later volume carries a volume number', ({ assert }) => {
|
|
const info = parseRarHeaders(Buffer.concat([RAR5, rar5Block(1, 0, Buffer.concat([vint(0x0003), vint(4)]))]))!
|
|
assert.isFalse(info.firstVolume)
|
|
})
|
|
|
|
test('detects encrypted files and encrypted headers', ({ assert }) => {
|
|
const files = parseRarHeaders(Buffer.concat([RAR5, rar5Block(1, 0, vint(0)), rar5File('x.mkv', 1, { packed: 1, encrypted: true })]))!
|
|
assert.isTrue(files.entries[0].encrypted)
|
|
const headers = parseRarHeaders(Buffer.concat([RAR5, rar5Block(4, 0, Buffer.alloc(3))]))!
|
|
assert.isTrue(headers.headersEncrypted)
|
|
})
|
|
|
|
test('marks the listing complete at the end-of-archive header', ({ assert }) => {
|
|
const info = parseRarHeaders(Buffer.concat([RAR5, rar5Block(1, 0, vint(0)), rar5File('a', 1, { packed: 1 }), rar5Block(5, 0, vint(0))]))!
|
|
assert.isFalse(info.truncated)
|
|
assert.lengthOf(info.entries, 1)
|
|
})
|
|
|
|
test('copies out entries stored without compression', ({ assert }) => {
|
|
const data = Buffer.concat([RAR5, rar5Block(1, 0, vint(0)), rar5File('a.nzb', 5, { packed: 5 }), rar5Block(5, 0, vint(0))])
|
|
const entry = parseRarHeaders(data)!.entries[0]
|
|
assert.isTrue(entry.stored)
|
|
data.write('<nzb>', entry.dataOffset)
|
|
assert.equal(storedEntryData(data, entry)?.toString(), '<nzb>')
|
|
assert.isNull(storedEntryData(data.subarray(0, entry.dataOffset + 2), entry))
|
|
assert.isNull(storedEntryData(data, { ...entry, encrypted: true }))
|
|
})
|
|
|
|
test('returns null for other data', ({ assert }) => {
|
|
assert.isNull(parseRarHeaders(Buffer.from('PAR2\0PKT', 'latin1')))
|
|
})
|
|
})
|