124 lines
4.9 KiB
TypeScript
124 lines
4.9 KiB
TypeScript
import { promises as fs } from "node:fs";
|
|
import path from "node:path";
|
|
import { Unpacker } from "@suaveplan/packer";
|
|
import {
|
|
SERVER_KEYS_CODES,
|
|
ServerKeysError,
|
|
} from "../../error-codes/error-codes.js";
|
|
import { checkSignal, loadFile } from "../keys/keys-io.js";
|
|
import type { FileCommandOptions, FilesLogger } from "./files.types.js";
|
|
import { deserializePacket, hexToBytes, readManifest } from "./packet-io.js";
|
|
|
|
const SILENT: () => void = () => {};
|
|
const SILENT_LOGGER: FilesLogger = { info: SILENT, error: SILENT };
|
|
|
|
function pickLogger(options: FileCommandOptions | undefined): FilesLogger {
|
|
return options?.logger ?? SILENT_LOGGER;
|
|
}
|
|
|
|
/** Result envelope for {@link serveFileCommand}. */
|
|
export interface ServeFileResult {
|
|
readonly outputPath: string;
|
|
readonly bytesWritten: number;
|
|
readonly packetsRead: number;
|
|
readonly fileId: string;
|
|
}
|
|
|
|
/**
|
|
* Reconstruct the original file from the prepared packet directory.
|
|
* Verifies each packet's signature + checksum against the manifest's
|
|
* `signerPublicKey` before writing to disk. Output path defaults to
|
|
* `<packetDir>/<manifest.originalFilename>`.
|
|
*/
|
|
export async function serveFileCommand(
|
|
packetDir: string,
|
|
options: FileCommandOptions = {}
|
|
): Promise<ServeFileResult> {
|
|
if (!packetDir) {
|
|
throw new ServerKeysError(
|
|
SERVER_KEYS_CODES.INVALID_INPUT,
|
|
"packetDir is required"
|
|
);
|
|
}
|
|
checkSignal(options.signal, "serve-file");
|
|
const manifest = await readManifest(packetDir);
|
|
const verifyKey = hexToBytes(manifest.signerPublicKey);
|
|
const reconstructed = new Uint8Array(manifest.originalSize);
|
|
let writeOffset = 0;
|
|
let packetsRead = 0;
|
|
const sorted = [...manifest.packets].sort((a, b) => a.index - b.index);
|
|
if (sorted.length !== manifest.totalPackets) {
|
|
throw new ServerKeysError(
|
|
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
|
`Manifest declares ${manifest.totalPackets} packets but only ${sorted.length} entries present`
|
|
);
|
|
}
|
|
for (let i = 0; i < sorted.length; i++) {
|
|
const record = sorted[i];
|
|
/* istanbul ignore start -- defensive: `sorted` is derived from manifest.packets via Array.sort, which preserves length and never introduces sparse holes. */
|
|
if (!record) continue;
|
|
/* istanbul ignore stop */
|
|
if (record.index !== i) {
|
|
throw new ServerKeysError(
|
|
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
|
`Packet sequence broken at index ${i}: manifest entry has index=${record.index}`
|
|
);
|
|
}
|
|
checkSignal(options.signal, "serve-file");
|
|
const buf = await loadFile(path.join(packetDir, record.filename));
|
|
const { packed } = deserializePacket(buf);
|
|
const unpacker = new Unpacker();
|
|
let result: Awaited<ReturnType<typeof unpacker.unpack>>;
|
|
try {
|
|
result = await unpacker.unpack(packed, {
|
|
verifySignature: true,
|
|
verifyKey,
|
|
verifyChecksum: true,
|
|
});
|
|
} catch (err) {
|
|
let extras: {
|
|
cause?: Error;
|
|
context: { index: number };
|
|
} = { context: { index: record.index } };
|
|
/* istanbul ignore start -- defensive: Unpacker.unpack rejects with Error subclasses on every documented failure mode, so the else branch is unreachable. */
|
|
if (err instanceof Error) {
|
|
extras = { cause: err, context: { index: record.index } };
|
|
}
|
|
/* istanbul ignore stop */
|
|
throw new ServerKeysError(
|
|
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
|
`Packet ${record.index} (${record.filename}) failed verification`,
|
|
extras
|
|
);
|
|
}
|
|
if (result.data.byteLength !== record.originalSize) {
|
|
throw new ServerKeysError(
|
|
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
|
`Packet ${record.index} size mismatch: expected ${record.originalSize} got ${result.data.byteLength}`
|
|
);
|
|
}
|
|
reconstructed.set(result.data, writeOffset);
|
|
writeOffset += result.data.byteLength;
|
|
packetsRead++;
|
|
}
|
|
if (writeOffset !== manifest.originalSize) {
|
|
throw new ServerKeysError(
|
|
SERVER_KEYS_CODES.PACKET_INTEGRITY_FAILED,
|
|
`Reassembled size ${writeOffset} does not match manifest.originalSize ${manifest.originalSize}`
|
|
);
|
|
}
|
|
const outputPath =
|
|
options.output ?? path.join(packetDir, manifest.originalFilename);
|
|
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
await fs.writeFile(outputPath, reconstructed);
|
|
pickLogger(options).info(
|
|
`serve-file: fileId=${manifest.fileId} packets=${packetsRead} bytes=${writeOffset} out=${outputPath}`
|
|
);
|
|
return {
|
|
outputPath,
|
|
bytesWritten: writeOffset,
|
|
packetsRead,
|
|
fileId: manifest.fileId,
|
|
};
|
|
}
|