import { YencError } from './errors.js' /** Fields of the `=ybegin` line. `part`/`total` are absent in single-part posts. */ export interface YencBegin { line: number size: number name: string part?: number total?: number } /** Fields of the `=ypart` line. Offsets are 1-based and `end` is inclusive. */ export interface YencPart { begin: number end: number } /** Fields of the `=yend` line. CRCs are lowercase 8-digit hex. */ export interface YencEnd { size: number part?: number pcrc32?: string crc32?: string } export interface YencMeta { begin: YencBegin part?: YencPart end: YencEnd /** Byte range of the encoded data inside the article body. */ dataStart: number dataEnd: number } const CRLF = Buffer.from('\r\n') const BEGIN = Buffer.from('=ybegin ') const PART = Buffer.from('=ypart ') const END = Buffer.from('\r\n=yend ') /** * Parses `key=value` pairs from a yEnc header line. `name` is always the last * field and runs to the end of the line, because filenames may contain spaces * and `=`. */ function parseFields(line: string): Record { const fields: Record = {} const nameIndex = line.search(/(^|\s)name=/) let rest = line if (nameIndex !== -1) { const nameStart = line.indexOf('name=', nameIndex) + 'name='.length fields.name = line.slice(nameStart).trim() rest = line.slice(0, nameIndex) } for (const match of rest.matchAll(/(\w+)=(\S+)/g)) { fields[match[1]] = match[2] } return fields } function toInt(fields: Record, key: string, required: true): number function toInt(fields: Record, key: string, required?: false): number | undefined function toInt(fields: Record, key: string, required = false) { const raw = fields[key] if (raw === undefined) { if (required) throw new YencError(`Missing "${key}" in yEnc header`) return undefined } const value = Number.parseInt(raw, 10) if (!Number.isFinite(value)) throw new YencError(`Invalid "${key}" in yEnc header: ${raw}`) return value } function toCrc(fields: Record, key: string) { const raw = fields[key] if (raw === undefined) return undefined if (!/^[0-9a-fA-F]{1,8}$/.test(raw)) throw new YencError(`Invalid "${key}" in yEnc trailer: ${raw}`) return raw.toLowerCase().padStart(8, '0') } /** * Number of parts in the file. Many posters leave `total=` out of `=ybegin`; * then it follows from the part size, since all parts except the last one * are the same size. */ export function inferTotalParts(meta: Pick): number { const { begin, part } = meta if (begin.total) return begin.total if (!part || begin.part === undefined) return 1 if (part.end >= begin.size) return begin.part return Math.ceil(begin.size / (part.end - part.begin + 1)) } function lineAt(buffer: Buffer, start: number) { const eol = buffer.indexOf(CRLF, start) const end = eol === -1 ? buffer.length : eol return { text: buffer.subarray(start, end).toString('latin1'), next: end + CRLF.length } } /** * Locates and parses the yEnc header/trailer lines of one article body, * without decoding the data itself. */ export function parseYencMeta(body: Buffer): YencMeta { const beginIndex = body.indexOf(BEGIN) if (beginIndex === -1) throw new YencError('No =ybegin line found') const beginLine = lineAt(body, beginIndex) const beginFields = parseFields(beginLine.text) const begin: YencBegin = { line: toInt(beginFields, 'line', true), size: toInt(beginFields, 'size', true), name: beginFields.name ?? '', part: toInt(beginFields, 'part'), total: toInt(beginFields, 'total'), } if (!begin.name) throw new YencError('Missing "name" in =ybegin line') let dataStart = beginLine.next let part: YencPart | undefined if (body.subarray(dataStart, dataStart + PART.length).equals(PART)) { const partLine = lineAt(body, dataStart) const partFields = parseFields(partLine.text) part = { begin: toInt(partFields, 'begin', true), end: toInt(partFields, 'end', true) } dataStart = partLine.next } const endIndex = body.lastIndexOf(END) if (endIndex === -1 || endIndex < dataStart - CRLF.length) { throw new YencError('No =yend line found') } const endFields = parseFields(lineAt(body, endIndex + CRLF.length).text) const end: YencEnd = { size: toInt(endFields, 'size', true), part: toInt(endFields, 'part'), pcrc32: toCrc(endFields, 'pcrc32'), crc32: toCrc(endFields, 'crc32'), } return { begin, part, end, dataStart, dataEnd: Math.max(dataStart, endIndex) } }