141 lines
4.5 KiB
TypeScript
141 lines
4.5 KiB
TypeScript
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<string, string> {
|
|
const fields: Record<string, string> = {}
|
|
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<string, string>, key: string, required: true): number
|
|
function toInt(fields: Record<string, string>, key: string, required?: false): number | undefined
|
|
function toInt(fields: Record<string, string>, 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<string, string>, 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<YencMeta, 'begin' | 'part'>): 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) }
|
|
}
|