@pgxsinkit/pgwasm
Version:
1,700 lines • 60.6 kB
text/typescript
import { FsError, StoreLimitError } from "./errors";
import {
MAX_EXTENTS_PER_INODE,
MAX_INODES,
MAX_FRAME_PAYLOAD_BYTES,
MAX_METADATA_BASE_WRITER_BYTES,
MAX_PATH_BYTES,
MAX_PATH_DEPTH,
MAX_SYMLINK_HOPS,
MAX_TOTAL_EXTENTS,
MAX_U64,
checkedAdd,
checkedU64,
validateExtentSize,
} from "./limits";
import { encodedUtf8Length, parsePath, validatePathComponent, validateSymlinkTarget } from "./path";
export type InodeId = bigint;
export type ExtentId = bigint;
export type MetadataFile = "a" | "b";
export function otherMetadataFile(file: MetadataFile): MetadataFile {
return file === "a" ? "b" : "a";
}
interface BaseInode {
id: InodeId;
mode: number;
atimeMs: bigint;
mtimeMs: bigint;
ctimeMs: bigint;
}
export interface DirectoryInode extends BaseInode {
kind: "directory";
children: Map<string, InodeId>;
}
export interface FileInode extends BaseInode {
kind: "file";
size: bigint;
extents: ExtentId[];
}
/**
* A symbolic link. It owns no extents and no children — the whole entry IS its absolute `target`,
* which path resolution restarts the walk at. See {@link validateSymlinkTarget} for why relative
* targets are refused.
*/
export interface SymlinkInode extends BaseInode {
kind: "symlink";
target: string;
}
export type Inode = DirectoryInode | FileInode | SymlinkInode;
export interface OrphanRecord {
inodeId: InodeId;
mode: number;
atimeMs: bigint;
mtimeMs: bigint;
ctimeMs: bigint;
size: bigint;
extents: ExtentId[];
}
export function makeOrphanRecord(inode: FileInode): OrphanRecord {
return {
inodeId: inode.id,
mode: inode.mode,
atimeMs: inode.atimeMs,
mtimeMs: inode.mtimeMs,
ctimeMs: inode.ctimeMs,
size: inode.size,
extents: [...inode.extents],
};
}
export class IndexedExtentSet {
readonly #items: ExtentId[] = [];
readonly #indexes = new Map<ExtentId, number>();
get size(): number {
return this.#items.length;
}
has(extentId: ExtentId): boolean {
return this.#indexes.has(extentId);
}
add(extentId: ExtentId): void {
if (this.#indexes.has(extentId)) {
return;
}
this.#indexes.set(extentId, this.#items.length);
this.#items.push(extentId);
}
delete(extentId: ExtentId): boolean {
const index = this.#indexes.get(extentId);
if (index === undefined) {
return false;
}
const last = this.#items.pop();
this.#indexes.delete(extentId);
if (last !== undefined && index < this.#items.length) {
this.#items[index] = last;
this.#indexes.set(last, index);
}
return true;
}
values(): readonly ExtentId[] {
return this.#items;
}
peekLast(count: number): ExtentId[] {
if (!Number.isSafeInteger(count) || count < 0 || count > this.#items.length) {
throw new StoreLimitError("invalid available-extent selection count");
}
return this.#items.slice(this.#items.length - count).reverse();
}
clone(): IndexedExtentSet {
const copy = new IndexedExtentSet();
for (const extentId of this.#items) {
copy.add(extentId);
}
return copy;
}
}
export interface AllocatorState {
totalExtents: bigint;
ownedBy: Map<ExtentId, InodeId>;
available: IndexedExtentSet;
quarantine: Map<ExtentId, bigint | null>;
}
export interface VfsState {
generation: bigint;
nextInodeId: bigint;
rootInodeId: bigint;
extentSize: number;
activeMetadataFile: MetadataFile;
retainedGenerations: Record<MetadataFile, bigint | null>;
basePayloadBytes: number;
inodes: Map<InodeId, Inode>;
parentByInode: Map<InodeId, { parentId: InodeId; name: string }>;
allocator: AllocatorState;
}
export interface CreateDirectoryChoice {
parentId: InodeId;
name: string;
inodeId: InodeId;
mode: number;
atimeMs: bigint;
mtimeMs: bigint;
ctimeMs: bigint;
}
export interface ExtentRun {
start: ExtentId;
count: number;
}
export type TxnRecord =
| {
kind: "createDirectories";
entries: readonly CreateDirectoryChoice[];
}
| {
kind: "createFile";
parentId: InodeId;
name: string;
inodeId: InodeId;
mode: number;
atimeMs: bigint;
mtimeMs: bigint;
ctimeMs: bigint;
size: bigint;
extents: readonly ExtentRun[];
}
| {
kind: "removeFile";
parentId: InodeId;
name: string;
parentMtimeMs: bigint;
parentCtimeMs: bigint;
}
| {
kind: "changeMode";
inodeId: InodeId;
mode: number;
ctimeMs: bigint;
}
| {
kind: "changeTimes";
inodeId: InodeId;
atimeMs: bigint;
mtimeMs: bigint;
ctimeMs: bigint;
}
| {
kind: "resizeFile";
operation: "truncate" | "write";
inodeId: InodeId;
size: bigint;
allocated: readonly ExtentRun[];
mtimeMs: bigint;
ctimeMs: bigint;
}
| {
kind: "removeDirectory";
parentId: InodeId;
name: string;
parentMtimeMs: bigint;
parentCtimeMs: bigint;
}
| {
kind: "rename";
sourceParentId: InodeId;
sourceName: string;
destinationParentId: InodeId;
destinationName: string;
timestampMs: bigint;
}
| {
kind: "createSymlink";
parentId: InodeId;
name: string;
inodeId: InodeId;
mode: number;
atimeMs: bigint;
mtimeMs: bigint;
ctimeMs: bigint;
target: string;
}
| {
kind: "reserveQuarantine";
extents: readonly ExtentRun[];
};
export interface TxnPlan<RecordType extends TxnRecord = TxnRecord> {
record: RecordType;
}
export interface PreflightLimits {
maxFramePayloadBytes: number;
maxBasePayloadBytes: number;
}
const BASE_STATE_FIXED_BYTES = 20;
const DIRECTORY_INODE_FIXED_BYTES = 41;
const FILE_INODE_FIXED_BYTES = 49;
/** The common inode header plus the target's `u16` length prefix; the target bytes are added on. */
const SYMLINK_INODE_FIXED_BYTES = 39;
const CHILD_FIXED_BYTES = 10;
/** The mode a symbolic link carries: `S_IFLNK | 0777`, the only mode POSIX gives a link. */
export const SYMLINK_MODE = 0o120777;
function childBaseBytes(name: string): number {
return CHILD_FIXED_BYTES + encodedUtf8Length(name);
}
function inodeBaseBytes(inode: Inode): number {
if (inode.kind === "file") {
return FILE_INODE_FIXED_BYTES + inode.extents.length * 8;
}
if (inode.kind === "symlink") {
return SYMLINK_INODE_FIXED_BYTES + encodedUtf8Length(inode.target);
}
let bytes = DIRECTORY_INODE_FIXED_BYTES;
for (const name of inode.children.keys()) {
bytes += childBaseBytes(name);
}
return bytes;
}
export function estimateMetadataBasePayloadBytes(state: VfsState): number {
let bytes = BASE_STATE_FIXED_BYTES;
for (const inode of state.inodes.values()) {
bytes += inodeBaseBytes(inode);
}
bytes += state.allocator.available.size * 8;
bytes += state.allocator.quarantine.size * 16;
return bytes;
}
function extentRunsPayloadBytes(runs: readonly ExtentRun[]): number {
return 4 + runs.length * 12;
}
export function estimateTxnPayloadBytes(record: TxnRecord): number {
switch (record.kind) {
case "createDirectories":
return 5 + record.entries.reduce((bytes, entry) => bytes + 8 + 2 + encodedUtf8Length(entry.name) + 8 + 4 + 24, 0);
case "createFile":
return 1 + 8 + 2 + encodedUtf8Length(record.name) + 8 + 4 + 24 + 8 + extentRunsPayloadBytes(record.extents);
case "removeFile":
case "removeDirectory":
return 1 + 8 + 2 + encodedUtf8Length(record.name) + 16;
case "changeMode":
return 1 + 8 + 4 + 8;
case "changeTimes":
return 1 + 8 + 24;
case "resizeFile":
return 1 + 1 + 8 + 8 + extentRunsPayloadBytes(record.allocated) + 16;
case "rename":
return 1 + 8 + 2 + encodedUtf8Length(record.sourceName) + 8 + 2 + encodedUtf8Length(record.destinationName) + 8;
case "createSymlink":
return 1 + 8 + 2 + encodedUtf8Length(record.name) + 8 + 4 + 24 + 2 + encodedUtf8Length(record.target);
case "reserveQuarantine":
return 1 + extentRunsPayloadBytes(record.extents);
}
}
function selectedExtentsAlreadyAvailable(state: VfsState, runs: readonly ExtentRun[]): number {
let count = 0;
for (const extentId of expandExtentRuns(runs, MAX_EXTENTS_PER_INODE)) {
if (extentId < state.allocator.totalExtents) {
count += 1;
}
}
return count;
}
function projectedBaseDelta(state: VfsState, record: TxnRecord): number {
switch (record.kind) {
case "createDirectories":
return record.entries.reduce(
(bytes, entry) => bytes + DIRECTORY_INODE_FIXED_BYTES + childBaseBytes(entry.name),
0,
);
case "createFile": {
const extentCount = expandExtentRuns(record.extents, MAX_EXTENTS_PER_INODE).length;
const reused = selectedExtentsAlreadyAvailable(state, record.extents);
return FILE_INODE_FIXED_BYTES + extentCount * 8 + childBaseBytes(record.name) - reused * 8;
}
case "removeFile": {
const parent = directoryById(state, record.parentId);
const inodeId = parent.children.get(record.name);
const inode = inodeId === undefined ? undefined : state.inodes.get(inodeId);
if (inode === undefined || inode.kind === "directory") {
throw new FsError("ENOENT", "file does not exist");
}
const quarantined = inode.kind === "file" ? inode.extents.length * 16 : 0;
return -inodeBaseBytes(inode) - childBaseBytes(record.name) + quarantined;
}
case "changeMode":
case "changeTimes":
return 0;
case "resizeFile": {
const inode = fileById(state, record.inodeId);
const required = extentCountForSize(record.size, state.extentSize);
const allocatedCount = Math.max(0, required - inode.extents.length);
const releasedCount = Math.max(0, inode.extents.length - required);
const reused = selectedExtentsAlreadyAvailable(state, record.allocated);
return (allocatedCount - releasedCount) * 8 - reused * 8 + releasedCount * 16;
}
case "removeDirectory": {
const parent = directoryById(state, record.parentId);
const inodeId = parent.children.get(record.name);
const inode = inodeId === undefined ? undefined : state.inodes.get(inodeId);
if (inode?.kind !== "directory") {
throw new FsError("ENOENT", "directory does not exist");
}
return -inodeBaseBytes(inode) - childBaseBytes(record.name);
}
case "rename": {
const sourceParent = directoryById(state, record.sourceParentId);
const destinationParent = directoryById(state, record.destinationParentId);
const destinationId = destinationParent.children.get(record.destinationName);
const destination = destinationId === undefined ? undefined : state.inodes.get(destinationId);
let delta = -childBaseBytes(record.sourceName);
if (destination === undefined) {
delta += childBaseBytes(record.destinationName);
} else {
delta -= inodeBaseBytes(destination);
if (destination.kind === "file") {
delta += destination.extents.length * 16;
}
}
const sourceId = sourceParent.children.get(record.sourceName);
if (sourceId === undefined) {
throw new FsError("ENOENT", "rename source does not exist");
}
return delta;
}
case "createSymlink":
return SYMLINK_INODE_FIXED_BYTES + encodedUtf8Length(record.target) + childBaseBytes(record.name);
case "reserveQuarantine": {
const count = expandExtentRuns(record.extents, MAX_EXTENTS_PER_INODE).length;
const reused = selectedExtentsAlreadyAvailable(state, record.extents);
return count * 16 - reused * 8;
}
}
}
export function preflightTxn(
state: VfsState,
record: TxnRecord,
limits: PreflightLimits = {
maxFramePayloadBytes: MAX_FRAME_PAYLOAD_BYTES,
maxBasePayloadBytes: MAX_METADATA_BASE_WRITER_BYTES,
},
): number {
const payloadBytes = estimateTxnPayloadBytes(record);
if (payloadBytes > limits.maxFramePayloadBytes) {
throw new StoreLimitError(`transaction payload would exceed ${limits.maxFramePayloadBytes} bytes`);
}
const projected = state.basePayloadBytes + projectedBaseDelta(state, record);
if (!Number.isSafeInteger(projected) || projected < 0 || projected > limits.maxBasePayloadBytes) {
throw new StoreLimitError(`metadata base would exceed ${limits.maxBasePayloadBytes} bytes`);
}
return projected;
}
const PREPARED_TXN_PROJECTION = Symbol("prepared transaction projection");
export interface PreparedTxnProjection {
readonly projectedBasePayloadBytes: number;
readonly state: VfsState;
readonly record: TxnRecord;
readonly [PREPARED_TXN_PROJECTION]: true;
}
/** @internal Bind one projected size to the exact live state and record it preflighted. */
export function prepareTxnProjection(state: VfsState, record: TxnRecord): PreparedTxnProjection {
return {
projectedBasePayloadBytes: preflightTxn(state, record),
state,
record,
[PREPARED_TXN_PROJECTION]: true,
};
}
function checkedPlan<RecordType extends TxnRecord>(state: VfsState, record: RecordType): TxnPlan<RecordType> {
preflightTxn(state, record);
return { record };
}
export function createInitialState(extentSize: number): VfsState {
validateExtentSize(extentSize);
const root: DirectoryInode = {
id: 1n,
kind: "directory",
mode: 0o40777,
atimeMs: 0n,
mtimeMs: 0n,
ctimeMs: 0n,
children: new Map(),
};
return {
generation: 1n,
nextInodeId: 2n,
rootInodeId: root.id,
extentSize,
activeMetadataFile: "a",
retainedGenerations: { a: 1n, b: null },
basePayloadBytes: BASE_STATE_FIXED_BYTES + DIRECTORY_INODE_FIXED_BYTES,
inodes: new Map([[root.id, root]]),
parentByInode: new Map(),
allocator: {
totalExtents: 0n,
ownedBy: new Map(),
available: new IndexedExtentSet(),
quarantine: new Map(),
},
};
}
function validateMode(mode: number): number {
if (!Number.isSafeInteger(mode) || mode < 0 || mode > 0xffff_ffff) {
throw new FsError("EINVAL", "mode must be an unsigned 32-bit integer");
}
return mode;
}
function validateTimestamp(timestamp: bigint): bigint {
return checkedU64(timestamp, "timestamp");
}
function advanceNextInodeId(inodeId: InodeId): InodeId {
if (inodeId === MAX_U64) {
throw new StoreLimitError("next inode ID is exhausted; the store must be recreated");
}
return checkedAdd(inodeId, 1n, "next inode ID");
}
function directoryById(state: VfsState, inodeId: InodeId): DirectoryInode {
const inode = state.inodes.get(inodeId);
if (inode === undefined) {
throw new FsError("ENOENT", "directory does not exist");
}
if (inode.kind !== "directory") {
throw new FsError("ENOTDIR", "path component is not a directory");
}
return inode;
}
function inodeById(state: VfsState, inodeId: InodeId): Inode {
const inode = state.inodes.get(inodeId);
if (inode === undefined) {
throw new FsError("ENOENT", "inode does not exist");
}
return inode;
}
function fileById(state: VfsState, inodeId: InodeId): FileInode {
const inode = inodeById(state, inodeId);
if (inode.kind !== "file") {
throw new FsError("EISDIR", "inode is not a file");
}
return inode;
}
function joinPath(base: string, rest: readonly string[]): string {
if (rest.length === 0) return base;
return base === "/" ? `/${rest.join("/")}` : `${base}/${rest.join("/")}`;
}
function parentAndName(state: VfsState, path: string): { parent: DirectoryInode; name: string } {
const components = parsePath(path);
const name = components.pop();
if (name === undefined) {
throw new FsError("EINVAL", "operation is not permitted on root", { path });
}
let parent = directoryById(state, state.rootInodeId);
for (const component of components) {
const childId = parent.children.get(component);
if (childId === undefined) {
throw new FsError("ENOENT", "parent directory does not exist", { path });
}
parent = directoryById(state, childId);
}
return { parent, name };
}
function extentCountForSize(size: bigint, extentSize: number): number {
checkedU64(size, "file size");
if (size === 0n) {
return 0;
}
const count = (size + BigInt(extentSize) - 1n) / BigInt(extentSize);
if (count > BigInt(MAX_EXTENTS_PER_INODE)) {
throw new StoreLimitError(`file would exceed ${MAX_EXTENTS_PER_INODE} extents`);
}
return Number(count);
}
export function toExtentRuns(extents: readonly ExtentId[]): ExtentRun[] {
const runs: ExtentRun[] = [];
for (const extentId of extents) {
checkedU64(extentId, "extent ID");
const last = runs.at(-1);
if (last !== undefined && last.start + BigInt(last.count) === extentId && last.count < 0xffff_ffff) {
last.count += 1;
} else {
runs.push({ start: extentId, count: 1 });
}
}
return runs;
}
export function expandExtentRuns(runs: readonly ExtentRun[], maximum: number): ExtentId[] {
const extents: ExtentId[] = [];
for (const run of runs) {
checkedU64(run.start, "extent run start");
if (!Number.isSafeInteger(run.count) || run.count <= 0) {
throw new FsError("EINVAL", "extent run count must be a positive safe integer");
}
if (extents.length + run.count > maximum) {
throw new StoreLimitError(`extent list exceeds ${maximum} entries`);
}
for (let offset = 0; offset < run.count; offset += 1) {
extents.push(checkedAdd(run.start, BigInt(offset), "extent ID"));
}
}
return extents;
}
function chooseExtents(state: VfsState, count: number): ExtentId[] {
if (count > MAX_EXTENTS_PER_INODE) {
throw new StoreLimitError(`allocation exceeds ${MAX_EXTENTS_PER_INODE} extents`);
}
const availableCount = Math.min(count, state.allocator.available.size);
const chosen = state.allocator.available.peekLast(availableCount);
const freshCount = count - availableCount;
if (state.allocator.totalExtents + BigInt(freshCount) > BigInt(MAX_TOTAL_EXTENTS)) {
throw new StoreLimitError(`total extent count would exceed ${MAX_TOTAL_EXTENTS}`);
}
for (let offset = 0; offset < freshCount; offset += 1) {
chosen.push(state.allocator.totalExtents + BigInt(offset));
}
return chosen;
}
export function getInodeAtPath(state: VfsState, path: string): Inode {
const components = parsePath(path);
let inode = state.inodes.get(state.rootInodeId);
if (inode === undefined) {
throw new StoreLimitError("root inode is missing");
}
for (const component of components) {
if (inode.kind !== "directory") {
throw new FsError("ENOTDIR", "path component is not a directory", { path });
}
const childId = inode.children.get(component);
if (childId === undefined) {
throw new FsError("ENOENT", "path does not exist", { path });
}
const child = state.inodes.get(childId);
if (child === undefined) {
throw new StoreLimitError("directory entry references a missing inode");
}
inode = child;
}
return inode;
}
/**
* Resolve every symbolic link on `path` and report the link-free absolute path it names.
*
* `follow` governs the FINAL component only — the POSIX `stat`/`lstat` split. Every INTERMEDIATE
* link is always followed, because a path can only continue through the thing a link points at.
*
* A component that does not exist, or one whose parent turned out not to be a directory, stops the
* walk and the remainder is carried through verbatim: resolution answers "which path does this
* name", not "does it exist". The operation that follows reports `ENOENT`/`ENOTDIR` itself, or
* creates the tail — which is exactly what an `open(O_CREAT)` through a symlinked directory needs.
*
* Targets are absolute, so a hop restarts the walk at the root. `MAX_SYMLINK_HOPS` hops is `ELOOP`,
* and each expansion is re-validated by `parsePath`, so a chain that outgrows the store's depth or
* byte limits is rejected here rather than in a planner.
*/
export function resolveLinks(state: VfsState, path: string, follow: boolean): string {
const root = state.inodes.get(state.rootInodeId);
if (root === undefined) {
throw new StoreLimitError("root inode is missing");
}
let pending = parsePath(path);
let index = 0;
let current: Inode = root;
const resolved: string[] = [];
let hops = 0;
while (index < pending.length) {
const name = pending[index]!;
if (current.kind !== "directory") break;
const childId = current.children.get(name);
if (childId === undefined) break;
const child = state.inodes.get(childId);
if (child === undefined) {
throw new StoreLimitError("directory entry references a missing inode");
}
if (child.kind === "symlink" && (follow || index < pending.length - 1)) {
hops += 1;
if (hops > MAX_SYMLINK_HOPS) {
throw new FsError("ELOOP", `path traverses more than ${MAX_SYMLINK_HOPS} symbolic links`, { path });
}
pending = parsePath(joinPath(child.target, pending.slice(index + 1)));
index = 0;
current = root;
resolved.length = 0;
continue;
}
resolved.push(name);
current = child;
index += 1;
}
return joinPath(`/${resolved.join("/")}`, pending.slice(index));
}
export function planMkdir(
state: VfsState,
path: string,
options: { recursive?: boolean; mode?: number; nowMs: bigint },
): TxnPlan<Extract<TxnRecord, { kind: "createDirectories" }>> {
const components = parsePath(path);
if (components.length === 0) {
throw new FsError("EEXIST", "root directory already exists", { operation: "mkdir", path });
}
const mode = validateMode(options.mode ?? 0o40777);
const nowMs = validateTimestamp(options.nowMs);
const entries: CreateDirectoryChoice[] = [];
let parent = directoryById(state, state.rootInodeId);
let nextInodeId = state.nextInodeId;
for (let index = 0; index < components.length; index += 1) {
const name = components[index]!;
const existingId = parent.children.get(name);
if (existingId !== undefined) {
const existing = state.inodes.get(existingId);
if (existing?.kind !== "directory") {
throw new FsError("ENOTDIR", "path component is not a directory", { operation: "mkdir", path });
}
if (index === components.length - 1) {
throw new FsError("EEXIST", "path already exists", { operation: "mkdir", path });
}
parent = existing;
continue;
}
if (!options.recursive && index !== components.length - 1) {
throw new FsError("ENOENT", "parent directory does not exist", { operation: "mkdir", path });
}
if (state.inodes.size + entries.length >= MAX_INODES) {
throw new StoreLimitError(`inode count would exceed ${MAX_INODES}`);
}
checkedU64(nextInodeId, "next inode ID");
const followingInodeId = advanceNextInodeId(nextInodeId);
const entry: CreateDirectoryChoice = {
parentId: parent.id,
name,
inodeId: nextInodeId,
mode,
atimeMs: nowMs,
mtimeMs: nowMs,
ctimeMs: nowMs,
};
entries.push(entry);
parent = { ...entry, kind: "directory", id: entry.inodeId, children: new Map() };
nextInodeId = followingInodeId;
}
if (entries.length === 0) {
throw new FsError("EEXIST", "path already exists", { operation: "mkdir", path });
}
return checkedPlan(state, { kind: "createDirectories", entries });
}
export function planCreateFile(
state: VfsState,
path: string,
options: { mode?: number; nowMs: bigint; size?: bigint },
): TxnPlan<Extract<TxnRecord, { kind: "createFile" }>> {
const { parent, name } = parentAndName(state, path);
if (parent.children.has(name)) {
throw new FsError("EEXIST", "path already exists", { operation: "writeFile", path });
}
if (state.inodes.size >= MAX_INODES) {
throw new StoreLimitError(`inode count would exceed ${MAX_INODES}`);
}
const inodeId = checkedU64(state.nextInodeId, "next inode ID");
advanceNextInodeId(inodeId);
const mode = validateMode(options.mode ?? 0o100666);
const nowMs = validateTimestamp(options.nowMs);
const size = checkedU64(options.size ?? 0n, "file size");
const extents = toExtentRuns(chooseExtents(state, extentCountForSize(size, state.extentSize)));
return checkedPlan(state, {
kind: "createFile",
parentId: parent.id,
name,
inodeId,
mode,
atimeMs: nowMs,
mtimeMs: nowMs,
ctimeMs: nowMs,
size,
extents,
});
}
/** A symbolic link. Nothing about the TARGET is consulted — the link entry is the whole record. */
export function planSymlink(
state: VfsState,
path: string,
target: string,
options: { mode?: number; nowMs: bigint },
): TxnPlan<Extract<TxnRecord, { kind: "createSymlink" }>> {
const { parent, name } = parentAndName(state, path);
if (parent.children.has(name)) {
throw new FsError("EEXIST", "path already exists", { operation: "symlink", path });
}
if (state.inodes.size >= MAX_INODES) {
throw new StoreLimitError(`inode count would exceed ${MAX_INODES}`);
}
validateSymlinkTarget(target);
const inodeId = checkedU64(state.nextInodeId, "next inode ID");
advanceNextInodeId(inodeId);
const nowMs = validateTimestamp(options.nowMs);
return checkedPlan(state, {
kind: "createSymlink",
parentId: parent.id,
name,
inodeId,
mode: validateMode(options.mode ?? SYMLINK_MODE),
atimeMs: nowMs,
mtimeMs: nowMs,
ctimeMs: nowMs,
target,
});
}
/** Removes a FILE or a SYMBOLIC LINK. A link is unlinked as itself; its target is never touched. */
export function planUnlink(
state: VfsState,
path: string,
nowMs: bigint,
): TxnPlan<Extract<TxnRecord, { kind: "removeFile" }>> {
const { parent, name } = parentAndName(state, path);
const inodeId = parent.children.get(name);
const inode = inodeId === undefined ? undefined : state.inodes.get(inodeId);
if (inode === undefined) {
throw new FsError("ENOENT", "path does not exist", { operation: "unlink", path });
}
if (inode.kind === "directory") {
throw new FsError("EISDIR", "path is a directory", { operation: "unlink", path });
}
const timestamp = validateTimestamp(nowMs);
return checkedPlan(state, {
kind: "removeFile",
parentId: parent.id,
name,
parentMtimeMs: timestamp,
parentCtimeMs: timestamp,
});
}
export function planChmod(
state: VfsState,
path: string,
mode: number,
nowMs: bigint,
): TxnPlan<Extract<TxnRecord, { kind: "changeMode" }>> {
const inode = getInodeAtPath(state, path);
return checkedPlan(state, {
kind: "changeMode",
inodeId: inode.id,
mode: validateMode(mode),
ctimeMs: validateTimestamp(nowMs),
});
}
export function planUtimes(
state: VfsState,
path: string,
atimeMs: bigint,
mtimeMs: bigint,
ctimeMs: bigint,
): TxnPlan<Extract<TxnRecord, { kind: "changeTimes" }>> {
const inode = getInodeAtPath(state, path);
return checkedPlan(state, {
kind: "changeTimes",
inodeId: inode.id,
atimeMs: validateTimestamp(atimeMs),
mtimeMs: validateTimestamp(mtimeMs),
ctimeMs: validateTimestamp(ctimeMs),
});
}
export function planResizeFile(
state: VfsState,
path: string,
size: bigint,
nowMs: bigint,
operation: "truncate" | "write",
): TxnPlan<Extract<TxnRecord, { kind: "resizeFile" }>> {
const inode = getInodeAtPath(state, path);
if (inode.kind !== "file") {
throw new FsError("EISDIR", "path is a directory", { operation, path });
}
return planResizeFileForInode(state, inode, size, nowMs, operation);
}
/** @internal Inode-first planner for descriptor operations that already resolved the file. */
export function planResizeFileForInode(
state: VfsState,
inode: FileInode,
size: bigint,
nowMs: bigint,
operation: "truncate" | "write",
): TxnPlan<Extract<TxnRecord, { kind: "resizeFile" }>> {
if (state.inodes.get(inode.id) !== inode) {
throw new StoreLimitError("resize planner requires the current file inode");
}
const checkedSize = checkedU64(size, "file size");
const required = extentCountForSize(checkedSize, state.extentSize);
const additional = Math.max(0, required - inode.extents.length);
const timestamp = validateTimestamp(nowMs);
return checkedPlan(state, {
kind: "resizeFile",
operation,
inodeId: inode.id,
size: checkedSize,
allocated: toExtentRuns(chooseExtents(state, additional)),
mtimeMs: timestamp,
ctimeMs: timestamp,
});
}
export function planRmdir(
state: VfsState,
path: string,
nowMs: bigint,
): TxnPlan<Extract<TxnRecord, { kind: "removeDirectory" }>> {
const { parent, name } = parentAndName(state, path);
const inodeId = parent.children.get(name);
const inode = inodeId === undefined ? undefined : state.inodes.get(inodeId);
if (inode === undefined) {
throw new FsError("ENOENT", "directory does not exist", { operation: "rmdir", path });
}
if (inode.kind !== "directory") {
throw new FsError("ENOTDIR", "path is not a directory", { operation: "rmdir", path });
}
if (inode.children.size !== 0) {
throw new FsError("ENOTEMPTY", "directory is not empty", { operation: "rmdir", path });
}
const timestamp = validateTimestamp(nowMs);
return checkedPlan(state, {
kind: "removeDirectory",
parentId: parent.id,
name,
parentMtimeMs: timestamp,
parentCtimeMs: timestamp,
});
}
function directoryContains(state: VfsState, directory: DirectoryInode, candidateId: InodeId): boolean {
if (directory.id === candidateId) {
return true;
}
for (const childId of directory.children.values()) {
const child = inodeById(state, childId);
if (child.kind === "directory" && directoryContains(state, child, candidateId)) {
return true;
}
}
return false;
}
interface PathMetrics {
depth: number;
bytes: number;
}
function childPathMetrics(parent: PathMetrics, name: string): PathMetrics {
return {
depth: parent.depth + 1,
bytes: parent.bytes + (parent.depth === 0 ? 0 : 1) + encodedUtf8Length(name),
};
}
function findPathMetrics(state: VfsState, targetId: InodeId): PathMetrics {
let currentId = targetId;
let depth = 0;
let componentBytes = 0;
const visited = new Set<InodeId>();
while (currentId !== state.rootInodeId) {
if (visited.has(currentId)) {
throw new StoreLimitError("inode parent index contains a cycle");
}
visited.add(currentId);
const entry = state.parentByInode.get(currentId);
if (entry === undefined) {
throw new StoreLimitError("inode parent index does not reach root");
}
depth += 1;
componentBytes += encodedUtf8Length(entry.name);
currentId = entry.parentId;
}
return { depth, bytes: 1 + componentBytes + Math.max(0, depth - 1) };
}
function validateRenamedSubtreeBounds(
state: VfsState,
source: Inode,
destinationParent: DirectoryInode,
destinationName: string,
): void {
const visit = (inode: Inode, metrics: PathMetrics): void => {
if (metrics.depth > MAX_PATH_DEPTH) {
throw new FsError("EINVAL", `rename would exceed ${MAX_PATH_DEPTH} path components`);
}
if (metrics.bytes > MAX_PATH_BYTES) {
throw new FsError("EINVAL", `rename would exceed ${MAX_PATH_BYTES} UTF-8 path bytes`);
}
if (inode.kind === "directory") {
for (const [name, childId] of inode.children) {
visit(inodeById(state, childId), childPathMetrics(metrics, name));
}
}
};
const destinationMetrics = findPathMetrics(state, destinationParent.id);
visit(source, childPathMetrics(destinationMetrics, destinationName));
}
function validateRenameTypes(source: Inode, destination: Inode | undefined): void {
if (destination === undefined) {
return;
}
if (source.kind !== "directory" && destination.kind === "directory") {
throw new FsError("EISDIR", "cannot replace a directory with a file");
}
if (source.kind === "directory" && destination.kind !== "directory") {
throw new FsError("ENOTDIR", "cannot replace a file with a directory");
}
if (destination.kind === "directory" && destination.children.size !== 0) {
throw new FsError("ENOTEMPTY", "destination directory is not empty");
}
}
export function planRename(
state: VfsState,
oldPath: string,
newPath: string,
nowMs: bigint,
): TxnPlan<Extract<TxnRecord, { kind: "rename" }>> {
if (oldPath === newPath) {
throw new FsError("EINVAL", "source and destination are identical", { operation: "rename", path: oldPath });
}
const sourceLocation = parentAndName(state, oldPath);
const destinationLocation = parentAndName(state, newPath);
const sourceId = sourceLocation.parent.children.get(sourceLocation.name);
if (sourceId === undefined) {
throw new FsError("ENOENT", "rename source does not exist", { operation: "rename", path: oldPath });
}
const source = inodeById(state, sourceId);
const destinationId = destinationLocation.parent.children.get(destinationLocation.name);
const destination = destinationId === undefined ? undefined : inodeById(state, destinationId);
validateRenameTypes(source, destination);
if (source.kind === "directory" && directoryContains(state, source, destinationLocation.parent.id)) {
throw new FsError("EINVAL", "cannot move a directory into itself", { operation: "rename", path: newPath });
}
validateRenamedSubtreeBounds(state, source, destinationLocation.parent, destinationLocation.name);
return checkedPlan(state, {
kind: "rename",
sourceParentId: sourceLocation.parent.id,
sourceName: sourceLocation.name,
destinationParentId: destinationLocation.parent.id,
destinationName: destinationLocation.name,
timestampMs: validateTimestamp(nowMs),
});
}
export function planReserveQuarantine(
state: VfsState,
count: number,
): TxnPlan<Extract<TxnRecord, { kind: "reserveQuarantine" }>> {
if (!Number.isSafeInteger(count) || count <= 0 || count > MAX_EXTENTS_PER_INODE) {
throw new StoreLimitError(`orphan reservation must contain 1 to ${MAX_EXTENTS_PER_INODE} extents`);
}
return checkedPlan(state, {
kind: "reserveQuarantine",
extents: toExtentRuns(chooseExtents(state, count)),
});
}
function validateCreateDirectories(state: VfsState, entries: readonly CreateDirectoryChoice[]): void {
if (entries.length === 0) {
throw new FsError("EINVAL", "createDirectories record is empty");
}
const staged = new Map<InodeId, DirectoryInode>();
const stagedDirectory = (inodeId: InodeId): DirectoryInode => {
const alreadyStaged = staged.get(inodeId);
if (alreadyStaged !== undefined) {
return alreadyStaged;
}
const existing = directoryById(state, inodeId);
const copy = { ...existing, children: new Map(existing.children) };
staged.set(inodeId, copy);
return copy;
};
let expectedInodeId = state.nextInodeId;
for (const entry of entries) {
validatePathComponent(entry.name);
validateMode(entry.mode);
validateTimestamp(entry.atimeMs);
validateTimestamp(entry.mtimeMs);
validateTimestamp(entry.ctimeMs);
if (entry.inodeId !== expectedInodeId || entry.inodeId > MAX_U64) {
throw new FsError("EINVAL", "createDirectories record has a non-canonical inode sequence");
}
const parent = stagedDirectory(entry.parentId);
if (parent.children.has(entry.name)) {
throw new FsError("EEXIST", "directory entry already exists");
}
const created: DirectoryInode = {
id: entry.inodeId,
kind: "directory",
mode: entry.mode,
atimeMs: entry.atimeMs,
mtimeMs: entry.mtimeMs,
ctimeMs: entry.ctimeMs,
children: new Map(),
};
parent.children.set(entry.name, entry.inodeId);
staged.set(entry.inodeId, created);
expectedInodeId = advanceNextInodeId(expectedInodeId);
}
if (state.inodes.size + entries.length > MAX_INODES) {
throw new StoreLimitError(`inode count would exceed ${MAX_INODES}`);
}
}
function validateExtentAssignment(state: VfsState, runs: readonly ExtentRun[], expectedCount: number): ExtentId[] {
const extents = expandExtentRuns(runs, MAX_EXTENTS_PER_INODE);
if (extents.length !== expectedCount) {
throw new FsError("EINVAL", "extent choices do not cover the file size exactly");
}
const unique = new Set(extents);
if (unique.size !== extents.length) {
throw new FsError("EINVAL", "extent choices contain a duplicate");
}
let nextFresh = state.allocator.totalExtents;
for (const extentId of extents) {
if (extentId < state.allocator.totalExtents) {
if (!state.allocator.available.has(extentId)) {
throw new FsError("EINVAL", "extent choice is not safely available");
}
continue;
}
if (extentId !== nextFresh) {
throw new FsError("EINVAL", "fresh extent choices must grow the arena contiguously");
}
nextFresh += 1n;
}
if (nextFresh > BigInt(MAX_TOTAL_EXTENTS)) {
throw new StoreLimitError(`total extent count would exceed ${MAX_TOTAL_EXTENTS}`);
}
return extents;
}
function applyCreateFile(state: VfsState, record: Extract<TxnRecord, { kind: "createFile" }>): void {
validatePathComponent(record.name);
const parent = directoryById(state, record.parentId);
if (parent.children.has(record.name)) {
throw new FsError("EEXIST", "directory entry already exists");
}
if (record.inodeId !== state.nextInodeId || state.inodes.has(record.inodeId)) {
throw new FsError("EINVAL", "createFile record has a non-canonical inode ID");
}
const nextInodeId = advanceNextInodeId(record.inodeId);
validateMode(record.mode);
validateTimestamp(record.atimeMs);
validateTimestamp(record.mtimeMs);
validateTimestamp(record.ctimeMs);
const size = checkedU64(record.size, "file size");
const extents = validateExtentAssignment(state, record.extents, extentCountForSize(size, state.extentSize));
if (state.inodes.size >= MAX_INODES) {
throw new StoreLimitError(`inode count would exceed ${MAX_INODES}`);
}
const inode: FileInode = {
id: record.inodeId,
kind: "file",
mode: record.mode,
atimeMs: record.atimeMs,
mtimeMs: record.mtimeMs,
ctimeMs: record.ctimeMs,
size,
extents,
};
for (const extentId of extents) {
if (extentId < state.allocator.totalExtents) {
state.allocator.available.delete(extentId);
} else {
state.allocator.totalExtents += 1n;
}
state.allocator.ownedBy.set(extentId, inode.id);
}
parent.children.set(record.name, inode.id);
state.inodes.set(inode.id, inode);
state.parentByInode.set(inode.id, { parentId: parent.id, name: record.name });
state.nextInodeId = nextInodeId;
}
function applyRemoveFile(state: VfsState, record: Extract<TxnRecord, { kind: "removeFile" }>): void {
validatePathComponent(record.name);
validateTimestamp(record.parentMtimeMs);
validateTimestamp(record.parentCtimeMs);
const parent = directoryById(state, record.parentId);
const inodeId = parent.children.get(record.name);
const inode = inodeId === undefined ? undefined : state.inodes.get(inodeId);
if (inode === undefined) {
throw new FsError("ENOENT", "file does not exist");
}
if (inode.kind === "directory") {
throw new FsError("EISDIR", "path is a directory");
}
const extents = inode.kind === "file" ? inode.extents : [];
for (const extentId of extents) {
if (state.allocator.ownedBy.get(extentId) !== inode.id || state.allocator.quarantine.has(extentId)) {
throw new StoreLimitError("file extent ownership is inconsistent");
}
}
parent.children.delete(record.name);
parent.mtimeMs = record.parentMtimeMs;
parent.ctimeMs = record.parentCtimeMs;
state.inodes.delete(inode.id);
state.parentByInode.delete(inode.id);
for (const extentId of extents) {
state.allocator.ownedBy.delete(extentId);
state.allocator.quarantine.set(extentId, null);
}
}
function applyCreateSymlink(state: VfsState, record: Extract<TxnRecord, { kind: "createSymlink" }>): void {
validatePathComponent(record.name);
const parent = directoryById(state, record.parentId);
if (parent.children.has(record.name)) {
throw new FsError("EEXIST", "directory entry already exists");
}
if (record.inodeId !== state.nextInodeId || state.inodes.has(record.inodeId)) {
throw new FsError("EINVAL", "createSymlink record has a non-canonical inode ID");
}
const nextInodeId = advanceNextInodeId(record.inodeId);
validateMode(record.mode);
validateTimestamp(record.atimeMs);
validateTimestamp(record.mtimeMs);
validateTimestamp(record.ctimeMs);
validateSymlinkTarget(record.target);
if (state.inodes.size >= MAX_INODES) {
throw new StoreLimitError(`inode count would exceed ${MAX_INODES}`);
}
const inode: SymlinkInode = {
id: record.inodeId,
kind: "symlink",
mode: record.mode,
atimeMs: record.atimeMs,
mtimeMs: record.mtimeMs,
ctimeMs: record.ctimeMs,
target: record.target,
};
parent.children.set(record.name, inode.id);
state.inodes.set(inode.id, inode);
state.parentByInode.set(inode.id, { parentId: parent.id, name: record.name });
state.nextInodeId = nextInodeId;
}
function applyChangeMode(state: VfsState, record: Extract<TxnRecord, { kind: "changeMode" }>): void {
const inode = inodeById(state, record.inodeId);
const mode = validateMode(record.mode);
const ctimeMs = validateTimestamp(record.ctimeMs);
inode.mode = mode;
inode.ctimeMs = ctimeMs;
}
function applyChangeTimes(state: VfsState, record: Extract<TxnRecord, { kind: "changeTimes" }>): void {
const inode = inodeById(state, record.inodeId);
const atimeMs = validateTimestamp(record.atimeMs);
const mtimeMs = validateTimestamp(record.mtimeMs);
const ctimeMs = validateTimestamp(record.ctimeMs);
inode.atimeMs = atimeMs;
inode.mtimeMs = mtimeMs;
inode.ctimeMs = ctimeMs;
}
function applyResizeFile(state: VfsState, record: Extract<TxnRecord, { kind: "resizeFile" }>): void {
const inode = fileById(state, record.inodeId);
const size = checkedU64(record.size, "file size");
const required = extentCountForSize(size, state.extentSize);
const additionalCount = Math.max(0, required - inode.extents.length);
const allocated = validateExtentAssignment(state, record.allocated, additionalCount);
const retained = required >= inode.extents.length ? inode.extents : inode.extents.slice(0, required);
const released = inode.extents.slice(required);
for (const extentId of released) {
if (state.allocator.ownedBy.get(extentId) !== inode.id || state.allocator.quarantine.has(extentId)) {
throw new StoreLimitError("file extent ownership is inconsistent");
}
}
const mtimeMs = validateTimestamp(record.mtimeMs);
const ctimeMs = validateTimestamp(record.ctimeMs);
for (const extentId of allocated) {
if (extentId < state.allocator.totalExtents) {
state.allocator.available.delete(extentId);
} else {
state.allocator.totalExtents += 1n;
}
state.allocator.ownedBy.set(extentId, inode.id);
retained.push(extentId);
}
for (const extentId of released) {
state.allocator.ownedBy.delete(extentId);
state.allocator.quarantine.set(extentId, null);
}
inode.size = size;
inode.extents = retained;
inode.mtimeMs = mtimeMs;
inode.ctimeMs = ctimeMs;
}
function applyRemoveDirectory(state: VfsState, record: Extract<TxnRecord, { kind: "removeDirectory" }>): void {
validatePathComponent(record.name);
const parent = directoryById(state, record.parentId);
const inodeId = parent.children.get(record.name);
const inode = inodeId === undefined ? undefined : state.inodes.get(inodeId);
if (inode === undefined) {
throw new FsError("ENOENT", "directory does not exist");
}
if (inode.kind !== "directory") {
throw new FsError("ENOTDIR", "path is not a directory");
}
if (inode.children.size !== 0) {
throw new FsError("ENOTEMPTY", "directory is not empty");
}
const mtimeMs = validateTimestamp(record.parentMtimeMs);
const ctimeMs = validateTimestamp(record.parentCtimeMs);
parent.children.delete(record.name);
parent.mtimeMs = mtimeMs;
parent.ctimeMs = ctimeMs;
state.inodes.delete(inode.id);
state.parentByInode.delete(inode.id);
}
function applyRename(state: VfsState, record: Extract<TxnRecord, { kind: "rename" }>): void {
validatePathComponent(record.sourceName);
validatePathComponent(record.destinationName);
if (record.sourceParentId === record.destinationParentId && record.sourceName === record.destinationName) {
throw new FsError("EINVAL", "rename source and destination are identical");
}
const timestampMs = validateTimestamp(record.timestampMs);
const sourceParent = directoryById(state, record.sourceParentId);
const destinationParent = directoryById(state, record.destinationParentId);
const sourceId = sourceParent.children.get(record.sourceName);
if (sourceId === undefined) {
throw new FsError("ENOENT", "rename source does not exist");
}
const source = inodeById(state, sourceId);
const destinationId = destinationParent.children.get(record.destinationName);
const destination = destinationId === undefined ? undefined : inodeById(state, destinationId);
validateRenameTypes(source, destination);
if (source.kind === "directory" && directoryContains(state, source, destinationParent.id)) {
throw new FsError("EINVAL", "cannot move a directory into itself");
}
validateRenamedSubtreeBounds(state, source, destinationParent, record.destinationName);
if (destination?.kind === "file") {
for (const extentId of destination.extents) {
if (state.allocator.ownedBy.get(extentId) !== destination.id || state.allocator.quarantine.has(extentId)) {
throw new StoreLimitError("replacement extent ownership is inconsistent");
}
}
}
sourceParent.children.delete(record.sourceName);
if (destination !== undefined) {
destinationParent.children.delete(record.destinationName);
state.inodes.delete(destination.id);
state.parentByInode.delete(destination.id);
if (destination.kind === "file") {
for (const extentId of destination.extents) {
state.allocator.ownedBy.delete(extentId);
state.allocator.quarantine.set(extentId, null);
}
}
}
destinationParent.children.set(record.destinationName, source.id);
state.parentByInode.set(source.id, {
parentId: destinationParent.id,
name: record.destinationName,
});
source.ctimeMs = timestampMs;
sourceParent.mtimeMs = timestampMs;
sourceParent.ctimeMs = timestampMs;
destinationParent.mtimeMs = timestampMs;
destinationParent.ctimeMs = timestampMs;
}
function applyReserveQuarantine(state: VfsState, record: Extract<TxnRecord, { kind: "reserveQuarantine" }>): void {
const declaredCount = record.extents.reduce((sum, run) => sum + run.count, 0);
if (declaredCount <= 0 || declaredCount > MAX_EXTENTS_PER_INODE) {
throw new StoreLimitError("orphan reservation extent count is outside limits");
}
const extents = validateExtentAssignment(state, record.extents, declaredCount);
for (const extentId of extents) {
if (extentId < state.allocator.totalExtents) {
state.allocator.available.delete(extentId);
} else {
state.allocator.totalExtents += 1n;
}
state.allocator.quarantine.set(extentId, null);
}
}
export function applyTxn(state: VfsState, record: TxnRecord, prepared?: PreparedTxnProjection): void {
if (
prepared !== undefined &&
(prepared[PREPARED_TXN_PROJECTION] !== true || prepared.state !== state || prepared.record !== record)
) {
throw new StoreLimitError("prepared transaction projection does not match the live transition");
}
const projectedBasePayloadBytes = prepared?.projectedBasePayloadBytes ?? preflightTxn(state, record);
switch (record.kind) {
case "createDirectories":
validateCreateDirectories(state, record.entries);
for (const entry of record.entries) {
const parent = directoryById(state, entry.parentId);
const created: DirectoryInode = {
id: entry.inodeId,
kind: "directory",
mode: entry.mode,
atimeMs: entry.atimeMs,
mtimeMs: entry.mtimeMs,
ctimeMs: entry.ctimeMs,
children: new Map(),
};
parent.children.set(entry.name, created.id);
state.inodes.set(created.id, created);
state.parentByInode.set(created.id, { parentId: parent.id, name: entry.name });
state.nextInodeId = advanceNextInodeId(created.id);
}
break;
case "createFile":
applyCreateFile(state, record);
break;
case "removeFile":
applyRemoveFile(state, record);
break;
case "changeMode":
applyChangeMode(state, record);
break;
case "changeTimes":
applyChangeTimes(state, record);
break;
case "resizeFile":
applyResizeFile(state, record);
break;
case "removeDirectory":
applyRemoveDirectory(state, record);
break;
case "rename":
applyRename(state, record);
break;
case "createSymlink":
applyCreateSymlink(state, record);
break;
case "reserveQuarantine":
applyReserveQuarantine(state, record);
break;
}
state.basePayloadBytes = projectedBasePayloadBytes;
}
function cloneState(state: VfsState): VfsState {
return {
...state,
retainedGenerations: { ...state.retainedGenerations },
inodes: new Map(
[...state.inodes].map(([inodeId, inode]) => [
inodeId,
inode.kind === "directory"
? { ...inode, children: new Map(inode.children) }
: inode.kind === "file"
? { ...inode, extents: [...inode.extents] }
: { ...inode },
]),
),
parentByInode: new Map([...state.parentByInode].map(([inodeId, entry]) => [inodeId, { ...entry }])),
allocator: {
totalExtents: state.allocator.totalExtents,
ownedBy: new Map(state.allocator.ownedBy),
available: state.allocator.available.clone(),
quarantine: new Map(state.allocator.quarantine),
},
};
}
function projectRepackFromCopy(
state: VfsState,
projected: VfsState,
pinnedQuarantine: ReadonlySet<ExtentId>,
): VfsState {
// Activated/recovered state and every live reducer transition are already validated.
// The projected result is validated below before its canonical base can be activated.
if (state.generation === MAX_U64) {
throw new StoreLimitError("generation is exhausted; the store must be recreated");
}
let projectedBasePayloadBytes = state.basePayloadBytes;
const nextGeneration = state.generation + 1n;
const target = otherMetadataFile(state.activeMetadataFile);
const targetPreviousGeneration = state.retainedGenerations[target];
for (const [extentId, firstExcludedGeneration] of projected.allocator.quarantine) {
if (pinnedQuarantine.has(extentId)) {
projected.allocator.quarantine.set(extentId, null);
} else if (firstExcludedGeneration === null) {
projected.allocator.quarantine.set(extentId, nextGeneration);
} else if (targetPreviousGeneration === null || targetPreviousGeneration < firstExcludedGeneration) {
projected.allocator.quarantine.delete(extentId);
projected.allocator.available.add(extentId);
projectedBasePayloadBytes -= 8;
}
}
while (projected.allocator.totalExtents > 0n) {
const tail = projected.allocator.totalExtents - 1n;
if (!projected.allocator.available.delete(tail)) break;
projected.allocator.totalExtents = tail;
projectedBasePayloadBytes -= 8;
}
projected.generation = nextGeneration;
projected.activeMetadataFile = target;
projected.retainedGenerations[target] = nextGeneration;
projected.basePayloadBytes = projectedBasePayloadBytes;
validateState(projected);
return projected;
}
export function projectRepack(state: VfsState, pinnedQuarantine: ReadonlySet<ExtentId> = new Set()): VfsState {
return projectRepackFromCopy(state, cloneState(state), pinnedQuarantine);
}
/**
* @internal Activation-only projection. Structures repack never mutates are shared read-only until
* durable activation; allocator and generation structures remain isolated from the live state.
*/
export function projectRepackForActivation(
state: VfsState,
pinnedQuarantine: ReadonlySet<ExtentId> = new Set(),
): VfsState {
const projected: VfsState = {
...state,
retainedGenerations: { ...state.retainedGenerations },
inodes: state.inodes,
parentByInode: state.parentByInode,
allocator: {
totalExtents: state.allocator.totalExtents,
ownedBy: state.allocator.ownedBy,
available: state.allocator.available.clone(),
quarantine: new Map(state.allocator.quarantine),
},
};
return projectRepackFromCopy(state, projected, pinnedQuarantine);
}
export function validateState(state: VfsState): void {
validateExtentSize(state.extentSize);
checkedU64(state.generation, "generation");
if (state.generation === 0n) {
throw new StoreLimitError("generation must be positive");
}
checkedU64(state.nextInodeId, "next inode ID");
const root = state.inodes.get(state.rootInodeId);
if (root?.kind !== "directory") {
throw new StoreLimitError("root inode is missing or is not a directory");
}
if (state.parentByInode.has(state.rootInodeId)) {
throw new StoreLimitError("root inode must not have a parent entry");
}
if (state.inodes.size > MAX_INODES) {
throw new StoreLimitError(`inode count exceeds ${MAX_INODES}`);
}
checkedU64(state.allocator.totalExtents, "total extent count");
if (state.allocator.totalExtents > BigInt(MAX_TOTAL_EXTENTS)) {
throw new StoreLimitError(`total extent count exceeds ${MAX_TOTAL_EXTENTS}`);
}
const classified = new Uint8Array(Number(state.allocator.totalExtents));
let classifiedCount = 0;
const classify = (extentId: ExtentId, label: string): void => {
if (extentId < 0n || extentId >= state.allocator.totalExtents) {
throw new StoreLimitError(`${label} extent is out of range or multiply classified`);
}
const index = Number(extentId);
if (classified[index] !== 0) {
throw new StoreLimitError(`${label} extent is out of range or multiply classified`);
}
classified[index] = 1;
classifiedCount += 1;
};
const reached = new Set<InodeId>();
let calculatedBasePayloadBytes = BASE_STATE_FIXED_BYTES;
const visit = (inodeKey: InodeId, inode: Inode, metrics: PathMetrics): void => {
checkedU64(inodeKey, "inode map key");
checkedU64(inode.id, "inode ID");
if (inodeKey !== inode.id) {
throw new StoreLimitError("inode map key does not match the inode ID");
}
if (inode.id >= state.nextInodeId) {
throw new StoreLimitError("inode ID is not below the unused next inode ID");
}
if (reached.has(inode.id)) {
throw new StoreLimitError("inode has more than one directory owner");
}
reached.add(inode.id);
validateMode(inode.mode);
validateTimestamp(inode.atimeMs);
validateTimestamp(inode.mtimeMs);
validateTimestamp(inode.ctimeMs);
if (inode.kind === "directory") {
calculatedBasePayloadBytes += DIRECTORY_INODE_FIXED_BYTES;
for (const [name, childId] of inode.children) {
const nameBytes = validatePathComponent(name);
calculatedBasePayloadBytes += CHILD_FIXED_BYTES + nameBytes;
const parentEntry = state.parentByInode.get(childId);
if (parentEntry?.parentId !== inode.id || parentEntry.name !== name) {
throw new StoreLimitError("inode parent index does not match its directory entry");
}
const childMetrics = {
depth: metrics.depth + 1,
bytes: metrics.bytes + (metrics.depth === 0 ? 0 : 1) + nameBytes,
};
if (childMetrics.depth > MAX_PATH_DEPTH) {
throw new StoreLimitError(`inode path exceeds ${MAX_PATH_DEPTH} components`);
}
if (childMetrics.bytes > MAX_PATH_BYTES) {
throw new StoreLimitError(`inode path exceeds ${MAX_PATH_BYTES} UTF-8 bytes`);
}
const child = state.inodes.get(childId);
if (child === undefined) {
throw new StoreLimitError("directory entry references a missing inode");
}
visit(childId, child, childMetrics);
}
} else if (inode.kind === "symlink") {
let targetBytes: number;
try {
targetBytes = validateSymlinkTarget(inode.target);
} catch (cause) {
throw new StoreLimitError(`symlink target is invalid: ${String(cause)}`, { cause });
}
calculatedBasePayloadBytes += SYMLINK_INODE_FIXED_BYTES + targetBytes;
} else {
calculatedBasePayloadBytes += FILE_INODE_FIXED_BYTES + inode.extents.length * 8;
if (inode.extents.length !== extentCountForSize(inode.size, state.extentSize)) {
throw new StoreLimitError("file extent count does not cover its size exactly");
}
for (const extentId of inode.extents) {
classify(extentId, "owned");
if (state.allocator.ownedBy.get(extentId) !== inode.id) {
throw new StoreLimitError("owned extent does not map back to its inode");
}
}
}
};
visit(state.rootInodeId, root, { depth: 0, bytes: 1 });
if (reached.size !== state.inodes.size) {
throw new StoreLimitError("inode graph contains unreachable inodes");
}
if (state.parentByInode.size !== state.inodes.size - 1) {
throw new StoreLimitError("inode parent index does not cover every non-root inode");
}
if (classifiedCount !== state.allocator.ownedBy.size) {
throw new StoreLimitError("allocator contains an owner not referenced by a file");
}
calculatedBasePayloadBytes += state.allocator.available.size * 8 + state.allocator.quarantine.size * 16;
if (state.basePayloadBytes !== calculatedBasePayloadBytes) {
throw new StoreLimitError("tracked metadata-base size does not match canonical state size");
}
if (state.basePayloadBytes > MAX_METADATA_BASE_WRITER_BYTES) {
throw new StoreLimitError(`metadata base exceeds ${MAX_METADATA_BASE_WRITER_BYTES} bytes`);
}
if (state.retainedGenerations[state.activeMetadataFile] !== state.generation) {
throw new StoreLimitError("active metadata identity does not match the state generation");
}
const inactiveMetadataFile = otherMetadataFile(state.activeMetadataFile);
const inactiveGeneration = state.retainedGenerations[inactiveMetadataFile];
if (inactiveGeneration !== null) {
checkedU64(inactiveGeneration, "inactive retained generation");
if (inactiveGeneration === 0n || inactiveGeneration + 1n !== state.generation) {
throw new StoreLimitError("inactive retained generation is not the immediate predecessor");
}
}
for (const extentId of state.allocator.available.values()) {
classify(extentId, "available");
}
for (const [extentId, firstExcludedGeneration] of state.allocator.quarantine) {
classify(extentId, "quarantined");
if (firstExcludedGeneration !== null && firstExcludedGeneration !== state.generation) {
throw new StoreLimitError("quarantine generation is inconsistent with retained metadata");
}
}
if (BigInt(classifiedCount) !== state.allocator.totalExtents) {
throw new StoreLimitError("allocator partition does not cover every extent");
}
}
export function canonicalStateView(state: VfsState) {
const inodes = [...state.inodes.values()]
.sort((left, right) => (left.id < right.id ? -1 : left.id > right.id ? 1 : 0))
.map((inode) =>
inode.kind === "directory"
? {
...inode,
children: [...inode.children.entries()].sort(([left], [right]) => left.localeCompare(right)),
}
: inode.kind === "file"
? { ...inode, extents: [...inode.extents] }
: { ...inode },
);
return {
generation: state.generation,
nextInodeId: state.nextInodeId,
rootInodeId: state.rootInodeId,
extentSize: state.extentSize,
activeMetadataFile: state.activeMetadataFile,
retainedGenerations: { ...state.retainedGenerations },
basePayloadBytes: state.basePayloadBytes,
inodes,
parentByInode: [...state.parentByInode.entries()].sort(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
allocator: {
totalExtents: state.allocator.totalExtents,
ownedBy: [...state.allocator.ownedBy.entries()].sort(([left], [right]) => (left < right ? -1 : 1)),
available: [...state.allocator.available.values()].sort((left, right) => (left < right ? -1 : 1)),
quarantine: [...state.allocator.quarantine.entries()].sort(([left], [right]) => (left < right ? -1 : 1)),
},
};
}