Files
suaveplan/packages/foundation/data/packer/src/chunked/codec/header.ts
2026-05-27 23:47:53 +01:00

163 lines
5.3 KiB
TypeScript

import { PACKER_CODES, PackerError } from "../../error-codes.js";
import { resolvePackerMessage } from "../../i18n/messages.js";
import {
CHUNK_HEADER_SIZE,
CHUNK_METADATA_SIZE,
CHUNK_VERSION_V1,
type ChunkV1Metadata,
} from "../../types/chunked.types.js";
const FILE_ID_SIZE = 32;
const NONCE_SIZE = 24;
const AEAD_TAG_SIZE = 16;
/**
* Encode a {@link ChunkV1Metadata} into the locked 100-byte big-endian metadata header.
*
* Throws `PackerError(PACKER.INVALID_FORMAT)` on any field-size mismatch.
*/
export function encodeHeader(meta: ChunkV1Metadata): Uint8Array {
if (meta.fileId.byteLength !== FILE_ID_SIZE) {
throw new PackerError(
PACKER_CODES.INVALID_FORMAT,
`fileId must be ${FILE_ID_SIZE} bytes, got ${meta.fileId.byteLength}`
);
}
if (meta.nonce.byteLength !== NONCE_SIZE) {
throw new PackerError(
PACKER_CODES.INVALID_FORMAT,
`nonce must be ${NONCE_SIZE} bytes, got ${meta.nonce.byteLength}`
);
}
if (meta.aeadTag.byteLength !== AEAD_TAG_SIZE) {
throw new PackerError(
PACKER_CODES.INVALID_FORMAT,
`aeadTag must be ${AEAD_TAG_SIZE} bytes, got ${meta.aeadTag.byteLength}`
);
}
const out = new Uint8Array(CHUNK_METADATA_SIZE);
const view = new DataView(out.buffer);
view.setUint16(0, meta.version, false);
out.set(meta.fileId, 2);
view.setUint32(34, meta.chunkId, false);
view.setUint32(38, meta.totalChunks, false);
view.setBigUint64(42, meta.fileSize, false);
view.setUint32(50, meta.chunkSize, false);
view.setUint32(54, meta.payloadLen, false);
view.setUint16(58, meta.flags, false);
out.set(meta.nonce, 60);
out.set(meta.aeadTag, 84);
return out;
}
/**
* Parse the first 100 bytes of `buf` as a v1 metadata header.
*
* Validates: buffer length, version=0x0001, flags=0x0000 (v1 reserved),
* totalChunks ≥ 1, chunkId < totalChunks, chunkSize > 164, and the payloadLen
* invariants for non-final and final chunks (PACKER-CHUNK-03).
*
* Field byte ranges are returned as zero-copy `Uint8Array.subarray` views into
* `buf` — callers must not mutate.
*/
export function parseHeader(buf: Uint8Array): ChunkV1Metadata {
if (buf.byteLength < CHUNK_METADATA_SIZE) {
throw new PackerError(
PACKER_CODES.INVALID_FORMAT,
`buffer too short for metadata: ${buf.byteLength} < ${CHUNK_METADATA_SIZE}`
);
}
const view = new DataView(buf.buffer, buf.byteOffset, CHUNK_METADATA_SIZE);
const version = view.getUint16(0, false);
if (version !== CHUNK_VERSION_V1) {
throw new PackerError(
PACKER_CODES.VERSION_UNSUPPORTED,
resolvePackerMessage(
undefined,
"packer.error.VERSION_UNSUPPORTED.unknown-version",
{ version: `0x${version.toString(16).padStart(4, "0")}` }
)
);
}
const flags = view.getUint16(58, false);
if (flags !== 0) {
throw new PackerError(
PACKER_CODES.FLAGS_RESERVED_NONZERO,
resolvePackerMessage(
undefined,
"packer.error.FLAGS_RESERVED_NONZERO.nonzero"
)
);
}
const fileId = buf.subarray(2, 34);
const chunkId = view.getUint32(34, false);
const totalChunks = view.getUint32(38, false);
const fileSize = view.getBigUint64(42, false);
const chunkSize = view.getUint32(50, false);
const payloadLen = view.getUint32(54, false);
const nonce = buf.subarray(60, 84);
const aeadTag = buf.subarray(84, 100);
if (totalChunks === 0) {
throw new PackerError(
PACKER_CODES.INVALID_FORMAT,
"totalChunks must be >= 1"
);
}
if (chunkId >= totalChunks) {
throw new PackerError(
PACKER_CODES.INVALID_FORMAT,
`chunkId=${chunkId} out of range; totalChunks=${totalChunks}`
);
}
if (chunkSize <= CHUNK_HEADER_SIZE) {
throw new PackerError(
PACKER_CODES.INVALID_FORMAT,
`chunkSize=${chunkSize} must exceed header size ${CHUNK_HEADER_SIZE}`
);
}
const payloadPerChunk = chunkSize - CHUNK_HEADER_SIZE;
if (chunkId < totalChunks - 1) {
if (payloadLen !== payloadPerChunk) {
throw new PackerError(
PACKER_CODES.PAYLOAD_LEN_INVALID,
resolvePackerMessage(
undefined,
"packer.error.PAYLOAD_LEN_INVALID.non-final",
{ actual: payloadLen, expected: payloadPerChunk }
)
);
}
} else {
const expectedFinal =
fileSize - BigInt(chunkId) * BigInt(payloadPerChunk);
if (BigInt(payloadLen) !== expectedFinal) {
throw new PackerError(
PACKER_CODES.PAYLOAD_LEN_INVALID,
resolvePackerMessage(
undefined,
"packer.error.PAYLOAD_LEN_INVALID.final",
{
actual: payloadLen,
expected: expectedFinal.toString(),
}
)
);
}
}
return {
version,
fileId,
chunkId,
totalChunks,
fileSize,
chunkSize,
payloadLen,
flags,
nonce,
aeadTag,
};
}