Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/lossless-file-identity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Lossless file identity snapshots

Lock and transcript observations request Node's `BigIntStats` before creating a
public snapshot. Device and inode identities remain decimal strings, including
values above JavaScript's safe integer limit. Converting a rounded numeric inode
to a string or to `BigInt` cannot recover its original value.

The JSON v1 snapshot shape is unchanged. Mode, UID, link count, and byte size are
numbers only when they are safe, non-negative integers. An unrepresentable value
throws; lock observations become `unknown`, transcript inspection becomes
`unreadable`, and stable hashing rejects rather than claiming stability.

`modifiedMs` remains fractional milliseconds as a number. BigInt nanoseconds are
split into integer milliseconds and a fractional remainder instead of using the
truncated BigInt `mtimeMs`. This preserves the existing public representation;
it does not claim nanosecond-exact comparison. `modifiedAt` remains an ISO date.

These snapshots establish file identity, not flock ownership. They do not solve
opener attribution, process identity, namespace visibility, or remote filesystem
lock semantics. Linux fdinfo adoption still requires its separate safety gates.
8 changes: 4 additions & 4 deletions src/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,14 +85,14 @@ export function observeLockDirectory(lockDirectory: string): LockDirectoryObserv

export function observeLockFile(path: string): LockFileObservation {
try {
const value = lstatSync(path);
const value = lstatSync(path, { bigint: true });
const uid = process.getuid?.();
return {
status: "present",
exists: true,
regularFile: value.isFile(),
symlink: value.isSymbolicLink(),
ownedByCurrentUser: uid === undefined ? null : value.uid === uid,
ownedByCurrentUser: uid === undefined ? null : value.uid === BigInt(uid),
snapshot: publicSnapshot(value),
};
} catch (error) {
Expand Down Expand Up @@ -156,7 +156,7 @@ export function probeThreadLockOnce(
try {
const noFollow = constants.O_NOFOLLOW ?? 0;
fd = openSync(path, constants.O_RDWR | noFollow | constants.O_NONBLOCK);
const value = fstatSync(fd);
const value = fstatSync(fd, { bigint: true });
const opened = publicSnapshot(value);
if (
!value.isFile() ||
Expand All @@ -179,7 +179,7 @@ export function probeThreadLockOnce(
error: errorText(error),
};
}
const after = publicSnapshot(lstatSync(path));
const after = publicSnapshot(lstatSync(path, { bigint: true }));
if (after.device !== opened.device || after.inode !== opened.inode) {
throw new Error("lock file changed during the probe");
}
Expand Down
8 changes: 4 additions & 4 deletions src/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ export async function inspectTranscriptPath(
candidates: string[] = [path],
): Promise<TranscriptInspection> {
try {
const beforeStat = await lstat(path);
const beforeStat = await lstat(path, { bigint: true });
if (!beforeStat.isFile() || beforeStat.isSymbolicLink()) {
return {
status: "unreadable",
Expand All @@ -135,7 +135,7 @@ export async function inspectTranscriptPath(
}
const before = publicSnapshot(beforeStat);
const lastRecord = parseLastRecord(await readLastNonEmptyLine(path, before.size));
const after = publicSnapshot(await lstat(path));
const after = publicSnapshot(await lstat(path, { bigint: true }));
return {
status: "found",
path,
Expand Down Expand Up @@ -194,10 +194,10 @@ export function sameLastRecord(
export async function stableFileHash(
path: string,
): Promise<{ hash: string; snapshot: PublicFileSnapshot }> {
const before = publicSnapshot(await lstat(path));
const before = publicSnapshot(await lstat(path, { bigint: true }));
const { sha256File } = await import("./util.js");
const hash = await sha256File(path);
const after = publicSnapshot(await lstat(path));
const after = publicSnapshot(await lstat(path, { bigint: true }));
if (!sameSnapshot(before, after)) {
throw new Error("transcript changed while hashing");
}
Expand Down
6 changes: 3 additions & 3 deletions src/unlock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,14 +70,14 @@ function sameStringArray(before: string[] | null, after: string[] | null): boole
}

function transcriptSnapshot(path: string): PublicFileSnapshot {
const before = lstatSync(path);
const before = lstatSync(path, { bigint: true });
if (!before.isFile() || before.isSymbolicLink()) {
throw new Error("transcript is a symlink or non-regular file");
}
const fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const opened = fstatSync(fd);
const after = lstatSync(path);
const opened = fstatSync(fd, { bigint: true });
const after = lstatSync(path, { bigint: true });
if (
!opened.isFile() || !after.isFile() || after.isSymbolicLink() ||
!sameSnapshot(publicSnapshot(before), publicSnapshot(opened)) ||
Expand Down
42 changes: 33 additions & 9 deletions src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { spawn } from "node:child_process";
import type { ChildProcess, SpawnOptions } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import type { Stats } from "node:fs";
import type { BigIntStats, Stats } from "node:fs";

import type { PublicFileSnapshot } from "./types.js";

Expand Down Expand Up @@ -232,17 +232,41 @@ export function errorText(error: unknown): string {
}

export function publicSnapshot(
value: Stats,
value: Stats | BigIntStats,
): PublicFileSnapshot {
const integer = (field: string, input: number | bigint): number => {
const converted = Number(input);
if (!Number.isSafeInteger(converted) || converted < 0) {
throw new Error(`file snapshot ${field} is not a safe non-negative integer`);
}
return converted;
};
const identity = (field: string, input: number | bigint): string => {
if (typeof input === "bigint") {
if (input < 0n) throw new Error(`file snapshot ${field} is negative`);
return input.toString();
}
// Already-rounded numeric identities cannot be recovered by BigInt().
return String(integer(field, input));
};
// BigIntStats.mtimeMs truncates sub-millisecond precision. Retain the v1
// number field and its fractional milliseconds without converting the
// entire nanosecond timestamp to a (potentially rounded) Number first.
const modifiedMs = "mtimeNs" in value
? Number(value.mtimeNs / 1_000_000n) + Number(value.mtimeNs % 1_000_000n) / 1_000_000
: value.mtimeMs;
if (!Number.isFinite(modifiedMs) || Math.abs(modifiedMs) > Number.MAX_SAFE_INTEGER) {
throw new Error("file snapshot modification time is not representable");
}
return {
device: String(value.dev),
inode: String(value.ino),
mode: value.mode,
uid: value.uid,
links: value.nlink,
size: value.size,
device: identity("device", value.dev),
inode: identity("inode", value.ino),
mode: integer("mode", value.mode),
uid: integer("uid", value.uid),
links: integer("links", value.nlink),
size: integer("size", value.size),
modifiedAt: value.mtime.toISOString(),
modifiedMs: value.mtimeMs,
modifiedMs,
};
}

Expand Down
115 changes: 115 additions & 0 deletions test/lossless-identity.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import assert from "node:assert/strict";
import { mkdtemp, writeFile, rm, lstat } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import process from "node:process";
import { spawnSync } from "node:child_process";
import { URL } from "node:url";

import { publicSnapshot, sameSnapshot } from "../dist/util.js";
import { observeLockFile } from "../dist/lock.js";
import { inspectTranscriptPath, stableFileHash } from "../dist/transcript.js";

const fixture = (extra = {}) => ({
dev: 1n, ino: 9007199254740992n, mode: 33152n, uid: 501n,
nlink: 1n, size: 5n, mtime: new Date(1700000000000),
mtimeMs: 1700000000000n, mtimeNs: 1700000000000123456n, ...extra,
});

test("adjacent 64-bit identities stay distinct and JSON serializable", () => {
const first = publicSnapshot(fixture());
const second = publicSnapshot(fixture({ ino: 9007199254740993n }));
assert.equal(first.inode, "9007199254740992");
assert.equal(second.inode, "9007199254740993");
assert.equal(sameSnapshot(first, second), false);
assert.equal(sameSnapshot(first, publicSnapshot(fixture())), true);
const device = publicSnapshot(fixture({ dev: 18446744073709551615n }));
assert.equal(device.device, "18446744073709551615");
assert.equal(sameSnapshot(first, device), false);
assert.doesNotThrow(() => JSON.stringify(device));
});

test("v1 fractional modification time is retained, not truncated", () => {
const first = publicSnapshot(fixture());
assert.equal(first.modifiedMs, 1700000000000 + 123456 / 1000000);
assert.equal(first.modifiedAt, "2023-11-14T22:13:20.000Z");
assert.equal(sameSnapshot(first, publicSnapshot(fixture({
mtimeNs: 1700000000000987654n,
}))), false);
});

test("rounded numeric identities and unrepresentable v1 fields are rejected", () => {
for (const field of ["dev", "ino", "mode", "uid", "nlink", "size"]) {
assert.throws(() => publicSnapshot(fixture({ [field]: Number(9007199254740993n) })), /safe/);
}
for (const field of ["mode", "uid", "nlink", "size"]) {
assert.throws(() => publicSnapshot(fixture({ [field]: 9007199254740993n })), /safe/);
assert.throws(() => publicSnapshot(fixture({ [field]: -1n })), /safe/);
}
assert.throws(() => publicSnapshot(fixture({ dev: -1n })), /negative/);
const numeric = { ...fixture(), dev: 1, ino: 2, mtimeMs: 1700000000000.125 };
delete numeric.mtimeNs;
assert.equal(publicSnapshot(numeric).modifiedMs, numeric.mtimeMs);
assert.throws(() => publicSnapshot({ ...numeric, mtimeMs: NaN }), /representable/);
});

test("real lock and rollout producers preserve bigint identity and stable hashes", async () => {
const root = await mkdtemp(join(tmpdir(), "codex-lossless-"));
try {
const path = join(root, "fixture.jsonl");
await writeFile(path, '{"type":"event_msg","payload":{"type":"task_complete"}}\n');
const stat = await lstat(path, { bigint: true });
const expected = publicSnapshot(stat);
const observed = observeLockFile(path);
assert.equal(observed.status, "present");
assert.deepEqual(observed.snapshot, expected);
assert.equal(observed.ownedByCurrentUser, stat.uid === BigInt(process.getuid()));
const transcript = await inspectTranscriptPath(path);
assert.equal(transcript.status, "found");
assert.equal(transcript.stable, true);
assert.deepEqual(transcript.snapshot, expected);
assert.deepEqual((await stableFileHash(path)).snapshot, expected);
assert.equal(observeLockFile(join(root, "missing")).status, "absent");
assert.equal((await inspectTranscriptPath(join(root, "missing"))).status, "unreadable");
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("unrepresentable snapshot fields fail closed at diagnostic boundaries", async () => {
const root = await mkdtemp(join(tmpdir(), "codex-lossless-failure-"));
try {
const path = join(root, "fixture.jsonl");
await writeFile(path, '{"type":"event_msg","payload":{"type":"task_complete"}}\n');
const child = spawnSync(process.execPath, ["--input-type=module", "--eval", `
import fs from "node:fs";
import fsp from "node:fs/promises";
import { syncBuiltinESMExports } from "node:module";
const sync = fs.lstatSync;
const asyncStat = fsp.lstat;
fs.lstatSync = (...args) => ({ ...sync(...args), size: 9007199254740993n,
isFile: () => true, isSymbolicLink: () => false });
fsp.lstat = async (...args) => ({ ...await asyncStat(...args), size: 9007199254740993n,
isFile: () => true, isSymbolicLink: () => false });
syncBuiltinESMExports();
const { observeLockFile } = await import(${JSON.stringify(new URL("../dist/lock.js", import.meta.url).href)});
const { inspectTranscriptPath, stableFileHash } = await import(${JSON.stringify(new URL("../dist/transcript.js", import.meta.url).href)});
const path = ${JSON.stringify(path)};
const lock = observeLockFile(path);
const transcript = await inspectTranscriptPath(path);
let hashRejected = false;
try { await stableFileHash(path); } catch { hashRejected = true; }
console.log(JSON.stringify({ lock, transcript, hashRejected }));
`], { encoding: "utf8", timeout: 5000 });
assert.equal(child.status, 0, child.stderr);
const result = JSON.parse(child.stdout);
assert.equal(result.lock.status, "unknown");
assert.equal(result.lock.snapshot, null);
assert.equal(result.transcript.status, "unreadable");
assert.equal(result.transcript.snapshot, null);
assert.equal(result.hashRejected, true);
} finally {
await rm(root, { recursive: true, force: true });
}
});
Loading