87 lines
2.7 KiB
TypeScript
87 lines
2.7 KiB
TypeScript
import { crc32Hex, type DecodedSegment } from './decode.js'
|
|
import { YencCrcError, YencError } from './errors.js'
|
|
import { inferTotalParts } from './meta.js'
|
|
|
|
/**
|
|
* Assembles the decoded segments of one file into a single buffer. Segments
|
|
* may be added in any order.
|
|
*/
|
|
export class YencFile {
|
|
readonly name: string
|
|
readonly size: number
|
|
readonly totalParts: number
|
|
|
|
private readonly buffer: Buffer
|
|
private readonly received = new Set<number>()
|
|
private bytesWritten = 0
|
|
private fileCrc?: string
|
|
|
|
constructor(name: string, size: number, totalParts = 1) {
|
|
this.name = name
|
|
this.size = size
|
|
this.totalParts = totalParts
|
|
this.buffer = Buffer.alloc(size)
|
|
}
|
|
|
|
/** Creates a file sized from the metadata of any of its segments. */
|
|
static fromSegment(segment: DecodedSegment): YencFile {
|
|
const { begin } = segment.meta
|
|
// Many posters leave total= out; it follows from the part size.
|
|
return new YencFile(begin.name, begin.size, inferTotalParts(segment.meta))
|
|
}
|
|
|
|
add(segment: DecodedSegment): void {
|
|
const { meta, data, offset } = segment
|
|
// Only the size is compared: obfuscating posters can give every segment
|
|
// of one file a different random name.
|
|
if (meta.begin.size !== this.size) {
|
|
throw new YencError(
|
|
`Segment of "${meta.begin.name}" (${meta.begin.size} bytes) does not belong to "${this.name}" (${this.size} bytes)`
|
|
)
|
|
}
|
|
if (offset + data.length > this.size) {
|
|
throw new YencError(`Segment at offset ${offset} overruns file size ${this.size}`)
|
|
}
|
|
|
|
const partNumber = meta.begin.part ?? 1
|
|
if (this.received.has(partNumber)) return
|
|
|
|
data.copy(this.buffer, offset)
|
|
this.received.add(partNumber)
|
|
this.bytesWritten += data.length
|
|
if (meta.end.crc32) this.fileCrc = meta.end.crc32
|
|
}
|
|
|
|
get partsReceived(): number {
|
|
return this.received.size
|
|
}
|
|
|
|
get isComplete(): boolean {
|
|
return this.received.size === this.totalParts && this.bytesWritten === this.size
|
|
}
|
|
|
|
/** Part numbers (1-based) that have not been added yet. */
|
|
missingParts(): number[] {
|
|
const missing: number[] = []
|
|
for (let part = 1; part <= this.totalParts; part++) {
|
|
if (!this.received.has(part)) missing.push(part)
|
|
}
|
|
return missing
|
|
}
|
|
|
|
/**
|
|
* Returns the assembled file. Throws if parts are missing or, when the poster
|
|
* included a whole-file crc32, if it does not match.
|
|
*/
|
|
getBuffer(): Buffer {
|
|
if (!this.isComplete) {
|
|
throw new YencError(`File "${this.name}" is incomplete, missing parts: ${this.missingParts().join(', ')}`)
|
|
}
|
|
if (this.fileCrc) {
|
|
const actual = crc32Hex(this.buffer)
|
|
if (actual !== this.fileCrc) throw new YencCrcError(this.fileCrc, actual)
|
|
}
|
|
return this.buffer
|
|
}
|
|
}
|