{"version":3,"file":"checkpoint.d.ts","sourceRoot":"","sources":["../../../src/core/safety/checkpoint.ts"],"names":[],"mappings":"AAKA,MAAM,MAAM,mBAAmB,GAAG,MAAM,GAAG,WAAW,GAAG,SAAS,GAAG,SAAS,CAAC;AAC/E,MAAM,MAAM,gBAAgB,GAAG,SAAS,GAAG,SAAS,GAAG,WAAW,GAAG,aAAa,GAAG,mBAAmB,CAAC;AAEzG,MAAM,WAAW,eAAe;IAC/B,0CAA0C;IAC1C,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,mBAAmB,CAAC;IAC1B,mEAAmE;IACnE,OAAO,EAAE,OAAO,CAAC;IACjB,iDAAiD;IACjD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,IAAI,CAAC,EAAE,MAAM,CAAC;CACd;AAED,MAAM,WAAW,mBAAmB;IACnC,YAAY,EAAE,MAAM,CAAC;IACrB,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,EAAE,MAAM,CAAC;IAClB,OAAO,EAAE,eAAe,EAAE,CAAC;IAC3B,cAAc,EAAE,MAAM,CAAC;IACvB,MAAM,EAAE,gBAAgB,CAAC;CACzB;AAED,MAAM,WAAW,sBAAsB;IACtC,UAAU,EAAE,MAAM,CAAC;IACnB,8DAA8D;IAC9D,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,iDAAiD;IACjD,aAAa,CAAC,EAAE,OAAO,CAAC;CACxB;AAED,qBAAa,eAAgB,SAAQ,KAAK;IACzC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,YAAY,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAIxC;CACD;AAED,wBAAgB,MAAM,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,CAEpD;AAMD;;;;;;GAMG;AACH,qBAAa,eAAe;IAC3B,OAAO,CAAC,QAAQ,CAAC,IAAI,CACG;IACxB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B,YAAY,OAAO,EAAE,sBAAsB,EAO1C;IAED,OAAO,CAAC,aAAa;IAGrB,OAAO,CAAC,QAAQ;IAGhB,OAAO,CAAC,YAAY;YAIN,aAAa;IA+C3B;;;OAGG;IACG,MAAM,CACX,WAAW,EAAE,MAAM,EACnB,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,EAAE,EACrB,KAAK,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,mBAAmB,CAAC,CA6B9B;IAEK,IAAI,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAO1D;IAED,yEAAyE;IACnE,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,mBAAmB,CAAC,CAmBrD;IAED,sDAAsD;IAChD,WAAW,CAAC,EAAE,EAAE,MAAM,EAAE,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAG5E;IAEK,YAAY,CAAC,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,gBAAgB,GAAG,OAAO,CAAC,IAAI,CAAC,CAKtE;IAEK,IAAI,IAAI,OAAO,CAAC,mBAAmB,EAAE,CAAC,CAc3C;IAED,yFAAyF;IACnF,MAAM,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAEtC;IAED;;;;;OAKG;IACG,EAAE,CAAC,IAAI,GAAE;QAAE,QAAQ,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAA;KAAO,GAAG,OAAO,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,CAAA;KAAE,CAAC,CAoBvF;CACD","sourcesContent":["import { createHash, randomUUID } from \"node:crypto\";\nimport fs, { lstat, readFile, readlink, writeFile } from \"node:fs/promises\";\nimport nodePath from \"node:path\";\nimport { isSecretPath } from \"./policy.js\";\n\nexport type CheckpointEntryType = \"file\" | \"directory\" | \"symlink\" | \"missing\";\nexport type CheckpointStatus = \"created\" | \"applied\" | \"confirmed\" | \"rolled_back\" | \"recovery_required\";\n\nexport interface CheckpointEntry {\n\t/** Absolute path within the workspace. */\n\tpath: string;\n\ttype: CheckpointEntryType;\n\t/** Whether the path existed in any form before the transaction. */\n\texisted: boolean;\n\t/** content-addressed sha256 for file entries. */\n\tcontentSha256?: string;\n\tmode?: number;\n\tsymlinkTarget?: string;\n\tsize?: number;\n}\n\nexport interface WorkspaceCheckpoint {\n\tcheckpointId: string;\n\tworkspaceId: string;\n\ttransactionId: string;\n\trunId?: string;\n\tcreatedAt: number;\n\tentries: CheckpointEntry[];\n\tmanifestSha256: string;\n\tstatus: CheckpointStatus;\n}\n\nexport interface CheckpointStoreOptions {\n\tstorageDir: string;\n\t/** Refuse to checkpoint files larger than this many bytes. */\n\tmaxCheckpointBytes?: number;\n\t/** Refuse to checkpoint secret-bearing paths. */\n\trejectSecrets?: boolean;\n}\n\nexport class CheckpointError extends Error {\n\treadonly code: string;\n\tconstructor(code: string, message: string) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t\tthis.name = \"CheckpointError\";\n\t}\n}\n\nexport function sha256(data: string | Buffer): string {\n\treturn createHash(\"sha256\").update(data).digest(\"hex\");\n}\n\nfunction canonicalEntriesJson(entries: CheckpointEntry[]): string {\n\treturn JSON.stringify(entries);\n}\n\n/**\n * Content-addressed, integrity-protected pre-mutation checkpoint store.\n *\n * A checkpoint is a recovery artifact, never execution authority. Blobs are\n * deduplicated by sha256. The manifest carries a hash over its own entries so\n * tampering (including blob swapping) is detectable.\n */\nexport class CheckpointStore {\n\tprivate readonly opts: Required<Pick<CheckpointStoreOptions, \"storageDir\" | \"maxCheckpointBytes\">> &\n\t\tCheckpointStoreOptions;\n\treadonly storageDir: string;\n\n\tconstructor(options: CheckpointStoreOptions) {\n\t\tthis.storageDir = options.storageDir;\n\t\tthis.opts = {\n\t\t\tstorageDir: options.storageDir,\n\t\t\tmaxCheckpointBytes: options.maxCheckpointBytes ?? 256 * 1024 * 1024,\n\t\t\trejectSecrets: options.rejectSecrets ?? true,\n\t\t};\n\t}\n\n\tprivate checkpointDir(id: string): string {\n\t\treturn nodePath.join(this.storageDir, \"checkpoints\", id);\n\t}\n\tprivate blobsDir(id: string): string {\n\t\treturn nodePath.join(this.checkpointDir(id), \"blobs\");\n\t}\n\tprivate manifestPath(id: string): string {\n\t\treturn nodePath.join(this.checkpointDir(id), \"manifest.json\");\n\t}\n\n\tprivate async snapshotEntry(entry: CheckpointEntry, dirs: CheckpointEntry[], cpBlobsDir: string): Promise<void> {\n\t\tconst abs = entry.path;\n\t\ttry {\n\t\t\tconst st = await lstat(abs);\n\t\t\tif (st.isSymbolicLink()) {\n\t\t\t\tentry.type = \"symlink\";\n\t\t\t\tentry.existed = true;\n\t\t\t\tentry.symlinkTarget = await readlink(abs);\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (st.isDirectory()) {\n\t\t\t\tentry.type = \"directory\";\n\t\t\t\tentry.existed = true;\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tif (st.isFile()) {\n\t\t\t\tif (st.size > this.opts.maxCheckpointBytes) {\n\t\t\t\t\tthrow new CheckpointError(\"oversized\", `cannot checkpoint oversized file (${st.size} bytes): ${abs}`);\n\t\t\t\t}\n\t\t\t\tconst content = await readFile(abs);\n\t\t\t\tif (this.opts.rejectSecrets && isSecretPath(abs)) {\n\t\t\t\t\tthrow new CheckpointError(\"secret\", `refusing to checkpoint secret-bearing file: ${abs}`);\n\t\t\t\t}\n\t\t\t\tentry.type = \"file\";\n\t\t\t\tentry.existed = true;\n\t\t\t\tentry.contentSha256 = sha256(content);\n\t\t\t\tentry.size = content.length;\n\t\t\t\tentry.mode = st.mode;\n\t\t\t\tconst entryBlobDir = nodePath.join(cpBlobsDir, entry.contentSha256);\n\t\t\t\tawait fs.mkdir(entryBlobDir, { recursive: true });\n\t\t\t\tawait writeFile(nodePath.join(entryBlobDir, \"blob\"), content, {\n\t\t\t\t\tmode: 0o600,\n\t\t\t\t\tflag: \"wx\",\n\t\t\t\t}).catch(() => {});\n\t\t\t}\n\t\t} catch (err) {\n\t\t\tif ((err as NodeJS.ErrnoException).code === \"ENOENT\") {\n\t\t\t\tentry.type = \"missing\";\n\t\t\t\tentry.existed = false;\n\t\t\t\t// Record nearest existing ancestor metadata for restore.\n\t\t\t\tdirs.push({ path: abs, type: \"missing\", existed: false });\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthrow err;\n\t\t}\n\t}\n\n\t/**\n\t * Create a checkpoint capturing the given absolute target paths.\n\t * The parent blob dir is created before writing.\n\t */\n\tasync create(\n\t\tworkspaceId: string,\n\t\ttransactionId: string,\n\t\ttargetPaths: string[],\n\t\trunId?: string,\n\t): Promise<WorkspaceCheckpoint> {\n\t\tconst id = randomUUID();\n\t\tawait fs.mkdir(this.checkpointDir(id), { recursive: true });\n\t\t// snapshot parent metadata first\n\t\tconst entries: CheckpointEntry[] = [];\n\t\tconst dirs: CheckpointEntry[] = [];\n\t\tfor (const p of targetPaths) {\n\t\t\tconst entry: CheckpointEntry = {\n\t\t\t\tpath: p,\n\t\t\t\ttype: \"missing\",\n\t\t\t\texisted: false,\n\t\t\t};\n\t\t\tawait this.snapshotEntry(entry, dirs, this.blobsDir(id));\n\t\t\tentries.push(entry);\n\t\t}\n\t\tentries.push(...dirs);\n\t\tconst manifestSha256 = sha256(canonicalEntriesJson(entries));\n\t\tconst checkpoint: WorkspaceCheckpoint = {\n\t\t\tcheckpointId: id,\n\t\t\tworkspaceId,\n\t\t\ttransactionId,\n\t\t\trunId,\n\t\t\tcreatedAt: Date.now(),\n\t\t\tentries,\n\t\t\tmanifestSha256,\n\t\t\tstatus: \"created\",\n\t\t};\n\t\tawait writeFile(this.manifestPath(id), JSON.stringify(checkpoint, null, 2), { mode: 0o600 });\n\t\treturn checkpoint;\n\t}\n\n\tasync read(id: string): Promise<WorkspaceCheckpoint | null> {\n\t\ttry {\n\t\t\tconst raw = await readFile(this.manifestPath(id), \"utf-8\");\n\t\t\treturn JSON.parse(raw) as WorkspaceCheckpoint;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n\n\t/** Verify manifest integrity and every blob hash. Throws on mismatch. */\n\tasync verify(id: string): Promise<WorkspaceCheckpoint> {\n\t\tconst cp = await this.read(id);\n\t\tif (!cp) throw new CheckpointError(\"not_found\", `checkpoint not found: ${id}`);\n\t\tconst recomputed = sha256(canonicalEntriesJson(cp.entries));\n\t\tif (recomputed !== cp.manifestSha256) {\n\t\t\tthrow new CheckpointError(\"tampered\", `checkpoint manifest hash mismatch: ${id}`);\n\t\t}\n\t\tfor (const e of cp.entries) {\n\t\t\tif (e.type === \"file\" && e.contentSha256) {\n\t\t\t\tconst blobBuf = await readFile(nodePath.join(this.blobsDir(id), e.contentSha256, \"blob\")).catch(() => null);\n\t\t\t\tif (!blobBuf) {\n\t\t\t\t\tthrow new CheckpointError(\"tampered\", `checkpoint blob missing: ${e.path}`);\n\t\t\t\t}\n\t\t\t\tif (sha256(blobBuf) !== e.contentSha256) {\n\t\t\t\t\tthrow new CheckpointError(\"tampered\", `checkpoint blob hash mismatch: ${e.path}`);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn cp;\n\t}\n\n\t/** materialize the stored content of a file entry. */\n\tasync materialize(id: string, entry: CheckpointEntry): Promise<Buffer | null> {\n\t\tif (entry.type !== \"file\" || !entry.contentSha256) return null;\n\t\treturn readFile(nodePath.join(this.blobsDir(id), entry.contentSha256, \"blob\")).catch(() => null);\n\t}\n\n\tasync updateStatus(id: string, status: CheckpointStatus): Promise<void> {\n\t\tconst cp = await this.read(id);\n\t\tif (!cp) return;\n\t\tcp.status = status;\n\t\tawait writeFile(this.manifestPath(id), JSON.stringify(cp, null, 2), { mode: 0o600 });\n\t}\n\n\tasync list(): Promise<WorkspaceCheckpoint[]> {\n\t\tconst base = nodePath.join(this.storageDir, \"checkpoints\");\n\t\tlet ids: string[] = [];\n\t\ttry {\n\t\t\tids = await fs.readdir(base);\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t\tconst out: WorkspaceCheckpoint[] = [];\n\t\tfor (const id of ids) {\n\t\t\tconst cp = await this.read(id);\n\t\t\tif (cp) out.push(cp);\n\t\t}\n\t\treturn out;\n\t}\n\n\t/** Delete a checkpoint's storage atomically (used by GC for confirmed, expired ones). */\n\tasync delete(id: string): Promise<void> {\n\t\tawait fs.rm(this.checkpointDir(id), { recursive: true, force: true });\n\t}\n\n\t/**\n\t * Concurrency-safe garbage collection. Only `confirmed` checkpoints older\n\t * than the retention window are removed. rollback-required,\n\t * recovery-required, active and other states are never collected. Uses an\n\t * atomic rename tombstone so two GC passes cannot race on the same dir.\n\t */\n\tasync gc(opts: { retainMs?: number; now?: number } = {}): Promise<{ removed: string[] }> {\n\t\tconst now = opts.now ?? Date.now();\n\t\tconst retainMs = opts.retainMs ?? 24 * 60 * 60 * 1000;\n\t\tconst checkpoints = await this.list();\n\t\tconst removed: string[] = [];\n\t\tfor (const cp of checkpoints) {\n\t\t\tif (cp.status !== \"confirmed\") continue;\n\t\t\tif (now - cp.createdAt <= retainMs) continue;\n\t\t\tconst src = this.checkpointDir(cp.checkpointId);\n\t\t\tconst tombstone = this.checkpointDir(`${cp.checkpointId}.gc`);\n\t\t\ttry {\n\t\t\t\tawait fs.rename(src, tombstone);\n\t\t\t} catch {\n\t\t\t\tcontinue; // already being collected or in use\n\t\t\t}\n\t\t\t// Never follow external symlinks: use rm on the (regular) directory.\n\t\t\tawait fs.rm(tombstone, { recursive: true, force: true });\n\t\t\tremoved.push(cp.checkpointId);\n\t\t}\n\t\treturn { removed };\n\t}\n}\n"]}