163 lines
5.3 KiB
TypeScript
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,
|
|
};
|
|
}
|