/** * Filesystem CAS adapter — mtime + content-hash VersionToken and atomic-rename write. * * Uses the host contract helper executeIfUnchanged (conditional-write.ts). Does not invent * a parallel outcome shape. Token format is opaque to the guard (string equality only). * * Token encoding (this adapter only; not interpreted by the core): * fs:v1:: * Missing file → createFsVersionToken still works for "absent" via a dedicated absent token * only if we choose; for create we require the file to exist (ENOENT throws) so hosts * measure an existing prior. For write of new files, expected_token may be ABSENT_TOKEN. */ import { tokensEqual, type ExecuteIfUnchangedOutcome, type VersionToken, type VersionedContent } from '../conditional-write.js'; import type { PriorContentResolver } from '../freshness.js'; /** Prefix for this adapter's opaque tokens (equality-only outside this module). */ export declare const FS_VERSION_TOKEN_PREFIX = "fs:v1:"; /** * Token used when the path does not exist yet (create-if-absent CAS). * Hosts that measured absence should pass this as expected_token for first write. */ export declare const FS_ABSENT_TOKEN: VersionToken; /** Extract content-hash segment from an fs:v1 token (null if absent/malformed). */ export declare function fsTokenContentHash(token: VersionToken | null | undefined): string | null; /** * Refuse a path this adapter must not treat as a CAS target. * * MEASURED ABSENT before this existed (2026-08-27): `..` was silently resolved and the write landed * outside the intended directory, and a symlinked target was followed by stat/readFile while the * rename replaced the LINK — so the check and the write referred to DIFFERENT objects. That object * mismatch is the other half of the TOCTOU: a path interpreted twice can have a symlink swapped in * between the two interpretations. * * Three refusals, each for a different escape: * - a literal `..` segment in the caller's own path (traversal, judged BEFORE resolution), * - a symlinked final component (lstat — the object we would check is not the one we would write). * * The parent directory is CANONICALISED rather than rejected. Requiring realpath(dir) === dir was * written first and measured wrong within the hour: on macOS /var is itself a symlink, so every * path under a normal temp directory was refused. A symlinked ancestor is not the danger — an * ancestor that CHANGES between the check and the write is, and that is closed by the inode/device * identity re-check in writeFileIfUnchanged, not by refusing ordinary filesystems. Resolving the * parent once and building the target from it means both the check and the write name one object. */ export declare class UnsafeCasPathError extends Error { readonly reason: 'path_traversal' | 'symlink_target'; readonly casPath: string; constructor(reason: 'path_traversal' | 'symlink_target', casPath: string); } export declare function assertSafeCasPath(filePath: string): Promise; /** Filesystem identity of the object a token was measured on. Evidence, not a comparison key. */ export type FsObjectIdentity = { dev: number; ino: number; }; /** * Identity of the object CURRENTLY at a path, read through a FILE DESCRIPTOR. * * Opening once and fstat-ing the handle is what makes this an identity rather than another path * interpretation: the fd names the object, so a symlink swapped in afterwards cannot redirect it. * null when the path does not exist. */ export declare function fsObjectIdentity(filePath: string): Promise; export declare function identitiesEqual(a: FsObjectIdentity | null | undefined, b: FsObjectIdentity | null | undefined): boolean; /** * Build a VersionToken from path: mtime (ms) + sha256 of file bytes. * Throws on I/O errors other than callers may catch. ENOENT → returns FS_ABSENT_TOKEN * so hosts can CAS create vs create-raced. */ export declare function createFsVersionToken(filePath: string): Promise; /** * Read file content + version token for VersionedContent (host reporting / preflight prior). * ENOENT → content '' and FS_ABSENT_TOKEN. */ export declare function readVersionedFile(filePath: string): Promise; export type WriteFileIfUnchangedArgs = { path: string; /** Token measured before the host decided to write (from createFsVersionToken). */ expected_token: VersionToken; /** * Identity (dev+ino) of the object the AUTHORIZATION examined, from fsObjectIdentity at T1. * Optional, and when omitted no identity claim is made. Supplying it is strictly stronger than * the token alone: an inode swapped in carrying identical bytes and mtime produces an IDENTICAL * token, so content equality cannot see it. null means "absent at T1" (create-if-absent). */ expected_identity?: FsObjectIdentity | null; /** Full new file contents (utf8 string or Buffer). */ content: string | Buffer; }; /** * Conditional write: re-stat+re-hash; if token matches expected → temp file + atomic rename. * * MEASURED windows (pre-fix, 2fcd74e-era): * 1. executeIfUnchanged: current_token() then await write() — no re-check in between. * 2. write(): writeFile(tmp) then rename(tmp,target) with NO re-stat of target before rename * (fs.ts former lines 106–108) — concurrent mutation of target is clobbered by rename. * * Closures: * - Pre-rename: re-createFsVersionToken(target); on mismatch abort rename, unlink tmp, * throw StaleVersionTokenAbort → refused/stale_version_token (write did not land). * - Post-rename: detect_stale_during_commit — content-hash of path must match sha256(body); * mismatch → committed_stale_detected (write landed; someone overwrote after rename). * * Wired through executeIfUnchanged — not a parallel path. */ export declare function writeFileIfUnchanged(args: WriteFileIfUnchangedArgs): Promise>; /** * Opt-in PriorContentResolver that reads a file path from tool arguments. * Returns file utf8 content for freshness measurement (string only — tokens travel separately * via versioned_content / conditioned_on_token on the call context). * * Plug-in: pass as GuardConfig.resolvePriorContent / withCodeRifts({ resolvePriorContent }). */ export declare function createFsPriorContentResolver(options?: { /** * Map artifactId → absolute or relative file path. * If omitted, uses req.path or arguments-style path when provided on the resolve request. */ pathForArtifact?: (artifactId: string) => string | null | undefined; }): PriorContentResolver; /** Re-export equality helper for hosts comparing tokens without importing core module. */ export { tokensEqual }; //# sourceMappingURL=fs.d.ts.map