diff --git a/docs/lossless-file-identity.md b/docs/lossless-file-identity.md new file mode 100644 index 0000000..0e0f4b2 --- /dev/null +++ b/docs/lossless-file-identity.md @@ -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. diff --git a/src/lock.ts b/src/lock.ts index 7fe5599..950e7ab 100644 --- a/src/lock.ts +++ b/src/lock.ts @@ -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) { @@ -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() || @@ -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"); } diff --git a/src/transcript.ts b/src/transcript.ts index d4bf49b..60b3714 100644 --- a/src/transcript.ts +++ b/src/transcript.ts @@ -121,7 +121,7 @@ export async function inspectTranscriptPath( candidates: string[] = [path], ): Promise { try { - const beforeStat = await lstat(path); + const beforeStat = await lstat(path, { bigint: true }); if (!beforeStat.isFile() || beforeStat.isSymbolicLink()) { return { status: "unreadable", @@ -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, @@ -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"); } diff --git a/src/unlock.ts b/src/unlock.ts index 60b265a..1dcd05b 100644 --- a/src/unlock.ts +++ b/src/unlock.ts @@ -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)) || diff --git a/src/util.ts b/src/util.ts index 2a68795..c3ddf31 100644 --- a/src/util.ts +++ b/src/util.ts @@ -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"; @@ -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, }; } diff --git a/test/lossless-identity.test.mjs b/test/lossless-identity.test.mjs new file mode 100644 index 0000000..57451c3 --- /dev/null +++ b/test/lossless-identity.test.mjs @@ -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 }); + } +});