Initial Commit
This commit is contained in:
162
packages/foundation/data/packer/src/chunked/codec/header.ts
Normal file
162
packages/foundation/data/packer/src/chunked/codec/header.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user