192 lines
5.7 KiB
JavaScript
192 lines
5.7 KiB
JavaScript
/**
|
||
* bundle.js — Parse an Arboricx portable bundle binary into a JavaScript object.
|
||
*
|
||
* Format (v1):
|
||
* Header (32 bytes):
|
||
* Magic 8B "ARBORICX"
|
||
* Major 2B u16 BE (must be 1)
|
||
* Minor 2B u16 BE
|
||
* SectionCount 4B u32 BE
|
||
* Flags 8B u64 BE
|
||
* DirOffset 8B u64 BE
|
||
* Section Directory (SectionCount × 60 bytes):
|
||
* Type 4B u32 BE
|
||
* Version 2B u16 BE
|
||
* Flags 2B u16 BE (bit 0 = critical)
|
||
* Compression 2B u16 BE
|
||
* DigestAlgo 2B u16 BE
|
||
* Offset 8B u64 BE
|
||
* Length 8B u64 BE
|
||
* SHA256Digest 32B raw
|
||
* Manifest: fixed-order core + TLV tail (ARBMNFST magic)
|
||
* Nodes: binary section
|
||
*/
|
||
|
||
import { createHash } from "node:crypto";
|
||
import { decodeManifest } from "./manifest.js";
|
||
|
||
// ── Constants ───────────────────────────────────────────────────────────────
|
||
|
||
const MAGIC = Buffer.from([0x41, 0x52, 0x42, 0x4f, 0x52, 0x49, 0x43, 0x58]); // "ARBORICX"
|
||
const HEADER_LENGTH = 32;
|
||
const SECTION_ENTRY_LENGTH = 60;
|
||
const SECTION_MANIFEST = 1;
|
||
const SECTION_NODES = 2;
|
||
const FLAG_CRITICAL = 0x0001;
|
||
const COMPRESSION_NONE = 0;
|
||
const DIGEST_SHA256 = 1;
|
||
const MAJOR_VERSION = 1;
|
||
const MINOR_VERSION = 0;
|
||
|
||
// ── Helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
function readU16BE(buf, offset) {
|
||
return buf.readUint16BE(offset);
|
||
}
|
||
function readU32BE(buf, offset) {
|
||
return buf.readUint32BE(offset);
|
||
}
|
||
function readU64BE(buf, offset) {
|
||
return buf.readBigUInt64BE(offset);
|
||
}
|
||
|
||
function sha256(data) {
|
||
return createHash("sha256").update(data).digest();
|
||
}
|
||
|
||
// ── Public API ──────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Parse a bundle Buffer into a Bundle object.
|
||
*
|
||
* Returns { version, sectionCount, sections } where sections maps
|
||
* section type numbers to parsed section info (offset, length, data).
|
||
*/
|
||
export function parseBundle(buffer) {
|
||
if (buffer.length < HEADER_LENGTH) {
|
||
throw new Error("bundle too short for header");
|
||
}
|
||
|
||
// Check magic
|
||
if (!buffer.slice(0, 8).equals(MAGIC)) {
|
||
throw new Error("invalid magic: expected ARBORICX");
|
||
}
|
||
|
||
// Parse header
|
||
const major = readU16BE(buffer, 8);
|
||
const minor = readU16BE(buffer, 10);
|
||
const sectionCount = readU32BE(buffer, 12);
|
||
|
||
if (major !== MAJOR_VERSION) {
|
||
throw new Error(
|
||
`unsupported bundle major version: ${major} (expected ${MAJOR_VERSION})`
|
||
);
|
||
}
|
||
|
||
const dirOffset = Number(readU64BE(buffer, 24));
|
||
|
||
// Parse section directory
|
||
const dirStart = dirOffset;
|
||
const dirEnd = dirStart + sectionCount * SECTION_ENTRY_LENGTH;
|
||
|
||
if (buffer.length < dirEnd) {
|
||
throw new Error("bundle truncated in section directory");
|
||
}
|
||
|
||
const entries = [];
|
||
for (let i = 0; i < sectionCount; i++) {
|
||
const off = dirStart + i * SECTION_ENTRY_LENGTH;
|
||
const entry = {
|
||
type: readU32BE(buffer, off),
|
||
version: readU16BE(buffer, off + 4),
|
||
flags: readU16BE(buffer, off + 6),
|
||
compression: readU16BE(buffer, off + 8),
|
||
digestAlgorithm: readU16BE(buffer, off + 10),
|
||
offset: Number(readU64BE(buffer, off + 12)),
|
||
length: Number(readU64BE(buffer, off + 20)),
|
||
digest: buffer.slice(off + 28, off + 28 + 32),
|
||
};
|
||
entries.push(entry);
|
||
}
|
||
|
||
// Validate sections
|
||
for (const entry of entries) {
|
||
const isCritical = (entry.flags & FLAG_CRITICAL) !== 0;
|
||
const isKnown =
|
||
entry.type === SECTION_MANIFEST || entry.type === SECTION_NODES;
|
||
if (isCritical && !isKnown) {
|
||
throw new Error(`unknown critical section type: ${entry.type}`);
|
||
}
|
||
if (entry.compression !== COMPRESSION_NONE) {
|
||
throw new Error(
|
||
`unsupported compression codec in section ${entry.type}`
|
||
);
|
||
}
|
||
if (entry.digestAlgorithm !== DIGEST_SHA256) {
|
||
throw new Error(
|
||
`unsupported digest algorithm in section ${entry.type}`
|
||
);
|
||
}
|
||
}
|
||
|
||
// Verify section digests and extract data
|
||
const sections = new Map();
|
||
for (const entry of entries) {
|
||
if (entry.offset < 0 || entry.length < 0) {
|
||
throw new Error(`section ${entry.type} has negative offset/length`);
|
||
}
|
||
if (buffer.length < entry.offset + entry.length) {
|
||
throw new Error(
|
||
`section ${entry.type} extends beyond bundle end`
|
||
);
|
||
}
|
||
|
||
const data = buffer.slice(entry.offset, entry.offset + entry.length);
|
||
|
||
// Verify digest
|
||
const computed = sha256(data);
|
||
if (!computed.equals(entry.digest)) {
|
||
throw new Error(
|
||
`section digest mismatch for section type ${entry.type}`
|
||
);
|
||
}
|
||
|
||
sections.set(entry.type, {
|
||
...entry,
|
||
data,
|
||
});
|
||
}
|
||
|
||
// Check required sections
|
||
if (!sections.has(SECTION_MANIFEST)) {
|
||
throw new Error("missing required section: manifest");
|
||
}
|
||
if (!sections.has(SECTION_NODES)) {
|
||
throw new Error("missing required section: nodes");
|
||
}
|
||
|
||
return {
|
||
version: `${major}.${minor}`,
|
||
sectionCount,
|
||
sections,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Convenience: parse and return the manifest from the fixed-order binary format.
|
||
*/
|
||
export function parseManifest(buffer) {
|
||
const bundle = parseBundle(buffer);
|
||
const manifestEntry = bundle.sections.get(SECTION_MANIFEST);
|
||
return decodeManifest(manifestEntry.data);
|
||
}
|
||
|
||
/**
|
||
* Convenience: parse and return the node section binary.
|
||
*/
|
||
export function parseNodeSection(buffer) {
|
||
const bundle = parseBundle(buffer);
|
||
const nodesEntry = bundle.sections.get(SECTION_NODES);
|
||
return nodesEntry.data;
|
||
}
|