UNPKG

@pgxsinkit/pgwasm

Version:
1,052 lines (978 loc) • 36.5 kB
import { crc32, crc32WithZeroedRange } from "./checksum"; import { CorruptStoreError, ExtentSizeMismatchError, StoreLimitError, StoreRecreationRequiredError } from "./errors"; import { ARENA_HEADER_BYTES, FORMAT_VERSION, LIMITS_PROFILE_VERSION, MAX_COMPONENT_BYTES, MAX_EXTENTS_PER_INODE, MAX_FRAME_PAYLOAD_BYTES, MAX_INODES, MAX_METADATA_BASE_READER_BYTES, MAX_METADATA_BASE_WRITER_BYTES, MAX_PATH_BYTES, MAX_TOTAL_EXTENTS, checkedU64, validateExtentSize, } from "./limits"; import { validatePathComponent, validateSymlinkTarget } from "./path"; import { IndexedExtentSet, estimateTxnPayloadBytes, validateState } from "./state-machine"; import type { DirectoryInode, ExtentRun, FileInode, Inode, MetadataFile, SymlinkInode, TxnRecord, VfsState, } from "./state-machine"; const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder("utf-8", { fatal: true }); const ARENA_MAGIC = textEncoder.encode("PGXRPA01"); const METADATA_MAGIC = textEncoder.encode("PGXRPM01"); const FRAME_MAGIC = textEncoder.encode("PGXRPF01"); const ACTIVATION_MAGIC = textEncoder.encode("PGXRPK01"); export const ARENA_HEADER_CHECKSUM_OFFSET = 24; export const METADATA_HEADER_BYTES = 80; export const TXN_FRAME_HEADER_BYTES = 48; export const ACTIVATION_SLOT_BYTES = 128; export const METADATA_HEADER_CHECKSUM_OFFSET = 64; export const FRAME_CHECKSUM_OFFSET = 40; export const ACTIVATION_CHECKSUM_OFFSET = 52; class BinaryWriter { readonly bytes: Uint8Array; readonly #view: DataView; #offset = 0; constructor(length: number, initialOffset = 0) { if ( !Number.isSafeInteger(length) || length < 0 || !Number.isSafeInteger(initialOffset) || initialOffset < 0 || initialOffset > length ) { throw new StoreLimitError("binary output length is invalid"); } this.bytes = new Uint8Array(length); this.#view = new DataView(this.bytes.buffer); this.#offset = initialOffset; } u8(value: number): void { this.#require(1); if (!Number.isSafeInteger(value) || value < 0 || value > 0xff) { throw new StoreLimitError("unsigned 8-bit value is out of range"); } this.#view.setUint8(this.#offset, value); this.#offset += 1; } u16(value: number): void { this.#require(2); if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff) { throw new StoreLimitError("unsigned 16-bit value is out of range"); } this.#view.setUint16(this.#offset, value, true); this.#offset += 2; } u32(value: number): void { this.#require(4); if (!Number.isSafeInteger(value) || value < 0 || value > 0xffff_ffff) { throw new StoreLimitError("unsigned 32-bit value is out of range"); } this.#view.setUint32(this.#offset, value, true); this.#offset += 4; } u64(value: bigint): void { this.#require(8); this.#view.setBigUint64(this.#offset, checkedU64(value, "encoded integer"), true); this.#offset += 8; } validatedU64(value: bigint): void { this.#require(8); this.#view.setBigUint64(this.#offset, value, true); this.#offset += 8; } raw(value: Uint8Array): void { this.#require(value.byteLength); this.bytes.set(value, this.#offset); this.#offset += value.byteLength; } string(value: string): void { validatePathComponent(value); this.validatedString(textEncoder.encode(value)); } /** A symbolic link's target: a whole PATH, so it is bounded by `MAX_PATH_BYTES`, not by a name. */ symlinkTarget(value: string): void { validateSymlinkTarget(value); this.validatedString(textEncoder.encode(value)); } validatedString(encoded: Uint8Array): void { this.u16(encoded.byteLength); this.raw(encoded); } finish(): Uint8Array { if (this.#offset !== this.bytes.byteLength) { throw new StoreLimitError( `binary encoder wrote ${this.#offset} bytes into a ${this.bytes.byteLength}-byte output`, ); } return this.bytes; } #require(length: number): void { if (this.#offset + length > this.bytes.byteLength) { throw new StoreLimitError("binary encoder exceeded its preflighted size"); } } } class BinaryReader { readonly #bytes: Uint8Array; readonly #view: DataView; #offset = 0; constructor(bytes: Uint8Array) { this.#bytes = bytes; this.#view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); } get remaining(): number { return this.#bytes.byteLength - this.#offset; } u8(): number { this.#require(1); const value = this.#view.getUint8(this.#offset); this.#offset += 1; return value; } u16(): number { this.#require(2); const value = this.#view.getUint16(this.#offset, true); this.#offset += 2; return value; } u32(): number { this.#require(4); const value = this.#view.getUint32(this.#offset, true); this.#offset += 4; return value; } u64(): bigint { this.#require(8); const value = this.#view.getBigUint64(this.#offset, true); this.#offset += 8; return value; } raw(length: number): Uint8Array { this.#require(length); const value = this.#bytes.subarray(this.#offset, this.#offset + length); this.#offset += length; return value; } string(): string { const length = this.u16(); if (length === 0 || length > MAX_COMPONENT_BYTES) { throw new CorruptStoreError("encoded path component length is invalid"); } let value: string; try { value = textDecoder.decode(this.raw(length)); } catch (cause) { throw new CorruptStoreError("encoded path component is not valid UTF-8", { cause }); } if (textEncoder.encode(value).byteLength !== length) { throw new CorruptStoreError("encoded path component is not canonical UTF-8"); } try { validatePathComponent(value); } catch (cause) { throw new CorruptStoreError(`encoded path component is invalid: ${String(cause)}`, { cause }); } return value; } /** The reader half of {@link BinaryWriter.symlinkTarget}. */ symlinkTarget(): string { const length = this.u16(); if (length === 0 || length > MAX_PATH_BYTES) { throw new CorruptStoreError("encoded symlink target length is invalid"); } let value: string; try { value = textDecoder.decode(this.raw(length)); } catch (cause) { throw new CorruptStoreError("encoded symlink target is not valid UTF-8", { cause }); } if (textEncoder.encode(value).byteLength !== length) { throw new CorruptStoreError("encoded symlink target is not canonical UTF-8"); } try { validateSymlinkTarget(value); } catch (cause) { throw new CorruptStoreError(`encoded symlink target is invalid: ${String(cause)}`, { cause }); } return value; } end(): void { if (this.remaining !== 0) { throw new CorruptStoreError("binary value has trailing bytes"); } } #require(length: number): void { if (!Number.isSafeInteger(length) || length < 0 || length > this.remaining) { throw new CorruptStoreError("binary value is truncated"); } } } function bytesEqual(left: Uint8Array, right: Uint8Array): boolean { if (left.byteLength !== right.byteLength) { return false; } for (let index = 0; index < left.byteLength; index += 1) { if (left[index] !== right[index]) { return false; } } return true; } function requireMagic(bytes: Uint8Array, magic: Uint8Array, label: string): void { if (!bytesEqual(bytes.subarray(0, magic.byteLength), magic)) { throw new CorruptStoreError(`${label} magic is invalid`); } } function requireZero(bytes: Uint8Array, start: number, end: number, label: string): void { for (let offset = start; offset < end; offset += 1) { if (bytes[offset] !== 0) { throw new CorruptStoreError(`${label} padding is not canonical`); } } } function requireChecksum(bytes: Uint8Array, offset: number, label: string): void { const stored = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).getUint32(offset, true); const calculated = crc32WithZeroedRange(bytes, offset, 4); if (stored !== calculated) { throw new CorruptStoreError(`${label} checksum is invalid`); } } function requireCurrentIdentity(version: number, profile: number): void { if (version !== FORMAT_VERSION || profile !== LIMITS_PROFILE_VERSION) { throw new StoreRecreationRequiredError( `store format ${version}/limits profile ${profile} is unsupported; delete the store and create it fresh`, ); } } export interface ArenaHeader { extentSize: number; limitsProfileVersion: number; formatVersion: number; } export function encodeArenaHeader(options: { extentSize: number }): Uint8Array { const extentSize = validateExtentSize(options.extentSize); const bytes = new Uint8Array(ARENA_HEADER_BYTES); const view = new DataView(bytes.buffer); bytes.set(ARENA_MAGIC, 0); view.setUint32(8, FORMAT_VERSION, true); view.setUint32(12, LIMITS_PROFILE_VERSION, true); view.setUint32(16, extentSize, true); view.setUint32(ARENA_HEADER_CHECKSUM_OFFSET, crc32WithZeroedRange(bytes, ARENA_HEADER_CHECKSUM_OFFSET, 4), true); return bytes; } export function decodeArenaHeader(bytes: Uint8Array, configuredExtentSize?: number): ArenaHeader { if (configuredExtentSize !== undefined) { validateExtentSize(configuredExtentSize); } if (bytes.byteLength !== ARENA_HEADER_BYTES) { throw new CorruptStoreError("arena header has the wrong length"); } requireMagic(bytes, ARENA_MAGIC, "arena header"); requireZero(bytes, 20, 24, "arena header"); requireZero(bytes, 28, bytes.byteLength, "arena header"); requireChecksum(bytes, ARENA_HEADER_CHECKSUM_OFFSET, "arena header"); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const formatVersion = view.getUint32(8, true); const limitsProfileVersion = view.getUint32(12, true); requireCurrentIdentity(formatVersion, limitsProfileVersion); const extentSize = view.getUint32(16, true); try { validateExtentSize(extentSize); } catch (cause) { throw new CorruptStoreError("arena header extent size is invalid", { cause }); } if (configuredExtentSize !== undefined && configuredExtentSize !== extentSize) { throw new ExtentSizeMismatchError(configuredExtentSize, extentSize); } return { extentSize, limitsProfileVersion, formatVersion }; } function metadataFileCode(file: MetadataFile): number { return file === "a" ? 0 : 1; } function decodeMetadataFile(value: number): MetadataFile { if (value === 0) return "a"; if (value === 1) return "b"; throw new CorruptStoreError("metadata file selector is invalid"); } function compareBigint(left: bigint, right: bigint): number { return left < right ? -1 : left > right ? 1 : 0; } function compareUtf8Names(left: string, right: string): number { let leftIndex = 0; let rightIndex = 0; while (leftIndex < left.length && rightIndex < right.length) { const leftPoint = left.codePointAt(leftIndex)!; const rightPoint = right.codePointAt(rightIndex)!; if (leftPoint !== rightPoint) return leftPoint - rightPoint; leftIndex += leftPoint > 0xffff ? 2 : 1; rightIndex += rightPoint > 0xffff ? 2 : 1; } return left.length - right.length; } function sortIfNeeded<Value>(values: Value[], compare: (left: Value, right: Value) => number): Value[] { for (let index = 1; index < values.length; index += 1) { if (compare(values[index - 1]!, values[index]!) > 0) { values.sort(compare); break; } } return values; } const INODE_KIND_DIRECTORY = 0; const INODE_KIND_FILE = 1; const INODE_KIND_SYMLINK = 2; function writeInode(writer: BinaryWriter, inode: Inode): void { writer.validatedU64(inode.id); writer.u8( inode.kind === "directory" ? INODE_KIND_DIRECTORY : inode.kind === "file" ? INODE_KIND_FILE : INODE_KIND_SYMLINK, ); writer.u32(inode.mode); writer.validatedU64(inode.atimeMs); writer.validatedU64(inode.mtimeMs); writer.validatedU64(inode.ctimeMs); if (inode.kind === "directory") { const children = sortIfNeeded([...inode.children], ([left], [right]) => compareUtf8Names(left, right)); writer.u32(children.length); for (const [name, inodeId] of children) { writer.validatedString(textEncoder.encode(name)); writer.validatedU64(inodeId); } } else if (inode.kind === "symlink") { writer.symlinkTarget(inode.target); } else { writer.validatedU64(inode.size); writer.u32(inode.extents.length); for (const extentId of inode.extents) writer.validatedU64(extentId); } } function encodeMetadataPayload(state: VfsState, length: number, prefixBytes = 0): Uint8Array { if (length > MAX_METADATA_BASE_WRITER_BYTES) { throw new StoreLimitError(`metadata base payload exceeds ${MAX_METADATA_BASE_WRITER_BYTES} bytes`); } const writer = new BinaryWriter(prefixBytes + length, prefixBytes); writer.validatedU64(state.allocator.totalExtents); writer.validatedU64(state.nextInodeId); writer.u32(state.inodes.size); const inodes = sortIfNeeded([...state.inodes.values()], (left, right) => compareBigint(left.id, right.id)); for (const inode of inodes) { writeInode(writer, inode); } const available = sortIfNeeded([...state.allocator.available.values()], compareBigint); for (const extentId of available) writer.validatedU64(extentId); const quarantine = sortIfNeeded([...state.allocator.quarantine], ([left], [right]) => compareBigint(left, right)); for (const [extentId, generation] of quarantine) { writer.validatedU64(extentId); writer.validatedU64(generation ?? 0n); } return writer.finish(); } export interface DecodedMetadataBase { state: VfsState; payloadBytes: number; baseEnd: bigint; baseDigest: number; } export interface MetadataBaseHeader { formatVersion: number; limitsProfileVersion: number; generation: bigint; extentSize: number; metadataFileCode: number; rootInodeId: bigint; payloadLength: number; availableCount: number; quarantineCount: number; baseEnd: bigint; } export function inspectMetadataBaseHeader(header: Uint8Array): MetadataBaseHeader { if (header.byteLength !== METADATA_HEADER_BYTES) { throw new CorruptStoreError("metadata base header has the wrong length"); } requireMagic(header, METADATA_MAGIC, "metadata base"); requireZero(header, 29, 32, "metadata base"); requireZero(header, 52, 56, "metadata base"); requireZero(header, 68, 80, "metadata base"); const view = new DataView(header.buffer, header.byteOffset, header.byteLength); const payloadLength = view.getUint32(40, true); if (payloadLength > MAX_METADATA_BASE_READER_BYTES) { throw new CorruptStoreError("metadata base payload declaration exceeds the reader limit"); } const baseEnd = view.getBigUint64(56, true); if (baseEnd !== BigInt(METADATA_HEADER_BYTES + payloadLength)) { throw new CorruptStoreError("metadata base end offset is inconsistent"); } const availableCount = view.getUint32(44, true); const quarantineCount = view.getUint32(48, true); return { formatVersion: view.getUint32(8, true), limitsProfileVersion: view.getUint32(12, true), generation: view.getBigUint64(16, true), extentSize: view.getUint32(24, true), metadataFileCode: view.getUint8(28), rootInodeId: view.getBigUint64(32, true), payloadLength, availableCount, quarantineCount, baseEnd, }; } export function encodeMetadataBase(state: VfsState): Uint8Array { validateState(state); return encodeValidatedMetadataBase(state); } /** @internal The caller must have validated this exact state immediately before encoding it. */ export function encodeValidatedMetadataBase(state: VfsState): Uint8Array { const baseEnd = METADATA_HEADER_BYTES + state.basePayloadBytes; const bytes = encodeMetadataPayload(state, state.basePayloadBytes, METADATA_HEADER_BYTES); const view = new DataView(bytes.buffer); bytes.set(METADATA_MAGIC, 0); view.setUint32(8, FORMAT_VERSION, true); view.setUint32(12, LIMITS_PROFILE_VERSION, true); view.setBigUint64(16, state.generation, true); view.setUint32(24, state.extentSize, true); view.setUint8(28, metadataFileCode(state.activeMetadataFile)); view.setBigUint64(32, state.rootInodeId, true); view.setUint32(40, state.basePayloadBytes, true); view.setUint32(44, state.allocator.available.size, true); view.setUint32(48, state.allocator.quarantine.size, true); view.setBigUint64(56, BigInt(baseEnd), true); view.setUint32( METADATA_HEADER_CHECKSUM_OFFSET, crc32WithZeroedRange(bytes, METADATA_HEADER_CHECKSUM_OFFSET, 4), true, ); return bytes; } function readBoundedCount(reader: BinaryReader, maximum: number, label: string): number { const count = reader.u32(); if (count > maximum) { throw new CorruptStoreError(`${label} count exceeds ${maximum}`); } return count; } function readInode(reader: BinaryReader): Inode { const id = reader.u64(); const kind = reader.u8(); const mode = reader.u32(); const atimeMs = reader.u64(); const mtimeMs = reader.u64(); const ctimeMs = reader.u64(); if (kind === INODE_KIND_DIRECTORY) { const childCount = readBoundedCount(reader, MAX_INODES, "directory child"); const children = new Map<string, bigint>(); let previousName: string | undefined; for (let index = 0; index < childCount; index += 1) { const name = reader.string(); if (previousName !== undefined && compareUtf8Names(previousName, name) >= 0) { throw new CorruptStoreError("directory children are not in canonical order"); } children.set(name, reader.u64()); previousName = name; } const inode: DirectoryInode = { id, kind: "directory", mode, atimeMs, mtimeMs, ctimeMs, children }; return inode; } if (kind === INODE_KIND_FILE) { const size = reader.u64(); const extentCount = readBoundedCount(reader, MAX_EXTENTS_PER_INODE, "file extent"); const extents: bigint[] = []; for (let index = 0; index < extentCount; index += 1) extents.push(reader.u64()); const inode: FileInode = { id, kind: "file", mode, atimeMs, mtimeMs, ctimeMs, size, extents }; return inode; } if (kind === INODE_KIND_SYMLINK) { const inode: SymlinkInode = { id, kind: "symlink", mode, atimeMs, mtimeMs, ctimeMs, target: reader.symlinkTarget(), }; return inode; } throw new CorruptStoreError("inode type is invalid"); } export function decodeMetadataBase(bytes: Uint8Array): DecodedMetadataBase { if (bytes.byteLength < METADATA_HEADER_BYTES) { throw new CorruptStoreError("metadata base header is truncated"); } const inspected = inspectMetadataBaseHeader(bytes.subarray(0, METADATA_HEADER_BYTES)); const { payloadLength, baseEnd } = inspected; if (bytes.byteLength !== Number(baseEnd)) { throw new CorruptStoreError("metadata base end offset is inconsistent"); } requireChecksum(bytes, METADATA_HEADER_CHECKSUM_OFFSET, "metadata base"); const payload = bytes.subarray(METADATA_HEADER_BYTES); requireCurrentIdentity(inspected.formatVersion, inspected.limitsProfileVersion); const { generation, extentSize, rootInodeId, availableCount, quarantineCount } = inspected; if (generation === 0n) throw new CorruptStoreError("metadata generation must be positive"); try { validateExtentSize(extentSize); } catch (cause) { throw new CorruptStoreError("metadata base extent size is invalid", { cause }); } const activeMetadataFile = decodeMetadataFile(inspected.metadataFileCode); if (availableCount > MAX_TOTAL_EXTENTS || quarantineCount > MAX_TOTAL_EXTENTS) { throw new CorruptStoreError("metadata allocator count exceeds the extent limit"); } try { const reader = new BinaryReader(payload); const totalExtents = reader.u64(); if (totalExtents > BigInt(MAX_TOTAL_EXTENTS)) { throw new CorruptStoreError("metadata total extent count exceeds the extent limit"); } const nextInodeId = reader.u64(); const inodeCount = readBoundedCount(reader, MAX_INODES, "inode"); const inodes = new Map<bigint, Inode>(); const ownedBy = new Map<bigint, bigint>(); let previousInodeId: bigint | undefined; for (let index = 0; index < inodeCount; index += 1) { const inode = readInode(reader); if (previousInodeId !== undefined && inode.id <= previousInodeId) { throw new CorruptStoreError("inodes are not in canonical order"); } inodes.set(inode.id, inode); previousInodeId = inode.id; if (inode.kind === "file") { for (const extentId of inode.extents) { if (ownedBy.has(extentId)) throw new CorruptStoreError("metadata contains duplicate extent ownership"); ownedBy.set(extentId, inode.id); } } } const available = new IndexedExtentSet(); let previousAvailable: bigint | undefined; for (let index = 0; index < availableCount; index += 1) { const extentId = reader.u64(); if (previousAvailable !== undefined && extentId <= previousAvailable) { throw new CorruptStoreError("available extents are not in canonical order"); } available.add(extentId); previousAvailable = extentId; } const quarantine = new Map<bigint, bigint | null>(); let previousQuarantine: bigint | undefined; for (let index = 0; index < quarantineCount; index += 1) { const extentId = reader.u64(); const encodedGeneration = reader.u64(); if (previousQuarantine !== undefined && extentId <= previousQuarantine) { throw new CorruptStoreError("quarantine extents are not in canonical order"); } quarantine.set(extentId, encodedGeneration === 0n ? null : encodedGeneration); previousQuarantine = extentId; } reader.end(); const parentByInode = new Map<bigint, { parentId: bigint; name: string }>(); for (const inode of inodes.values()) { if (inode.kind !== "directory") continue; for (const [name, childId] of inode.children) { if (parentByInode.has(childId)) { throw new CorruptStoreError("metadata inode has multiple directory owners"); } parentByInode.set(childId, { parentId: inode.id, name }); } } const retainedGenerations: Record<MetadataFile, bigint | null> = { a: null, b: null }; retainedGenerations[activeMetadataFile] = generation; const state: VfsState = { generation, nextInodeId, rootInodeId, extentSize, activeMetadataFile, retainedGenerations, basePayloadBytes: payloadLength, inodes, parentByInode, allocator: { totalExtents, ownedBy, available, quarantine }, }; validateState(state); return { state, payloadBytes: payloadLength, baseEnd, baseDigest: crc32(bytes) }; } catch (cause) { if (cause instanceof CorruptStoreError) throw cause; throw new CorruptStoreError(`metadata base is semantically invalid: ${String(cause)}`, { cause }); } } function writeExtentRuns(writer: BinaryWriter, runs: readonly ExtentRun[]): void { writer.u32(runs.length); for (const run of runs) { writer.u64(run.start); writer.u32(run.count); } } function readExtentRuns(reader: BinaryReader): ExtentRun[] { const runCount = readBoundedCount(reader, MAX_EXTENTS_PER_INODE, "extent run"); const runs: ExtentRun[] = []; let extentCount = 0; for (let index = 0; index < runCount; index += 1) { const start = reader.u64(); const count = reader.u32(); if (count === 0 || extentCount + count > MAX_EXTENTS_PER_INODE) { throw new CorruptStoreError("extent run count is invalid"); } extentCount += count; runs.push({ start, count }); } return runs; } const RECORD_CODES: Record<TxnRecord["kind"], number> = { createDirectories: 1, createFile: 2, removeFile: 3, changeMode: 4, changeTimes: 5, resizeFile: 6, removeDirectory: 7, rename: 8, reserveQuarantine: 9, createSymlink: 10, }; function encodeTxnRecord(record: TxnRecord): Uint8Array { const writer = new BinaryWriter(estimateTxnPayloadBytes(record)); writer.u8(RECORD_CODES[record.kind]); switch (record.kind) { case "createDirectories": writer.u32(record.entries.length); for (const entry of record.entries) { writer.u64(entry.parentId); writer.string(entry.name); writer.u64(entry.inodeId); writer.u32(entry.mode); writer.u64(entry.atimeMs); writer.u64(entry.mtimeMs); writer.u64(entry.ctimeMs); } break; case "createFile": writer.u64(record.parentId); writer.string(record.name); writer.u64(record.inodeId); writer.u32(record.mode); writer.u64(record.atimeMs); writer.u64(record.mtimeMs); writer.u64(record.ctimeMs); writer.u64(record.size); writeExtentRuns(writer, record.extents); break; case "removeFile": case "removeDirectory": writer.u64(record.parentId); writer.string(record.name); writer.u64(record.parentMtimeMs); writer.u64(record.parentCtimeMs); break; case "changeMode": writer.u64(record.inodeId); writer.u32(record.mode); writer.u64(record.ctimeMs); break; case "changeTimes": writer.u64(record.inodeId); writer.u64(record.atimeMs); writer.u64(record.mtimeMs); writer.u64(record.ctimeMs); break; case "resizeFile": writer.u8(record.operation === "truncate" ? 0 : 1); writer.u64(record.inodeId); writer.u64(record.size); writeExtentRuns(writer, record.allocated); writer.u64(record.mtimeMs); writer.u64(record.ctimeMs); break; case "rename": writer.u64(record.sourceParentId); writer.string(record.sourceName); writer.u64(record.destinationParentId); writer.string(record.destinationName); writer.u64(record.timestampMs); break; case "createSymlink": writer.u64(record.parentId); writer.string(record.name); writer.u64(record.inodeId); writer.u32(record.mode); writer.u64(record.atimeMs); writer.u64(record.mtimeMs); writer.u64(record.ctimeMs); writer.symlinkTarget(record.target); break; case "reserveQuarantine": writeExtentRuns(writer, record.extents); break; } return writer.finish(); } function decodeTxnRecord(bytes: Uint8Array): TxnRecord { const reader = new BinaryReader(bytes); const type = reader.u8(); let record: TxnRecord; switch (type) { case 1: { const count = readBoundedCount(reader, MAX_INODES, "created directory"); if (count === 0) throw new CorruptStoreError("directory transaction must not be empty"); const entries = []; for (let index = 0; index < count; index += 1) { entries.push({ parentId: reader.u64(), name: reader.string(), inodeId: reader.u64(), mode: reader.u32(), atimeMs: reader.u64(), mtimeMs: reader.u64(), ctimeMs: reader.u64(), }); } record = { kind: "createDirectories", entries }; break; } case 2: record = { kind: "createFile", parentId: reader.u64(), name: reader.string(), inodeId: reader.u64(), mode: reader.u32(), atimeMs: reader.u64(), mtimeMs: reader.u64(), ctimeMs: reader.u64(), size: reader.u64(), extents: readExtentRuns(reader), }; break; case 3: case 7: record = { kind: type === 3 ? "removeFile" : "removeDirectory", parentId: reader.u64(), name: reader.string(), parentMtimeMs: reader.u64(), parentCtimeMs: reader.u64(), }; break; case 4: record = { kind: "changeMode", inodeId: reader.u64(), mode: reader.u32(), ctimeMs: reader.u64() }; break; case 5: record = { kind: "changeTimes", inodeId: reader.u64(), atimeMs: reader.u64(), mtimeMs: reader.u64(), ctimeMs: reader.u64(), }; break; case 6: { const operation = reader.u8(); if (operation !== 0 && operation !== 1) throw new CorruptStoreError("resize operation is invalid"); record = { kind: "resizeFile", operation: operation === 0 ? "truncate" : "write", inodeId: reader.u64(), size: reader.u64(), allocated: readExtentRuns(reader), mtimeMs: reader.u64(), ctimeMs: reader.u64(), }; break; } case 8: record = { kind: "rename", sourceParentId: reader.u64(), sourceName: reader.string(), destinationParentId: reader.u64(), destinationName: reader.string(), timestampMs: reader.u64(), }; break; case 9: record = { kind: "reserveQuarantine", extents: readExtentRuns(reader) }; break; case 10: record = { kind: "createSymlink", parentId: reader.u64(), name: reader.string(), inodeId: reader.u64(), mode: reader.u32(), atimeMs: reader.u64(), mtimeMs: reader.u64(), ctimeMs: reader.u64(), target: reader.symlinkTarget(), }; break; default: throw new CorruptStoreError("transaction record type is invalid"); } reader.end(); return record; } export interface TxnFrame { generation: bigint; sequence: bigint; record: TxnRecord; } export interface TxnFrameHeader { generation: bigint; sequence: bigint; payloadLength: number; frameBytes: number; } export function txnFrameBytes(record: TxnRecord): number { const payloadBytes = estimateTxnPayloadBytes(record); if (payloadBytes > MAX_FRAME_PAYLOAD_BYTES) { throw new StoreLimitError(`transaction payload exceeds ${MAX_FRAME_PAYLOAD_BYTES} bytes`); } return TXN_FRAME_HEADER_BYTES + payloadBytes; } export function inspectTxnFrameHeader(header: Uint8Array): TxnFrameHeader { if (header.byteLength !== TXN_FRAME_HEADER_BYTES) { throw new CorruptStoreError("transaction frame header has the wrong length"); } const view = new DataView(header.buffer, header.byteOffset, header.byteLength); requireMagic(header, FRAME_MAGIC, "transaction frame"); requireZero(header, 36, 40, "transaction frame"); requireZero(header, 44, 48, "transaction frame"); const payloadLength = view.getUint32(32, true); if (payloadLength > MAX_FRAME_PAYLOAD_BYTES) { throw new CorruptStoreError("transaction payload declaration exceeds the frame limit"); } if (view.getUint32(8, true) !== FORMAT_VERSION || view.getUint32(12, true) !== LIMITS_PROFILE_VERSION) { throw new CorruptStoreError("transaction frame format is invalid"); } return { generation: view.getBigUint64(16, true), sequence: view.getBigUint64(24, true), payloadLength, frameBytes: TXN_FRAME_HEADER_BYTES + payloadLength, }; } export function encodeTxnFrame(frame: TxnFrame): Uint8Array { const payload = encodeTxnRecord(frame.record); if (payload.byteLength > MAX_FRAME_PAYLOAD_BYTES) { throw new StoreLimitError(`transaction payload exceeds ${MAX_FRAME_PAYLOAD_BYTES} bytes`); } const bytes = new Uint8Array(TXN_FRAME_HEADER_BYTES + payload.byteLength); const view = new DataView(bytes.buffer); bytes.set(FRAME_MAGIC, 0); view.setUint32(8, FORMAT_VERSION, true); view.setUint32(12, LIMITS_PROFILE_VERSION, true); view.setBigUint64(16, checkedU64(frame.generation, "frame generation"), true); view.setBigUint64(24, checkedU64(frame.sequence, "frame sequence"), true); view.setUint32(32, payload.byteLength, true); bytes.set(payload, TXN_FRAME_HEADER_BYTES); view.setUint32(FRAME_CHECKSUM_OFFSET, crc32WithZeroedRange(bytes, FRAME_CHECKSUM_OFFSET, 4), true); return bytes; } export function decodeTxnFrame(bytes: Uint8Array): TxnFrame { if (bytes.byteLength < TXN_FRAME_HEADER_BYTES) throw new CorruptStoreError("transaction frame is truncated"); const inspected = inspectTxnFrameHeader(bytes.subarray(0, TXN_FRAME_HEADER_BYTES)); if (bytes.byteLength !== inspected.frameBytes) { throw new CorruptStoreError("transaction frame length is inconsistent"); } requireChecksum(bytes, FRAME_CHECKSUM_OFFSET, "transaction frame"); return { generation: inspected.generation, sequence: inspected.sequence, record: decodeTxnRecord(bytes.subarray(TXN_FRAME_HEADER_BYTES)), }; } export interface ActivationRecord { sequence: bigint; metadataFile: MetadataFile; generation: bigint; baseEnd: bigint; baseDigest: number; } export function encodeActivationSlot(record: ActivationRecord): Uint8Array { if (record.sequence === 0n || record.generation === 0n || record.baseEnd < BigInt(METADATA_HEADER_BYTES)) { throw new StoreLimitError("activation identity values are outside the encodable domain"); } const bytes = new Uint8Array(ACTIVATION_SLOT_BYTES); const view = new DataView(bytes.buffer); bytes.set(ACTIVATION_MAGIC, 0); view.setUint32(8, FORMAT_VERSION, true); view.setUint32(12, LIMITS_PROFILE_VERSION, true); view.setBigUint64(16, checkedU64(record.sequence, "activation sequence"), true); view.setUint8(24, metadataFileCode(record.metadataFile)); view.setBigUint64(32, checkedU64(record.generation, "activation generation"), true); view.setBigUint64(40, checkedU64(record.baseEnd, "activation base end"), true); if (!Number.isSafeInteger(record.baseDigest) || record.baseDigest < 0 || record.baseDigest > 0xffff_ffff) { throw new StoreLimitError("activation base digest is outside the unsigned 32-bit range"); } view.setUint32(48, record.baseDigest, true); view.setUint32(ACTIVATION_CHECKSUM_OFFSET, crc32WithZeroedRange(bytes, ACTIVATION_CHECKSUM_OFFSET, 4), true); return bytes; } export function decodeActivationSlot(bytes: Uint8Array): ActivationRecord { if (bytes.byteLength !== ACTIVATION_SLOT_BYTES) { throw new CorruptStoreError("activation slot has the wrong length"); } requireMagic(bytes, ACTIVATION_MAGIC, "activation slot"); requireZero(bytes, 25, 32, "activation slot"); requireZero(bytes, 56, bytes.byteLength, "activation slot"); requireChecksum(bytes, ACTIVATION_CHECKSUM_OFFSET, "activation slot"); const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); requireCurrentIdentity(view.getUint32(8, true), view.getUint32(12, true)); const sequence = view.getBigUint64(16, true); const generation = view.getBigUint64(32, true); const baseEnd = view.getBigUint64(40, true); if (sequence === 0n || generation === 0n || baseEnd < BigInt(METADATA_HEADER_BYTES)) { throw new CorruptStoreError("activation slot contains invalid identity values"); } return { sequence, metadataFile: decodeMetadataFile(view.getUint8(24)), generation, baseEnd, baseDigest: view.getUint32(48, true), }; } export function tryDecodeActivationSlot(bytes: Uint8Array): ActivationRecord | null { try { return decodeActivationSlot(bytes); } catch (cause) { if (cause instanceof CorruptStoreError) return null; throw cause; } } export function selectActivation(leftBytes: Uint8Array, rightBytes: Uint8Array): ActivationRecord { return selectActivationPair(leftBytes, rightBytes).selected; } export interface ActivationPairSelection { selected: ActivationRecord; other: ActivationRecord | null; } export function selectActivationPair(leftBytes: Uint8Array, rightBytes: Uint8Array): ActivationPairSelection { const left = tryDecodeActivationSlot(leftBytes); const right = tryDecodeActivationSlot(rightBytes); if (left === null && right === null) { throw new CorruptStoreError("both activation slots are invalid"); } if (left === null) return { selected: right!, other: null }; if (right === null) return { selected: left, other: null }; const older = left.sequence < right.sequence ? left : right; const newer = older === left ? right : left; if (newer.sequence !== older.sequence + 1n || newer.metadataFile === older.metadataFile) { throw new CorruptStoreError("activation slots do not form one exact alternating progression"); } return { selected: newer, other: older }; } export function metadataBaseDigest(bytes: Uint8Array): number { return crc32(bytes); }