diff --git a/src/lab/artifacts/secure-fs.ts b/src/lab/artifacts/secure-fs.ts index 80cd82d0f6c..c10a02ac6ad 100644 --- a/src/lab/artifacts/secure-fs.ts +++ b/src/lab/artifacts/secure-fs.ts @@ -19,8 +19,10 @@ import { unlinkSync, writeSync, type Stats, + type BigIntStats, } from "node:fs"; import { join } from "node:path"; +import { fileIdentityKey, sameFileIdentity, type FileIdentity } from "../../lib/file-identity"; import { ARTIFACT_FILENAME_EXT, MAX_BYTES_PER_ARTIFACT, @@ -72,7 +74,7 @@ function openFlags(base: number, noFollow: boolean): number { return base; } -function assertRegularFileStats(stats: Stats, label: string): void { +function assertRegularFileStats(stats: Stats | BigIntStats, label: string): void { if ( !stats.isFile() || stats.isSymbolicLink() || @@ -84,19 +86,19 @@ function assertRegularFileStats(stats: Stats, label: string): void { ) { harnessFailure(`${label}: not a regular file`, "artifact_unsafe_target"); } - if (stats.nlink !== 1) { + if (Number(stats.nlink) !== 1) { harnessFailure(`${label}: hard links prohibited (nlink=${stats.nlink})`, "artifact_unsafe_target"); } } -function assertDirectoryStats(stats: Stats, label: string): void { +function assertDirectoryStats(stats: Stats | BigIntStats, label: string): void { if (!stats.isDirectory() || stats.isSymbolicLink()) { harnessFailure(`${label}: must be a real directory (no symlink/reparse redirection)`, "artifact_unsafe_target"); } } -function identityOf(stats: Stats): string { - return `${stats.dev}:${stats.ino}`; +function identityOf(stats: FileIdentity): string { + return fileIdentityKey(stats); } function assertRelativeName(name: string): void { @@ -154,14 +156,13 @@ function childPath(dir: TrustedArtifactDir, name: string): string { function assertOpenedPathMatchesDescriptor(dir: TrustedArtifactDir, name: string, fd: number): void { if (detectArtifactIoMode(dir) !== "win32_pinned") return; - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); assertRegularFileStats(opened, "artifact fd"); - const pathEntry = lstatSync(childPath(dir, name)); + const pathEntry = lstatSync(childPath(dir, name), { bigint: true }); if ( !pathEntry.isFile() || pathEntry.isSymbolicLink() || - pathEntry.dev !== opened.dev || - pathEntry.ino !== opened.ino + !sameFileIdentity(pathEntry, opened) ) { closeSync(fd); harnessFailure("artifact path identity mismatch after open", "artifact_unsafe_target"); @@ -202,7 +203,7 @@ function unlinkAtDir(dir: TrustedArtifactDir, name: string): void { } function revalidateDir(dir: TrustedArtifactDir): void { - const stats = fstatSync(dir.fd); + const stats = fstatSync(dir.fd, { bigint: true }); assertDirectoryStats(stats, "artifacts dir"); if (identityOf(stats) !== dir.identity) { harnessFailure("artifacts directory identity changed", "artifact_unsafe_target"); @@ -225,7 +226,7 @@ export function openTrustedArtifactDir(artifactsDir: string): TrustedArtifactDir fd = openSync(abs, O_RDONLY); } - const stats = fstatSync(fd); + const stats = fstatSync(fd, { bigint: true }); assertDirectoryStats(stats, "artifacts dir"); const trusted = { path: abs, fd, identity: identityOf(stats) }; detectArtifactIoMode(trusted); diff --git a/src/lab/fabric/scratch.ts b/src/lab/fabric/scratch.ts index a634df8022e..64282a0b9f7 100644 --- a/src/lab/fabric/scratch.ts +++ b/src/lab/fabric/scratch.ts @@ -12,9 +12,11 @@ import { statSync, writeSync, type Stats, + type BigIntStats, } from "node:fs"; import { dirname, join, posix, relative, resolve, sep } from "node:path"; import { randomBytes } from "node:crypto"; +import { fileIdentityKey, type FileIdentity } from "../../lib/file-identity"; import { ensureLabDirs, ensureRestrictedDir, labRoot, labScratchDir } from "../paths"; import { FABRIC_LIMITS, SYNTHETIC_BEFORE_UTF8, SYNTHETIC_VALUE_PATH } from "./constants"; import { FabricTaskError } from "./types"; @@ -35,22 +37,22 @@ interface TrustedScratchDir { // inside producer-writable scratch nor its age grants later deletion authority. /** Require stats to describe a regular file, not a symlink or special node. */ -function assertRegularFile(stats: Stats, label: string): void { +function assertRegularFile(stats: Stats | BigIntStats, label: string): void { if (!stats.isFile() || stats.isSymbolicLink() || stats.isDirectory() || stats.isFIFO() || stats.isSocket() || stats.isCharacterDevice() || stats.isBlockDevice()) { throw new FabricTaskError(`${label} must be a regular file`, "sandbox_violation", "harness"); } } /** Require stats to describe a real directory, not a symlink. */ -function assertRealDirectory(stats: Stats, label: string): void { +function assertRealDirectory(stats: Stats | BigIntStats, label: string): void { if (stats.isSymbolicLink() || !stats.isDirectory()) { throw new FabricTaskError(`${label} must be a real directory`, "sandbox_violation", "harness"); } } /** Stable device/inode identity string for a filesystem node. */ -function identityOf(stats: Stats): string { - return `${stats.dev}:${stats.ino}`; +function identityOf(stats: FileIdentity): string { + return fileIdentityKey(stats); } /** Whether the host supports O_NOFOLLOW for scratch open operations. */ @@ -73,7 +75,7 @@ function assertScratchName(name: string): void { /** Confirm the scratch root path still refers to the same directory inode. */ function revalidateScratchDir(dir: TrustedScratchDir): void { - const stats = lstatSync(dir.path); + const stats = lstatSync(dir.path, { bigint: true }); assertRealDirectory(stats, "scratch dir"); if (identityOf(stats) !== dir.identity) { throw new FabricTaskError("scratch directory identity changed", "sandbox_violation", "harness"); @@ -93,12 +95,12 @@ function openAtScratch(dir: TrustedScratchDir, name: string, flags: number, mode assertScratchName(name); const path = childScratchPath(dir, name); const fd = openSync(path, openFlags(flags), mode); - const opened = fstatSync(fd); + const opened = fstatSync(fd, { bigint: true }); if (expectDirectory) { assertRealDirectory(opened, name); } else { assertRegularFile(opened, name); - const pathEntry = lstatSync(path); + const pathEntry = lstatSync(path, { bigint: true }); if (identityOf(opened) !== identityOf(pathEntry)) { throw new FabricTaskError("scratch path redirection detected", "sandbox_violation", "harness"); } @@ -115,7 +117,7 @@ function openTrustedScratchRoot(scratchRoot: string): TrustedScratchDir { } else { fd = openSync(abs, fsConstants.O_RDONLY); } - const stats = fstatSync(fd); + const stats = fstatSync(fd, { bigint: true }); assertRealDirectory(stats, "scratch root"); return { path: abs, fd, identity: identityOf(stats) }; } @@ -149,7 +151,7 @@ function openScratchRelativePath( : openFlags(fsConstants.O_RDONLY, true); const subFd = openAtScratch(current, part, dirFlags, 0, true); intermediateFds.push(subFd); - const subStats = fstatSync(subFd); + const subStats = fstatSync(subFd, { bigint: true }); assertRealDirectory(subStats, part); current = { path: join(current.path, part), fd: subFd, identity: identityOf(subStats) }; continue; diff --git a/src/lab/public/community.ts b/src/lab/public/community.ts index 4a6d0d6b12a..23f8bab5061 100644 --- a/src/lab/public/community.ts +++ b/src/lab/public/community.ts @@ -5,7 +5,7 @@ import { ensureLabDirs, labCommunityDir } from "../paths"; import { validateCommunityEvidenceAuthorities } from "./community-authority"; import { communityBundleFileName } from "./community-files"; import { privateRegularFileSize, readPrivateRegularFile } from "./file-safety"; -import { withPublicEvidenceMutationLock } from "./mutation-lock"; +import { isMutationLockEntryName, withPublicEvidenceMutationLock } from "./mutation-lock"; import { recordLocalPublicOrigin } from "./origin"; import { cleanupStalePrivateFileStages, @@ -31,7 +31,6 @@ const MAX_DEPTH = 8; const MAX_OBJECT_KEYS = 64; const MAX_ARRAY_ELEMENTS = 512; const MAX_GENERIC_STRING_BYTES = 384 * 1024; -const COMMUNITY_MUTATION_LOCK_NAME = ".mutation-lock"; const COMMUNITY_BUNDLE_FILE_RE = /^bundle-([0-9a-f]{64})-([0-9a-f]{64})\.json$/; const COMMUNITY_REVOCATION_FILE_RE = /^revocation-([0-9a-f]{64})\.json$/; @@ -148,7 +147,7 @@ function cacheUsage(configDir?: string): { names: string[]; bytes: number } { const dir = labCommunityDir(configDir); cleanupStalePrivateFileStagesInDir(dir); const names = readdirSync(dir) - .filter((name) => name !== COMMUNITY_MUTATION_LOCK_NAME && !isPrivateFileStageName(name)) + .filter((name) => !isMutationLockEntryName(name) && !isPrivateFileStageName(name)) .sort(); if (names.length > MAX_CACHE_FILES) { throw new PublicEvidenceValidationError("community_cache_bound", "community cache file bound exceeded"); diff --git a/src/lab/public/file-safety.ts b/src/lab/public/file-safety.ts index 78134929cf6..530a612edd7 100644 --- a/src/lab/public/file-safety.ts +++ b/src/lab/public/file-safety.ts @@ -10,6 +10,7 @@ import { unlinkSync, } from "node:fs"; import { basename, dirname, join } from "node:path"; +import { sameFileIdentity } from "../../lib/file-identity"; import { isPrivateFileStageName } from "./private-file"; import { PublicEvidenceValidationError } from "./validate"; @@ -38,8 +39,8 @@ function sizeError(options: PrivateRegularFileReadOptions): PublicEvidenceValida */ function recoverPublishedPrivateFileStage(path: string): void { if (process.platform === "win32") return; - const finalStats = lstatSync(path); - if (finalStats.isSymbolicLink() || !finalStats.isFile() || finalStats.nlink <= 1) return; + const finalStats = lstatSync(path, { bigint: true }); + if (finalStats.isSymbolicLink() || !finalStats.isFile() || finalStats.nlink <= 1n) return; const dir = dirname(path); const prefix = `.${basename(path)}.`; @@ -48,12 +49,11 @@ function recoverPublishedPrivateFileStage(path: string): void { if (!name.startsWith(prefix) || !isPrivateFileStageName(name)) continue; const stagePath = join(dir, name); try { - const stageStats = lstatSync(stagePath); + const stageStats = lstatSync(stagePath, { bigint: true }); if ( stageStats.isFile() && !stageStats.isSymbolicLink() - && stageStats.dev === finalStats.dev - && stageStats.ino === finalStats.ino + && sameFileIdentity(stageStats, finalStats) ) { candidates.push(stagePath); } @@ -75,12 +75,11 @@ function recoverPublishedPrivateFileStage(path: string): void { for (const stagePath of candidates) { try { - const stageStats = lstatSync(stagePath); + const stageStats = lstatSync(stagePath, { bigint: true }); if ( stageStats.isFile() && !stageStats.isSymbolicLink() - && stageStats.dev === finalStats.dev - && stageStats.ino === finalStats.ino + && sameFileIdentity(stageStats, finalStats) ) { unlinkSync(stagePath); } @@ -95,32 +94,31 @@ function withPrivateRegularFile( options: PrivateRegularFileReadOptions, consume: (fd: number, size: number) => T, ): T { - let pathStats = lstatSync(path); - if (!pathStats.isSymbolicLink() && pathStats.isFile() && pathStats.nlink > 1) { + let pathStats = lstatSync(path, { bigint: true }); + if (!pathStats.isSymbolicLink() && pathStats.isFile() && Number(pathStats.nlink) > 1) { recoverPublishedPrivateFileStage(path); - pathStats = lstatSync(path); + pathStats = lstatSync(path, { bigint: true }); } - if (pathStats.isSymbolicLink() || !pathStats.isFile() || pathStats.nlink !== 1) { + if (pathStats.isSymbolicLink() || !pathStats.isFile() || Number(pathStats.nlink) !== 1) { throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); } - if (pathStats.size > options.maxBytes) throw sizeError(options); + if (Number(pathStats.size) > options.maxBytes) throw sizeError(options); const fd = openSync(path, fsConstants.O_RDONLY | O_NOFOLLOW); try { - const stats = fstatSync(fd); + const stats = fstatSync(fd, { bigint: true }); if ( !stats.isFile() - || stats.nlink !== 1 - || stats.dev !== pathStats.dev - || stats.ino !== pathStats.ino + || Number(stats.nlink) !== 1 + || !sameFileIdentity(stats, pathStats) ) { throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); } - if (stats.size > options.maxBytes) throw sizeError(options); - if (options.requireMode600 && process.platform !== "win32" && (stats.mode & 0o777) !== 0o600) { + if (Number(stats.size) > options.maxBytes) throw sizeError(options); + if (options.requireMode600 && process.platform !== "win32" && (Number(stats.mode) & 0o777) !== 0o600) { throw new PublicEvidenceValidationError(options.errorCode, options.errorMessage); } - return consume(fd, stats.size); + return consume(fd, Number(stats.size)); } finally { closeSync(fd); } diff --git a/src/lab/public/mutation-lock.ts b/src/lab/public/mutation-lock.ts index 8b52012e28e..6fc1f37890d 100644 --- a/src/lab/public/mutation-lock.ts +++ b/src/lab/public/mutation-lock.ts @@ -1,25 +1,18 @@ -import { randomUUID } from "node:crypto"; -import { - lstatSync, - mkdirSync, - readdirSync, - renameSync, - rmSync, - unlinkSync, - writeFileSync, -} from "node:fs"; -import { dirname, join } from "node:path"; -import { ensureLabDirs, labCommunityDir } from "../paths"; +import { Database } from "bun:sqlite"; +import { lstatSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { sameFileIdentity, statTimeMs, type FileIdentity } from "../../lib/file-identity"; +import { ensureLabDirs, labCommunityDir, labRoot } from "../paths"; import { readPrivateRegularFile } from "./file-safety"; import { PublicEvidenceValidationError } from "./validate"; -const PUBLIC_EVIDENCE_MUTATION_LOCK_NAME = ".mutation-lock"; -const PUBLIC_EVIDENCE_MUTATION_LOCK_OWNER = "owner.json"; -const PUBLIC_EVIDENCE_MUTATION_LOCK_RECLAIM = ".reclaim.json"; -const PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS = 24 * 60 * 60 * 1000; -const PUBLIC_EVIDENCE_MUTATION_LOCK_ABSOLUTE_STALE_MS = 7 * 24 * 60 * 60 * 1000; +const PUBLIC_EVIDENCE_MUTATION_DB_NAME = "public-evidence-mutation.sqlite"; +const LEGACY_MUTATION_LOCK_NAME = ".mutation-lock"; +const LEGACY_MUTATION_LOCK_OWNER = "owner.json"; +const LEGACY_MUTATION_LOCK_INCOMPLETE_STALE_MS = 24 * 60 * 60 * 1000; +const LEGACY_MUTATION_LOCK_ABSOLUTE_STALE_MS = 7 * 24 * 60 * 60 * 1000; const DETACHED_MUTATION_LOCK_RE = /^\.mutation-lock-(?:stale|release)-\d+-[0-9a-f-]{36}$/; -const MUTATION_LOCK_META_FILE_OPTIONS = { +const LEGACY_OWNER_FILE_OPTIONS = { maxBytes: 1024, errorCode: "community_cache_lock", errorMessage: "community cache mutation lock metadata is unsafe", @@ -28,33 +21,22 @@ const MUTATION_LOCK_META_FILE_OPTIONS = { requireMode600: true, } as const; -type MutationLockOwner = { +type LegacyMutationLockOwner = { pid: number; - token: string; createdAt: number; }; -type MutationLockReclaim = { - pid: number; - token: string; - createdAt: number; -}; - -type MutationLockDirectoryIdentity = { - dev: number; - ino: number; -}; - -function mutationLockPath(configDir?: string): string { - return join(labCommunityDir(configDir), PUBLIC_EVIDENCE_MUTATION_LOCK_NAME); +function mutationLockDatabasePath(configDir?: string): string { + return join(labRoot(configDir), PUBLIC_EVIDENCE_MUTATION_DB_NAME); } -function mutationLockOwnerPath(lockPath: string): string { - return join(lockPath, PUBLIC_EVIDENCE_MUTATION_LOCK_OWNER); +/** Lock bookkeeping entries an older build may leave in the community cache directory. */ +export function isMutationLockEntryName(name: string): boolean { + return name === LEGACY_MUTATION_LOCK_NAME || DETACHED_MUTATION_LOCK_RE.test(name); } -function mutationLockReclaimPath(lockPath: string): string { - return join(lockPath, PUBLIC_EVIDENCE_MUTATION_LOCK_RECLAIM); +function legacyMutationLockPath(configDir?: string): string { + return join(labCommunityDir(configDir), LEGACY_MUTATION_LOCK_NAME); } function pidDefinitelyDead(pid: number): boolean { @@ -66,47 +48,22 @@ function pidDefinitelyDead(pid: number): boolean { } } -function exceedsAbsoluteLockAge(createdAt: number, nowMs: number): boolean { - return nowMs - createdAt > PUBLIC_EVIDENCE_MUTATION_LOCK_ABSOLUTE_STALE_MS; -} - -function readLockMetadata( - path: string, -): T | null { +function readLegacyMutationLockOwner(lockPath: string): LegacyMutationLockOwner | null { try { - const bytes = readPrivateRegularFile(path, MUTATION_LOCK_META_FILE_OPTIONS); - const raw = JSON.parse(bytes.toString("utf8")) as Partial; + const bytes = readPrivateRegularFile(join(lockPath, LEGACY_MUTATION_LOCK_OWNER), LEGACY_OWNER_FILE_OPTIONS); + const raw = JSON.parse(bytes.toString("utf8")) as Partial; if ( - Number.isSafeInteger(raw.pid) - && Number(raw.pid) > 0 - && typeof raw.token === "string" - && /^[0-9a-f-]{36}$/.test(raw.token) - && Number.isSafeInteger(raw.createdAt) - && Number(raw.createdAt) > 0 - ) { - return { - pid: Number(raw.pid), - token: raw.token, - createdAt: Number(raw.createdAt), - } as T; - } + Number.isSafeInteger(raw.pid) && Number(raw.pid) > 0 + && Number.isSafeInteger(raw.createdAt) && Number(raw.createdAt) > 0 + ) return { pid: Number(raw.pid), createdAt: Number(raw.createdAt) }; } catch { - // Incomplete metadata is handled conservatively by the age fallback where - // applicable. Normal acquisition never trusts malformed metadata. + // Missing or malformed metadata falls back to the directory-age rule below. } return null; } -function readMutationLockOwner(lockPath: string): MutationLockOwner | null { - return readLockMetadata(mutationLockOwnerPath(lockPath)); -} - -function readMutationLockReclaim(lockPath: string): MutationLockReclaim | null { - return readLockMetadata(mutationLockReclaimPath(lockPath)); -} - function assertMutationLockDirectory(lockPath: string) { - const stat = lstatSync(lockPath); + const stat = lstatSync(lockPath, { bigint: true }); if (!stat.isDirectory() || stat.isSymbolicLink()) { throw new PublicEvidenceValidationError( "community_cache_lock", @@ -116,309 +73,124 @@ function assertMutationLockDirectory(lockPath: string) { return stat; } -function sameDirectoryIdentity( - left: MutationLockDirectoryIdentity, - right: MutationLockDirectoryIdentity, -): boolean { - return left.dev === right.dev && left.ino === right.ino; -} - -function currentDirectoryIdentity(lockPath: string): MutationLockDirectoryIdentity { - const stat = assertMutationLockDirectory(lockPath); - return { dev: stat.dev, ino: stat.ino }; -} - -function mutationLockIsReclaimable(lockPath: string, nowMs: number): boolean { - const stat = assertMutationLockDirectory(lockPath); - const owner = readMutationLockOwner(lockPath); - if (owner) { - // A live PID is strong evidence only while the recorded ownership generation is - // reasonably recent. The absolute ceiling recovers from PID reuse after a crash. - return pidDefinitelyDead(owner.pid) || exceedsAbsoluteLockAge(owner.createdAt, nowMs); - } - // The only ownerless state is the tiny mkdir-to-owner publication window. Use - // a deliberately long fallback so a crashed acquisition can eventually heal - // without treating an ordinary pause as proof that the owner disappeared. - return nowMs - stat.mtimeMs > PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS; -} - -function unlinkIfPresent(path: string): void { +/** + * Whether a directory lock left by an older OpenCodex build still has a holder. + * + * Older builds serialized through a `.mutation-lock` directory. This build never creates, + * renames or removes one; it only declines to run beside a holder that still looks alive, using + * the same evidence the older builds used: a live owner PID within the absolute age ceiling, or + * an ownerless directory recent enough to be an acquisition in progress. + */ +function legacyMutationLockBlocks(lockPath: string, nowMs: number): boolean { + let stat: ReturnType; try { - unlinkSync(path); + stat = assertMutationLockDirectory(lockPath); } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } + const owner = readLegacyMutationLockOwner(lockPath); + if (owner) { + return !pidDefinitelyDead(owner.pid) && nowMs - owner.createdAt <= LEGACY_MUTATION_LOCK_ABSOLUTE_STALE_MS; } + return nowMs - statTimeMs(stat.mtimeNs) <= LEGACY_MUTATION_LOCK_INCOMPLETE_STALE_MS; } -function cleanupDetachedMutationLocks(lockPath: string): void { - const dir = dirname(lockPath); +function cleanupDetachedMutationLocks(configDir: string | undefined, nowMs: number): void { + const dir = labCommunityDir(configDir); for (const name of readdirSync(dir)) { if (!DETACHED_MUTATION_LOCK_RE.test(name)) continue; - // Detached lock directories are no longer authoritative once a new canonical - // lock has been acquired. Removing only their UUID-scoped names prevents them - // from leaking storage or being mistaken for community cache objects. - rmSync(join(dir, name), { recursive: true, force: true }); + const detachedPath = join(dir, name); + // Older builds moved a released or reclaimed lock directory to one of these UUID-scoped + // names before deleting it. One that still records a live owner may belong to a running + // older-build holder, so it is left alone. Removing the rest keeps them from leaking + // storage or being mistaken for community cache objects. + const owner = readLegacyMutationLockOwner(detachedPath); + if (owner && !pidDefinitelyDead(owner.pid) && nowMs - owner.createdAt <= LEGACY_MUTATION_LOCK_ABSOLUTE_STALE_MS) continue; + rmSync(detachedPath, { recursive: true, force: true }); } } -function publishMutationLockOwner( - lockPath: string, - owner: MutationLockOwner, - expectedDirectory: MutationLockDirectoryIdentity, -): void { - // Write through a unique temporary pathname first. If an ancient ownerless - // lock is reclaimed while this process was suspended, the inode check prevents - // this acquisition from publishing its owner metadata into a replacement lock. - const tempPath = join(lockPath, `.owner-${owner.token}.tmp`); - try { - writeFileSync(tempPath, JSON.stringify(owner), { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), expectedDirectory)) { - throw new PublicEvidenceValidationError( - "community_cache_lock", - "community cache mutation lock changed during owner publication", - ); - } - renameSync(tempPath, mutationLockOwnerPath(lockPath)); - if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), expectedDirectory)) { - throw new PublicEvidenceValidationError( - "community_cache_lock", - "community cache mutation lock changed after owner publication", - ); - } - const persisted = readMutationLockOwner(lockPath); - if (!persisted || persisted.pid !== owner.pid || persisted.token !== owner.token) { - throw new PublicEvidenceValidationError( - "community_cache_lock", - "community cache mutation lock owner publication was not durable", - ); - } - } finally { - // The rename normally makes this ENOENT. If the lock pathname was replaced, - // the UUID-scoped temporary name can be removed without touching successor state. - unlinkIfPresent(tempPath); +/** The lock file's identity, after proving it is a single-link regular file. */ +function mutationLockFileIdentity(path: string): FileIdentity { + const stat = lstatSync(path, { bigint: true }); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) { + throw new PublicEvidenceValidationError("community_cache_lock", "community cache mutation lock is unsafe"); } + return { dev: stat.dev, ino: stat.ino }; } -function reclaimClaimIsRecoverable(lockPath: string, nowMs: number): boolean { - const claimPath = mutationLockReclaimPath(lockPath); - const claim = readMutationLockReclaim(lockPath); - if (claim) { - return pidDefinitelyDead(claim.pid) || exceedsAbsoluteLockAge(claim.createdAt, nowMs); - } +/** + * Open the lock database on a file this module created as a private regular file. + * + * The Lab root is owner-only, so the processes that can replace this pathname are the user's own, + * the same trust boundary as the config mutation lock. Within it, the file is created exclusively + * with mode 0600 (never chmod-ed through the pathname), and the caller re-proves after locking + * that the pathname still names the file inspected before the open. + */ +function openMutationLockDatabase(path: string): { database: Database; identity: FileIdentity } { try { - const stat = lstatSync(claimPath); - return nowMs - stat.mtimeMs > PUBLIC_EVIDENCE_MUTATION_LOCK_INCOMPLETE_STALE_MS; + writeFileSync(path, "", { flag: "wx", mode: 0o600 }); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; - throw error; + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } -} - -function recoverStaleReclaimClaim(lockPath: string, nowMs: number): boolean { - if (!reclaimClaimIsRecoverable(lockPath, nowMs)) return false; - const claimPath = mutationLockReclaimPath(lockPath); - const quarantinePath = join(lockPath, `.reclaim-stale-${randomUUID()}.json`); + const identity = mutationLockFileIdentity(path); try { - renameSync(claimPath, quarantinePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; - throw error; - } - unlinkIfPresent(quarantinePath); - return true; -} - -function tryAcquireReclaimClaim( - lockPath: string, - nowMs: number, -): MutationLockReclaim | null { - for (let attempt = 0; attempt < 2; attempt += 1) { - const claim: MutationLockReclaim = { - pid: process.pid, - token: randomUUID(), - createdAt: nowMs, - }; - try { - writeFileSync(mutationLockReclaimPath(lockPath), JSON.stringify(claim), { - encoding: "utf8", - flag: "wx", - mode: 0o600, - }); - return claim; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") return null; - if (code !== "EEXIST") throw error; - if (!recoverStaleReclaimClaim(lockPath, nowMs)) return null; - } - } - return null; -} - -function reclaimClaimStillOwned(lockPath: string, claim: MutationLockReclaim): boolean { - const current = readMutationLockReclaim(lockPath); - return current?.pid === claim.pid && current.token === claim.token; -} - -function releaseReclaimClaim(lockPath: string, claim: MutationLockReclaim): void { - if (!reclaimClaimStillOwned(lockPath, claim)) return; - unlinkIfPresent(mutationLockReclaimPath(lockPath)); -} - -function tryReclaimMutationLock(lockPath: string, nowMs: number): boolean { - // Capture the exact stale directory before the claim write changes its mtime. - // After claiming, revalidate the inode and owner instead of reusing an age check - // that our own .reclaim.json creation would make appear fresh. - if (!mutationLockIsReclaimable(lockPath, nowMs)) return false; - const staleDirectory = currentDirectoryIdentity(lockPath); - const claim = tryAcquireReclaimClaim(lockPath, nowMs); - if (!claim) return false; - - let moved = false; - try { - if (!reclaimClaimStillOwned(lockPath, claim)) return false; - if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), staleDirectory)) return false; - const currentOwner = readMutationLockOwner(lockPath); - if ( - currentOwner - && !pidDefinitelyDead(currentOwner.pid) - && !exceedsAbsoluteLockAge(currentOwner.createdAt, nowMs) - ) return false; - if (!reclaimClaimStillOwned(lockPath, claim)) return false; - - const quarantinePath = join( - dirname(lockPath), - `.mutation-lock-stale-${process.pid}-${randomUUID()}`, - ); - try { - // Rename the exact claimed directory away from the canonical pathname before - // deleting it. A successor can create a new lock immediately afterwards, but - // cleanup is confined to this unique quarantine path and cannot delete it. - renameSync(lockPath, quarantinePath); - moved = true; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return true; - throw error; - } - rmSync(quarantinePath, { recursive: true, force: true }); - return true; - } finally { - if (!moved) releaseReclaimClaim(lockPath, claim); - } -} - -function discardUncommittedMutationLock( - lockPath: string, - expectedDirectory: MutationLockDirectoryIdentity, -): void { - try { - if (!sameDirectoryIdentity(currentDirectoryIdentity(lockPath), expectedDirectory)) return; - const quarantinePath = join( - dirname(lockPath), - `.mutation-lock-release-${process.pid}-${randomUUID()}`, - ); - renameSync(lockPath, quarantinePath); - rmSync(quarantinePath, { recursive: true, force: true }); + return { database: new Database(path, { create: false, readwrite: true }), identity }; } catch { - // Preserve the owner-publication error. An unrecoverable cleanup witness stays - // ownerless and can be reclaimed by the long incomplete-acquisition fallback. + // The SQLite error text can name local paths; the stable code is what callers act on. + throw new PublicEvidenceValidationError("community_cache_lock", "community cache mutation lock is unavailable"); } } -function releaseMutationLock( - lockPath: string, - owner: MutationLockOwner, - expectedDirectory: MutationLockDirectoryIdentity, -): void { - let currentDirectory: MutationLockDirectoryIdentity; - try { - currentDirectory = currentDirectoryIdentity(lockPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - if (!sameDirectoryIdentity(currentDirectory, expectedDirectory)) return; - const current = readMutationLockOwner(lockPath); - if (!current || current.pid !== owner.pid || current.token !== owner.token) return; - - const quarantinePath = join( - dirname(lockPath), - `.mutation-lock-release-${process.pid}-${randomUUID()}`, - ); - try { - renameSync(lockPath, quarantinePath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") return; - throw error; - } - rmSync(quarantinePath, { recursive: true, force: true }); -} - -/** Serialize public-evidence mutations across CLI/server processes with ownership-safe stale recovery. */ +/** + * Serialize public-evidence mutations across CLI/server processes. + * + * The lock is an OS-held SQLite write transaction on a dedicated database, with + * `busy_timeout=0` so a second holder fails fast as `community_cache_busy`. Closing the handle, + * including at process exit or crash, releases it, so there is no stale-owner recovery and no + * step that renames or deletes another process's lock state. Never return a Promise from `run`. + */ export function withPublicEvidenceMutationLock( configDir: string | undefined, run: () => T, ): T { ensureLabDirs(configDir); - const lockPath = mutationLockPath(configDir); - let owner: MutationLockOwner | null = null; - let ownedDirectory: MutationLockDirectoryIdentity | null = null; - - while (true) { + const path = mutationLockDatabasePath(configDir); + const { database, identity } = openMutationLockDatabase(path); + let transactionOpen = false; + try { try { - mkdirSync(lockPath, { mode: 0o700 }); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; - try { - if (tryReclaimMutationLock(lockPath, Date.now())) continue; - } catch (reclaimError) { - if ((reclaimError as NodeJS.ErrnoException).code === "ENOENT") continue; - throw reclaimError; + database.exec("PRAGMA busy_timeout = 0; BEGIN IMMEDIATE"); + transactionOpen = true; + } catch (cause) { + const code = cause && typeof cause === "object" && "code" in cause ? String((cause as { code?: unknown }).code) : ""; + if (code.startsWith("SQLITE_BUSY") || code.startsWith("SQLITE_LOCKED")) { + throw new PublicEvidenceValidationError("community_cache_busy", "community cache is busy"); } - throw new PublicEvidenceValidationError("community_cache_busy", "community cache is busy"); + throw new PublicEvidenceValidationError("community_cache_lock", "community cache mutation lock is unavailable"); } - - const directory = currentDirectoryIdentity(lockPath); - const candidate: MutationLockOwner = { - pid: process.pid, - token: randomUUID(), - createdAt: Date.now(), - }; - try { - publishMutationLockOwner(lockPath, candidate, directory); - } catch (error) { - discardUncommittedMutationLock(lockPath, directory); - throw error; + if (!sameFileIdentity(mutationLockFileIdentity(path), identity)) { + throw new PublicEvidenceValidationError("community_cache_lock", "community cache mutation lock changed while opening"); } - owner = candidate; - ownedDirectory = directory; - break; - } - - try { - cleanupDetachedMutationLocks(lockPath); + const nowMs = Date.now(); + if (legacyMutationLockBlocks(legacyMutationLockPath(configDir), nowMs)) { + throw new PublicEvidenceValidationError("community_cache_busy", "community cache is busy"); + } + cleanupDetachedMutationLocks(configDir, nowMs); return run(); } finally { - if (owner && ownedDirectory) releaseMutationLock(lockPath, owner, ownedDirectory); + if (transactionOpen) { + try { database.exec("ROLLBACK"); } catch { /* closing the handle below still releases the OS lock */ } + } + try { database.close(); } catch { /* the OS lock is released with the handle */ } } } -/** Test-only seam for stale-owner policy. This module is not barrel-exported. */ -export function publicEvidenceMutationLockIsReclaimableForTests( - configDir: string | undefined, - nowMs = Date.now(), -): boolean { - return mutationLockIsReclaimable(mutationLockPath(configDir), nowMs); -} - -/** Test-only seam for the exclusive stale-reclaimer claim. */ -export function publicEvidenceTryReclaimMutationLockForTests( +/** Test-only seam for the older-build lock policy. This module is not barrel-exported. */ +export function publicEvidenceLegacyMutationLockBlocksForTests( configDir: string | undefined, nowMs = Date.now(), ): boolean { - return tryReclaimMutationLock(mutationLockPath(configDir), nowMs); + return legacyMutationLockBlocks(legacyMutationLockPath(configDir), nowMs); } diff --git a/src/lab/public/private-file.ts b/src/lab/public/private-file.ts index 65e98325f1a..ec2b76937c2 100644 --- a/src/lab/public/private-file.ts +++ b/src/lab/public/private-file.ts @@ -12,6 +12,7 @@ import { } from "node:fs"; import type { Stats } from "node:fs"; import { basename, dirname, join } from "node:path"; +import { sameFileIdentity } from "../../lib/file-identity"; export type PrivateFileCommitFault = "before_publish" | "parent_directory_sync" | null; let privateFileCommitFaultForTests: PrivateFileCommitFault = null; @@ -152,7 +153,7 @@ export function cleanupStalePrivateFileStages(finalPath: string): void { function cleanupPublishedPrivateFileStages(finalPath: string): void { let finalStats; try { - finalStats = lstatSync(finalPath); + finalStats = lstatSync(finalPath, { bigint: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return; throw error; @@ -166,9 +167,9 @@ function cleanupPublishedPrivateFileStages(finalPath: string): void { if (!name.startsWith(prefix) || !PRIVATE_STAGE_RE.test(name)) continue; const stagePath = join(dir, name); try { - const stageStats = lstatSync(stagePath); + const stageStats = lstatSync(stagePath, { bigint: true }); if (!stageStats.isFile() || stageStats.isSymbolicLink()) continue; - if (stageStats.dev !== finalStats.dev || stageStats.ino !== finalStats.ino) continue; + if (!sameFileIdentity(stageStats, finalStats)) continue; unlinkSync(stagePath); changed = true; } catch (error) { diff --git a/structure/adapters/compatibility-lab.md b/structure/adapters/compatibility-lab.md index 72d374743fd..5984aca235d 100644 --- a/structure/adapters/compatibility-lab.md +++ b/structure/adapters/compatibility-lab.md @@ -168,12 +168,26 @@ CL-05 adds a read-only Models tab (`#models/compatibility`) that visualizes the ## Public-evidence mutation, purge and revocation -Public-evidence mutation is serialized across processes by `src/lab/public/mutation-lock.ts`. A live, -non-reclaimable owner is a fail-fast condition: the caller receives `PublicEvidenceValidationError` -code `community_cache_busy` without running the protected work, and +Public-evidence mutation is serialized among cooperating current-build processes by `src/lab/public/mutation-lock.ts`: an +OS-held SQLite write transaction (`BEGIN IMMEDIATE`, `busy_timeout=0`) on +`lab/public-evidence-mutation.sqlite`, outside the community cache, the same primitive as the +config mutation lock. A second holder is a fail-fast condition: the caller receives +`PublicEvidenceValidationError` code `community_cache_busy` without running the protected work, and `src/server/management/lab-routes.ts` maps that code to HTTP 503 with `Retry-After: 1`. Other -public-evidence validation failures stay 400. Rejection leaves the owner's lock bytes and directory -identity untouched. +public-evidence validation failures stay 400. Closing the handle, including at process exit or +crash, releases the lock, so there is no stale-owner recovery and no step renames or removes another +process's lock state. A lock path that is not a single-link regular file fails closed. The lock +assumes the owner-only Lab root and does not exclude a hostile process running as the same user. +Concurrent mutations across older and current builds during upgrades are unsupported. + +Older builds serialized through a `lab/community/.mutation-lock` directory. While holding the +transaction, this build still refuses to run beside such a directory whose owner PID is alive +within the seven-day ceiling, or which is ownerless and less than a day old, and fails closed when +that path is not a directory. It never creates, moves or removes that directory. It removes +older builds' UUID-named detached copies while preserving a live older holder. +`tests/lab/lab-community-mutation-lock.test.ts` +covers cross-process refusal, release on crash, and that acquire and release never rename or +remove the canonical lock path. Sensitive purge removes a community cache pathname that durable local provenance marks as locally originated, even when the cached object is oversized, hardlinked, symlinked or otherwise unreadable diff --git a/tests/lab/lab-community-mutation-lock.test.ts b/tests/lab/lab-community-mutation-lock.test.ts index 7c3132d155d..c7de91d580b 100644 --- a/tests/lab/lab-community-mutation-lock.test.ts +++ b/tests/lab/lab-community-mutation-lock.test.ts @@ -1,16 +1,18 @@ import { afterEach, describe, expect, spyOn, test } from "bun:test"; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from "node:fs"; +import * as fs from "node:fs"; +import { existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, utimesSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { ensureLabDirs, labCommunityDir } from "../../src/lab/paths"; +import { ensureLabDirs, labCommunityDir, labRoot } from "../../src/lab/paths"; import { listCommunityEvidence } from "../../src/lab/public/community"; import { - publicEvidenceMutationLockIsReclaimableForTests, - publicEvidenceTryReclaimMutationLockForTests, + publicEvidenceLegacyMutationLockBlocksForTests, withPublicEvidenceMutationLock, } from "../../src/lab/public/mutation-lock"; import { PublicEvidenceValidationError } from "../../src/lab/public/validate"; import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; +import { SPAWN_BUDGET_MS } from "../helpers/test-budget"; const roots: string[] = []; @@ -25,99 +27,221 @@ function configDir(): string { return root; } -function createLiveOwnerLock(config: string): string { - const lockPath = join(labCommunityDir(config), ".mutation-lock"); +function legacyLockPath(config: string): string { + return join(labCommunityDir(config), ".mutation-lock"); +} + +function createLegacyOwnerLock(config: string, createdAt = Date.now(), pid = process.pid): string { + const lockPath = legacyLockPath(config); mkdirSync(lockPath, { mode: 0o700 }); writeFileSync( join(lockPath, "owner.json"), - JSON.stringify({ - pid: process.pid, - token: "00000000-0000-4000-8000-000000000000", - createdAt: Date.now(), - }), + JSON.stringify({ pid, token: "00000000-0000-4000-8000-000000000000", createdAt }), { encoding: "utf8", mode: 0o600 }, ); return lockPath; } +function busyFailure(run: () => unknown): PublicEvidenceValidationError { + let failure: unknown; + try { run(); } catch (error) { failure = error; } + expect(failure).toBeInstanceOf(PublicEvidenceValidationError); + expect((failure as PublicEvidenceValidationError).code).toBe("community_cache_busy"); + return failure as PublicEvidenceValidationError; +} + describe("community mutation lock", () => { - test("recovers an ancient incomplete lock before reading committed cache state", () => { + test("acquire and release never rename or remove the canonical lock path or its contents", () => { const config = configDir(); - const lockPath = join(labCommunityDir(config), ".mutation-lock"); - mkdirSync(lockPath, { mode: 0o700 }); - const stale = new Date(Date.now() - (25 * 60 * 60 * 1000)); - utimesSync(lockPath, stale, stale); + const lockPath = legacyLockPath(config); + const touched: string[] = []; + const watch = (name: string, path: fs.PathLike) => { + const text = String(path); + if (text === lockPath || text.startsWith(lockPath + "\\") || text.startsWith(lockPath + "/")) touched.push(`${name} ${text}`); + }; + const realRename = fs.renameSync, realRm = fs.rmSync, realRmdir = fs.rmdirSync, realUnlink = fs.unlinkSync; + const spies = [ + spyOn(fs, "renameSync").mockImplementation(((from, to) => { watch("rename", from); return realRename(from, to); }) as typeof fs.renameSync), + spyOn(fs, "rmSync").mockImplementation(((path, options) => { watch("rm", path); return realRm(path, options); }) as typeof fs.rmSync), + spyOn(fs, "rmdirSync").mockImplementation(((path, options) => { watch("rmdir", path); return realRmdir(path, options); }) as typeof fs.rmdirSync), + spyOn(fs, "unlinkSync").mockImplementation(((path) => { watch("unlink", path); return realUnlink(path); }) as typeof fs.unlinkSync), + ]; + let ran = false; + try { + withPublicEvidenceMutationLock(config, () => { ran = true; }); + // An older build's ancient lock no longer blocks, and it is still never moved or removed. + mkdirSync(lockPath, { mode: 0o700 }); + const stale = new Date(Date.now() - (25 * 60 * 60 * 1000)); + utimesSync(lockPath, stale, stale); + withPublicEvidenceMutationLock(config, () => {}); + } finally { + for (const spy of spies) spy.mockRestore(); + } + expect(ran).toBe(true); + expect(touched).toEqual([]); + expect(existsSync(lockPath)).toBe(true); + }); - expect(listCommunityEvidence(config)).toEqual([]); - expect(existsSync(lockPath)).toBe(false); + test("a second holder in the same process is refused without running its work", () => { + const config = configDir(); + let inner = false, outer = false; + withPublicEvidenceMutationLock(config, () => { + busyFailure(() => withPublicEvidenceMutationLock(config, () => { inner = true; })); + outer = true; + }); + expect(outer).toBe(true); + expect(inner).toBe(false); + let after = false; + withPublicEvidenceMutationLock(config, () => { after = true; }); + expect(after).toBe(true); }); - test("does not reclaim an old lock while its recorded owner process is alive", () => { + test("a holder that throws releases the lock", () => { const config = configDir(); - const lockPath = join(labCommunityDir(config), ".mutation-lock"); - mkdirSync(lockPath, { mode: 0o700 }); - writeFileSync( - join(lockPath, "owner.json"), - JSON.stringify({ - pid: process.pid, - token: "00000000-0000-4000-8000-000000000000", - createdAt: Date.now() - (25 * 60 * 60 * 1000), - }), - { encoding: "utf8", mode: 0o600 }, - ); + expect(() => withPublicEvidenceMutationLock(config, () => { throw new Error("protected work failed"); })) + .toThrow("protected work failed"); + let ran = false; + withPublicEvidenceMutationLock(config, () => { ran = true; }); + expect(ran).toBe(true); + }); - expect(publicEvidenceMutationLockIsReclaimableForTests(config)).toBe(false); - expect(existsSync(lockPath)).toBe(true); + test("another process holding the lock is refused, and its crash releases the lock", async () => { + const config = configDir(); + const child = Bun.spawn([process.execPath, "-e", [ + `const { writeSync } = require("node:fs");`, + `const { withPublicEvidenceMutationLock } = require(${JSON.stringify(repoPath("src/lab/public/mutation-lock.ts"))});`, + `withPublicEvidenceMutationLock(${JSON.stringify(config)}, () => {`, + ` writeSync(1, "held\\n");`, + ` Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ${SPAWN_BUDGET_MS});`, + `});`, + ].join("\n")], { env: process.env, stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + try { + const reader = child.stdout.getReader(); + let seen = ""; + while (!seen.includes("held\n")) { + const chunk = await reader.read(); + if (chunk.done) throw new Error(`lock holder exited early: ${await new Response(child.stderr).text()}`); + seen += new TextDecoder().decode(chunk.value); + } + reader.releaseLock(); + let ran = false; + busyFailure(() => withPublicEvidenceMutationLock(config, () => { ran = true; })); + expect(ran).toBe(false); + child.kill("SIGKILL"); + await child.exited; + withPublicEvidenceMutationLock(config, () => { ran = true; }); + expect(ran).toBe(true); + } finally { + if (child.exitCode === null) child.kill("SIGKILL"); + await child.exited; + } + }, SPAWN_BUDGET_MS); + + test("the lock database lives outside the community cache and is never followed when unsafe", () => { + const config = configDir(); + withPublicEvidenceMutationLock(config, () => {}); + const lockDb = join(labRoot(config), "public-evidence-mutation.sqlite"); + expect(lstatSync(lockDb).isFile()).toBe(true); + expect(listCommunityEvidence(config)).toEqual([]); + + const linked = configDir(); + const target = join(linked, "elsewhere.sqlite"); + writeFileSync(target, ""); + linkSync(target, join(labRoot(linked), "public-evidence-mutation.sqlite")); + expect(() => withPublicEvidenceMutationLock(linked, () => {})).toThrow(/mutation lock is unsafe/); + + const directory = configDir(); + mkdirSync(join(labRoot(directory), "public-evidence-mutation.sqlite")); + let ran = false; + expect(() => withPublicEvidenceMutationLock(directory, () => { ran = true; })).toThrow(/mutation lock is unsafe/); + expect(ran).toBe(false); }); - test("rejects a live owner after one check without running protected work or changing ownership", () => { + test("an older build's live directory lock is honoured once and left untouched", () => { const config = configDir(); - const lockPath = createLiveOwnerLock(config); - const lockBefore = lstatSync(lockPath); + const lockPath = createLegacyOwnerLock(config); + const lockBefore = lstatSync(lockPath, { bigint: true }); const ownerBefore = readFileSync(join(lockPath, "owner.json")); let ranProtectedWork = false; - let failure: unknown; - // Observe the real signal-zero owner check. A retry loop must not poll a - // live owner before eventually returning the same refusal. + // Observe the real signal-zero owner check: exactly one, with no retry loop. const ownerCheck = spyOn(process, "kill"); try { - try { - withPublicEvidenceMutationLock(config, () => { ranProtectedWork = true; }); - } catch (error) { - failure = error; - } - - expect(failure).toBeInstanceOf(PublicEvidenceValidationError); - expect((failure as PublicEvidenceValidationError).code).toBe("community_cache_busy"); - expect(ranProtectedWork).toBe(false); - expect(readFileSync(join(lockPath, "owner.json"))).toEqual(ownerBefore); - const lockAfter = lstatSync(lockPath); - expect([lockAfter.dev, lockAfter.ino]).toEqual([lockBefore.dev, lockBefore.ino]); + busyFailure(() => withPublicEvidenceMutationLock(config, () => { ranProtectedWork = true; })); expect(ownerCheck).toHaveBeenCalledTimes(1); expect(ownerCheck).toHaveBeenCalledWith(process.pid, 0); } finally { ownerCheck.mockRestore(); } + expect(ranProtectedWork).toBe(false); + expect(readFileSync(join(lockPath, "owner.json"))).toEqual(ownerBefore); + const lockAfter = lstatSync(lockPath, { bigint: true }); + expect([lockAfter.dev, lockAfter.ino]).toEqual([lockBefore.dev, lockBefore.ino]); }); - test("a competing reclaim claim prevents a second stale reclaimer from deleting the lock", () => { + test("an older build's recent ownerless directory blocks, and an ancient one does not", () => { const config = configDir(); - const lockPath = join(labCommunityDir(config), ".mutation-lock"); + const lockPath = legacyLockPath(config); mkdirSync(lockPath, { mode: 0o700 }); + expect(publicEvidenceLegacyMutationLockBlocksForTests(config)).toBe(true); + const stale = new Date(Date.now() - (25 * 60 * 60 * 1000)); utimesSync(lockPath, stale, stale); - writeFileSync( - join(lockPath, ".reclaim.json"), - JSON.stringify({ token: "00000000-0000-4000-8000-000000000000" }), - { encoding: "utf8", mode: 0o600 }, - ); - - expect(publicEvidenceTryReclaimMutationLockForTests(config)).toBe(false); + expect(publicEvidenceLegacyMutationLockBlocksForTests(config)).toBe(false); + expect(listCommunityEvidence(config)).toEqual([]); expect(existsSync(lockPath)).toBe(true); }); - test("cleans detached lock quarantine before scanning community quota state", () => { + test("an older build's lock with a live owner blocks even when old enough for the ownerless rule", () => { + const config = configDir(); + createLegacyOwnerLock(config, Date.now() - (25 * 60 * 60 * 1000)); + expect(publicEvidenceLegacyMutationLockBlocksForTests(config)).toBe(true); + }); + + test("a lock file replaced between inspection and locking is refused before protected work", () => { + const config = configDir(); + const lockDb = join(labRoot(config), "public-evidence-mutation.sqlite"); + const original = fs.lstatSync; + let observations = 0; + const spy = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stats = options?.bigint ? original(path, { bigint: true }) : original(path); + if (String(path) !== lockDb) return stats; + // Adjacent NTFS-sized identities: equal as numbers, distinct as bigints. + const ino = 9_007_199_254_740_992n + BigInt(Math.min(observations++, 1)); + return Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.lstatSync); + let ran = false; + try { + expect(() => withPublicEvidenceMutationLock(config, () => { ran = true; })).toThrow(/changed while opening/); + } finally { spy.mockRestore(); } + expect(observations).toBe(2); + expect(ran).toBe(false); + let after = false; + withPublicEvidenceMutationLock(config, () => { after = true; }); + expect(after).toBe(true); + }); + + test.skipIf(process.platform === "win32")("the lock file is created private to the user", () => { + const config = configDir(); + withPublicEvidenceMutationLock(config, () => {}); + expect(lstatSync(join(labRoot(config), "public-evidence-mutation.sqlite")).mode & 0o777).toBe(0o600); + }); + + test("detached cleanup keeps an older build's moved lock whose owner is alive", () => { + const config = configDir(); + const live = join(labCommunityDir(config), ".mutation-lock-release-123-11111111-1111-4111-8111-111111111111"); + mkdirSync(live, { mode: 0o700 }); + writeFileSync(join(live, "owner.json"), + JSON.stringify({ pid: process.pid, token: "11111111-1111-4111-8111-111111111111", createdAt: Date.now() }), + { encoding: "utf8", mode: 0o600 }); + withPublicEvidenceMutationLock(config, () => {}); + expect(existsSync(join(live, "owner.json"))).toBe(true); + // A kept lock directory is lock bookkeeping, not a cache object: scans skip it. + expect(listCommunityEvidence(config)).toEqual([]); + expect(existsSync(join(live, "owner.json"))).toBe(true); + }); + + test("cleans an older build's detached lock quarantine before scanning community quota state", () => { const config = configDir(); const quarantinePath = join( labCommunityDir(config), @@ -130,9 +254,9 @@ describe("community mutation lock", () => { expect(existsSync(quarantinePath)).toBe(false); }); - test("fails closed when the lock path is not a directory", () => { + test("fails closed when the older build's lock path is not a directory", () => { const config = configDir(); - writeFileSync(join(labCommunityDir(config), ".mutation-lock"), "unsafe", "utf8"); + writeFileSync(legacyLockPath(config), "unsafe", "utf8"); expect(() => listCommunityEvidence(config)).toThrow(/mutation lock is not a directory/i); }); diff --git a/tests/lab/lab-fabric-task.test.ts b/tests/lab/lab-fabric-task.test.ts index 4ee3acaa5b0..7f7e0def9b8 100644 --- a/tests/lab/lab-fabric-task.test.ts +++ b/tests/lab/lab-fabric-task.test.ts @@ -1,3 +1,4 @@ +import { samePath } from "../helpers/same-path"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import * as nodeFs from "node:fs"; import { @@ -70,6 +71,25 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; import { repoRoot } from "../helpers/repo-root"; const REPO_ROOT = repoRoot(); + +test("scratch directory rejects an adjacent NTFS-sized identity", () => { + const root = nodeFs.mkdtempSync(join(tmpdir(), "ocx-scratch-identity-")); + const originalFstat = nodeFs.fstatSync, originalLstat = nodeFs.lstatSync; + nodeFs.writeFileSync(join(root, "value"), "test"); + const fdSpy = spyOn(nodeFs, "fstatSync").mockImplementation(((fd, options) => { + const stats = options?.bigint ? originalFstat(fd, { bigint: true }) : originalFstat(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: options?.bigint ? 9_007_199_254_740_992n : Number(9_007_199_254_740_992n) }); + }) as typeof nodeFs.fstatSync); + const pathSpy = spyOn(nodeFs, "lstatSync").mockImplementation(((path, options) => { + const stats = options?.bigint ? originalLstat(path, { bigint: true }) : originalLstat(path); + return samePath(path, root) && options?.bigint ? Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: 9_007_199_254_740_993n }) : samePath(path, root) ? Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: Number(9_007_199_254_740_993n) }) : stats; + }) as typeof nodeFs.lstatSync); + try { expect(() => readScratchFileUtf8(root, "value", 1024)).toThrow("scratch directory identity changed"); } + finally { fdSpy.mockRestore(); pathSpy.mockRestore(); removeTreeWithRetry(root); } +}); const CHILD_REAP_GRACE_MS = 2_000; async function awaitChildExitWithin(child: Bun.Subprocess, timeoutMs: number): Promise { diff --git a/tests/lab/lab-post-merge-hardening.test.ts b/tests/lab/lab-post-merge-hardening.test.ts index 6504d7606aa..1ba8eb38035 100644 --- a/tests/lab/lab-post-merge-hardening.test.ts +++ b/tests/lab/lab-post-merge-hardening.test.ts @@ -1,4 +1,5 @@ -import { afterEach, expect, test } from "bun:test"; +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -28,6 +29,26 @@ import { removeTreeWithRetry } from "../helpers/remove-tree"; const HOMES: string[] = []; const previousHome = process.env.OPENCODEX_HOME; +test("artifact directory rejects an adjacent NTFS-sized identity", () => { + const path = join(tempHome(), "artifacts"); + const original = fs.fstatSync; + let observedFd: number | undefined, observations = 0; + const spy = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stats = options?.bigint ? original(fd, { bigint: true }) : original(fd); + if (observedFd === undefined) observedFd = fd; + if (fd !== observedFd) return stats; + const ino = 9_007_199_254_740_992n + BigInt(Math.min(observations++, 1)); + return Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.fstatSync); + let dir: ReturnType | undefined; + try { + dir = openTrustedArtifactDir(path); + expect(() => putNamedDigestBytes(dir!, "a".repeat(64), Buffer.from("test"), () => "a".repeat(64))) + .toThrow("artifacts directory identity changed"); + } finally { if (dir) closeTrustedArtifactDir(dir); spy.mockRestore(); } +}); + function tempHome(): string { const dir = join(tmpdir(), `ocx-lab-hardening-${process.pid}-${Math.random().toString(16).slice(2)}`); mkdirSync(dir, { recursive: true, mode: 0o700 }); diff --git a/tests/lab/lab-private-file-durability.test.ts b/tests/lab/lab-private-file-durability.test.ts index 8956451a29c..0f5b9149623 100644 --- a/tests/lab/lab-private-file-durability.test.ts +++ b/tests/lab/lab-private-file-durability.test.ts @@ -1,4 +1,6 @@ -import { afterEach, describe, expect, test } from "bun:test"; +import { samePath } from "../helpers/same-path"; +import { afterEach, describe, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { existsSync, mkdtempSync, readFileSync, readdirSync, utimesSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -27,6 +29,24 @@ function tempRoot(): string { } describe("CL-10 private-file durability", () => { + test("published-stage cleanup preserves an adjacent NTFS-sized replacement", () => { + const root = tempRoot(), finalPath = join(root, "bundle.json"); + const stagePath = join(root, `.bundle.json.${process.pid}.00000000-0000-4000-8000-000000000000.tmp`); + fs.writeFileSync(finalPath, "final"); + fs.writeFileSync(stagePath, "replacement"); + const original = fs.lstatSync; + const spy = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stats = options?.bigint ? original(path, { bigint: true }) : original(path); + if (!samePath(path, finalPath) && !samePath(path, stagePath)) return stats; + const ino = 9_007_199_254_740_992n + (samePath(path, stagePath) ? 1n : 0n); + return Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.lstatSync); + try { + expect(publishPrivateFileExclusive(finalPath, Buffer.from("candidate"))).toEqual({ created: false }); + expect(existsSync(stagePath)).toBe(true); + } finally { spy.mockRestore(); } + }); test("POSIX parent-directory sync failure preserves the published stage until retry", () => { if (process.platform === "win32") return; const root = tempRoot(); diff --git a/tests/lab/lab-public-coderabbit-regressions.test.ts b/tests/lab/lab-public-coderabbit-regressions.test.ts index 7653de2d682..6e90f71bddb 100644 --- a/tests/lab/lab-public-coderabbit-regressions.test.ts +++ b/tests/lab/lab-public-coderabbit-regressions.test.ts @@ -28,8 +28,8 @@ import { type PublicEvidenceRecordV1, } from "../../src/lab/public"; import { - publicEvidenceMutationLockIsReclaimableForTests, - publicEvidenceTryReclaimMutationLockForTests, + publicEvidenceLegacyMutationLockBlocksForTests, + withPublicEvidenceMutationLock, } from "../../src/lab/public/mutation-lock"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -102,7 +102,7 @@ async function captureCli(argv: string[], home: string): Promise<{ code: number; } } -test("ancient live-PID mutation owner becomes reclaimable after the absolute ceiling", () => { +test("an older build's ancient live-PID mutation owner stops blocking after the absolute ceiling", () => { const home = configDir("ocx-cl10-lock-owner-ceiling-"); ensureLabDirs(home); const lockPath = join(labCommunityDir(home), ".mutation-lock"); @@ -118,10 +118,10 @@ test("ancient live-PID mutation owner becomes reclaimable after the absolute cei { mode: 0o600 }, ); - expect(publicEvidenceMutationLockIsReclaimableForTests(home, now)).toBe(true); + expect(publicEvidenceLegacyMutationLockBlocksForTests(home, now)).toBe(false); }); -test("ancient live-PID reclaim claim cannot block stale lock recovery forever", () => { +test("an older build's ancient lock with a live-PID reclaim claim does not block mutation", () => { const home = configDir("ocx-cl10-lock-claim-ceiling-"); ensureLabDirs(home); const lockPath = join(labCommunityDir(home), ".mutation-lock"); @@ -147,8 +147,10 @@ test("ancient live-PID reclaim claim cannot block stale lock recovery forever", { mode: 0o600 }, ); - expect(publicEvidenceTryReclaimMutationLockForTests(home, now)).toBe(true); - expect(existsSync(lockPath)).toBe(false); + let ran = false; + withPublicEvidenceMutationLock(home, () => { ran = true; }); + expect(ran).toBe(true); + expect(existsSync(lockPath)).toBe(true); }); test("foreign origin-directory entries do not consume marker quota", () => { diff --git a/tests/lab/lab-public-file-safety.test.ts b/tests/lab/lab-public-file-safety.test.ts index 113d7837acc..78633ce2c33 100644 --- a/tests/lab/lab-public-file-safety.test.ts +++ b/tests/lab/lab-public-file-safety.test.ts @@ -1,4 +1,6 @@ -import { afterEach, expect, test } from "bun:test"; +import { samePath } from "../helpers/same-path"; +import { afterEach, expect, spyOn, test } from "bun:test"; +import * as fs from "node:fs"; import { linkSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,6 +19,60 @@ function tempRoot(): string { return root; } +test("private reads reject adjacent NTFS-sized descriptor identities", () => { + const path = join(tempRoot(), "target.txt"); + writeFileSync(path, "safe-bytes"); + const original = fs.fstatSync; + const spy = spyOn(fs, "fstatSync").mockImplementation(((fd, options) => { + const stats = options?.bigint ? original(fd, { bigint: true }) : original(fd); + return Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: options?.bigint ? 9_007_199_254_740_993n : Number(9_007_199_254_740_993n) }); + }) as typeof fs.fstatSync); + const originalLstat = fs.lstatSync; + const pathSpy = spyOn(fs, "lstatSync").mockImplementation(((p, options) => { + const stats = options?.bigint ? originalLstat(p, { bigint: true }) : originalLstat(p); + return samePath(p, path) ? Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: options?.bigint ? 9_007_199_254_740_992n : Number(9_007_199_254_740_992n) }) : stats; + }) as typeof fs.lstatSync); + try { + expect(() => readPrivateRegularFile(path, { maxBytes: 1024, errorCode: "unsafe", errorMessage: "unsafe" })) + .toThrow(PublicEvidenceValidationError); + } finally { spy.mockRestore(); pathSpy.mockRestore(); } +}); + +for (const replaceOnRecheck of [false, true]) { + test.skipIf(process.platform === "win32")( + `private reads preserve a distinct large-inode stage ${replaceOnRecheck ? "on recheck" : "on discovery"}`, + () => { + const root = tempRoot(); + const target = join(root, "target.txt"); + const alias = join(root, "alias.txt"); + const stage = join(root, `.target.txt.${process.pid}.00000000-0000-4000-8000-000000000000.tmp`); + writeFileSync(target, "safe-bytes", { mode: 0o600 }); + linkSync(target, alias); + writeFileSync(stage, "unrelated", { mode: 0o600 }); + const original = fs.lstatSync; + let stageChecks = 0; + const spy = spyOn(fs, "lstatSync").mockImplementation(((path, options) => { + const stats = options?.bigint ? original(path, { bigint: true }) : original(path); + if (!samePath(path, target) && !samePath(path, stage)) return stats; + const isStage = samePath(path, stage); + if (isStage) stageChecks++; + const different = isStage && (!replaceOnRecheck || stageChecks > 1); + const ino = 9_007_199_254_740_992n + (different ? 1n : 0n); + return Object.assign(Object.create(Object.getPrototypeOf(stats)), stats, + { ino: options?.bigint ? ino : Number(ino) }); + }) as typeof fs.lstatSync); + try { + expect(() => readPrivateRegularFile(target, { + maxBytes: 1024, errorCode: "unsafe", errorMessage: "unsafe", + })).toThrow(PublicEvidenceValidationError); + expect(fs.existsSync(stage)).toBe(true); + } finally { spy.mockRestore(); } + }, + ); +} + test("descriptor-bound private reads reject a symlink even when O_NOFOLLOW is unavailable", () => { const root = tempRoot(); const target = join(root, "target.txt"); diff --git a/tests/lib/file-identity-callers.test.ts b/tests/lib/file-identity-callers.test.ts index 43004e286f2..88cda1266ae 100644 --- a/tests/lib/file-identity-callers.test.ts +++ b/tests/lib/file-identity-callers.test.ts @@ -6,6 +6,11 @@ import { repoPath } from "../helpers/repo-root"; // calls made with { bigint: true }. A lexical tripwire over the named declarations, not a // full parser: it catches the regression forms below and fails closed on scopes it cannot find. const scopes: Record = { + "src/lab/public/mutation-lock.ts": ["assertMutationLockDirectory"], + "src/lab/public/private-file.ts": ["cleanupPublishedPrivateFileStages"], + "src/lab/public/file-safety.ts": ["withPrivateRegularFile", "recoverPublishedPrivateFileStage"], + "src/lab/artifacts/secure-fs.ts": ["assertOpenedPathMatchesDescriptor", "revalidateDir", "openTrustedArtifactDir"], + "src/lab/fabric/scratch.ts": ["revalidateScratchDir", "openAtScratch", "openTrustedScratchRoot", "openScratchRelativePath"], "src/codex/codex-home-owner.ts": ["readCodexHomeJournal"], "src/codex/cli-update-artifacts.ts": ["verifyCodexCliArchive"], "src/codex/cli-install-provenance.ts": ["readBoundedFile", "observeHeldFileSet"], @@ -111,7 +116,7 @@ function matching(code: string, open: number, left: string, right: string): numb /** The body of NAME's declaration: a function, or a method head whose body opens on the same line. */ function declarationBody(code: string, name: string): [number, number] | null { - const head = new RegExp(`(?:\\bfunction\\s+|^[ \\t]*(?:(?:export|async|public|private|protected|static|override)\\s+)*)${name}\\s*\\(`, "gm"); + const head = new RegExp(`(?:\\bfunction\\s+|^[ \\t]*(?:(?:export|async|public|private|protected|static|override)\\s+)*)${name}(?:<[^<>]*>)?\\s*\\(`, "gm"); for (const match of code.matchAll(head)) { const close = matching(code, match.index! + match[0].length - 1, "(", ")"); if (close < 0) continue; @@ -199,6 +204,8 @@ describe("file identity caller stats", () => { }); test("resolves the declaration, not an earlier call, and needs a real observation", () => { + expect(inspect("function check(value: T) { return lstatSync(path).ino; }", ["check"])) + .toEqual(["numeric stat: lstatSync(path)"]); expect(inspect("function caller() { return check(); }\nfunction check() { return lstatSync(path).ino; }", ["check"])) .toEqual(["numeric stat: lstatSync(path)"]); expect(inspect("check()\n{ lstatSync(p, { bigint: true }); }\nfunction check() { return lstatSync(path).ino; }", ["check"]))