diff --git a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts index 875c7dcb9a..3505f439cf 100644 --- a/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts +++ b/src/core/task-persistence/__tests__/TaskHistoryStore.deleteSemantics.spec.ts @@ -36,6 +36,20 @@ vi.mock("fs/promises", async () => { const actualFs = await vi.importActual("fs/promises") const actualFileLock = await vi.importActual("../../../utils/fileLock") +// The delete path hands fs.unlink and the per-file lock a canonical spelling - it comes out of +// resolveLockKey (services/file-safety/safeWriteText.ts:367), which follows links - while this file's +// own expected paths come from the spelling os.tmpdir() answered. On a hosted Windows runner those +// two spellings name the same file while differing as strings (the 8.3 form of the profile directory +// against its long name), so comparing paths with === pins a spelling rather than a file: the +// injected EACCES stops recognising its own target, the real unlink runs, and the read-back rows +// see ENOENT. Reproduced on this machine by pointing TMP/TEMP at a junction alias of a real +// directory, which produced the same four failures the hosted Windows lane reported. +async function sameFileAs(candidate: unknown, expected: string): Promise { + const resolved = await fs.realpath(String(candidate)).catch(() => String(candidate)) + const expectedCanonical = await fs.realpath(expected).catch(() => expected) + return resolved === expectedCanonical +} + function makeHistoryItem(overrides: Partial = {}): HistoryItem { return { id: `task-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`, @@ -137,7 +151,8 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { const backup = path.join(path.dirname(filePath), `.${path.basename(filePath)}.bak_1_saved.tmp`) await fs.writeFile(backup, JSON.stringify(history)) vi.mocked(fs.unlink).mockImplementation(async (target) => { - if (target === backup) throw Object.assign(new Error("Cleanup failed"), { code: "EACCES" }) + if (await sameFileAs(target, backup)) + throw Object.assign(new Error("Cleanup failed"), { code: "EACCES" }) return actualFs.unlink(target) }) if (operation === "delete") await store.delete(history.id) @@ -156,10 +171,28 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { await expect(store.delete("locked-delete")).resolves.toBeUndefined() - expect(vi.mocked(withFileLock)).toHaveBeenCalledWith( - historyFilePath(storagePath, "locked-delete"), - expect.any(Function), - ) + // Production hands withFileLock the canonical spelling of the key (it comes out of + // resolveLockKey, which follows links), while historyFilePath builds this row's path from + // the spelling os.tmpdir() answered. On a hosted Windows runner those two name the same + // file and differ as strings - the 8.3 profile directory against its long name - so a string + // match pins a spelling. This row pins the file instead: the key the store locked must name + // this file inside the directory object this row's path resolves to. The parent directory is + // what gets canonicalised rather than the file, because the delete removes the file and after + // that neither realpath nor stat can answer for it. + const expectedFile = historyFilePath(storagePath, "locked-delete") + const expectedDir = await fs.realpath(path.dirname(expectedFile)).catch(() => path.dirname(expectedFile)) + let matchingKeys = 0 + let matchedHandlerType = "" + for (const call of vi.mocked(withFileLock).mock.calls) { + const acquired = String(call[0]) + const acquiredDir = await fs.realpath(path.dirname(acquired)).catch(() => path.dirname(acquired)) + if (acquiredDir === expectedDir && path.basename(acquired) === path.basename(expectedFile)) { + matchingKeys++ + matchedHandlerType = typeof call[1] + } + } + expect(matchingKeys).toBeGreaterThan(0) + expect(matchedHandlerType).toBe("function") await expect(fs.access(historyFilePath(storagePath, "locked-delete"))).rejects.toMatchObject({ code: "ENOENT", }) @@ -242,7 +275,7 @@ describe("TaskHistoryStore best-effort deletion semantics", () => { onWrite.mockClear() vi.mocked(fs.unlink).mockImplementation(async (p) => { - if (p === historyFilePath(storagePath, "batch-b")) { + if (await sameFileAs(p, historyFilePath(storagePath, "batch-b"))) { throw Object.assign(new Error("EACCES: permission denied, unlink"), { code: "EACCES" }) } return actualFs.unlink(p) diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index aa6026e56b..e590360b2a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -106,6 +106,7 @@ import { buildNativeToolsArrayWithRestrictions } from "./build-tools" import { ToolRepetitionDetector } from "../tools/ToolRepetitionDetector" import { restoreTodoListForTask } from "../tools/UpdateTodoListTool" import { FileContextTracker } from "../context-tracking/FileContextTracker" +import { ObservationRegistry } from "./observationRegistry" import { RooIgnoreController } from "../ignore/RooIgnoreController" import { RooProtectedController } from "../protect/RooProtectedController" import { type AssistantMessageContent, presentAssistantMessage } from "../assistant-message" @@ -296,6 +297,10 @@ export class Task extends EventEmitter implements TaskLike { readonly instanceId: string readonly metadata: TaskMetadata + // The observed on-disk version of each file this task has read. Declared here so the + // read tools can record it; a write guard later compares a token against this registry. + readonly observationRegistry = new ObservationRegistry() + todoList?: TodoItem[] readonly rootTask: Task | undefined = undefined diff --git a/src/core/task/__tests__/observationRegistry.spec.ts b/src/core/task/__tests__/observationRegistry.spec.ts new file mode 100644 index 0000000000..a3c55ebc6d --- /dev/null +++ b/src/core/task/__tests__/observationRegistry.spec.ts @@ -0,0 +1,108 @@ +import { describe, it, expect, vi } from "vitest" + +import { ObservationRegistry } from "../observationRegistry" + +describe("ObservationRegistry", () => { + it("observe → get returns the recorded version and observedAt", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "1:2:300:4000000000:5000000000") + + const obs = reg.get("/a/b/c.ts") + expect(obs).toBeDefined() + expect(obs!.version).toBe("1:2:300:4000000000:5000000000") + expect(typeof obs!.observedAt).toBe("number") + }) + + it("re-observe replaces the entry with a fresh observedAt", () => { + vi.useFakeTimers() + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + const first = reg.get("/a/b/c.ts")! + expect(first.version).toBe("v1") + + vi.advanceTimersByTime(50) + reg.observe("/a/b/c.ts", "v2") + const second = reg.get("/a/b/c.ts")! + expect(second.version).toBe("v2") + expect(second.observedAt).toBeGreaterThan(first.observedAt) + + vi.useRealTimers() + }) + + it("has returns true for observed paths, false otherwise", () => { + const reg = new ObservationRegistry() + reg.observe("/x.ts", "t1") + expect(reg.has("/x.ts")).toBe(true) + expect(reg.has("/y.ts")).toBe(false) + }) + + it("size reflects the number of observed entries", () => { + const reg = new ObservationRegistry() + expect(reg.size).toBe(0) + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + expect(reg.size).toBe(2) + }) + + it("clear removes all entries and resets size to 0", () => { + const reg = new ObservationRegistry() + reg.observe("/a.ts", "t1") + reg.observe("/b.ts", "t2") + reg.clear() + expect(reg.size).toBe(0) + expect(reg.get("/a.ts")).toBeUndefined() + expect(reg.has("/b.ts")).toBe(false) + }) + + it("get on empty registry returns undefined", () => { + const reg = new ObservationRegistry() + expect(reg.get("/any.ts")).toBeUndefined() + }) + + it("separate instances are independent — observing in one does not appear in the other", () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")).toBeDefined() + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + + describe("completeness scope (S4b follow-up #46)", () => { + it("defaults to a complete observation when the read scope is not given", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + + expect(reg.get("/a/b/c.ts")!.complete).toBe(true) + }) + + it("records a partial observation when the read only returned a view of the file", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + + expect(reg.get("/a/b/c.ts")!.complete).toBe(false) + }) + + it("re-observing replaces the entry's completeness with the new read's scope", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1", false) + reg.observe("/a/b/c.ts", "v2") + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(true) + }) + + it("re-observing with a partial scope downgrades a previously complete entry", () => { + const reg = new ObservationRegistry() + reg.observe("/a/b/c.ts", "v1") + reg.observe("/a/b/c.ts", "v2", false) + + const obs = reg.get("/a/b/c.ts")! + expect(obs.version).toBe("v2") + expect(obs.complete).toBe(false) + }) + }) +}) diff --git a/src/core/task/observationRegistry.ts b/src/core/task/observationRegistry.ts new file mode 100644 index 0000000000..78a1f3d4cc --- /dev/null +++ b/src/core/task/observationRegistry.ts @@ -0,0 +1,69 @@ +/** + * Per-task file observation registry (upstream epic #1375, phase A2). + * + * Each Task owns its own instance so parent and subtask observations are + * independent. The S4 guarded-write will compare these versions against the + * token recomputed pre-write to detect stale reads or file replacement. + * + * Pure in-memory — zero I/O, no dependencies. The S4 guarded-write consults + * these observations for the version check and for the completeness check that + * gates a full-file replacement. + */ + +export interface FileObservation { + /** Version token derived from on-disk fs.stat (bigint mode). */ + version: string + /** Millisecond timestamp when the observation was recorded. */ + observedAt: number + /** + * Whether the read that produced this observation returned the complete + * file. A slice, line-range, truncated, or indentation-block read returns + * only a view of the file; such an observation authorizes targeted edits + * on the view the model saw, but never a full-file replacement. + */ + complete: boolean +} + +export class ObservationRegistry { + private readonly entries = new Map() + + /** + * Record an observation for a file at its absolute path. + * + * Re-observing replaces the entry with a fresh observedAt timestamp, the + * new version token, and the read's completeness. `complete` defaults to + * true for callers that read the whole file themselves (spec doubles, + * WriteToFileTool). A caller whose read is internal to a targeted edit must + * carry the model's prior completeness instead, so the tool's own read cannot + * upgrade a partial read into authority for a full-file replacement. + */ + observe(absolutePath: string, version: string, complete: boolean = true): void { + this.entries.set(absolutePath, { version, observedAt: Date.now(), complete }) + } + + get(absolutePath: string): FileObservation | undefined { + return this.entries.get(absolutePath) + } + + has(absolutePath: string): boolean { + return this.entries.has(absolutePath) + } + + /** + * Drop the observation for one path. A caller that must revoke an + * authorization it did not earn - a preview that observed a version the model + * never read - needs this instead of clear(), which would also discard the + * observations other reads of the same task still rely on. + */ + forget(absolutePath: string): boolean { + return this.entries.delete(absolutePath) + } + + clear(): void { + this.entries.clear() + } + + get size(): number { + return this.entries.size + } +} diff --git a/src/core/tools/ApplyDiffTool.ts b/src/core/tools/ApplyDiffTool.ts index 4011f802e9..bdf7902d63 100644 --- a/src/core/tools/ApplyDiffTool.ts +++ b/src/core/tools/ApplyDiffTool.ts @@ -5,6 +5,7 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { TelemetryService } from "@roo-code/telemetry" import { getReadablePath } from "../../utils/path" +import { versionTokenOfStat } from "../../utils/versionToken" import { Task } from "../task/Task" import { formatResponse } from "../prompts/responses" import { fileExistsAtPath } from "../../utils/fs" @@ -37,6 +38,20 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { diffContent = unescapeHtmlEntities(diffContent) } + // Identity of a partial observation this call created (undefined when the model had + // already observed the file): only this exact observation may be removed on a failure + // exit, so a replaced or published observation is never discarded here. Declared outside + // the try so the catch exit can use it too. + let partialObservation: { path: string; version: string } | undefined + const forgetPartialObservationIfCurrent = () => { + if ( + partialObservation && + task.observationRegistry.get(partialObservation.path)?.version === partialObservation.version + ) { + task.observationRegistry.forget(partialObservation.path) + } + } + try { if (!relPath) { task.consecutiveMistakeCount++ @@ -73,7 +88,33 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } + // The diff below is built from this exact read, so the save that follows must be + // authorized against the version captured here - not against whatever version the + // preview happens to stat afterwards. Same contract as ApplyPatchTool's hunk read: + // stat around the read and observe only when the file did not change underneath it. + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) const originalContent: string = await fs.readFile(absolutePath, "utf-8") + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + // A tool read is not a model read. With no prior observation this stays a + // partial observation of the version the diff was computed against - the only + // authorization the save can have, since apply_diff computes its hunks from + // this read. When the model already observed the file, keep the completeness it + // earned, but only on the version it was earned on: refreshing an OLDER + // observation to the current version would let content the model built from a + // stale read pass the compare-and-swap, so an out-of-date observation is left + // alone and the save fails with the re-read remediation. + const prior = task.observationRegistry.get(absolutePath) + if (prior === undefined) { + task.observationRegistry.observe(absolutePath, preReadToken, false) + partialObservation = { path: absolutePath, version: preReadToken } + } else if (prior.version === preReadToken) { + task.observationRegistry.observe(absolutePath, preReadToken, prior.complete === true) + } + } + } // Apply the diff to the original content const diffResult = (await task.diffStrategy?.applyDiff( @@ -115,6 +156,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { task.recordToolError("apply_diff", formattedError) pushToolResult(formattedError) + forgetPartialObservationIfCurrent() return } @@ -171,10 +213,12 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { const didApprove = await askApproval("tool", completeMessage, toolProgressStatus, isWriteProtected) if (!didApprove) { + forgetPartialObservationIfCurrent() return } - // Save directly without showing diff view or opening the file + // Save directly without showing diff view or opening the file. The diff is + // applied to an existing file, so edit-guard semantics require a prior read. task.diffViewProvider.editType = "modify" task.diffViewProvider.originalContent = originalContent await task.diffViewProvider.saveDirectly( @@ -183,6 +227,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { false, diagnosticsEnabled, writeDelayMs, + "edit", ) } else { // Original behavior with diff view @@ -217,12 +262,13 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { if (!didApprove) { await task.diffViewProvider.revertChanges() + forgetPartialObservationIfCurrent() task.processQueuedMessages() return } // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation @@ -273,6 +319,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { return } catch (error) { await handleError("applying diff", error as Error) + forgetPartialObservationIfCurrent() await task.diffViewProvider.reset() this.resetPartialState() task.processQueuedMessages() diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 2713e08ffe..b6f30190b2 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -1,4 +1,5 @@ import fs from "fs/promises" +import type { BigIntStats } from "fs" import path from "path" import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" @@ -6,20 +7,43 @@ import { type ClineSayTool, DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { getReadablePath } from "../../utils/path" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { Task } from "../task/Task" +import type { FileObservation } from "../task/observationRegistry" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { fileExistsAtPath } from "../../utils/fs" import { EXPERIMENT_IDS, experiments } from "../../shared/experiments" import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" +import { versionTokenOfStat } from "../../utils/versionToken" +import { canonicalizeForApproval, GuardRejectedError, errorCode } from "./guardedWrite" +import { CommittedWriteBufferConflictError } from "../../integrations/editor/committedWriteBufferConflictError" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" -import { parsePatch, ParseError, processAllHunks } from "./apply-patch" -import type { ApplyPatchFileChange } from "./apply-patch" +import { ApplyPatchError, parsePatch, ParseError, processAllHunks } from "./apply-patch" +import type { ApplyPatchFileChange, PatchReadIntent } from "./apply-patch" interface ApplyPatchParams { patch: string } +/** + * Whether two stats name one file, judged the way the compares around this handler judge it: as + * bigints, because on NTFS and ReFS a file identity can exceed Number.MAX_SAFE_INTEGER and a + * rounded value makes two files look alike or two spellings of one file look different. Both sides + * have to actually carry a bigint identity - a stat that does not is a double that was never asked + * to answer identity, and reading it as "the same file" would refuse ordinary moves for a shape no + * real filesystem produces. + */ +function _sameFileIdentity(left: BigIntStats, right: BigIntStats): boolean { + return ( + typeof left.ino === "bigint" && + typeof right.ino === "bigint" && + typeof left.dev === "bigint" && + typeof right.dev === "bigint" && + left.ino === right.ino && + left.dev === right.dev + ) +} + export class ApplyPatchTool extends BaseTool<"apply_patch"> { readonly name = "apply_patch" as const @@ -56,6 +80,25 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const { patch } = params const { handleError, pushToolResult } = callbacks + // Every observation this call creates is tracked here by path -> the exact entry the + // registry stored. A hunk read is the tool read, not a model read: an exit that does + // not publish must withdraw what this call recorded, otherwise a later edit, + // search_replace, edit_file or apply_diff finds the leftover partial entry, the + // compare-and-swap matches, and the file publishes without the model ever reading it. + // Withdrawal is keyed to the stored entry, so an entry a publish re-earned or one a + // concurrent read replaced is never discarded here. Declared outside the try because + // the finally exit needs it. ApplyDiffTool enforces the same contract for its single + // read with forgetPartialObservationIfCurrent; apply_patch reads several files per + // call, so this side keeps one entry per path. + const toolObservations = new Map() + const forgetToolObservationsIfCurrent = (): void => { + for (const [observedPath, entry] of toolObservations) { + if (task.observationRegistry.get(observedPath) === entry) { + task.observationRegistry.forget(observedPath) + } + } + toolObservations.clear() + } try { // Validate required parameters if (!patch) { @@ -85,10 +128,73 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each hunk - const readFile = async (filePath: string): Promise => { + // Process each hunk. The read doubles as the S2 observation for the + // guarded publish (ReadFileTool contract: stat before and after the + // read, observe only when the on-disk version is unchanged between the + // two stats). Without it, the in-place modify publish is an unobserved + // write and the composed chat-diff default rejects it ("File already + // exists ... and was not read before this write") even though this tool + // just read the exact content the patch was applied to. + const readFile = async (filePath: string, intent: PatchReadIntent): Promise => { const absolutePath = path.resolve(task.cwd, filePath) - return await fs.readFile(absolutePath, "utf8") + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + // Read the bytes, not a string: readFile with "utf8" has already substituted + // the sequences a strict decoder would refuse on, so the check below could not + // tell a file that contains a real replacement character from one whose bytes + // were replaced on the way in. + const raw = await fs.readFile(absolutePath) + let content: string + try { + content = new TextDecoder("utf-8", { fatal: true }).decode(raw) + } catch { + if (intent !== "publish") { + // Nothing is republished from this reading - a delete publishes by unlinking - + // so a file whose bytes nobody can name may still be deleted. The snapshot + // stays the lossy reading it always was. + content = raw.toString("utf8") + } else { + // Refuse before any observation is recorded: the version observed here is the + // version a later full-file write would be allowed to publish, and publishing + // it means rewriting the bytes this read could not name. + // The lossy reading substitutes from exactly the byte the strict decoder + // refused on, so re-encoding the text before its first replacement lands on + // the offending sequence. + const lossy = raw.toString("utf8") + const firstSubstitution = lossy.indexOf("\uFFFD") + const offset = + firstSubstitution < 0 + ? raw.length + : Buffer.byteLength(lossy.slice(0, firstSubstitution), "utf8") + throw new ApplyPatchError( + `Cannot apply patch to ${filePath}: the file is not valid UTF-8 (byte ${offset} cannot be decoded). apply_patch refuses rather than rewriting bytes the patch never touched.`, + ) + } + } + const postReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + // The tool's own hunk read, not a model read. When the model already observed the + // file, keep the completeness it earned and only on the version it was earned on; a + // partial view stays partial. With no prior observation the model has seen only the + // patch's hunks, so this read stays incomplete: it must not become authority for a + // later full-file replacement. + const prior = task.observationRegistry.get(absolutePath) + // With no prior observation there is nothing to carry, and the read stays + // incomplete. Carry completeness only when a prior observation exists and still + // describes the version that was read. + const complete = + prior === undefined ? false : prior.complete === true && prior.version === preReadToken + task.observationRegistry.observe(absolutePath, preReadToken, complete) + // Keep the stored entry so a non-publishing exit can withdraw exactly this + // observation later. + const storedObservation = task.observationRegistry.get(absolutePath) + if (storedObservation) { + toolObservations.set(absolutePath, storedObservation) + } + } + } + return content } let changes: ApplyPatchFileChange[] @@ -120,13 +226,36 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { if (change.type === "add") { // Create new file - await this.handleAddFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + await this.handleAddFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + toolObservations, + ) } else if (change.type === "delete") { // Delete file - await this.handleDeleteFile(absolutePath, relPath, task, callbacks, isWriteProtected) + await this.handleDeleteFile( + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + toolObservations, + ) } else if (change.type === "update") { // Update file - await this.handleUpdateFile(change, absolutePath, relPath, task, callbacks, isWriteProtected) + await this.handleUpdateFile( + change, + absolutePath, + relPath, + task, + callbacks, + isWriteProtected, + toolObservations, + ) } } @@ -134,6 +263,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } catch (error) { await handleError("apply patch", error as Error) await task.diffViewProvider.reset() + } finally { + // Every exit that did not publish passes through here: processAllHunks throwing (the + // files read before the failure stay observed), a refused approval, a rooignore or + // outside-workspace refusal, and the outer catch. Paths that published were removed + // from the tracker at the publish site, so this withdraws only what no publish earned. + forgetToolObservationsIfCurrent() } } @@ -144,6 +279,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + toolObservations: Map, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -160,6 +296,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const newContent = change.newContent || "" const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined // Initialize diff view for new file task.diffViewProvider.editType = "create" @@ -214,13 +356,38 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, true, diagnosticsEnabled, writeDelayMs) + // Guarded publish: the patch supplies the complete new content, so create-guard + // semantics apply (an unobserved existing target is rejected, not overwritten). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + true, + diagnosticsEnabled, + writeDelayMs, + "create", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, + isOutsideWorkspace, + approvedCanonicalTarget, + ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The add path publishes a whole new file, so create-guard semantics + // apply here as well. + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "create", + isOutsideWorkspace, + approvedCanonicalTarget, + ) } // Track file edit operation await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + // This call published the path, so the observation it carries is earned: the sweep in + // the finally block leaves it alone. + toolObservations.delete(absolutePath) task.didEditFile = true const message = await task.diffViewProvider.pushToolWriteResult(task, task.cwd, true) @@ -235,6 +402,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + toolObservations: Map, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -281,6 +449,10 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } + // The delete consumed the observation: the version the hunk read recorded is no longer + // reachable at this path, so there is nothing here for the sweep to withdraw. + toolObservations.delete(absolutePath) + task.didEditFile = true pushToolResult(`Successfully deleted ${relPath}`) task.processQueuedMessages() @@ -293,6 +465,7 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { task: Task, callbacks: ToolCallbacks, isWriteProtected: boolean, + toolObservations: Map, ): Promise { const { askApproval, pushToolResult } = callbacks @@ -310,6 +483,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { const originalContent = change.originalContent || "" const newContent = change.newContent || "" const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined // Initialize diff view task.diffViewProvider.editType = "modify" @@ -396,6 +575,12 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Check if destination path is outside workspace const isMoveOutsideWorkspace = isPathOutsideWorkspace(moveAbsolutePath) + // A destination outside every workspace root is refused right here, so the publish at the + // end of this branch is always an inside-workspace one: it needs neither the approval flag + // nor a canonical identity. Capturing the identity here instead - as this did - ran + // canonicalizeForApproval for a destination that was about to be refused, so a destination + // whose name could not be resolved surfaced an exception where the user should get the tool + // error below. if (isMoveOutsideWorkspace) { task.consecutiveMistakeCount++ task.recordToolError("apply_patch") @@ -406,15 +591,139 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } + // A move whose destination names the same file as its source publishes onto that file and + // then unlinks it: the write lands, the unlink at the end of this branch removes what was + // just written, and the bookkeeping below counts both paths as published. No reading of a + // move patch makes deleting the file you just wrote a correct outcome, so the pair is refused + // here - before the save, before the unlink, before the tracker call - which leaves every file + // exactly as it was found. + // + // Sameness is asked of the filesystem rather than of the two strings. Under an alias two + // spellings name one file, and across an alias one spelling names two files, so a string + // compare is wrong in both directions: it would refuse the alias-spelled move that ought to + // happen and allow the two-names-one-file move that ought not. The identity is read as bigints + // for the reason the compare beside it gives (safeWriteText.ts:247-:265): on NTFS and ReFS a + // file identity can exceed Number.MAX_SAFE_INTEGER, and a rounded value makes two files look + // alike or one file look like two. + // + // Both stats are taken on the spellings the patch named, resolved against the workspace and + // deliberately not canonicalized: canonicalizing first would fold an alias's two spellings + // into one string here and leave the identity question unasked. + // + // A refusal to answer is not read as "different files". Neither stat is allowed to throw + // through this handler: an exception here would replace the tool error the user should get, + // which is the mistake the note above the outside-workspace refusal records for canonicalising + // a destination that was about to be refused. Absence is read as what it means - a destination + // that is not there is not the same file as the source - and a refusal refuses the move. + const identityOf = async (target: string): Promise => { + try { + return await fs.stat(target, { bigint: true }) + } catch (error: unknown) { + return errorCode(error) === "ENOENT" ? "absent" : "refused" + } + } + const sourceIdentity = await identityOf(absolutePath) + const destinationIdentity = await identityOf(moveAbsolutePath) + const moveRefusal = + sourceIdentity === "refused" || destinationIdentity === "refused" + ? "the filesystem would not say what either path refers to, so the tool cannot tell whether the two names are one file" + : sourceIdentity !== "absent" && + destinationIdentity !== "absent" && + _sameFileIdentity(sourceIdentity, destinationIdentity) + ? `the two paths name the same file (dev ${String(sourceIdentity.dev)}, ino ${String(sourceIdentity.ino)}), so the move would publish onto it and then unlink what it just published` + : undefined + if (moveRefusal !== undefined) { + task.consecutiveMistakeCount++ + task.recordToolError("apply_patch") + const errorMessage = `Cannot move ${change.path} onto ${change.movePath}: ${moveRefusal}` + await task.say("error", errorMessage) + pushToolResult(formatResponse.toolError(errorMessage)) + await task.diffViewProvider.reset() + return + } + // Save new content to the new path if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly( - change.movePath, - newContent, - false, - diagnosticsEnabled, - writeDelayMs, - ) + // The destination content is the source file plus one targeted hunk, so it can + // only be as complete as the view the model had of the source. Carry that + // completeness to the destination BEFORE the guarded publish: the guard decides + // completeness at publish time, so a partial view must already be recorded when + // the per-path chain runs. Downgrading only after saveDirectly leaves a window + // in which a concurrent writer sees the destination as complete for content the + // model never fully read. + const sourceObs = task.observationRegistry.get(absolutePath) + const sourceComplete = sourceObs !== undefined && sourceObs.complete === true + if (!sourceComplete) { + // Only carry completeness the model already had for the destination. An + // unobserved destination stays unobserved so the guard's "read before a + // full-file write" rule still applies; recording a partial observation here + // would hand the model authority to edit a file it never read. + const destObs = task.observationRegistry.get(moveAbsolutePath) + if (destObs !== undefined) { + let destAbsent = false + try { + await fs.access(moveAbsolutePath) + } catch (error: unknown) { + // Only an access error that means "not there" is an absence verdict. An + // ELOOP or EACCES is a real I/O failure: reading it as absent would mark + // the destination partial and let the publish run against a path the tool + // never actually observed. + if (errorCode(error) !== "ENOENT") throw error + destAbsent = true + } + if (!destAbsent) { + // Replacing an existing destination with content built from a partial + // source view must be re-authorized by reading the source in full. Reject + // before any state changes and name the source: a remediation that names + // only the destination sends the model to re-read the wrong file, and a + // downgrade that survives a rejected publish loses a full destination read. + throw new GuardRejectedError( + `Cannot move a partially read file onto ${change.movePath}: re-read the whole source (${change.path}) first, then retry.`, + change.movePath, + ) + } + // The destination was read and then deleted: the create guard permits the + // publish, and the content is still only as complete as the view the model + // had of the source, so carry the source's completeness instead of the + // destination's stale one. + task.observationRegistry.observe(moveAbsolutePath, destObs.version, false) + // Tracked like a hunk read: if the move does not publish, this partial entry must + // not survive as authority for a destination the model never read. + const storedDestObservation = task.observationRegistry.get(moveAbsolutePath) + if (storedDestObservation) { + toolObservations.set(moveAbsolutePath, storedDestObservation) + } + } + } + try { + await task.diffViewProvider.saveDirectly( + change.movePath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + sourceComplete, + // Guaranteed by the refusal above: this destination is inside a workspace root, + // so the guard's own containment check is what applies and no approval is claimed. + false, + undefined, + ) + } catch (error) { + if (error instanceof CommittedWriteBufferConflictError) { + // The destination committed and the source unlink below never runs, so the move is + // half-applied: the content is at the new path and the source is still on disk. A model + // that re-applies the patch duplicates the change at the destination while a third path + // still holds the pre-move bytes, so both paths are named and the shared error keeps its + // no-retry guidance. + throw new CommittedWriteBufferConflictError( + moveAbsolutePath, + `apply_patch move: ${change.movePath} committed but ${change.path} was not removed, so the move ` + + "is incomplete: read both paths before continuing.", + ) + } + throw error + } } else { // Write to new path and delete old file const parentDir = path.dirname(moveAbsolutePath) @@ -422,23 +731,62 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { await fs.writeFile(moveAbsolutePath, newContent, "utf8") } - // Delete the original file + // Delete the original file. A failure here is logged and tolerated: the destination + // has already published, but the source is still sitting on disk with the bytes it + // had before the move, so nothing was published at that path. + let sourceRemoved = true try { await fs.unlink(absolutePath) } catch (error) { + sourceRemoved = false console.error(`Failed to delete original file after move: ${error}`) } await task.fileContextTracker.trackFileContext(change.movePath, "roo_edited" as RecordSource) + // The destination published through the save above, so its entry leaves the sweep. + // The source only counts as published once its own unlink succeeded: otherwise the + // partial observation this call recorded for it is withdrawn in the finally block, + // instead of authorizing a later edit on a file the model never read in full. + toolObservations.delete(moveAbsolutePath) + if (sourceRemoved) { + toolObservations.delete(absolutePath) + } } else { // Save changes to the same file if (isPreventFocusDisruptionEnabled) { - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: an update hunk is a targeted change, so it is guarded + // as an edit -- a partial observation authorizes it and stays partial. A + // stale observed version is still rejected with the re-read hint. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, + isOutsideWorkspace, + approvedCanonicalTarget, + ) } else { - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + // The diff-view save is the same targeted hunk as the guarded save above: + // it must select the same edit guard, otherwise a partial read is + // rejected and the approved patch is thrown away. + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "edit", + isOutsideWorkspace, + approvedCanonicalTarget, + ) } await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) + // The publish owns this path now, so the sweep in the finally block leaves its + // observation alone. + toolObservations.delete(absolutePath) } task.didEditFile = true diff --git a/src/core/tools/EditFileTool.ts b/src/core/tools/EditFileTool.ts index a7301e2ac9..963c6aabff 100644 --- a/src/core/tools/EditFileTool.ts +++ b/src/core/tools/EditFileTool.ts @@ -14,6 +14,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface EditFileParams { file_path: string @@ -400,6 +401,12 @@ export class EditFileTool extends BaseTool<"edit_file"> { const sanitizedDiff = sanitizeUnifiedDiff(diff || "") const diffStats = computeDiffStats(sanitizedDiff) || undefined const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined const sharedMessageProps: ClineSayTool = { tool: isNewFile ? "newFileCreated" : "appliedDiff", @@ -436,17 +443,31 @@ export class EditFileTool extends BaseTool<"edit_file"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file + // Direct file write without diff view or opening the file. In-place edits + // use edit-guard semantics (a prior read is required); new-file creation + // keeps create-guard semantics. await task.diffViewProvider.saveDirectly( relPath, newContent, isNewFile, diagnosticsEnabled, writeDelayMs, + isNewFile ? "create" : "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, + isOutsideWorkspace, + approvedCanonicalTarget, ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + isNewFile ? "create" : "edit", + isOutsideWorkspace, + approvedCanonicalTarget, + ) } // Track file edit operation diff --git a/src/core/tools/EditTool.ts b/src/core/tools/EditTool.ts index 895b929ed2..cf20fdb26a 100644 --- a/src/core/tools/EditTool.ts +++ b/src/core/tools/EditTool.ts @@ -14,6 +14,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface EditParams { file_path: string @@ -175,6 +176,12 @@ export class EditTool extends BaseTool<"edit"> { const sanitizedDiff = sanitizeUnifiedDiff(diff) const diffStats = computeDiffStats(sanitizedDiff) || undefined const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", @@ -211,11 +218,30 @@ export class EditTool extends BaseTool<"edit"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, + isOutsideWorkspace, + approvedCanonicalTarget, + ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "edit", + isOutsideWorkspace, + approvedCanonicalTarget, + ) } // Track file edit operation diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index e4e69b30ad..5634a1388d 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -17,13 +17,14 @@ import type { ReadFileParams, ReadFileMode, ReadFileToolParams, FileEntry, LineR import { isLegacyReadFileParams, type ClineSayTool } from "@roo-code/types" import { Task } from "../task/Task" +import { versionTokenOfStat } from "../../utils/versionToken" import { formatResponse } from "../prompts/responses" import { RecordSource } from "../context-tracking/FileContextTrackerTypes" import { isPathOutsideWorkspace } from "../../utils/pathUtils" import { getReadablePath } from "../../utils/path" import { extractTextFromFile, addLineNumbers, getSupportedBinaryFormats } from "../../integrations/misc/extract-text" import { readWithIndentation, readWithSlice } from "../../integrations/misc/indentation-reader" -import { DEFAULT_LINE_LIMIT } from "../prompts/tools/native-tools/read_file" +import { DEFAULT_LINE_LIMIT, MAX_LINE_LENGTH } from "../prompts/tools/native-tools/read_file" import type { ToolUse, PushToolResult } from "../../shared/tools" import { @@ -215,14 +216,36 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read text file content with lossy UTF-8 conversion // Reading as Buffer first allows graceful handling of non-UTF8 bytes // (they become U+FFFD replacement characters instead of throwing) + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) const buffer = await fs.readFile(fullPath) const fileContent = buffer.toString("utf-8") - const result = this.processTextFile(fileContent, entry) + // A lossy decode is not the whole file: the model never saw those bytes. + const lossyDecode = !Buffer.from(fileContent).equals(buffer) + // S4b follow-up (#46 / epic #1375): processTextFile reports whether the + // returned content is the whole file; the observation below records that + // scope so the write guard can deny full-file updates built on a partial view. + const processed = this.processTextFile(fileContent, entry) await task.fileContextTracker.trackFileContext(relPath, "read_tool" as RecordSource) + // A2 (plan #33 / epic #1375): record the observed on-disk version for the future write guard. + // The token is captured before AND after the read; the target is observed only + // when both match — a mutation between the two stats means the content the model + // received is not the on-disk state, and observing it would let a later write + // match a token the model never saw. A stat failure leaves the target + // unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken, processed.complete && !lossyDecode) + } + } + updateFileResult(relPath, { - nativeContent: `File: ${relPath}\n${result}`, + nativeContent: `File: ${relPath}\n${processed.content}`, }) } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) @@ -266,8 +289,14 @@ export class ReadFileTool extends BaseTool<"read_file"> { /** * Process a text file according to the requested mode. + * + * Returns the content string plus whether that content is the complete + * file (S4b follow-up #46 / epic #1375): slice mode is complete only + * when it starts at line 1, returns every line, and was not truncated; + * indentation mode is never complete because it returns semantic blocks + * of the file, not the file itself. */ - private processTextFile(content: string, entry: InternalFileEntry): string { + private processTextFile(content: string, entry: InternalFileEntry): { content: string; complete: boolean } { const mode = entry.mode || "slice" if (mode === "indentation") { @@ -300,7 +329,8 @@ export class ReadFileTool extends BaseTool<"read_file"> { output += `\n\nIncluded ranges: ${rangeStr} (total: ${result.totalLines} lines)` } - return output + // Indentation mode returns semantic blocks: never a complete file view. + return { content: output, complete: false } } // Slice mode (default): simple offset/limit reading @@ -323,11 +353,44 @@ export class ReadFileTool extends BaseTool<"read_file"> { To read more: Use the read_file tool with offset=${nextOffset} and limit=${limit}. ${result.content}` + if (result.hasClippedLines) { + // The slice cut lines off and also clipped long lines inside it, so both + // notices belong to the response. + output += `\nNote: Some lines in this view exceed ${MAX_LINE_LENGTH} characters and were clipped in this view.` + } + } else if (result.hasClippedLines) { + // Nothing was omitted, so there is no later offset to read: report the clipping + // without a next-offset hint, and keep the read incomplete so a full-file + // replacement cannot be built from a clipped line. "In full" is only true when the + // slice covered the whole file - a slice that started past line 1 and stopped at the + // last line is not truncated, but it is not the file either, and saying "read in + // full" would hide the lines before the offset from the reader. + const covered = result.includedRanges.map(([start, end]) => `${start}-${end}`).join(", ") + const coversWholeFile = + result.includedRanges.length > 0 && + result.includedRanges[0][0] === 1 && + result.includedRanges[result.includedRanges.length - 1][1] === result.totalLines + const scope = coversWholeFile + ? "The file was read in full, but the clipped lines were not shown in full." + : `Lines ${covered} of ${result.totalLines} total lines were shown, and the clipped lines were not shown in full.` + // The notice and the read are joined with a single newline: an indented template + // continuation here would put the indentation into the response itself, so the first + // numbered line would arrive carrying tabs an exact-match edit then has to reproduce. + output = [ + `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. ${scope}`, + result.content, + ].join("\n") } else if (result.returnedLines === 0 && result.totalLines === 0) { output = "Note: File is empty" } - return output + // Complete only when the slice starts at line 1, returned every line, and + // showed every line in full (returnedLines === totalLines follows from the + // first two conditions): a partial start, a truncated tail, or a clipped + // line means the model did not see the whole file. + const complete = offset0 === 0 && !result.wasTruncated && !result.hasClippedLines + + return { content: output, complete } } /** @@ -770,9 +833,20 @@ export class ReadFileTool extends BaseTool<"read_file"> { } // Read text file - const rawContent = await fs.readFile(fullPath, "utf8") + // A2 (epic #1375): capture the on-disk token before the read so a mutation + // landing mid-read is detected by the post-read stat below. + const preReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + const rawBuffer = await fs.readFile(fullPath) + const rawContent = rawBuffer.toString("utf-8") + // Same contract: a lossy decode is a partial view. + const lossyDecode = !Buffer.from(rawContent).equals(rawBuffer) // Handle line ranges if specified + // S4b follow-up (#46 / epic #1375): a line-range read returns only the requested + // ranges, and a slice truncated to DEFAULT_LINE_LIMIT returns only the head of + // the file — record such observations as partial so the write guard denies a + // full-file update built on them. + let readComplete = false let content: string if (entry.lineRanges && entry.lineRanges.length > 0) { const lines = rawContent.split("\n") @@ -792,8 +866,16 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Read with default limits using slice mode const result = readWithSlice(rawContent, 0, DEFAULT_LINE_LIMIT) content = result.content + readComplete = !result.wasTruncated && !result.hasClippedLines if (result.wasTruncated) { content += `\n\n[File truncated: showing ${result.returnedLines} of ${result.totalLines} total lines]` + if (result.hasClippedLines) { + // Both notices: the slice was truncated and a line inside it was + // clipped. + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` + } + } else if (result.hasClippedLines) { + content += `\n\n[Some lines exceed the per-line length cap and were clipped in this view]` } } @@ -801,6 +883,19 @@ export class ReadFileTool extends BaseTool<"read_file"> { // Track file in context await task.fileContextTracker.trackFileContext(relPath, "read_tool") + + // A2 (plan #33 / epic #1375): mirror the native path — record the observed + // on-disk version so legacy-format reads also feed the future write guard. + // Observe only when the pre-read and post-read tokens match (a mutation between + // them means the returned content is not the on-disk state). A stat failure + // leaves the target unobserved and never fails the read. + const postReadStats = await fs.stat(fullPath, { bigint: true }).catch(() => undefined) + if (preReadStats && postReadStats) { + const preReadToken = versionTokenOfStat(preReadStats) + if (preReadToken === versionTokenOfStat(postReadStats)) { + task.observationRegistry.observe(fullPath, preReadToken, readComplete && !lossyDecode) + } + } } catch (error) { const errorMsg = error instanceof Error ? error.message : String(error) results.push(`File: ${relPath}\nError: ${errorMsg}`) diff --git a/src/core/tools/SearchReplaceTool.ts b/src/core/tools/SearchReplaceTool.ts index a0f2dadfcc..41b7c8061b 100644 --- a/src/core/tools/SearchReplaceTool.ts +++ b/src/core/tools/SearchReplaceTool.ts @@ -14,6 +14,7 @@ import { sanitizeUnifiedDiff, computeDiffStats } from "../diff/stats" import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface SearchReplaceParams { file_path: string @@ -171,6 +172,12 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { const sanitizedDiff = sanitizeUnifiedDiff(diff) const diffStats = computeDiffStats(sanitizedDiff) || undefined const isOutsideWorkspace = isPathOutsideWorkspace(absolutePath) + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined const sharedMessageProps: ClineSayTool = { tool: "appliedDiff", @@ -207,11 +214,30 @@ export class SearchReplaceTool extends BaseTool<"search_replace"> { // Save the changes if (isPreventFocusDisruptionEnabled) { - // Direct file write without diff view or opening the file - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Direct file write without diff view or opening the file. This tool only + // edits existing files, so edit-guard semantics require a prior read. + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "edit", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, + isOutsideWorkspace, + approvedCanonicalTarget, + ) } else { // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + "edit", + isOutsideWorkspace, + approvedCanonicalTarget, + ) } // Track file edit operation diff --git a/src/core/tools/WriteToFileTool.ts b/src/core/tools/WriteToFileTool.ts index 0c5c80abb9..5b0e48cfd9 100644 --- a/src/core/tools/WriteToFileTool.ts +++ b/src/core/tools/WriteToFileTool.ts @@ -16,6 +16,7 @@ import { convertNewFileToUnifiedDiff, computeDiffStats, sanitizeUnifiedDiff } fr import type { ToolUse } from "../../shared/tools" import { BaseTool, ToolCallbacks } from "./BaseTool" +import { canonicalizeForApproval } from "./guardedWrite" interface WriteToFileParams { path: string @@ -86,7 +87,6 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { const fullPath = relPath ? path.resolve(task.cwd, relPath) : "" const isOutsideWorkspace = isPathOutsideWorkspace(fullPath) - const sharedMessageProps: ClineSayTool = { tool: fileExists ? "editedExistingFile" : "newFileCreated", path: getReadablePath(task.cwd, relPath), @@ -96,6 +96,14 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { } try { + // Captured before the approval is asked: guardedWrite binds the publish to this + // identity, so a name repointed between the approval and the publish is refused + // instead of publishing to whatever the name points at by then. Inside the try so a + // target that cannot be resolved goes through the tool's normal error handling + // (handleError and the diff-view reset) instead of escaping the write flow. + const approvedCanonicalTarget = isOutsideWorkspace + ? await canonicalizeForApproval(absolutePath, relPath) + : undefined task.consecutiveMistakeCount = 0 const provider = task.providerRef.deref() @@ -132,7 +140,21 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - await task.diffViewProvider.saveDirectly(relPath, newContent, false, diagnosticsEnabled, writeDelayMs) + // Guarded publish: this write carries the complete file content, so it uses + // create-guard semantics (unobserved targets may only be created when absent). + await task.diffViewProvider.saveDirectly( + relPath, + newContent, + false, + diagnosticsEnabled, + writeDelayMs, + "create", + // Seventh parameter is completeOverride (this call claims no completeness of its + // own); the eighth is the approval flag for a target outside every workspace root. + undefined, + isOutsideWorkspace, + approvedCanonicalTarget, + ) } else { if (!task.diffViewProvider.isEditing) { const partialMessage = JSON.stringify(sharedMessageProps) @@ -164,7 +186,16 @@ export class WriteToFileTool extends BaseTool<"write_to_file"> { return } - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges( + diagnosticsEnabled, + writeDelayMs, + // Name the guard explicitly: a whole-file write is an update, and + // relying on the parameter's default would silently re-select a + // different guard if the default ever moved. + "update", + isOutsideWorkspace, + approvedCanonicalTarget, + ) } if (relPath) { diff --git a/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts b/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts index bf7b3f5351..a0daefcf59 100644 --- a/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts +++ b/src/core/tools/__tests__/applyDiffTool.failureReporting.spec.ts @@ -15,6 +15,16 @@ vi.mock("@roo-code/telemetry", () => ({ vi.mock("fs/promises", () => ({ default: { readFile: vi.fn().mockResolvedValue(""), + // apply_diff stats around its own read (same contract as ApplyPatchTool's hunk + // read): without this double the stat call throws and every failure-path test + // sees a spurious handleError("applying diff", TypeError). + stat: vi.fn().mockResolvedValue({ + dev: 7n, + ino: 4242n, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + }), }, })) @@ -82,6 +92,10 @@ describe("ApplyDiffTool failure reporting", () => { }), }), }, + observationRegistry: { + get: vi.fn().mockReturnValue(undefined), + observe: vi.fn(), + }, diffViewProvider: { editType: undefined, originalContent: "", diff --git a/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts new file mode 100644 index 0000000000..d5ee1b2eb1 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -0,0 +1,463 @@ +// npx vitest run core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts + +import path from "path" +import type { BigIntStats } from "fs" + +import type { MockedFunction } from "vitest" + +import { fileExistsAtPath } from "../../../utils/fs" +import type { Task } from "../../task/Task" +import { ApplyDiffTool } from "../ApplyDiffTool" +import { ObservationRegistry } from "../../task/observationRegistry" + +vi.mock("fs/promises", () => ({ + default: { + readFile: vi.fn().mockResolvedValue("original file content\n"), + // The tool stats around its read to authorize the save against the version it + // actually read; one shared stats object means the file did not change. + stat: vi.fn().mockResolvedValue({ + dev: 1n, + ino: 2n, + size: 22n, + mtimeNs: 100n, + ctimeNs: 100n, + }), + }, +})) + +vi.mock("../../../utils/fs", () => ({ + fileExistsAtPath: vi.fn().mockResolvedValue(true), +})) + +vi.mock("../../prompts/responses", () => ({ + formatResponse: { + toolError: vi.fn((msg: string) => `Error: ${msg}`), + rooIgnoreError: vi.fn((filePath: string) => `Access denied: ${filePath}`), + createPrettyPatch: vi.fn(() => "mock-diff"), + }, +})) + +vi.mock("../../diff/stats", () => ({ + sanitizeUnifiedDiff: vi.fn((diff: string) => diff), + computeDiffStats: vi.fn(() => ({ additions: 1, deletions: 1 })), +})) + +describe("ApplyDiffTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyDiffTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "consecutiveMistakeCountForApplyDiff" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "api" + | "diffStrategy" + | "observationRegistry" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + afterEach(async () => { + // clearAllMocks drops call records but not queued once-values, so stats queued by + // one test would otherwise be handed to the next test's reads. Restore the default. + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat.mockReset() + stat.mockResolvedValue({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats) + // The role-aware stat mock above also drives readFile, so its implementation is reset here too: + // a leaked implementation would decide which stat call the NEXT test sees as pre-read. + const readFile = vi.mocked((await import("fs/promises")).default.readFile) + readFile.mockReset() + readFile.mockResolvedValue("original file content\n") + }) + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + // Used by the approval-denial branch: the denial must complete through its own + // path (no catch, no handleError) while the observation is cleaned up. + revertChanges: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + consecutiveMistakeCountForApplyDiff: new Map(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + api: { + getModel: () => ({ id: "claude-sonnet-4-5" }), + } as unknown as Task["api"], + diffStrategy: { + applyDiff: vi.fn().mockResolvedValue({ success: true, content: "modified file content\n" }), + } as unknown as Task["diffStrategy"], + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }), + }), + } as unknown as Task["providerRef"], + observationRegistry: new ObservationRegistry(), + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyDiffTool() + }) + + it("publishes through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalledWith("applying diff", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("passes the edit kind to the diff-view save path", async () => { + // Without focus disruption the tool saves through the diff view, and the + // kind it passes must carry its intent: a targeted edit, not the default + // full-file replacement. + // The settings double is rebuilt for this test so the focus-disruption + // experiment is off and the tool takes the diff-view save path. + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: {}, + }), + }), + } as unknown as Task["providerRef"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("authorizes the save against the version its own read was built on", async () => { + // The model never read this file, so the only authorization available at save + // time is the one the tool earns from its own hunk read. Without it the guarded + // save falls back to the preview's version token, which can describe a version + // the diff was never computed against. + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + const observation = mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts")) + expect(observation?.version).toBe("1:2:22:100:100") + // A tool read is not a model read, so completeness stays unearned. + expect(observation?.complete).toBe(false) + }) + + it("does not authorize a read that changed underneath it", async () => { + // Only the fields versionTokenOfStat reads; a full BigIntStats cannot be built + // against the mocked fs, so the double assertion is the narrowest option. + const stat = vi.mocked((await import("fs/promises")).default.stat) + stat.mockResolvedValueOnce({ + dev: 1n, + ino: 2n, + size: 22n, + mtimeNs: 100n, + ctimeNs: 100n, + } as unknown as BigIntStats).mockResolvedValueOnce({ + dev: 1n, + ino: 2n, + size: 30n, + mtimeNs: 200n, + ctimeNs: 100n, + } as unknown as BigIntStats) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + // Exactly the two bracketing stats: no queued value left over for the next test. + expect(stat).toHaveBeenCalledTimes(2) + }) + + it("removes the partial observation this call created when the diff cannot be applied", async () => { + // The edit never happens, so the partial authorization earned from the tool's own + // read must not outlive the failed call. It is removed only while it is still the + // same observation. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.diffStrategy = { + applyDiff: vi.fn().mockResolvedValue({ success: false, error: "no match" }), + } as unknown as Task["diffStrategy"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.get(key)).toBeUndefined() + }) + + it("keeps a partial observation replaced by another operation on a failed diff", async () => { + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.diffStrategy = { + applyDiff: vi.fn().mockImplementation(async () => { + // Another operation publishes/observes this path while the diff is failing. + mockTask.observationRegistry.observe(key, "1:2:99:9:9", true) + return { success: false, error: "no match" } + }), + } as unknown as Task["diffStrategy"] + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The replaced observation belongs to the other operation, not this failed call. + expect(mockTask.observationRegistry.get(key)?.version).toBe("1:2:99:9:9") + }) + + it("removes the partial observation when the user denies the edit", async () => { + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.providerRef = { + deref: () => ({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }), + }), + } as unknown as Task["providerRef"] + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The denial cleanup completed through its own path, not the catch: the observation is + // removed and no tool error was reported. + expect(mockTask.observationRegistry.get(key)).toBeUndefined() + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("keeps a complete model observation complete when the tool read matches its version", async () => { + // The model read the file in full first. The tool's own read of the same version + // must not downgrade that earned completeness to a partial observation. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.observationRegistry.observe(key, "1:2:22:100:100", true) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + const observation = mockTask.observationRegistry.get(key) + expect(observation?.version).toBe("1:2:22:100:100") + expect(observation?.complete).toBe(true) + }) + + it("does not refresh an observation the model earned on an older version", async () => { + // Refreshing a stale observation to the current version would authorize content the + // model built from the older read. The observation is left as the model earned it, + // so the save's compare-and-swap fails and the model is told to re-read. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + mockTask.observationRegistry.observe(key, "1:2:9:9:9", true) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + const observation = mockTask.observationRegistry.get(key) + expect(observation?.version).toBe("1:2:9:9:9") + expect(observation?.complete).toBe(true) + }) + + it("still performs the diff read when the pre-read stat fails, and records no observation", async () => { + // A stat failure is not a tool failure: the read still happens, the diff still + // applies, and the save still goes through the guard (which fails closed without + // an observation). What must not happen is an observation recorded for a version + // the tool could not bracket. + const fsMock = await import("fs/promises") + const stat = vi.mocked(fsMock.default.stat) + const readFile = vi.mocked(fsMock.default.readFile) + // Pin WHICH of the two bracketing stats fails. Both run against the same path, so a + // once-value cannot express the role: verified by flipping the injected failure to the + // post-read stat, which leaves this test green while testing a different scenario. The + // pre-read stat is the one that runs before the read, so the failure is keyed to that + // interleaving rather than to a call index. + let readStarted = false + readFile.mockImplementation(async () => { + readStarted = true + return "original file content\n" + }) + const statRoles: string[] = [] + stat.mockImplementation(async () => { + if (!readStarted) { + statRoles.push("pre:threw") + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + statRoles.push("post:ok") + return { dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n } as unknown as BigIntStats + }) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + // Both bracketing stats ran, and the one that failed was the PRE-read one. The + // roles are recorded from the interleaving with the read, so flipping which call throws + // makes this assertion fail - the outcome assertions alone cannot tell the two apart. + expect(statRoles).toEqual(["pre:threw", "post:ok"]) + // The read itself is unaffected: the diff was computed and the save attempted. + expect(mockTask.diffStrategy?.applyDiff).toHaveBeenCalled() + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + ) + expect(mockHandleError).not.toHaveBeenCalled() + // Both bracketing stats were still attempted - the failure is not swallowed into + // skipping the second one. + expect(stat).toHaveBeenCalledTimes(2) + }) + + it("records no observation when the post-read stat fails", async () => { + // The file changed or vanished between the read and the second stat; with no + // post-read token the tool cannot prove the version it read, so the read + // authorizes nothing and the save falls back to the re-read remediation. + const stat = vi.mocked((await import("fs/promises")).default.stat) + // Only the SECOND stat fails. Rejecting every call also fails the pre-read stat, so a + // regression that observes the file whenever the pre-read succeeds and ignores the post-read + // result would still pass here - the observation would be missing for the wrong reason. The + // first call answers with the same identity the harness's default provides. + stat.mockImplementationOnce( + async () => ({ dev: 1n, ino: 2n, size: 22n, mtimeNs: 100n, ctimeNs: 100n }) as never, + ) + stat.mockRejectedValueOnce(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await tool.execute({ path: "src/thing.ts", diff: "unified diff" }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.get(path.resolve(mockTask.cwd, "src/thing.ts"))).toBeUndefined() + expect(mockSaveDirectly).toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(stat).toHaveBeenCalledTimes(2) + }) +}) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index eb89838cad..9111de3ccd 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,14 +4,72 @@ import type { MockedFunction } from "vitest" import { fileExistsAtPath } from "../../../utils/fs" import { isPathOutsideWorkspace } from "../../../utils/pathUtils" +import path from "path" +import * as fsPromises from "fs/promises" import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { guardedWrite } from "../guardedWrite" import { ApplyPatchTool } from "../ApplyPatchTool" +import { CommittedWriteBufferConflictError } from "../../../integrations/editor/committedWriteBufferConflictError" +import { PostCommitDurabilityError } from "../../../services/file-safety/safeWriteText" -vi.mock("fs/promises", () => ({ - default: { - readFile: vi.fn().mockResolvedValue("original file content\n"), - unlink: vi.fn().mockResolvedValue(undefined), +// The vi.mock factory exposes the fs/promises functions under a `default` +// property (matching the SUT's default import), which the static module type +// does not declare; cast once at this boundary rather than at each call site. +const mockedFsPromises = vi.mocked( + fsPromises as unknown as { + default: { + access: MockedFunction + readFile: ReturnType + stat: ReturnType + unlink: MockedFunction + // Added for the outside-workspace refusal row: the refused destination must not be canonicalised. + realpath: ReturnType + // Added for the same-file move rows, which ask whether any write landed at the destination. + // Typed loosely because fsPromises.writeFile carries an overload set that MockedFunction + // would collapse to one arbitrary member; the rows only ever ask whether it was called. + writeFile: ReturnType + // Added for the flag-off move rows: the raw branch mkdirs the destination's parent before + // writing, and the rows ask whether that mkdir ran. Typed loosely for the same reason as + // writeFile above - fsPromises.mkdir is overloaded and the rows only ever ask about calls. + mkdir: ReturnType + } }, +) + +vi.mock("fs/promises", () => { + // The SUT imports the module two ways: applyPatchTool uses the default + // export, safeWriteText uses the namespace. Both must see the same doubles. + const doubles = { + // The target exists on disk, so the completeness gate applies to the + // follow-up guarded write in the partial-observation test. + access: vi.fn().mockResolvedValue(undefined), + // Raw bytes: the SUT reads without an encoding so a strict decoder can see the file as + // the file is, and this double has to answer the same way the real readFile() does. + readFile: vi.fn().mockResolvedValue(Buffer.from("original file content\n")), + // On-disk identity for the S2 self-read observation (the hunk read stats before and after; + // equal tokens record the observe) and for the move handler's same-file check. Answered per + // path by _identityOf below. + stat: vi.fn((target: unknown) => Promise.resolve(_identityOf(String(target)))), + unlink: vi.fn().mockResolvedValue(undefined), + // resolveLockKey canonicalizes the lock key, so the guard needs these + // even when no symlink is involved. + lstat: vi.fn().mockResolvedValue({ isSymbolicLink: () => false }), + readlink: vi.fn().mockRejectedValue(new Error("not a symbolic link")), + realpath: vi.fn(async (p: string) => String(p)), + // safeWriteText creates the backup directory before publishing. + mkdir: vi.fn().mockResolvedValue(undefined), + // The flag-off move branch (ApplyPatchTool.ts:610-:612) publishes with a raw + // fs.writeFile, so the double set needs writeFile as well as mkdir. + writeFile: vi.fn().mockResolvedValue(undefined), + } + return { default: doubles, ...doubles } +}) + +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), })) vi.mock("../../../utils/fs", () => ({ @@ -22,6 +80,43 @@ vi.mock("../../../utils/pathUtils", () => ({ isPathOutsideWorkspace: vi.fn().mockReturnValue(false), })) +// The identity a path answers with. It used to be ONE identity for every path (ino 4242 +// everywhere), which was harmless only because nothing asked the question: two names on a +// filesystem are two files unless something links them, and the move handler now asks which of the +// two its source and its destination are. The double distinguishes exactly the pair the move rows +// publish onto - src/new.ts, and the same basename reached through a link of its directory - and +// leaves every other path the identity the registry token strings below spell out. A row that wants +// two names for one file re-arms stat and says so in its own comment. +const _MOVE_SOURCE_INO = 4242n +const _MOVE_DESTINATION_INO = 4343n +const _MOVE_ALIAS_DESTINATION_INO = 4444n + +interface FileIdentity { + dev: bigint + ino: bigint + size: bigint + mtimeNs: bigint + ctimeNs: bigint +} + +function _identityOf(target: string): FileIdentity { + const resolvedNew = path.resolve("/workspace/project", "src/new.ts") + const resolvedAliasNew = path.resolve("/workspace/project", "src/aliasDir/new.ts") + const ino = + target === resolvedNew + ? _MOVE_DESTINATION_INO + : target === resolvedAliasNew + ? _MOVE_ALIAS_DESTINATION_INO + : _MOVE_SOURCE_INO + return { + dev: 7n, + ino, + size: 1234n, + mtimeNs: 1_700_000_000_123_456_789n, + ctimeNs: 1_700_000_000_789_999_999n, + } +} + describe("ApplyPatchTool.execute - delete file success path", () => { const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction const mockedIsPathOutsideWorkspace = isPathOutsideWorkspace as MockedFunction @@ -38,6 +133,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { | "say" | "processQueuedMessages" | "didEditFile" + | "observationRegistry" > let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> @@ -52,6 +148,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -95,3 +192,1394 @@ describe("ApplyPatchTool.execute - delete file success path", () => { expect(mockTask.recordToolError).not.toHaveBeenCalled() }) }) + +describe("ApplyPatchTool.execute - guarded write (S4b, epic #1375)", () => { + const mockedFileExistsAtPath = fileExistsAtPath as MockedFunction + + let tool: ApplyPatchTool + let mockTask: Pick< + Task, + | "cwd" + | "consecutiveMistakeCount" + | "recordToolError" + | "rooIgnoreController" + | "rooProtectedController" + | "say" + | "processQueuedMessages" + | "didEditFile" + | "diffViewProvider" + | "providerRef" + | "fileContextTracker" + | "observationRegistry" + > + let mockSaveDirectly: MockedFunction<(...args: unknown[]) => Promise> + let mockSaveChanges: MockedFunction<(...args: unknown[]) => Promise> + let mockGetState: MockedFunction<() => Promise> + let mockAskApproval: MockedFunction<(...args: unknown[]) => Promise> + let mockHandleError: MockedFunction<(...args: unknown[]) => Promise> + let mockPushToolResult: MockedFunction<(...args: unknown[]) => void> + + const updatePatch = `*** Begin Patch +*** Update File: src/thing.ts +@@ +-original file content ++modified file content +*** End Patch` + + const addPatch = `*** Begin Patch +*** Add File: src/new.ts ++new line one ++new line two +*** End Patch` + + const movePatch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/new.ts +@@ +-original file content ++modified file content +*** End Patch` + // The destination spelled through a link of the same directory, naming its own file: the other + // direction of the pair the same-file rows pin below. + const moveThroughAliasPatch = `*** Begin Patch +*** Update File: src/old.ts +*** Move to: src/aliasDir/new.ts +@@ +-original file content ++modified file content +*** End Patch` + + const mismatchedSecondPatch = `*** Begin Patch +*** Update File: src/first.ts +@@ +-original file content ++modified first content +*** Update File: src/second.ts +@@ +-context the mocked file does not contain ++modified second content +*** End Patch` + + const twoUpdatePatch = `*** Begin Patch +*** Update File: src/first.ts +@@ +-original file content ++modified first content +*** Update File: src/second.ts +@@ +-original file content ++modified second content +*** End Patch` + + beforeEach(() => { + vi.clearAllMocks() + + mockedFileExistsAtPath.mockResolvedValue(true) + + // The outside-workspace double is module-level, and vi.clearAllMocks() keeps implementations, + // so a row that flips it has to find it back at false. Restored here rather than per row so the + // next row does not have to know a neighbour changed it. + vi.mocked(isPathOutsideWorkspace).mockReturnValue(false) + // vi.clearAllMocks() keeps implementations, and rows below re-arm stat with a one-file-two- + // names answer. Restoring the default here means a row does not have to know what a neighbour + // installed. + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + Promise.resolve(_identityOf(String(target))), + ) + mockSaveDirectly = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockSaveChanges = vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "new content", + }) + mockGetState = vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + // Exercise the focus-disruption (saveDirectly) save path. + experiments: { preventFocusDisruption: true }, + }) + + // Structural stubs for the guarded-write path: the real DiffViewProvider is + // out of scope here, so vi.fn() doubles stand in for the members the tool + // touches (the saveDirectly double also records the writeKind plumbing). + const diffViewProviderStub = { + editType: undefined as "create" | "modify" | undefined, + originalContent: undefined as string | undefined, + saveDirectly: mockSaveDirectly, + saveChanges: mockSaveChanges, + open: vi.fn().mockResolvedValue(undefined), + update: vi.fn().mockResolvedValue(undefined), + scrollToFirstDiff: vi.fn(), + pushToolWriteResult: vi.fn().mockResolvedValue("Saved file"), + reset: vi.fn().mockResolvedValue(undefined), + } + mockTask = { + cwd: "/workspace/project", + consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), + recordToolError: vi.fn(), + rooIgnoreController: { + validateAccess: vi.fn().mockReturnValue(true), + } as unknown as Task["rooIgnoreController"], + rooProtectedController: { + isWriteProtected: vi.fn().mockReturnValue(false), + } as unknown as Task["rooProtectedController"], + say: vi.fn().mockResolvedValue(undefined), + processQueuedMessages: vi.fn(), + didEditFile: false, + diffViewProvider: diffViewProviderStub as unknown as Task["diffViewProvider"], + providerRef: { + deref: vi.fn().mockReturnValue({ + getState: mockGetState, + }), + } as unknown as Task["providerRef"], + fileContextTracker: { + trackFileContext: vi.fn().mockResolvedValue(undefined), + } as unknown as Task["fileContextTracker"], + } + + mockAskApproval = vi.fn().mockResolvedValue(true) + mockHandleError = vi.fn().mockResolvedValue(undefined) + mockPushToolResult = vi.fn() + + tool = new ApplyPatchTool() + }) + + it("update: publishes the targeted hunk through the guarded saveDirectly with edit kind", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + undefined, + false, + undefined, + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + // Row 4507764645: the finally sweep withdraws the read behind a publish that never + // landed. When the guarded commit landed and only the durability step failed, the guard + // re-records at the committed token, and the sweep must leave that entry alone - the + // stored entry is no longer the one the tool recorded, so the withdrawal does not fit. + it("update: a publish that committed without durability keeps the re-recorded observation", async () => { + const targetKey = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + // The double stands in for the guard's own pair of actions - re-record at the + // committed token, then report the durability failure - so this row tests the sweep's + // identity key, which is ApplyPatchTool's half of the rule. + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(targetKey, "committed-token", false) + throw new PostCommitDurabilityError( + targetKey, + new Error("EBADF: bad file descriptor"), + null, + "committed-token", + ) + }) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ name: "PostCommitDurabilityError" }), + ) + expect(reg.get(targetKey)?.version).toBe("committed-token") + expect(mockPushToolResult).not.toHaveBeenCalled() + }) + it("update: keeps a partial observation partial so a later full-file write is still rejected", async () => { + // The hunk read is the tool's own read, not a model read. Upgrading the + // model's partial view to complete here would let a later write_to_file + // replace the file with content built from the slice the model saw. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(key)?.complete).toBe(false) + // The targeted hunk itself is still allowed, guarded as an edit. + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n", + false, + true, + 1000, + "edit", + undefined, + false, + undefined, + ) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("update: keeps a complete observation complete across the tool's own hunk read", async () => { + // Completeness is the model's, not the tool's: a complete read stays complete + // and a partial one stays partial, so a later full-file write is still gated. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(key)?.complete).toBe(true) + }) + + it("update: a hunk read with no prior observation records a partial observation", async () => { + // Nothing was earned before, so there is nothing to carry. The tool read the whole + // file for its own hunk matching, but the model only ever saw the patch context, so + // this read cannot grant authority for a later full-file replacement. The targeted + // patch itself is still allowed: the "edit" guard accepts a partial observation. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(key)?.complete).toBe(false) + // The guard reads the same entry: a partial observation authorizes targeted edits on + // the view the model saw, but not a full-file replacement. + expect(reg.has(key)).toBe(true) + }) + + it("update: does not carry completeness across a version the model never read", async () => { + // The model earned completeness on a different version than the one the patch + // helper read: the intervening change was never seen, so a later full-file + // replacement must still fail closed. + const key = path.resolve("/workspace/project", "src/thing.ts") + const reg = mockTask.observationRegistry + reg.observe(key, "7:4242:1234:1700000000123456789:1700000000789999998", true) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(key)?.complete).toBe(false) + + await expect(guardedWrite(mockTask as Task, "src/thing.ts", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + it("update: withdraws the hunk-read observation when the user denies the publish", async () => { + // apply_patch read the file to compute the hunk, so the tool owns that observation. + // The user said no, nothing published, and a leftover partial entry would let a later + // edit, search_replace, edit_file or apply_diff publish against a version the model + // never read. This is the exit that made apply_patch the remaining tool-read path. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + const reg = mockTask.observationRegistry + mockAskApproval.mockResolvedValue(false) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith("Changes were rejected by the user.") + expect(reg.has(key)).toBe(false) + }) + + it("update: withdraws the hunk-read observation when the publish throws", async () => { + // The outer-catch exit: the hunk read happened, the save blew up before publishing, + // and the observation must not survive as authority for a later write. + const key = path.resolve(mockTask.cwd, "src/thing.ts") + const reg = mockTask.observationRegistry + mockSaveDirectly.mockRejectedValue(new Error("extension host went away")) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalled() + expect(reg.has(key)).toBe(false) + }) + + it("withdraws observations for files read before a hunk failure", async () => { + // processAllHunks throws on the second file context mismatch, so the first file was + // read, observed and then never published. Its leftover entry is the state a later + // targeted edit would wrongly accept. + const firstKey = path.resolve(mockTask.cwd, "src/first.ts") + const secondKey = path.resolve(mockTask.cwd, "src/second.ts") + const reg = mockTask.observationRegistry + + await tool.execute({ patch: mismatchedSecondPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Failed to process patch")) + expect(reg.has(firstKey)).toBe(false) + expect(reg.has(secondKey)).toBe(false) + }) + + it("keeps the observation of a path that published while withdrawing a denied one", async () => { + // Two updates in one patch: the first approves and publishes, the second is denied. + // Withdrawal is scoped by path and by the exact stored entry, so the published file + // keeps the observation its publish ran on (partial, as the hunk read recorded it) + // while the denied path is left with nothing. + const publishedKey = path.resolve(mockTask.cwd, "src/first.ts") + const deniedKey = path.resolve(mockTask.cwd, "src/second.ts") + const reg = mockTask.observationRegistry + mockAskApproval.mockResolvedValueOnce(true).mockResolvedValueOnce(false) + + await tool.execute({ patch: twoUpdatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.has(publishedKey)).toBe(true) + expect(reg.get(publishedKey)?.complete).toBe(false) + expect(reg.has(deniedKey)).toBe(false) + }) + + it("update: observes the hunk read so the guarded publish is not unobserved", async () => { + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The hunk read doubles as the S2 observation (ReadFileTool contract): + // stable pre/post stats record the version token, so the in-place modify + // publish is not rejected as an unobserved write. + const observed = mockTask.observationRegistry.get(path.resolve("/workspace/project", "src/thing.ts")) + expect(observed?.version).toBe("7:4242:1234:1700000000123456789:1700000000789999999") + }) + + it("update: does not observe when the pre- and post-read tokens disagree", async () => { + // The file changed mid-read: pre/post stats differ, so no observation is + // recorded and the guarded publish surfaces the unobserved-existing + // remediation instead of publishing against a stale version. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 9999n, mtimeNs: 3n, ctimeNs: 4n }) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + // The rejection value is controlled here, so assert the exact error that + // reached handleError instead of any call: a stat or parse failure would + // otherwise satisfy a bare toHaveBeenCalled(). + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) + + it("update: does not observe when the post-read stat rejects", async () => { + // The pre-read stat succeeds and the read itself succeeds, but the post-read + // stat fails, so the read cannot be tied to a version token and no + // observation is recorded. The guarded publish then fails closed against the + // existing, unobserved file rather than publishing content built from an + // unverifiable read. + const statMock = mockedFsPromises.default.stat + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + statMock.mockRejectedValueOnce(new Error("EACCES: permission denied")) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) + + it("update: does not observe when the pre-read stat rejects", async () => { + // The mirror of the post-read case: the FIRST bracketing stat fails, so there is + // no baseline token to compare the read against. The hunk read still happens and + // the patch still reaches the guarded publish, but nothing is observed - the + // publish fails closed against the existing, unobserved file. + const statMock = mockedFsPromises.default.stat + statMock.mockRejectedValueOnce(new Error("EACCES: permission denied")) + statMock.mockResolvedValueOnce({ dev: 7n, ino: 4242n, size: 1234n, mtimeNs: 1n, ctimeNs: 2n }) + + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + // The read was not skipped: the publish was still attempted through the guard. + expect(mockSaveDirectly).toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + }) + it("add: publishes the new file through the guarded saveDirectly with create kind", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "new line one\nnew line two\n", + true, + true, + 1000, + "create", + undefined, + false, + undefined, + ) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: publishes the destination through the guarded saveDirectly with create kind", async () => { + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // The source had no prior observation, so the hunk read records a partial + // observation: the model only saw the patch context, not the whole source. The + // destination is still published (create kind), but the completeness flag stays false. + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + false, + false, + undefined, + ) + }) + + it("move: refuses a destination that names the same file as its source, and writes nothing to it", async () => { + // One file under two names: on a real filesystem that is a hard link, or the destination + // spelled through a link of the source's own directory. The filesystem in this spec is its + // doubles, so the row states the geometry outright - one identity armed for both spellings - + // and the row below pins the opposite geometry, a destination spelled through an alias that + // is its own file. Which of the two it is cannot be read off the strings. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-is-the-source", + }) + + // Consequence one, the file at the shared path: the patched content never lands there. The + // save is what would have written it and the refusal runs before the save, so the file keeps + // the bytes it had before the patch and the tool claims no edit. + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + expect(mockedFsPromises.default.writeFile).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("the two paths name the same file")) + expect(mockHandleError).not.toHaveBeenCalled() + // Both spellings were put to the filesystem: the check asks about the pair, not about one of + // them while taking the strings' word for the other. + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(sourceKey, { bigint: true }) + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(destKey, { bigint: true }) + }) + + it("move: does not unlink the source when the destination names the same file", async () => { + // Consequence two. The unlink at the end of this branch is the statement that deleted what + // the publish had just written to the same inode; with the pair refused before the publish, + // the file is still where both names point and nothing has been removed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-is-the-source-no-unlink", + }) + + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + // The opened diff view is discarded rather than left open over an un-published file. + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + }) + + it("move: reports one refusal rather than two published files for one changed file", async () => { + // Consequence three, and the half a reviewer can check without a filesystem: the tool's own + // report. pushToolWriteResult builds the published-files message and trackFileContext is what + // records each published path as edited, so a move that published onto the source and then + // unlinked it would have reported two files for the one file the patch changed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-is-the-source-report", + }) + + expect(mockPushToolResult).toHaveBeenCalledTimes(1) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move src/old.ts onto src/new.ts"), + ) + expect(mockTask.diffViewProvider.pushToolWriteResult).not.toHaveBeenCalled() + expect(mockTask.fileContextTracker.trackFileContext).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + // The mistake counter is deliberately not asserted. This refusal raises it and the end-of- + // loop statement at ApplyPatchTool.ts:242 clears it again inside the same call, so reading + // the counter after the call would pin :242 rather than the refusal. + }) + + it("move: two files whose identities differ above the safe-integer range are not treated as one", async () => { + // Why the identity is read as bigints rather than numbers. An NTFS or ReFS file identity can + // sit above Number.MAX_SAFE_INTEGER, and two such files one apart round to the same + // Number: 2^53 and 2^53 + 1 are one double. A compare that goes through Number would call these + // two files one file and refuse this move; the compare beside this row keeps them apart. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const BASE_INO = 2n ** 53n + const NEIGHBOUR_INO = BASE_INO + 1n + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + Promise.resolve( + String(target) === destKey + ? { ..._identityOf(destKey), ino: NEIGHBOUR_INO } + : { ..._identityOf(sourceKey), ino: BASE_INO }, + ), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-identities-above-safe-integer-range", + }) + + expect(Number(BASE_INO), "the fixture no longer demonstrates a rounded collision").toBe(Number(NEIGHBOUR_INO)) + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + false, + false, + undefined, + ) + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(sourceKey) + }) + + it("move: publishes a destination spelled through a link of the source's directory when it is its own file", async () => { + // The other direction of the same rule, and the one a string compare gets wrong: + // src/aliasDir/new.ts is its own file even though its spelling runs through a link of the + // source's directory, so the move has to happen: content published at the destination, the + // source unlinked, a report produced. Refusing this pair would be the spelling-instead-of- + // identity bug in reverse, and it is why the check asks the filesystem. + await tool.execute({ patch: moveThroughAliasPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-through-directory-alias", + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/aliasDir/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + false, + false, + undefined, + ) + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(path.resolve("/workspace/project", "src/old.ts")) + expect(mockTask.didEditFile).toBe(true) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Saved file")) + expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith("src/aliasDir/new.ts", "roo_edited") + }) + + it("move: refuses with a tool error when the filesystem will not say what the destination refers to", async () => { + // The failure surface the identity check adds, pinned where it is visible. A destination + // whose identity the filesystem withholds used to be moved blindly; now the move is refused, + // and refused as a tool error - the shape the note above the outside-workspace refusal says a + // destination read failure should produce, rather than an exception reaching handleError. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const eacces = Object.assign(new Error("EACCES: permission denied, stat '" + destKey + "'"), { + code: "EACCES", + }) + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + String(target) === destKey ? Promise.reject(eacces) : Promise.resolve(_identityOf(String(target))), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-destination-identity-refused", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("the filesystem would not say what either path refers to"), + ) + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("move: refuses a same-file destination on the raw write path too, not only the guarded one", async () => { + // The refusal sits above the isPreventFocusDisruptionEnabled split (:646 vs. :727), so a + // flag-off run refuses for the same reason a flag-on run does. Until this row every test in + // this file ran with the flag on, which made that symmetry a claim about line numbers rather + // than something anyone had checked. The flag-off branch publishes with a raw mkdir plus + // fs.writeFile rather than through the diff view, so those are the calls this row expects not + // to see. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + // The harness arms preventFocusDisruption on for every row. The binding holds the function + // object the provider stub captured, so a row has to change what that function resolves + // to rather than re-point the binding. + mockGetState.mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }) + + mockedFsPromises.default.stat.mockImplementation(() => Promise.resolve(_identityOf(sourceKey))) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-destination-is-the-source", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("the two paths name the same file")) + expect(mockedFsPromises.default.writeFile).not.toHaveBeenCalled() + expect(mockedFsPromises.default.mkdir).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + // Both spellings went to the filesystem before the refusal, here as on the other path. + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(sourceKey, { bigint: true }) + expect(mockedFsPromises.default.stat).toHaveBeenCalledWith(destKey, { bigint: true }) + }) + + it("move: publishes through the raw write path when the flag is off and the two spellings name different files", async () => { + // The other half of the coverage, and the half that says the refusal did not become a ban: + // with different inodes this is an ordinary move, and the branch that answers for it is the + // mkdir + fs.writeFile one. The saveDirectly double staying unused is what pins the branch. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + // The harness arms preventFocusDisruption on for every row. The binding holds the function + // object the provider stub captured, so a row has to change what that function resolves + // to rather than re-point the binding. + mockGetState.mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-between-different-files", + }) + + expect(mockedFsPromises.default.mkdir).toHaveBeenCalledWith(path.dirname(destKey), { recursive: true }) + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith(destKey, "modified file content\n", "utf8") + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(sourceKey) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Saved file")) + expect(mockTask.didEditFile).toBe(true) + }) + + it("move: treats a destination that is not there as not-the-source on the raw write path", async () => { + // Absence is answered the same way on both paths: a destination that is not there is not the + // source, so the identity read returns "absent" and the move proceeds. The row that arms an + // EACCES destination pins the other answer the filesystem can give. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const enoent = Object.assign(new Error("ENOENT: no such file or directory, stat '" + destKey + "'"), { + code: "ENOENT", + }) + // The harness arms preventFocusDisruption on for every row. The binding holds the function + // object the provider stub captured, so a row has to change what that function resolves + // to rather than re-point the binding. + mockGetState.mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: { preventFocusDisruption: false }, + }) + + mockedFsPromises.default.stat.mockImplementation((target: unknown) => + String(target) === destKey ? Promise.reject(enoent) : Promise.resolve(_identityOf(String(target))), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-destination-absent", + }) + + expect(mockTask.recordToolError).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockedFsPromises.default.writeFile).toHaveBeenCalledWith(destKey, "modified file content\n", "utf8") + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(sourceKey) + }) + + it("move: rejects a partial source onto an observed destination before changing any state", async () => { + // The destination content is the source file plus one hunk, so it can only be + // as complete as the view the model had of the source. Rejecting before the + // registry is modified keeps a full destination read intact, and the message + // must name the source, because re-reading the destination cannot restore the + // source's completeness and would loop. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + // Nothing was downgraded: the destination keeps the completeness the model earned. + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ + message: + "Cannot move a partially read file onto src/new.ts: re-read the whole source (src/old.ts) first, then retry.", + }), + ) + }) + + it("move: carries the source's completeness when the observed destination was deleted", async () => { + // The destination was read and then deleted, so its observation is stale but the + // create guard permits the publish. Rejecting here would refuse a move onto an + // absent path; the content is still only as complete as the source view, so the + // source's completeness is carried instead of the destination's stale one. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + const completeAtPublish: Array = [] + mockSaveDirectly.mockImplementationOnce(async () => { + completeAtPublish.push(reg.get(destKey)?.complete) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(completeAtPublish).toEqual([false]) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: propagates a non-ENOENT destination access error before changing the observation", async () => { + // An ELOOP is not an absence verdict. Reading it as "the destination is gone" + // would mark the observation partial and let the publish run against a path the + // tool never actually observed. + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ELOOP: too many symbolic links"), { code: "ELOOP" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ message: "ELOOP: too many symbolic links" }), + ) + }) + + it("move: refuses a destination outside every workspace root without canonicalising or publishing it", async () => { + // Row 4507855163. The refusal at ApplyPatchTool.ts:564 sits above the experiment-flag branch at :575, + // so both modes share one code path and the flag-off twin below is the other half of that claim. + // Only the destination reads as outside a root: leaving the source inside means a canonicalisation + // of the source cannot stand in for the destination being canonicalised. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const outside = vi.mocked(isPathOutsideWorkspace) + outside.mockImplementation((candidate: string) => candidate === destKey) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-outside-workspace-destination", + }) + + expect(outside).toHaveBeenCalledWith(destKey) + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to path outside workspace: src/new.ts"), + ) + // The refused destination is never canonicalised. realpathNearest used to run at this point, so a + // destination whose name could not be resolved surfaced an exception where the user should get the + // tool error above. + expect(mockedFsPromises.default.realpath).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + // The mistake counter is deliberately not asserted here. The refusal raises it at :565 and the + // end-of-loop statement at :242 clears it again inside the same call, so reading the counter + // after this call would pin :242 rather than the refusal. + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: a complete source read keeps the destination complete", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockSaveDirectly.mockImplementationOnce(async () => { + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + // Rows 4507760687 follow-up (4507979054): the rows above pin what the registry holds at publish time. + // These two pin the value the tool hands the guard as its completeness authorization - the seventh + // argument - which the registry rows cannot see because a mocked save does not consult it. That is + // the whole case for keeping both: one row reads the state, these read the argument that state came + // from, and the false case below is the only situation where production passes false at all. + it("move: authorizes the destination publish as complete when the source was read in full", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + mockTask.observationRegistry.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-complete-source-argument", + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "modified file content\n", + false, + true, + 1000, + "create", + true, + false, + undefined, + ) + }) + + it("move: authorizes the destination publish as partial when the source view was partial and the destination was absent", async () => { + // The create guard permits publishing onto an absent path, so the partial source view has to travel + // with the publish instead of being refused: this is the case the argument exists to express, and + // it is the one case in which the shipped code passes false (ApplyPatchTool.ts:584, :635). + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", false) + reg.observe(destKey, "7:4343:1234:1700000000123456789:1700000000789999999", true) + mockedFsPromises.default.access.mockRejectedValueOnce( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "move-partial-source-absent-destination-argument", + }) + + // The destination's own stale completeness is not what authorizes the publish - the source view is - + // so the argument is false even though the destination had been read as complete before it vanished. + expect(mockSaveDirectly.mock.calls[0]?.[6]).toBe(false) + expect(mockSaveDirectly.mock.calls[0]?.[5]).toBe("create") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + // Row 4507855182 (measured): the destination commits and the source unlink below never runs, + // so a move that hits a buffer conflict is half-applied. The report has to name both paths + // and keep the shared error's no-retry guidance, because the model's next instinct is to + // re-apply the patch - which would duplicate the change at the destination. + it("move: a committed destination with the source still on disk names both paths", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe(sourceKey, "7:4242:1234:1700000000123456789:1700000000789999999", true) + mockSaveDirectly.mockRejectedValueOnce( + new CommittedWriteBufferConflictError( + destKey, + "saveDirectly: the buffer held unsaved edits that differ from the content the guard published.", + ), + ) + mockedFsPromises.default.unlink.mockClear() + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalledWith( + "apply patch", + expect.objectContaining({ name: "CommittedWriteBufferConflictError" }), + ) + const reported = (mockHandleError.mock.calls[0]?.[1] ?? new Error("")) as Error + expect(reported.message).toContain("src/new.ts committed but") + expect(reported.message).toContain("src/old.ts was not removed") + expect(reported.message).toContain("do not retry") + // The half-applied state is stated, not implied: the source was never unlinked, and the + // destination's tool-recorded observation is withdrawn by the finally sweep, so it cannot + // authorize a later edit of content the model never read. + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(reg.get(destKey)).toBeUndefined() + }) + + it("move: the destination cannot claim completeness when the source was never observed", async () => { + // The hunk read records no observation when its stat fails, so the model has + // no authority over the source content the move carried over. Nothing is + // recorded for the destination before the write chain runs, so the guard still + // refuses a full-file write to a file the model never read. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + mockedFsPromises.default.stat.mockImplementationOnce(() => Promise.reject(new Error("stat failed"))) + const observedAtPublish: boolean[] = [] + mockSaveDirectly.mockImplementationOnce(async () => { + observedAtPublish.push(reg.has(destKey)) + return { newProblemsMessage: "", userEdits: undefined, finalContent: "new content" } + }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(path.resolve("/workspace/project", "src/old.ts"))).toBeUndefined() + expect(observedAtPublish).toEqual([false]) + await expect(guardedWrite(mockTask as Task, "src/new.ts", "full replacement", "create")).rejects.toThrow( + "File already exists at " + + "src/new.ts" + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("move: leaves an unobserved destination alone when the source read was partial", async () => { + // The publish records an observation only when it can compute the new on-disk + // token. With nothing recorded for the destination there is nothing to + // downgrade, and the carry must not dereference a missing observation. + const destKey = path.resolve("/workspace/project", "src/new.ts") + const reg = mockTask.observationRegistry + reg.observe( + path.resolve("/workspace/project", "src/old.ts"), + "7:4242:1234:1700000000123456789:1700000000789999999", + false, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(reg.get(destKey)).toBeUndefined() + expect(mockHandleError).not.toHaveBeenCalled() + }) + + // The destination publishes before the source unlink is attempted, so a failed unlink + // leaves the source sitting on disk with its original bytes. Nothing was published at + // that path, and the only thing still able to authorize a later edit is the partial + // observation this call recorded for itself, so it has to be withdrawn here. + it("move: withdraws the source observation when its unlink fails after the destination published", async () => { + const sourceKey = path.resolve("/workspace/project", "src/old.ts") + const reg = mockTask.observationRegistry + mockedFsPromises.default.unlink.mockRejectedValueOnce( + Object.assign(new Error("EPERM: operation not permitted, unlink"), { code: "EPERM" }), + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveDirectly).toHaveBeenCalled() + expect(reg.has(sourceKey)).toBe(false) + // The consequence the finding names: with the source observation gone, a follow-up + // targeted write to the source needs a real read instead of riding on the leftover. + await expect(guardedWrite(mockTask as Task, "src/old.ts", "full replacement", "update")).rejects.toThrow( + "File already exists at src/old.ts and was not read before this write -- read the file first, then retry.", + ) + }) + + it("update: surfaces the unobserved-existing remediation as a tool error", async () => { + const guardError = new Error( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + expect(mockPushToolResult).not.toHaveBeenCalledWith("Saved file") + }) + + it("update: surfaces the stale-version remediation as a tool error", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockSaveDirectly.mockRejectedValue(guardError) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockHandleError).toHaveBeenCalledWith("apply patch", guardError) + expect(vi.mocked(mockTask.diffViewProvider.reset)).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("update: the diff-view save selects the same edit guard as the guarded save", async () => { + // With focus-disruption prevention off the tool saves through the diff view. + // A partial read must not be rejected there, otherwise the patch the user + // approved is thrown away. + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: updatePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("add: the diff-view save uses the create guard for a new file", async () => { + mockedFileExistsAtPath.mockResolvedValueOnce(false) + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: addPatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create", false, undefined) + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) + // The two rows below pin the flag-off move branch as it ships: a raw fs.mkdir plus + // fs.writeFile, with no guard, no completeness carry and no compare-and-swap. They + // document current behaviour - main's ApplyPatchTool (blob 2713e08ffe8d) writes the + // flag-off move with `await fs.writeFile(moveAbsolutePath, newContent, "utf8")` at + // :422 - and they are not a claim that this is the contract the branch should keep. + // Routing this branch through the guarded publish is a deliberate improvement and + // should turn these rows red; reading them as endorsement of the raw write would + // make that improvement look like a regression. + it("flag-off move: publishes through fs.mkdir then fs.writeFile, with no guard and no completeness carry", async () => { + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + // The cast above declares access/stat/unlink only because nothing else on the + // mocked module was reached before this branch was exercised; writeFile is read + // through its own narrow view of the doubles. + const writeDoubles = vi.mocked( + fsPromises as unknown as { + default: { writeFile: ReturnType; mkdir: ReturnType } + }, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-plain-write", + }) + + expect(writeDoubles.default.mkdir).toHaveBeenCalledWith( + path.dirname(path.resolve("/workspace/project", "src/new.ts")), + { recursive: true }, + ) + expect(writeDoubles.default.writeFile).toHaveBeenCalledWith( + path.resolve("/workspace/project", "src/new.ts"), + "modified file content\n", + "utf8", + ) + // No guard runs here: the guarded publish lives behind saveDirectly, and this + // shape reaches neither the guarded save nor the diff-view save. + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("flag-off move: an existing destination is never consulted before the write", async () => { + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-destination-present", + }) + + // The access double resolves, so the destination reads as present on disk, yet + // nothing asks: the branch does not read it, stat it, or guard against publishing + // over content the model never saw. + expect(mockedFsPromises.default.access).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + expect(mockPushToolResult).toHaveBeenCalled() + }) + + it("flag-off move: refuses a destination outside every workspace root before mkdir or writeFile", async () => { + // The twin of the guarded row above. The refusal is above the experiment-flag branch, so the raw + // publish path never sees an outside-workspace destination either: no directory is created, nothing + // is written, and the source is left where it was. + mockGetState.mockResolvedValue({ diagnosticsEnabled: true, writeDelayMs: 1000, experiments: {} }) + const destKey = path.resolve("/workspace/project", "src/new.ts") + vi.mocked(isPathOutsideWorkspace).mockImplementation((candidate: string) => candidate === destKey) + const writeDoubles = vi.mocked( + fsPromises as unknown as { + default: { writeFile: ReturnType; mkdir: ReturnType } + }, + ) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "flag-off-move-outside-workspace-destination", + }) + + expect(mockPushToolResult).toHaveBeenCalledWith( + expect.stringContaining("Cannot move file to path outside workspace: src/new.ts"), + ) + expect(writeDoubles.default.mkdir).not.toHaveBeenCalled() + expect(writeDoubles.default.writeFile).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockedFsPromises.default.unlink).not.toHaveBeenCalled() + expect(mockTask.recordToolError).toHaveBeenCalledWith("apply_patch") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + // The hunk read is the content the guarded publish republishes. An ASCII hunk can match a + // file whose bytes somewhere else cannot be decoded, and reading those bytes as UTF-8 + // substitutes them, so the write would change bytes the patch never mentioned. The refusal has + // to land before the read is recorded as an observation, or the substituted version becomes + // the version a later full-file write is allowed to publish. + const undecodableTail = (): Buffer => + Buffer.concat([Buffer.from("original file content\n"), Buffer.from([0xc3, 0x28])]) + const undecodableUpdatePatch = `*** Begin Patch +*** Update File: src/thing.ts +@@ +-original file content ++modified file content +*** End Patch` + const deleteThingPatch = `*** Begin Patch +*** Delete File: src/thing.ts +*** End Patch` + const runPatch = async (patch: string): Promise => { + await tool.execute({ patch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + toolCallId: "test-tool-call-id", + }) + } + + it("update: refuses a file it cannot decode rather than publishing replacement characters", async () => { + mockedFsPromises.default.readFile.mockResolvedValue(undecodableTail()) + + await runPatch(undecodableUpdatePatch) + + const pushed = String(mockPushToolResult.mock.calls[0]?.[0] ?? "") + expect(pushed).toContain("not valid UTF-8") + // 0xC3 0x28 is not a valid sequence and it sits at byte 22 - past the line the hunk + // matched. The hunk is ASCII and matched fine, which is the case the row is about: the + // bytes at fault are ones the patch never mentions. + expect(pushed).toContain("byte 22") + expect(pushed).toContain("src/thing.ts") + // Nothing published and nothing asked: the refusal happens while the hunks are still + // being processed, before any publish prompt exists. + expect(mockAskApproval).not.toHaveBeenCalled() + expect(mockSaveDirectly).not.toHaveBeenCalled() + expect(mockSaveChanges).not.toHaveBeenCalled() + // The read must not survive as an observation: the version it saw is the version a + // later full-file write would be permitted to publish. + expect(mockTask.observationRegistry.get(path.resolve("/workspace/project", "src/thing.ts"))).toBeUndefined() + expect(mockTask.consecutiveMistakeCount).toBe(1) + expect(mockTask.didEditFile).toBe(false) + }) + + it("update: a file that legitimately contains a replacement character still publishes", async () => { + // U+FFFD written as EF BF BD is valid UTF-8. The refusal is about bytes that cannot be + // decoded, not about a character appearing in the text - a check on the decoded string + // would refuse this file for no reason. + // EF BF BD is U+FFFD written as bytes: the file legitimately contains the character, and + // those bytes are valid UTF-8. (A \uFFFD... escape here would be ambiguous - FFFE is a + // legal 4-digit escape - so the sequence is spelled in bytes on purpose.) + const replacementCharacterIsLegitimate = Buffer.concat([ + Buffer.from("original file content\n"), + Buffer.from([0xef, 0xbf, 0xbd]), + Buffer.from("end\n"), + ]) + mockedFsPromises.default.readFile.mockResolvedValue(replacementCharacterIsLegitimate) + + await runPatch(undecodableUpdatePatch) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/thing.ts", + "modified file content\n\uFFFDend\n", + // The tail the patch never mentioned travels through the publish unchanged: + // that is what a file that legitimately contains a replacement character looks like. + false, + true, + 1000, + "edit", + undefined, + false, + undefined, + ) + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) + + it("delete: an undecodable file is still deleted, because nothing republishes its bytes", async () => { + // Narrowing, not blanket refusal: the delete publishes by unlinking, so the reading is a + // snapshot and the bytes nobody can name are not rewritten by anything this tool does. + mockedFsPromises.default.readFile.mockResolvedValue(undecodableTail()) + + await runPatch(deleteThingPatch) + + expect(mockedFsPromises.default.unlink).toHaveBeenCalledWith(path.resolve("/workspace/project", "src/thing.ts")) + expect(mockPushToolResult).toHaveBeenCalledWith(expect.stringContaining("Successfully deleted")) + expect(mockTask.consecutiveMistakeCount).toBe(0) + }) +}) diff --git a/src/core/tools/__tests__/editFileTool.spec.ts b/src/core/tools/__tests__/editFileTool.spec.ts index 95a67f04d7..4c24ce37a8 100644 --- a/src/core/tools/__tests__/editFileTool.spec.ts +++ b/src/core/tools/__tests__/editFileTool.spec.ts @@ -10,6 +10,9 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { editFileTool } from "../EditFileTool" vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), default: { readFile: vi.fn().mockResolvedValue(""), }, @@ -168,6 +171,7 @@ describe("editFileTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -178,6 +182,13 @@ describe("editFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -560,8 +571,19 @@ describe("editFileTool", () => { await executeEditFileTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockTask.didEditFile).toBe(true) + }) + + it("keeps create-guard semantics for a new file on the diff-view save path", async () => { + // A new file is still a create, even when it is saved through the diff + // view rather than directly; only an existing targeted edit becomes an + // edit kind. + mockAskApproval.mockResolvedValue(true) + + await executeEditFileTool({ old_string: "", new_string: "New file content" }, { fileExists: false }) + + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "create", false, undefined) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. expect(mockTask.recordToolUsage).not.toHaveBeenCalled() @@ -690,6 +712,117 @@ describe("editFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes an existing-file edit through saveDirectly with edit kind", async () => { + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + undefined, + false, + undefined, + ) + expect(mockTask.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(true) + expect(result).toContain("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { + mockedIsPathOutsideWorkspace.mockReturnValue(true) + try { + await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + } + }) + + it("publishes new-file creation through saveDirectly with create kind", async () => { + await executeEditFileTool( + { old_string: "", new_string: "New file content" }, + { fileExists: false, experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "New file content", + true, + true, + 1000, + "create", + undefined, + false, + undefined, + ) + expect(mockTask.didEditFile).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didToolFailInCurrentTurn).toBe(true) + expect(mockTask.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditFileTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit_file", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + + it("still fails a literal mismatch with the existing message before the guard runs", async () => { + const result = await executeEditFileTool( + { old_string: "NonExistent", new_string: "Whatever" }, + { fileExists: true, fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(result).toContain("No match found") + expect(result).toContain("") + expect(mockTask.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + expect(mockHandleError).not.toHaveBeenCalled() + }) + }) + describe("CRLF normalization", () => { it("preserves CRLF line endings on output", async () => { const contentWithCRLF = "Line 1\r\nLine 2\r\nLine 3" diff --git a/src/core/tools/__tests__/editTool.spec.ts b/src/core/tools/__tests__/editTool.spec.ts index fbb14d7ed2..9326fe3fa3 100644 --- a/src/core/tools/__tests__/editTool.spec.ts +++ b/src/core/tools/__tests__/editTool.spec.ts @@ -10,6 +10,9 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { editTool } from "../EditTool" vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), default: { readFile: vi.fn().mockResolvedValue(""), }, @@ -169,6 +172,7 @@ describe("editTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -179,6 +183,13 @@ describe("editTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockTask.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockTask.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const defaultParams = { file_path: testFilePath, @@ -344,7 +355,7 @@ describe("editTool", () => { await executeEditTool() - expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockTask.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockTask.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -426,4 +437,63 @@ describe("editTool", () => { expect(mockTask.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockTask.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + undefined, + false, + undefined, + ) + expect(mockTask.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { + mockedIsPathOutsideWorkspace.mockReturnValue(true) + try { + await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + const args = mockTask.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + } + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockTask.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeEditTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("edit", guardError) + expect(result).toBeUndefined() + expect(mockTask.diffViewProvider.reset).toHaveBeenCalled() + expect(mockTask.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts new file mode 100644 index 0000000000..794c31836c --- /dev/null +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -0,0 +1,1469 @@ +/** + * Tests for the guarded-write compare-and-swap core (upstream epic #1375, + * phase A4a). + * + * Covers guard selection through the S2 observation registry, version-token + * CAS, remediation messages, and the per-absolute-path FIFO chain: FIFO + * ordering, last-write-wins for same-task writes through the post-publish + * observation refresh, no wedge after a rejected link, and independence across + * paths. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { describe, expect, it, beforeEach, vi } from "vitest" + +import { createIfAbsent, guardedWrite, replaceIfVersion, resetChain, GuardRejectedError } from "../guardedWrite" +import { PostCommitDurabilityError, safeWriteText } from "../../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../../utils/versionToken" +import { withFileLock } from "../../../utils/fileLock" +import { resolveLockKey } from "../../../services/file-safety/safeWriteText" +import { ObservationRegistry } from "../../task/observationRegistry" +import type { Task } from "../../task/Task" + +// -- Mocks ------------------------------------------------------------------- + +vi.mock("fs/promises", () => ({ + access: vi.fn(), + stat: vi.fn(), + realpath: vi.fn(), + lstat: vi.fn(), +})) + +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + +vi.mock("../../../services/file-safety/safeWriteText", async (importOriginal) => { + // The durability branch is matched by name, so the class has to be the real one - a + // local stand-in would let the rows below pass against a shape production does not + // build. The publish primitive stays a stub. + const actual = await importOriginal() + return { + safeWriteText: vi.fn(), + resolveLockKey: vi.fn(async (p: string) => p), + PostCommitDurabilityError: actual.PostCommitDurabilityError, + } +}) + +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(), +})) + +const mockedWithFileLock = vi.mocked(withFileLock) +const mockedResolveLockKey = vi.mocked(resolveLockKey) +const mockedFsAccess = vi.mocked(fs.access) +const mockedFsRealpath = vi.mocked(fs.realpath) +const mockedFsLstat = vi.mocked(fs.lstat) +const mockedComputeVersionToken = vi.mocked(computeVersionToken) +const mockedSafeWriteText = vi.mocked(safeWriteText) + +// -- Fixtures ---------------------------------------------------------------- + +const WORKSPACE = "/test/workspace" + +/** Resolve a fixture path the same way guardedWrite resolves task.cwd-relative paths. */ +const abs = (relPath: string): string => path.resolve(WORKSPACE, relPath) + +interface MockTaskOptions { + cwd?: string + observationRegistry?: ObservationRegistry + abort?: boolean +} + +/** + * Minimal structural Task: guardedWrite only reads task.cwd and + * task.observationRegistry. The real Task constructor needs the full provider + * machinery, so a single documented double cast stands in for the class. + */ +function createMockTask(options: MockTaskOptions = {}): Task { + const task = { + cwd: options.cwd ?? WORKSPACE, + abort: options.abort ?? false, + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), + } + return task as unknown as Task +} + +// -- Tests ------------------------------------------------------------------- + +describe("guardedWrite (S4a, epic #1375)", () => { + beforeEach(() => { + vi.resetAllMocks() + mockedWithFileLock.mockImplementation((filePath, operation) => operation(path.resolve(filePath))) + // The canonical containment check resolves the workspace first, and an + // unresolvable workspace now refuses the write instead of falling back to the + // lexical decision. The fixture workspace is not a real directory, so the default + // is "every path resolves to itself": containment behaves exactly as the lexical + // spelling, and the containment tests below override it to exercise links. + mockedFsRealpath.mockImplementation(async (p) => String(p)) + // Nothing on the walked path is a symlink by default; the dangling-link test + // overrides this for the component it plants. + mockedFsLstat.mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + resetChain() + }) + + describe("unobserved create", () => { + it("succeeds when the file is absent and publishes via safeWriteText", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("publishes caller-supplied bytes unchanged", async () => { + // The extension host hands over bytes already encoded by VS Code's + // codec; the guard must pass them to the publish primitive as they are. + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "bytes.txt", Buffer.from([0x00, 0x68]), "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("bytes.txt"), Buffer.from([0x00, 0x68])) + }) + + it("records an unobserved create as complete so a later full-file update is allowed", async () => { + // Nothing was read, so the model supplied the whole file: the post-publish + // refresh must record completeness, otherwise the next update would be + // rejected as a partial read. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create") + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(true) + }) + + it("carries a caller-supplied completeness through the publish for a fresh destination", async () => { + // A move publishes content built from a view of another file. Recording the + // create as complete would hand the model authority over source lines it never + // read, so the caller's completeness has to survive the refresh. + const reg = new ObservationRegistry() + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "new-file.txt", "hello", "create", false) + + expect(reg.get(abs("new-file.txt"))?.complete).toBe(false) + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "create")).rejects.toThrow( + "File already exists at " + + "existing.txt" + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows I/O errors that are not ENOENT verbatim (no guard verdict on access failure)", async () => { + const failures = [{ code: "EACCES" }, null, "volume offline", new Error("EIO-ish failure")] + for (const failure of failures) { + mockedFsAccess.mockRejectedValueOnce(failure) + await expect(createIfAbsent(abs("io-error.txt"), "x", "io-error.txt")).rejects.toBe(failure) + } + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("deleted-after-read target", () => { + it("normalizes an ENOENT from the version token into the re-read remediation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("vanished.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + + // The file was deleted after the read: the token computation fails + // with a raw ENOENT, which the guard must convert into the standard + // re-read-then-retry contract. + mockedComputeVersionToken.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "vanished.txt", "next", "update")).rejects.toThrow( + "File was deleted after it was read", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rethrows non-ENOENT token failures verbatim from replaceIfVersion", async () => { + const failure = { code: "EACCES" } + mockedComputeVersionToken.mockRejectedValueOnce(failure) + + await expect(replaceIfVersion(abs("locked.txt"), "v1", "next", "locked.txt")).rejects.toBe(failure) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + describe("unobserved update", () => { + it("succeeds when the file is absent (same create guard)", async () => { + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask() + + await guardedWrite(task, "new-file.txt", "hello", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("new-file.txt"), "hello") + }) + + it("fails with the read-first remediation when the file exists - nothing published", async () => { + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + await expect(guardedWrite(task, "existing.txt", "hello", "update")).rejects.toThrow( + "File already exists at " + + "existing.txt" + + " and was not read before this write -- read the file first, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("workspace containment", () => { + it("rejects an absolute path outside the workspace before any lock or publish", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "/elsewhere/outside.txt", "data", "create")).rejects.toThrow( + "Path resolves outside the workspace", + ) + + // Nothing is queued, locked, or touched: the decision is made on the path + // alone, before the FIFO chain or the filesystem is involved. + expect(mockedWithFileLock).not.toHaveBeenCalled() + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("rejects a relative path that escapes the workspace through dot-dot", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "../outside.txt", "data", "create")).rejects.toThrow( + "Path resolves outside the workspace", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("rejects a target whose resolved path leaves the workspace", async () => { + // The lexical check cannot see a link that lands outside. With the workspace + // resolvable, the canonical comparison is what rejects the write. + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) { + return "/real/workspace" + } + return "/real/outside/secret.txt" + }) + const task = createMockTask() + + await expect(guardedWrite(task, "planted.txt", "data", "create")).rejects.toThrow( + "resolves through a link to outside the workspace", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("fails closed when the workspace itself cannot be resolved", async () => { + // EACCES/ELOOP on the workspace means a symlink inside it would never be resolved, so + // the containment decision cannot be made at all: the write is refused rather than + // falling back to the lexical-only check. + mockedFsRealpath.mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + const task = createMockTask() + + // The first resolution that fails is the target's own; either refusal is + // fail-closed and neither publishes. + await expect(guardedWrite(task, "inside.txt", "data", "create")).rejects.toThrow(/could not be resolved/) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("refuses a write when the workspace directory itself is missing", async () => { + // ENOENT on the workspace used to fall through to the lexical-only decision. + // A workspace that is not on disk cannot contain the write, and the lexical + // check is the one a planted symlink defeats, so the write is refused. + mockedFsRealpath.mockImplementation(async (p) => { + if (String(p) === path.resolve(WORKSPACE)) { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + } + return String(p) + }) + const task = createMockTask() + + await expect(guardedWrite(task, "inside.txt", "data", "create")).rejects.toThrow( + "Workspace could not be resolved", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedWithFileLock).not.toHaveBeenCalled() + }) + + it("accepts a write in a workspace folder that does resolve when another folder cannot", async () => { + // A second workspace folder that cannot be canonicalized must not veto every guarded + // write. It is dropped from the allow-list, which only narrows what is admitted: + // this write is inside a folder that DID resolve. + mockedFsRealpath.mockImplementation(async (p) => { + if (String(p) === path.resolve(WORKSPACE)) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + return String(p) + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "/other-folder/in.txt", "data", "create", undefined, { + additionalRoots: ["/other-folder"], + }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("/other-folder/in.txt"), "data") + }) + + it("still refuses a write that lives only under the folder that cannot be resolved", async () => { + // Dropping the broken root narrows the allow-list, it does not widen it: a target + // that only the broken root could have vouched for is still refused. + mockedFsRealpath.mockImplementation(async (p) => { + if (String(p) === path.resolve(WORKSPACE)) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + return String(p) + }) + const task = createMockTask() + + await expect( + guardedWrite(task, "inside.txt", "data", "create", undefined, { + additionalRoots: ["/other-folder"], + }), + ).rejects.toThrow("not inside any workspace folder that could be resolved") + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("refuses a target that runs through a dangling symlink ancestor", async () => { + // A component of the path exists as a symlink whose referent is gone. realpath + // reports ENOENT for it, which is also what a not-yet-created directory + // reports; rejoining the lexical names would authorize a write whose publish + // lands outside the container that was checked. + const planted = path.join(path.resolve(WORKSPACE), "linkdir") + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) return path.resolve(WORKSPACE) + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + mockedFsLstat.mockImplementation(async (p) => { + if (String(p) === planted) { + return { isSymbolicLink: () => true } as never + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + const task = createMockTask() + + await expect(guardedWrite(task, "linkdir/nested/new.txt", "data", "create")).rejects.toThrow( + "runs through a link", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("refuses a target whose leaf is a dangling symlink", async () => { + // realpath reports ENOENT for a dangling leaf exactly as for a missing + // file, and the ancestor walk then authorizes the lexical path. The + // publish resolves the name again - it could follow a referent the + // guard never checked - so a dangling leaf is refused before publish. + const leaf = path.join(path.resolve(WORKSPACE), "ghost.txt") + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) return path.resolve(WORKSPACE) + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + mockedFsLstat.mockImplementation(async (p) => { + if (String(p) === leaf) { + return { isSymbolicLink: () => true } as never + } + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + const task = createMockTask() + + await expect(guardedWrite(task, "ghost.txt", "data", "create")).rejects.toThrow( + "link that does not resolve", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks containment under the lock, after the wait on the FIFO chain", async () => { + // The path was inside the workspace when it was queued; a link is swapped in while the + // write waits. The publish must not follow the new link. + let targetLookups = 0 + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) { + return "/real/workspace" + } + targetLookups++ + return targetLookups === 1 ? "/real/workspace/in.txt" : "/real/outside/secret.txt" + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect(guardedWrite(task, "in.txt", "data", "create")).rejects.toThrow( + "resolves through a link to outside the workspace", + ) + + expect(targetLookups).toBeGreaterThan(1) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("publishes when the resolved path stays inside the workspace", async () => { + mockedFsRealpath.mockImplementation(async (p) => { + const s = String(p) + if (s === path.resolve(WORKSPACE)) { + return "/real/workspace" + } + return "/real/workspace/nested/in.txt" + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "nested/in.txt", "data", "create") + + // The publish goes to the canonical target the containment check + // authorized, not to the alias the caller spelled. + expect(mockedSafeWriteText).toHaveBeenCalledWith("/real/workspace/nested/in.txt", "data") + }) + + it("publishes to the canonical target the containment check authorized, not to the alias", async () => { + // The check resolves the alias once and must publish to THAT value: + // safeWriteText re-resolves whatever path it is given, so handing it + // the alias lets a link retargeted after the check send the bytes to a + // file the containment decision never covered. + const alias = abs("link.txt") + const referent = path.join(path.resolve(WORKSPACE), "real.txt") + mockedFsRealpath.mockImplementation(async (p) => { + const str = String(p) + if (str === alias) { + return referent + } + return str + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "link.txt", "data", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(referent, "data") + }) + + it("compares the expected version against the bound canonical target", async () => { + // The version check and the publish must act on one target: comparing + // the token against the alias while publishing to the canonical path + // (or vice versa) authorizes a file the check never looked at. + const alias = abs("link.txt") + const referent = path.join(path.resolve(WORKSPACE), "real.txt") + mockedFsRealpath.mockImplementation(async (p) => { + const str = String(p) + if (str === alias) { + return referent + } + return str + }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + task.observationRegistry.observe(alias, "v1", true) + + await guardedWrite(task, "link.txt", "data", "update") + + // The FIRST token read is the version comparison (the post-publish + // refresh also reads the bound target, so call order is the assertion). + expect(mockedComputeVersionToken.mock.calls[0]).toEqual([referent]) + expect(mockedSafeWriteText).toHaveBeenCalledWith(referent, "data") + }) + }) + + describe("approved outside-workspace writes", () => { + it("publishes a target outside the workspace once the tool layer obtained approval for it", async () => { + // The write tools classify against ALL workspace folders and put an outside- + // workspace path in front of the user. Once the user approved that path, the + // guard must not reject the write it was told about. + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + approvedCanonicalTarget: abs("/elsewhere/outside.txt"), + }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("/elsewhere/outside.txt"), "data") + }) + + it("still rejects the same target when no approval was obtained", async () => { + // The flag means 'an approval decision was obtained', not 'the path is outside'. + // An unapproved call keeps the containment checks in force. + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: false, + }), + ).rejects.toThrow("Path resolves outside the workspace") + + expect(mockedWithFileLock).not.toHaveBeenCalled() + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks the approved identity under the lock and rejects a swapped-in link", async () => { + // Containment was the user's decision, but the target must still be the target the + // user saw: a link planted while the write waits on the chain would move the + // publish, so the identity comparison rejects it. + let targetLookups = 0 + mockedFsRealpath.mockImplementation(async (_p) => { + targetLookups++ + return targetLookups === 1 ? "/real/approved/outside.txt" : "/real/elsewhere/secret.txt" + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + approvedCanonicalTarget: "/real/approved/outside.txt", + }), + ).rejects.toThrow("no longer resolves to the path that was approved") + + expect(targetLookups).toBeGreaterThan(1) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("refuses an approved write whose name was repointed before the guard ran", async () => { + // The user approved /elsewhere/approved.txt. Before the guarded publish the name is + // repointed at a link to /elsewhere/victim.txt, so EVERY lookup the guard performs + // sees the swapped identity. A baseline taken from any lookup would accept this + // write; the identity captured before the approval does not. + mockedFsRealpath.mockImplementation(async (p) => + String(p).startsWith("/elsewhere") ? "/elsewhere/victim.txt" : String(p), + ) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/approved.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + approvedCanonicalTarget: "/elsewhere/approved.txt", + }), + ).rejects.toThrow("no longer resolves to the path that was approved") + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("refuses an approved write that carries no identity captured before the approval", async () => { + // Without the pre-approval identity there is nothing to bind the publish to, so the + // approved path cannot be taken at all: fail closed rather than trusting a lookup + // performed after the approval. + mockedFsRealpath.mockImplementation(async (p) => String(p)) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask() + + await expect( + guardedWrite(task, "/elsewhere/outside.txt", "data", "create", undefined, { + approvedOutsideWorkspace: true, + }), + ).rejects.toThrow("was not captured before approval") + + expect(mockedWithFileLock).not.toHaveBeenCalled() + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("contains a write in another workspace folder named by the caller", async () => { + // Multi-root workspace: the tool layer treats a path in a second folder as an + // ordinary in-workspace edit and asks for no approval, so the guard must accept + // it once the caller names that root. + mockedFsRealpath.mockImplementation(async (p) => String(p)) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask() + + await guardedWrite(task, "/other-folder/in.txt", "data", "create", undefined, { + additionalRoots: ["/other-folder"], + }) + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("/other-folder/in.txt"), "data") + }) + + it("rejects that same second-folder path when the caller does not name the root", async () => { + mockedFsRealpath.mockImplementation(async (p) => String(p)) + const task = createMockTask() + + await expect(guardedWrite(task, "/other-folder/in.txt", "data", "create")).rejects.toThrow( + "Path resolves outside the workspace", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed create", () => { + it("recreates a file that vanished after the read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("gone.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "gone.txt", "back", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("gone.txt"), "back") + }) + + it("goes through the version guard when the file still exists", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "kept.txt", "rewritten", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("kept.txt"), "rewritten") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("kept.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "kept.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("defers to the version guard when the access check is denied (not ENOENT)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("locked.txt"), "v1") + mockedFsAccess.mockRejectedValue({ code: "EACCES" }) + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "locked.txt", "rewritten", "create")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed update (version CAS)", () => { + it("publishes when the on-disk version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedComputeVersionToken).toHaveBeenCalledWith(abs("doc.txt")) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("fails with the stale remediation suffix when the version moved - nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("observed update (read completeness, S4b follow-up #46)", () => { + it("rejects a full-file update when only a partial read observed the file - no I/O, nothing published", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "new content", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes a full-file update when the observation is complete and the version matches", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "new content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "new content") + }) + + it("leaves edit-kind publishes unaffected by a partial observation - the model saw the edited region", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("keeps a partial observation partial after an edit so a later full replacement is rejected", async () => { + // The edit replaced only the region the model saw. Refreshing the + // observation to complete would let a following full-file write publish + // content built from the slice alone. + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(reg.get(abs("doc.txt"))?.complete).toBe(false) + + await expect(guardedWrite(task, "doc.txt", "full replacement", "update")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + }) + + it("rejects a create-kind full-file overwrite of an existing file when only a partial read observed it", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockResolvedValue(undefined) // target still on disk + const task = createMockTask({ observationRegistry: reg }) + + // A "create" whose target exists publishes through the same + // full-file replacement path as "update": a partial observation must + // not authorize dropping the content the model never read. + await expect(guardedWrite(task, "doc.txt", "created", "create")).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + }) + + it("allows a create-kind recreate of a vanished file despite a partial observation - a fresh create needs no prior read", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1", false) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) // target absent + mockedComputeVersionToken.mockResolvedValue("v1") // post-publish refresh + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + + it("publishes a create-kind overwrite of an existing file when the observation is complete", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedFsAccess.mockResolvedValue(undefined) // target still on disk + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "created", "create") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "created") + }) + }) + + describe("observation refresh after publish (S4b review round)", () => { + it("refreshes the observation with the post-publish token so a consecutive edit does not fail stale", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The first publish moves the on-disk token: edit 1's pre-write CAS + // sees v1, the post-publish refresh sees v2, and edit 2's CAS sees v2. + mockedComputeVersionToken.mockResolvedValueOnce("v1").mockResolvedValueOnce("v2").mockResolvedValue("v2") + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("doc.txt"), "second edit") + // the observation now carries the post-publish token, complete + expect(reg.get(abs("doc.txt"))?.version).toBe("v2") + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + + it("refreshes as a COMPLETE observation so a consecutive full-file update is not rejected partial", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // update 1 pre-write CAS + .mockResolvedValueOnce("v2") // update 1 post-publish refresh + .mockResolvedValue("v2") // update 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first", "update") + await guardedWrite(task, "doc.txt", "second", "update") + + // a refresh recorded as partial would have the second update + // rejected by the completeness gate + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.complete).toBe(true) + }) + + it("keeps the previous observation when the post-publish token cannot be computed (deletion race after publish)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + const task = createMockTask({ observationRegistry: reg }) + // The publish succeeded but the file was deleted before the refresh + // stat: the token computation rejects (ENOENT) and the guard's + // .catch normalizes it to undefined. The observation must keep the + // pre-publish version (the next write fails closed through the + // standard deleted/stale path) rather than a token-less record. + mockedComputeVersionToken + .mockResolvedValueOnce("v1") // edit 1 pre-write CAS + .mockRejectedValueOnce({ code: "ENOENT" }) // edit 1 post-publish refresh - file deleted + .mockResolvedValue("v1") // edit 2 pre-write CAS + + await guardedWrite(task, "doc.txt", "first edit", "edit") + await guardedWrite(task, "doc.txt", "second edit", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(reg.get(abs("doc.txt"))?.version).toBe("v1") + }) + }) + + describe("edit", () => { + it("fails read-first when the file was never observed - nothing published, no I/O", async () => { + const task = createMockTask() + + await expect(guardedWrite(task, "any.txt", "patched", "edit")).rejects.toThrow( + "File not read yet -- read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + expect(mockedComputeVersionToken).not.toHaveBeenCalled() + expect(mockedFsAccess).not.toHaveBeenCalled() + }) + + it("publishes when the version matches the observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "doc.txt", "patched", "edit") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("doc.txt"), "patched") + }) + + it("fails with the stale remediation suffix when the version moved", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("doc.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v3") + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "doc.txt", "patched", "edit")).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v3); re-read the file, then retry.", + ) + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + }) + + describe("concurrency: per-path FIFO chain", () => { + it("two concurrent updates on one path - serialized, both publish against the refreshed observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("shared.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token), and the + // guarded write refreshes the observation to it, so the second + // write CASes against v2 and publishes too: same-task writes are + // serialized last-write-wins in submission order, while a token that + // moves outside the task's own publish still fails stale. + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + const p1 = guardedWrite(task, "shared.txt", "first", "update") + const p2 = guardedWrite(task, "shared.txt", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("shared.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("shared.txt"), "second") + }) + + it("observed-absent then two concurrent creates - the second publishes against the refreshed observation", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("absent.txt"), "v1") // read before, file later vanished + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + const task = createMockTask({ observationRegistry: reg }) + + let publishes = 0 + mockedSafeWriteText.mockImplementation(async () => { + publishes += 1 + if (publishes === 1) { + // After the first publish the file exists again under a new + // token, and the guarded write refreshes the observation to it. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + } + }) + + const p1 = guardedWrite(task, "absent.txt", "first", "create") + const p2 = guardedWrite(task, "absent.txt", "second", "create") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + // Both same-task creates serialize: the second CASes against the + // refreshed v2 observation and publishes its content last-write-wins. + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(publishes).toBe(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, abs("absent.txt"), "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, abs("absent.txt"), "second") + }) + + it("the second publication does not start while the first is still running", async () => { + // Row 4507855167. Call order and non-overlap are different claims: NthCalledWith says which + // publish ran first, and a second publish that began while the first was still inside the publish + // primitive satisfies it. The first publish is held open on a promise this row controls, the + // invocation list is read while that hold is open, and only then is the hold released. + const reg = new ObservationRegistry() + reg.observe(abs("overlap.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + const started: string[] = [] + let releaseFirst: () => void = () => {} + const firstHold = new Promise((resolve) => { + releaseFirst = resolve + }) + mockedSafeWriteText + .mockImplementationOnce(async () => { + started.push("first") + await firstHold + }) + .mockImplementationOnce(async () => { + started.push("second") + }) + + const p1 = guardedWrite(task, "overlap.txt", "first", "update") + const p2 = guardedWrite(task, "overlap.txt", "second", "update") + + // One macrotask tick is enough for every microtask-only step ahead of a publish to run for both + // writes, so a second link that was not waiting on the first would already have published here. + await new Promise((resolve) => { + setTimeout(resolve, 0) + }) + expect(started).toEqual(["first"]) + + releaseFirst() + const [r1, r2] = await Promise.allSettled([p1, p2]) + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(started).toEqual(["first", "second"]) + }) + + it("the chain settles after a rejection - a later matching write still runs", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("settle.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v2") // already stale at v1 + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "settle.txt", "first", "update") + await expect(p1).rejects.toThrow("Stale version") + + // No resetChain: the rejected link must not wedge the chain. The + // caller re-reads the file (observation refreshed to v2) and retries. + reg.observe(abs("settle.txt"), "v2") + const p2 = guardedWrite(task, "settle.txt", "second", "update") + await expect(p2).resolves.toBeUndefined() + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("settle.txt"), "second") + }) + + it("evicts settled chain entries - a later write still serializes in order", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("evict.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // A first write settles; its chain entry is evicted with it. + const p1 = guardedWrite(task, "evict.txt", "first", "update") + await expect(p1).resolves.toBeUndefined() + + // Two rapid writes submitted after the eviction must still run one + // at a time in submission order (the eviction must not drop the + // chain for in-flight or just-enqueued links). + const order: (string | Uint8Array)[] = [] + mockedSafeWriteText.mockImplementation(async (_path: string, content: string | Uint8Array) => { + order.push(content) + }) + const p2 = guardedWrite(task, "evict.txt", "second", "update") + const p3 = guardedWrite(task, "evict.txt", "third", "update") + await Promise.all([p2, p3]) + + expect(order).toEqual(["second", "third"]) + // Three publishes in total: the settled first write plus the two + // serialized rapid writes. + expect(mockedSafeWriteText).toHaveBeenCalledTimes(3) + }) + + it("writes on different paths are independent (no cross-path serialization)", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1") + reg.observe(abs("b.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + const p1 = guardedWrite(task, "a.txt", "a", "update") + const p2 = guardedWrite(task, "b.txt", "b", "update") + await Promise.all([p1, p2]) + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) + it("lets a write on another path publish while the first path's publish is blocked", async () => { + // The row is about progress during the blocked window, which a settled call + // count cannot show: with one chain shared by every path the second write would + // queue behind the first and this assertion would never see a publish at all. + const reg = new ObservationRegistry() + reg.observe(abs("gate-a.txt"), "v1") + reg.observe(abs("gate-b.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + let releaseGateA: () => void = () => {} + const gateA = new Promise((resolve) => { + releaseGateA = resolve + }) + const publishOrder: string[] = [] + mockedSafeWriteText.mockImplementation(async (target: string) => { + if (target === abs("gate-a.txt")) { + await gateA + } + publishOrder.push(target) + }) + + const writeA = guardedWrite(task, "gate-a.txt", "a", "update") + const writeB = guardedWrite(task, "gate-b.txt", "b", "update") + + // Settle until b has published; a is still inside its own publish here. + for (let tick = 0; tick < 200 && publishOrder.length === 0; tick++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(publishOrder).toEqual([abs("gate-b.txt")]) + + releaseGateA() + await Promise.all([writeA, writeB]) + + expect(publishOrder).toEqual([abs("gate-b.txt"), abs("gate-a.txt")]) + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + }) + }) + + describe("path resolution", () => { + it("resolves a relative path against task.cwd", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("sub/dir.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "sub/dir.txt", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledWith(abs("sub/dir.txt"), "content") + }) + + it("normalizes an already-absolute input (trailing separator) to the observation key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("sub/dir.txt") + // ReadFileTool observes under path.resolve(task.cwd, relPath) — the + // canonical spelling. A write addressed with a trailing separator used + // to bypass the observation (isAbsolute passthrough) and fail + // "File already exists" / "File not read yet" for a file that was read. + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, canonical + "/", "content", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(1) + expect(mockedSafeWriteText).toHaveBeenCalledWith(canonical, "content") + }) + + it("serializes two spellings of one file through a single chain key", async () => { + const reg = new ObservationRegistry() + const canonical = abs("shared2.txt") + reg.observe(canonical, "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + // The first publish changes the on-disk state (new token). + mockedSafeWriteText.mockImplementation(async () => { + mockedComputeVersionToken.mockResolvedValue("v2") + }) + + // Plain spelling vs the trailing-separator spelling: with one chain + // key they are strictly ordered. The first matches v1 and publishes; + // its post-publish refresh records v2, so the second CASes against + // v2 and publishes too (serialized same-task writes). + const p1 = guardedWrite(task, canonical, "first", "update") + const p2 = guardedWrite(task, canonical + "/", "second", "update") + const [r1, r2] = await Promise.allSettled([p1, p2]) + + expect(r1.status).toBe("fulfilled") + expect(r2.status).toBe("fulfilled") + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(1, canonical, "first") + expect(mockedSafeWriteText).toHaveBeenNthCalledWith(2, canonical, "second") + }) + }) + + describe("lock serialization with other writers", () => { + it("holds the shared advisory lock across the version check and the publish", async () => { + // The guard decision and the publish must be one operation under the same + // advisory lock that safeWriteJson and task-history deletion use, otherwise + // a lock-using writer can land between the check and the write. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") + + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) + expect(mockedWithFileLock).toHaveBeenCalledWith(abs("a.txt"), expect.any(Function)) + }) + + it("holds the same lock across the absence check and the publish for an unobserved create", async () => { + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedFsAccess.mockImplementation(async () => { + order.push("check") + throw { code: "ENOENT" } + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + }) + + mockedComputeVersionToken.mockImplementation(async () => { + order.push("token") + return "v1" + }) + + await createIfAbsent(abs("new.txt"), "hello", "new.txt") + + expect(order).toEqual(["lock", "check", "publish", "token", "release"]) + }) + + it("locks the resolved publish target instead of the link path", async () => { + // proper-lockfile keys by the path it is given, so a symlink alias and its + // referent would take two locks for one file. The guard must lock the key + // every other writer to that file uses. + const referent = abs("real/file.txt") + mockedResolveLockKey.mockResolvedValue(referent) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + mockedComputeVersionToken.mockResolvedValue("v1") + + await createIfAbsent(abs("link.txt"), "hello", "link.txt") + + expect(mockedResolveLockKey).toHaveBeenCalledWith(abs("link.txt")) + expect(mockedWithFileLock).toHaveBeenCalledWith(referent, expect.any(Function)) + }) + + it("reads the post-publish token inside the lock, before releasing it", async () => { + // A peer lock-using writer can publish in the gap between the publish and + // a post-publish stat made after the lock is released, and the task would + // then record that peer's token as its own observation. + const order: string[] = [] + mockedWithFileLock.mockImplementation(async (filePath, operation) => { + order.push("lock") + const result = await operation(path.resolve(filePath)) + order.push("release") + return result + }) + mockedComputeVersionToken.mockImplementation(async () => { + order.push("check") + return "v1" + }) + mockedSafeWriteText.mockImplementation(async () => { + order.push("publish") + return undefined + }) + + const token = await replaceIfVersion(abs("a.txt"), "v1", "content", "a.txt") + + expect(order).toEqual(["lock", "check", "publish", "check", "release"]) + expect(token).toBe("v1") + }) + }) + + describe("resetChain", () => { + it("detaches pending links so later writes start a fresh chain", async () => { + const reg = new ObservationRegistry() + reg.observe(abs("x.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + await guardedWrite(task, "x.txt", "a", "update") + resetChain() + await guardedWrite(task, "x.txt", "b", "update") + + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("x.txt"), "b") + }) + it("detaches a link that is still publishing so the next write starts immediately", async () => { + // The settled result of the row above cannot tell a detached chain from an + // emptied one, because nothing was in flight there. Here the first publish is + // still awaiting its gate when resetChain runs: if resetChain left the pending + // link as the path's tail, the second write would queue behind a publish that + // cannot finish until the second one is already waited on - the wedge the hook + // exists to undo. + const reg = new ObservationRegistry() + reg.observe(abs("chain.txt"), "v1") + mockedComputeVersionToken.mockResolvedValue("v1") + const task = createMockTask({ observationRegistry: reg }) + + let releaseGate: () => void = () => {} + const gate = new Promise((resolve) => { + releaseGate = resolve + }) + const entered: string[] = [] + mockedSafeWriteText.mockImplementation(async (_target: string, content: string | Uint8Array) => { + entered.push(String(content)) + if (content === "a") { + await gate + } + }) + + const firstWrite = guardedWrite(task, "chain.txt", "a", "update") + for (let tick = 0; tick < 200 && entered.length === 0; tick++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + // The first publish is in flight and unfinished. + expect(entered).toEqual(["a"]) + + resetChain() + const secondWrite = guardedWrite(task, "chain.txt", "b", "update") + for (let tick = 0; tick < 200 && entered.length < 2; tick++) { + await new Promise((resolve) => setTimeout(resolve, 5)) + } + expect(entered).toEqual(["a", "b"]) + + releaseGate() + await Promise.all([firstWrite, secondWrite]) + expect(entered).toEqual(["a", "b"]) + expect(mockedSafeWriteText).toHaveBeenLastCalledWith(abs("chain.txt"), "b") + }) + }) + + it("does not run a queued write once the owning task is cancelled", async () => { + const task = createMockTask({ abort: true }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write ran -- the queued publish is not performed.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks cancellation under the publish lock before writing", async () => { + // The dequeue check passed; the abort landed while the lock was being taken, + // so the guard must refuse before the publish rather than write for a task that + // is already gone. + const task = createMockTask() + mockedWithFileLock.mockImplementation(function (filePath, operation) { + ;(task as { abort: boolean }).abort = true // Task.abort is a getter; the double holds a plain field + return operation(path.resolve(filePath)) + }) + mockedFsAccess.mockRejectedValue({ code: "ENOENT" }) + + await expect(guardedWrite(task, "new-file.txt", "hello", "create")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("re-checks cancellation after the awaited preflight, before publication starts", async () => { + // The abort lands while the version token is being computed. The guard has to + // refuse on the way to the publish, not write for a task that is already gone. + mockedComputeVersionToken.mockImplementation(async () => { + ;(task as { abort: boolean }).abort = true // Task.abort is a getter; the double holds a plain field + return "v1" + }) + const reg = new ObservationRegistry() + reg.observe(abs("a.txt"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + await expect(guardedWrite(task, "a.txt", "content", "update")).rejects.toThrow( + "Task was cancelled before this write published -- nothing was written.", + ) + + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + it("names the caller's path, not the resolved absolute path, in a model-facing rejection", async () => { + // The guard key stays absolute, but the message and the error field go to the + // model, so they must not carry a user-specific absolute path. + mockedFsAccess.mockResolvedValue(undefined) + const task = createMockTask() + + let error: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "create").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + error = e + return + } + throw e + }) + + expect(error?.message).toBe( + "File already exists at src/thing.ts and was not read before this write -- read the file first, then retry.", + ) + expect(error?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + it("names the caller's path on a stale-version rejection as well", async () => { + // The stale branch is the most common rejection, so it has to follow the same rule + // as the create, delete and cancellation branches. + mockedFsAccess.mockResolvedValue(undefined) + mockedComputeVersionToken.mockResolvedValue("v2") + const reg = new ObservationRegistry() + reg.observe(abs("src/thing.ts"), "v1", true) + const task = createMockTask({ observationRegistry: reg }) + + let staleError: GuardRejectedError | undefined + await guardedWrite(task, "src/thing.ts", "hello", "update").catch((e: unknown) => { + if (e instanceof GuardRejectedError) { + staleError = e + return + } + throw e + }) + + expect(staleError?.message).toBe( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(staleError?.path).toBe("src/thing.ts") + expect(mockedSafeWriteText).not.toHaveBeenCalled() + }) + + describe("post-commit durability failure", () => { + // Row 4507764645 / 4507851715: the rename is the point of no return for the + // observation as well as for the content. A write whose commit landed but whose + // parent-directory fsync did not has to leave the caller authorized against the bytes + // that are actually on disk, while the durability failure is still reported. + const durabilityError = (target: string, committedVersion?: string): PostCommitDurabilityError => + new PostCommitDurabilityError(target, new Error("EBADF: bad file descriptor"), null, committedVersion) + + it("keeps the observation on the committed version and still reports the failure", async () => { + const target = abs("durability.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") // the version check passes + const failure = durabilityError(target, "v2") + mockedSafeWriteText.mockRejectedValueOnce(failure) + + await expect(guardedWrite(task, "durability.txt", "new", "update")).rejects.toBe(failure) + + // The content on disk is v2's bytes; recording v1 would refuse the next edit of a + // file this very write changed, and recording nothing would withdraw authority the + // tool earned by writing the content itself. + expect(task.observationRegistry.get(target)?.version).toBe("v2") + // The other direction of the same state: this publish replaced a complete file the + // model had read, so a caveat about durability must not report the write as + // half-applied - a consumer has to tell 'landed, not durable' from 'half-applied'. + expect(task.observationRegistry.get(target)?.complete).toBe(true) + expect(failure.message).toContain("committed, not durable") + expect(failure.committedVersion).toBe("v2") + }) + + it("accepts a later write that matches the committed version", async () => { + const target = abs("second-write.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + mockedSafeWriteText.mockRejectedValueOnce(durabilityError(target, "v2")) + + const first = await guardedWrite(task, "second-write.txt", "first", "update").catch( + (error: unknown) => error, + ) + expect(first).toBeInstanceOf(PostCommitDurabilityError) + + // The second write compares against the recorded version, which the refresh moved + // to the committed token: it runs, and its own publish is not refused as stale. + mockedComputeVersionToken.mockResolvedValue("v2") + mockedSafeWriteText.mockResolvedValue(undefined) + await guardedWrite(task, "second-write.txt", "second", "update") + + expect(mockedSafeWriteText).toHaveBeenCalledTimes(2) + expect(task.observationRegistry.get(target)?.version).toBe("v2") + }) + + it("leaves the observation at the read version when nothing was committed", async () => { + const target = abs("pre-commit.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", true) + mockedComputeVersionToken.mockResolvedValue("v1") + const enospc = Object.assign(new Error("ENOSPC: no space left on device"), { code: "ENOSPC" }) + mockedSafeWriteText.mockRejectedValueOnce(enospc) + + await expect(guardedWrite(task, "pre-commit.txt", "new", "update")).rejects.toBe(enospc) + + // The other half of the rule: only a commit re-records. A failure before the rename + // leaves the read's version as the only true one. + expect(task.observationRegistry.get(target)?.version).toBe("v1") + }) + + it("keeps a partial observation partial when the publish is remembered", async () => { + const target = abs("partial-durability.txt") + const task = createMockTask() + task.observationRegistry.observe(target, "v1", false) + mockedComputeVersionToken.mockResolvedValue("v1") + mockedSafeWriteText.mockRejectedValueOnce(durabilityError(target, "v2")) + + await expect(guardedWrite(task, "partial-durability.txt", "new", "edit")).rejects.toBeInstanceOf( + PostCommitDurabilityError, + ) + + // The branch that sets this never ran on the durability path: an edit authorized by + // a slice must not become authority for a full-file replacement of content the model + // never read. + expect(task.observationRegistry.get(target)?.complete).toBe(false) + expect(task.observationRegistry.get(target)?.version).toBe("v2") + }) + }) +}) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 52e04415ea..6def4c7a22 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -14,6 +14,9 @@ import os from "os" import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import type { ToolUse } from "../../../shared/tools" import type { Task, ToolExecutionContext } from "../../task/Task" @@ -21,6 +24,7 @@ import type { Task, ToolExecutionContext } from "../../task/Task" import { isBinaryFile } from "isbinaryfile" import { readFileTool } from "../ReadFileTool" +import { ObservationRegistry } from "../../task/observationRegistry" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, processImageFile, isSupportedImageFormat } from "../helpers/imageHelpers" import { extractTextFromFile, getSupportedBinaryFormats } from "../../../integrations/misc/extract-text" @@ -135,6 +139,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -142,6 +147,9 @@ function createMockTask(options: MockTaskOptions = {}) { return { cwd: "/test/workspace", + // Mirror Task: every task always owns an observation registry (A2, #1375). + // Tests asserting on observations pass their own instance via options. + observationRegistry: options.observationRegistry ?? new ObservationRegistry(), api: { getModel: vi.fn().mockReturnValue({ info: { supportsImages }, @@ -188,7 +196,18 @@ describe("ReadFileTool", () => { vi.clearAllMocks() // Default mock implementations - mockedFsStat.mockResolvedValue({ isDirectory: () => false } as any) + // The stat default carries BigIntStats fields (A2, epic #1375): reads now + // token-ize the pre/post stats, so the default must look like a real bigint stat. + // Tests overriding it do so per-call with mockResolvedValue(Once). + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) mockedIsBinaryFile.mockResolvedValue(false) mockedFsReadFile.mockResolvedValue(Buffer.from("test content")) mockedReadWithSlice.mockReturnValue({ @@ -975,7 +994,7 @@ describe("ReadFileTool", () => { mockTask.ask.mockResolvedValue({ response: "yesButtonClicked", text: undefined, images: undefined }) // fs.readFile with "utf8" encoding returns a string, not a Buffer - mockedFsReadFile.mockResolvedValue("line1\nline2\nline3\nline4\nline5" as any) + mockedFsReadFile.mockResolvedValue(Buffer.from("line1\nline2\nline3\nline4\nline5")) await readFileTool.execute( { files: [{ path: "test.ts", lineRanges: [{ start: 2, end: 4 }] }] } as any, @@ -1688,5 +1707,935 @@ describe("ReadFileTool", () => { expect(mockTask.didToolFailInCurrentTurn).toBe(true) }) + + describe("observation registry", () => { + it("records an observation on successful read of an existing file", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Override the beforeEach default stat mock with proper BigIntStats. + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + // Spy on observe to capture the exact key used (Windows path.resolve may use backslashes). + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "existing.ts" }, mockTask as unknown as Task, callbacks) + + // Verify the tool called observe exactly once with a valid token. + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("existing.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + + // Verify get() returns the same data using the spy-captured key. + const obs = reg.get(calledPath) + expect(obs).toBeDefined() + expect(obs!.version).toBe(calledVersion) + }) + + it("a failed read (absent path) leaves the registry size 0 and does not throw", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsReadFile.mockRejectedValue(new Error("ENOENT")) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "missing.ts" }, mockTask as unknown as Task, callbacks) + + // observationRegistry is guaranteed present because we passed it in createMockTask. + const reg = mockTask.observationRegistry + expect(reg).toBeDefined() + expect(reg!.size).toBe(0) + }) + + it("records an observation for legacy-format reads of existing files", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Typed legacy (pre-refactor) params: the multi-file format with the + // _legacyFormat discriminant (see LegacyReadFileParams). + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion] = observeSpy.mock.calls[0] + expect(calledPath).toContain("legacy.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + }) + + it("does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // A mutation lands mid-read: the post-read stat differs. + const postStats = { ...preStats, size: BigInt(301) } + + // Call order: directory check, pre-read stat, post-read stat. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce(postStats as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "mutated.ts" }, mockTask as unknown as Task, callbacks) + + // The read itself succeeded, but the target stays unobserved: the content the + // model received is not the on-disk state, so observing it would let a later + // write match a token the model never saw. + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("leaves the target unobserved without failing the read when the pre-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + // The read still succeeds — a stat failure never fails the read. + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + + it("leaves the target unobserved without failing the read when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "post-stat-fail.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + + it("legacy format: does not observe when the file mutates between the pre-read and post-read stats", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const preStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Call order: directory check, pre-read stat, post-read stat (mutated). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(preStats as unknown as Stats) + .mockResolvedValueOnce({ ...preStats, size: BigInt(301) } as unknown as Stats) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-mutated.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + }) + + it("legacy format: leaves the target unobserved when a stat fails without failing the read", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // Directory check OK; the pre-read stat fails (caught, target unobserved). + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + it("legacy format: leaves the target unobserved when the post-read stat fails", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const okStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } + // Directory check and pre-read stat OK; the post-read stat fails. + mockedFsStat + .mockResolvedValueOnce({ isDirectory: () => false } as unknown as Stats) + .mockResolvedValueOnce(okStats as unknown as Stats) + .mockRejectedValueOnce(new Error("EACCES")) + mockedIsBinaryFile.mockResolvedValue(false) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-post-stat-fail.ts" }], + _legacyFormat: true, + } + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).not.toHaveBeenCalled() + expect(reg.size).toBe(0) + expect(mockTask.didToolFailInCurrentTurn).toBe(false) + // Assert the pushed payload, not just that something was pushed. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("File: legacy-post-stat-fail.ts") + expect(pushed).toContain("test content") + expect(pushed).not.toContain("Error:") + }) + it("two separate Task-owned registries are independent", async () => { + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + regA.observe("/shared.ts", "v1") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")).toBeUndefined() + regB.observe("/shared.ts", "v2") + expect(regA.get("/shared.ts")!.version).toBe("v1") + expect(regB.get("/shared.ts")!.version).toBe("v2") + }) + it("reads from the same path land in each task's own registry, not the other task's", async () => { + // The row above proves two ObservationRegistry instances hold separate entries; it never calls the + // tool, so a read that reported its observation into another task's registry would still pass every + // line of it. This row drives ReadFileTool.execute twice, once per task, at one path, and asserts the + // observation lands in the registry of the task that made the call. + const regA = new ObservationRegistry() + const regB = new ObservationRegistry() + const taskA = createMockTask({ observationRegistry: regA }) + const taskB = createMockTask({ observationRegistry: regB }) + const callbacksA = createMockCallbacks() + const callbacksB = createMockCallbacks() + const observeA = vi.spyOn(regA, "observe") + const observeB = vi.spyOn(regB, "observe") + + const statForFirstRead = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats + mockedFsStat.mockResolvedValue(statForFirstRead) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("isolated\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | isolated", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + // Cast: the mock task only implements the members ReadFileTool.execute touches. + await readFileTool.execute({ path: "isolated.ts" }, taskA as unknown as Task, callbacksA) + + // A's registry received the observation and B's was not reached at all. + expect(observeA).toHaveBeenCalledTimes(1) + expect(observeB).not.toHaveBeenCalled() + const [keyA, versionA] = observeA.mock.calls[0] + expect(regA.get(keyA)!.version).toBe(versionA) + expect(regB.get(keyA)).toBeUndefined() + + // The same path read by B under a different stat, so B's version token is distinguishable from + // A's. Both halves are pinned to entries rather than to call counts: B holds its own observation + // at the same key and A still holds the one its own read wrote. + const statForSecondRead = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(7), + size: BigInt(300), + mtimeNs: BigInt(9_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + // Cast: the mock only implements the members the tool and versionToken read. + } as unknown as Stats + mockedFsStat.mockResolvedValue(statForSecondRead) + + await readFileTool.execute({ path: "isolated.ts" }, taskB as unknown as Task, callbacksB) + + expect(observeB).toHaveBeenCalledTimes(1) + const [keyB, versionB] = observeB.mock.calls[0] + expect(keyB).toBe(keyA) + expect(versionB).not.toBe(versionA) + expect(regB.get(keyB)!.version).toBe(versionB) + expect(regA.get(keyA)!.version).toBe(versionA) + expect(regA.size).toBe(1) + expect(regB.size).toBe(1) + }) + }) + + describe("read completeness scope (S4b follow-up #46)", () => { + // The stat mock only implements the members the tool and versionToken read. + const bigintStats = (): Stats => + ({ + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + }) as unknown as Stats + + it("native: a full, untruncated slice read from line 1 records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "full.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, calledVersion, calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("full.ts") + expect(calledVersion).toMatch(/^\d+:\d+:\d+:\d+:\d+$/) + expect(calledComplete).toBe(true) + expect(reg.get(calledPath)!.complete).toBe(true) + }) + + it("native: a truncated slice read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "trunc.ts", offset: 1, limit: 1 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("native: a full read whose line content was clipped records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "clipped.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + // Every line was returned, so the notice must not point at a next + // offset that is beyond the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("To read more") + // The notice is added on top of the read, it does not replace it. + expect(pushed).toContain("1 | a") + }) + + it("native: the clipped notice leaves the first numbered line at column 0", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\n")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + await readFileTool.execute({ path: "clipped-cols.ts" }, mockTask as unknown as Task, callbacks) + + const pushed = String(callbacks.pushToolResult.mock.calls[0][0]) + const responseLines = pushed.split("\n") + const noticeLine = responseLines.findIndex((line) => line.includes("clipped in this view")) + expect(noticeLine).toBeGreaterThanOrEqual(0) + // The read follows the notice on the very next line, at column 0. The + // previous shape joined the two with a template continuation line, which + // put this function's own indentation into the response: the first numbered + // line arrived indented, and an exact-match edit against a line number the + // model can see has to reproduce tabs it was never shown. + expect(responseLines[noticeLine + 1]).toBe("1 | a") + expect(responseLines[noticeLine + 2]).toBe("2 | b") + expect(pushed).not.toContain("\n\t") + }) + + it("native: a clipped slice that started past line 1 reports the range it covered", async () => { + // Not truncated does not mean complete: a slice that begins at offset 4 and + // stops at the last line returned the tail only. Claiming "read in full" here + // is what hides the first three lines from whatever reads the notice. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | d\n5 | e", + returnedLines: 2, + totalLines: 5, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[4, 5]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "tail.ts", offset: 4, limit: 2 }, + mockTask as unknown as Task, + callbacks, + ) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).toContain("Lines 4-5 of 5 total lines") + expect(pushed).not.toContain("The file was read in full") + // Still no next-offset hint: nothing was omitted after the slice. + expect(pushed).not.toContain("To read more") + expect(pushed).toContain("4 | d") + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("native: a clipped read that covered the whole file still says it read in full", async () => { + // The other half of the contract: the range sentence must not replace the + // accurate statement when the slice really did cover the file. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b\n3 | c", + returnedLines: 3, + totalLines: 3, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 3]], + }) + + await readFileTool.execute({ path: "whole.ts" }, mockTask as unknown as Task, callbacks) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("The file was read in full, but the clipped lines were not shown in full.") + expect(pushed).not.toContain("total lines were shown") + }) + + it("native: a truncated slice that also clipped a line reports both notices", async () => { + // A long line inside a slice that also cut lines off is a plausible case, + // and the response has to say both things. + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "both.ts" }, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("Showing lines 1-2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + + it("native: an offset read that is not truncated still records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + mockedReadWithSlice.mockReturnValue({ + content: "4 | d\n5 | e", + returnedLines: 2, + totalLines: 5, + wasTruncated: false, + includedRanges: [[4, 5]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "offset.ts", offset: 4, limit: 2 }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("native: an indentation-mode block read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("function f() { return 1 }")) + mockedReadWithIndentation.mockReturnValue({ + content: "10 | function f() {", + wasTruncated: false, + includedRanges: [[10, 20]], + totalLines: 100, + returnedLines: 11, + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute( + { path: "indent.ts", mode: "indentation" }, + mockTask as unknown as Task, + callbacks, + ) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + }) + + it("legacy: a line-range read records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc\nd\ne")) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "ranges.ts", lineRanges: [{ start: 2, end: 4 }] }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledPath).toContain("ranges.ts") + expect(calledComplete).toBe(false) + }) + + it("legacy: a full, untruncated slice read records a complete observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-full.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(true) + + // Nothing was omitted and nothing was clipped, so no note is added. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).not.toContain("clipped in this view") + expect(pushed).not.toContain("total lines") + }) + + it("legacy: a full read with a clipped line records a partial observation and reports the clipping", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 2, + wasTruncated: false, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-clipped.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + // Every line was returned, so the note reports the clipping instead of + // a showing-N-of-N count that would point past the file. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("clipped in this view") + expect(pushed).not.toContain("showing 2 of 2 total lines") + }) + + it("legacy: a truncated slice that also clipped a line reports both notices", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\n2 | b", + returnedLines: 2, + totalLines: 3, + wasTruncated: true, + hasClippedLines: true, + includedRanges: [[1, 2]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-both.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + const [, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 2 of 3 total lines") + expect(pushed).toContain("clipped in this view") + }) + + it("legacy: a slice truncated to the default limit records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(Buffer.from("a\nb\nc")) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a", + returnedLines: 1, + totalLines: 5000, + wasTruncated: true, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-trunc.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [_calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + + // Lines were omitted here, so the note reports the omitted range rather + // than clipping. + const pushed = callbacks.pushToolResult.mock.calls[0][0] + expect(pushed).toContain("showing 1 of 5000 total lines") + }) + + it("native: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + // 0xFF is not valid UTF-8, so the model receives U+FFFD instead of the byte. + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + await readFileTool.execute({ path: "lossy.ts" }, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + + it("legacy: a read whose bytes did not survive the UTF-8 decode records a partial observation", async () => { + const mockTask = createMockTask({ + observationRegistry: new ObservationRegistry(), + }) + const callbacks = createMockCallbacks() + + const raw = Buffer.from([0x61, 0xff]) + mockedFsStat.mockResolvedValue(bigintStats()) + mockedIsBinaryFile.mockResolvedValue(false) + mockedFsReadFile.mockResolvedValue(raw) + mockedReadWithSlice.mockReturnValue({ + content: "1 | a\uFFFD", + returnedLines: 1, + totalLines: 1, + wasTruncated: false, + includedRanges: [[1, 1]], + }) + + const reg = mockTask.observationRegistry! + const observeSpy = vi.spyOn(reg, "observe") + + const legacyParams: LegacyReadFileParams = { + files: [{ path: "legacy-lossy.ts" }], + _legacyFormat: true, + } + + await readFileTool.execute(legacyParams, mockTask as unknown as Task, callbacks) + + expect(observeSpy).toHaveBeenCalledTimes(1) + const [calledPath, , calledComplete] = observeSpy.mock.calls[0] + expect(calledComplete).toBe(false) + expect(reg.get(calledPath)!.complete).toBe(false) + }) + }) }) }) diff --git a/src/core/tools/__tests__/searchReplaceTool.spec.ts b/src/core/tools/__tests__/searchReplaceTool.spec.ts index 65311462cb..da944356e0 100644 --- a/src/core/tools/__tests__/searchReplaceTool.spec.ts +++ b/src/core/tools/__tests__/searchReplaceTool.spec.ts @@ -10,6 +10,9 @@ import { ToolUse, ToolResponse, AskApproval, HandleError, PushToolResult } from import { searchReplaceTool } from "../SearchReplaceTool" vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), default: { readFile: vi.fn().mockResolvedValue(""), }, @@ -166,6 +169,7 @@ describe("searchReplaceTool", () => { fileContent?: string isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { const fileExists = options.fileExists ?? true @@ -176,6 +180,13 @@ describe("searchReplaceTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockedFsReadFile.mockResolvedValue(fileContent) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) const nativeArgs: Record = { file_path: testFilePath, @@ -313,7 +324,7 @@ describe("searchReplaceTool", () => { await executeSearchReplaceTool() - expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalledWith(true, 1000, "edit", false, undefined) expect(mockCline.didEditFile).toBe(true) // Usage is recorded once at the central presentAssistantMessage // attribution point, not locally by the handler. @@ -441,4 +452,87 @@ describe("searchReplaceTool", () => { expect(mockAskApproval).toHaveBeenCalled() }) }) + + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with edit kind", async () => { + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + "Line 1\nModified Line 2\nLine 3", + false, + true, + 1000, + "edit", + undefined, + false, + undefined, + ) + expect(mockCline.didEditFile).toBe(true) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("forwards an approved outside-workspace edit as approved, not as completeness", async () => { + mockedIsPathOutsideWorkspace.mockReturnValue(true) + try { + await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + } + }) + + it("reports a committed-write buffer conflict without telling the model to retry the edit", async () => { + // saveDirectly publishes first and then throws when an open buffer has different + // unsaved edits. The tool must surface the committed-write contract, not a retry + // instruction: re-reading and re-applying a non-idempotent edit would duplicate it. + const { CommittedWriteBufferConflictError } = + await import("../../../integrations/editor/committedWriteBufferConflictError") + const conflictError = new CommittedWriteBufferConflictError("test.ts") + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(conflictError) + + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("search and replace", conflictError) + const message = conflictError.message + expect(message).toContain("write is already committed") + expect(message).toContain("do not retry") + expect(message).not.toContain("then retry") + // The rejected save did not edit the file. + expect(mockCline.didEditFile).toBe(false) + expect(result).toBeUndefined() + }) + + it("surfaces the unobserved edit remediation as a tool error and publishes nothing", async () => { + const guardError = new Error("File not read yet -- read the file, then retry.") + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeSearchReplaceTool( + { old_string: "Line 2", new_string: "Modified Line 2" }, + { fileContent: "Line 1\nLine 2\nLine 3", experiments: focusDisruption }, + ) + + expect(mockHandleError).toHaveBeenCalledWith("search and replace", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) }) diff --git a/src/core/tools/__tests__/writeToFileTool.spec.ts b/src/core/tools/__tests__/writeToFileTool.spec.ts index 7cb9d01585..9891b11b81 100644 --- a/src/core/tools/__tests__/writeToFileTool.spec.ts +++ b/src/core/tools/__tests__/writeToFileTool.spec.ts @@ -26,6 +26,16 @@ vi.mock("delay", () => ({ default: vi.fn(), })) +// The focus-disruption save path reads the original file content via fs.readFile. +vi.mock("fs/promises", () => ({ + // guardedWrite resolves the target through a namespace import, so the double has + // to expose realpath as a named export as well as on the default object. + realpath: vi.fn(async (p: string) => String(p)), + default: { + readFile: vi.fn().mockResolvedValue("original content"), + }, +})) + vi.mock("../../../utils/fs", () => ({ fileExistsAtPath: vi.fn().mockResolvedValue(false), createDirectoriesForFile: vi.fn().mockResolvedValue([]), @@ -156,6 +166,11 @@ describe("writeToFileTool", () => { userEdits: null, finalContent: "final content", }), + saveDirectly: vi.fn().mockResolvedValue({ + newProblemsMessage: "", + userEdits: undefined, + finalContent: "final content", + }), scrollToFirstDiff: vi.fn(), updateDiagnosticSettings: vi.fn(), pushToolWriteResult: vi.fn().mockImplementation(async function ( @@ -187,6 +202,7 @@ describe("writeToFileTool", () => { mockCline.say = vi.fn().mockResolvedValue(undefined) mockCline.ask = vi.fn().mockResolvedValue(undefined) mockCline.recordToolError = vi.fn() + mockCline.processQueuedMessages = vi.fn() mockCline.sayAndCreateMissingParamError = vi.fn().mockResolvedValue("Missing param error") mockAskApproval = vi.fn().mockResolvedValue(true) @@ -204,6 +220,7 @@ describe("writeToFileTool", () => { fileExists?: boolean isPartial?: boolean accessAllowed?: boolean + experiments?: Record } = {}, ): Promise { // Configure mocks based on test scenario @@ -213,6 +230,13 @@ describe("writeToFileTool", () => { mockedFileExistsAtPath.mockResolvedValue(fileExists) mockCline.rooIgnoreController.validateAccess.mockReturnValue(accessAllowed) + mockCline.providerRef.deref.mockReturnValue({ + getState: vi.fn().mockResolvedValue({ + diagnosticsEnabled: true, + writeDelayMs: 1000, + experiments: options.experiments ?? {}, + }), + }) // Create a tool use object const toolUse: ToolUse = { @@ -374,6 +398,10 @@ describe("writeToFileTool", () => { expect(mockCline.diffViewProvider.update).toHaveBeenCalledWith(testContent, true) expect(mockAskApproval).toHaveBeenCalled() expect(mockCline.diffViewProvider.saveChanges).toHaveBeenCalled() + // The approved whole-file write names its guard explicitly: the third + // argument selects the write kind - passing undefined relied on the + // parameter's default instead of the tool's own decision. + expect(mockCline.diffViewProvider.saveChanges.mock.calls[0][2]).toBe("update") expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") expect(mockCline.didEditFile).toBe(true) }) @@ -451,6 +479,159 @@ describe("writeToFileTool", () => { }) }) + describe("guarded write (S4b, epic #1375)", () => { + const focusDisruption = { preventFocusDisruption: true } + + it("publishes through saveDirectly with create kind when the write is approved", async () => { + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockCline.diffViewProvider.saveDirectly).toHaveBeenCalledWith( + testFilePath, + testContent, + false, + true, + 1000, + "create", + undefined, + false, + undefined, + ) + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.fileContextTracker.trackFileContext).toHaveBeenCalledWith(testFilePath, "roo_edited") + expect(mockCline.didEditFile).toBe(true) + expect(mockCline.consecutiveMistakeCount).toBe(0) + expect(result).toBe("Tool result message") + expect(mockHandleError).not.toHaveBeenCalled() + }) + + it("forwards an approved outside-workspace write as approved, not as completeness", async () => { + // Both trailing parameters are boolean | undefined, so only an assertion on the + + // positions catches a swap: seventh is completeOverride, eighth is the approval + + // flag. + + mockedIsPathOutsideWorkspace.mockReturnValue(true) + try { + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[6]).toBeUndefined() + expect(args[7]).toBe(true) + } finally { + // The flag is a module mock. A reset after the assertions only runs when they pass; + // a failing assertion would leave it true and the following tests would then fail for + // the wrong reason. + mockedIsPathOutsideWorkspace.mockReturnValue(false) + } + }) + + it("forwards the canonical target to saveDirectly alongside the approval flag", async () => { + // The lexical target carries a linked directory component; the resolver removes + // it, so the identity the tool has to forward is a different string from the + // path it resolved out of task.cwd. The ninth saveDirectly parameter is that + // identity: guardedWrite refuses a publish that no longer resolves to it. + const aliasPath = process.platform === "win32" ? "C:\\test\\link\\file.txt" : "/test/link/file.txt" + const canonicalTarget = absoluteFilePath + const { realpath } = await import("fs/promises") + mockedPathResolve.mockReturnValue(aliasPath) + vi.mocked(realpath).mockImplementation(async () => canonicalTarget) + mockedIsPathOutsideWorkspace.mockReturnValue(true) + + try { + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + const args = mockCline.diffViewProvider.saveDirectly.mock.calls.at(-1)! + expect(args[7]).toBe(true) + expect(args[8]).toBe(canonicalTarget) + expect(args[8]).not.toBe(aliasPath) + expect(args[8]).not.toBe(args[0]) + } finally { + // Both doubles are module-level: a reset after the assertions only runs when + // they pass, and beforeEach clears calls but not implementations. + vi.mocked(realpath).mockImplementation(async (p) => String(p)) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + mockedPathResolve.mockReturnValue(absoluteFilePath) + } + }) + + it("forwards the canonical target to saveChanges on the editor save path", async () => { + // The editor path saves through saveChanges, whose fifth parameter is the same + // identity. Checking only the focus-disruption path would let the approved target + // reach one save method and not the other. + const aliasPath = process.platform === "win32" ? "C:\\test\\link\\file.txt" : "/test/link/file.txt" + const canonicalTarget = absoluteFilePath + const { realpath } = await import("fs/promises") + mockedPathResolve.mockReturnValue(aliasPath) + vi.mocked(realpath).mockImplementation(async () => canonicalTarget) + mockedIsPathOutsideWorkspace.mockReturnValue(true) + + try { + await executeWriteFileTool({}, { fileExists: true }) + const args = mockCline.diffViewProvider.saveChanges.mock.calls.at(-1)! + expect(args[3]).toBe(true) + expect(args[4]).toBe(canonicalTarget) + expect(args[4]).not.toBe(aliasPath) + } finally { + vi.mocked(realpath).mockImplementation(async (p) => String(p)) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + mockedPathResolve.mockReturnValue(absoluteFilePath) + } + }) + + it("routes an outside-workspace target that cannot be resolved through the tool's error handling", async () => { + // The identity capture runs before the approval is asked, so a target whose + // resolution fails must not escape the write flow: it has to reach handleError and + // the diff-view reset like every other failure of this tool, and it must not get + // as far as asking the user about a path that cannot be checked. + const { realpath } = await import("fs/promises") + vi.mocked(realpath).mockRejectedValue({ code: "EPERM" }) + mockedIsPathOutsideWorkspace.mockReturnValue(true) + + try { + await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", expect.any(Error)) + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveDirectly).not.toHaveBeenCalled() + } finally { + // Reset both doubles: later tests in this file assume an in-workspace target that + // resolves to itself. A reset after the assertions only runs when they pass, and + // beforeEach clears calls but not implementations, so a failing assertion here + // would leave realpath rejecting for every later test in the file. + vi.mocked(realpath).mockImplementation(async (p) => String(p)) + mockedIsPathOutsideWorkspace.mockReturnValue(false) + } + }) + + it("surfaces the unobserved-existing remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + `File already exists at ${absoluteFilePath} and was not read before this write -- read the file first, then retry.`, + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.diffViewProvider.saveChanges).not.toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + + it("surfaces the stale-version remediation as a tool error and publishes nothing", async () => { + const guardError = new Error( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + mockCline.diffViewProvider.saveDirectly.mockRejectedValue(guardError) + + const result = await executeWriteFileTool({}, { fileExists: true, experiments: focusDisruption }) + + expect(mockHandleError).toHaveBeenCalledWith("writing file", guardError) + expect(result).toBeUndefined() + expect(mockCline.diffViewProvider.reset).toHaveBeenCalled() + expect(mockCline.didEditFile).toBe(false) + }) + }) + describe("error handling", () => { it("handles general file operation errors", async () => { mockCline.diffViewProvider.open.mockRejectedValue(new Error("General error")) diff --git a/src/core/tools/apply-patch/apply.ts b/src/core/tools/apply-patch/apply.ts index 4ab377f732..b6eb0e7613 100644 --- a/src/core/tools/apply-patch/apply.ts +++ b/src/core/tools/apply-patch/apply.ts @@ -16,6 +16,16 @@ export class ApplyPatchError extends Error { } } +/** + * Why a hunk wants the file read. + * + * "publish": the string handed back here is the string the tool writes back (an + * update, or a move's destination), so a read that cannot represent the file's bytes + * faithfully must not become the published content. "record": nothing is republished + * from it (a delete, whose publish is an unlink), so the reading is a snapshot. + */ +export type PatchReadIntent = "publish" | "record" + /** * Result of applying a patch to a file. */ @@ -150,7 +160,7 @@ export function applyChunksToContent(originalContent: string, filePath: string, */ export async function processHunk( hunk: Hunk, - readFile: (path: string) => Promise, + readFile: (path: string, intent: PatchReadIntent) => Promise, ): Promise { switch (hunk.type) { case "AddFile": @@ -161,7 +171,7 @@ export async function processHunk( } case "DeleteFile": { - const content = await readFile(hunk.path) + const content = await readFile(hunk.path, "record") return { type: "delete", path: hunk.path, @@ -170,7 +180,7 @@ export async function processHunk( } case "UpdateFile": { - const originalContent = await readFile(hunk.path) + const originalContent = await readFile(hunk.path, "publish") const newContent = applyChunksToContent(originalContent, hunk.path, hunk.chunks) return { type: "update", @@ -192,7 +202,7 @@ export async function processHunk( */ export async function processAllHunks( hunks: Hunk[], - readFile: (path: string) => Promise, + readFile: (path: string, intent: PatchReadIntent) => Promise, ): Promise { const changes: ApplyPatchFileChange[] = [] diff --git a/src/core/tools/apply-patch/index.ts b/src/core/tools/apply-patch/index.ts index fb69325104..1f7c8d8e01 100644 --- a/src/core/tools/apply-patch/index.ts +++ b/src/core/tools/apply-patch/index.ts @@ -11,4 +11,4 @@ export type { Hunk, UpdateFileChunk, ApplyPatchArgs } from "./parser" export { seekSequence } from "./seek-sequence" export { applyChunksToContent, processHunk, processAllHunks, ApplyPatchError } from "./apply" -export type { ApplyPatchFileChange } from "./apply" +export type { ApplyPatchFileChange, PatchReadIntent } from "./apply" diff --git a/src/core/tools/guardedWrite.ts b/src/core/tools/guardedWrite.ts new file mode 100644 index 0000000000..cdbb704359 --- /dev/null +++ b/src/core/tools/guardedWrite.ts @@ -0,0 +1,714 @@ +/** + * Guarded-write compare-and-swap core (upstream epic #1375, phase A4a). + * + * Wraps the S3 safeWriteText publish primitive behind version-token guards so + * that every write is deterministic: + * + * - an unobserved target may only be created when it is absent + * (createIfAbsent); + * - an observed target is published only when the on-disk version token still + * matches the current observation token (replaceIfVersion); + * - an edit-style write requires a prior observation (unobservedEditGuard). + * + * A per-absolute-path FIFO chain of tail promises orders concurrent + * in-process writes to the same path. Each publish refreshes the observation to + * the token it wrote when the new token can be computed, so same-task writes + * apply last-write-wins; when that refresh fails the observation keeps the + * previous observation token. A write that goes through replaceIfVersion fails + * stale when the on-disk token differs from the token the observation currently + * holds; an observed "create" whose target has disappeared instead uses + * createIfAbsent and can recreate it. Observations come from the task's S2 + * ObservationRegistry and authorize the write as well as the version check. + */ + +import * as fs from "fs/promises" +import * as path from "path" + +import { PostCommitDurabilityError, safeWriteText } from "../../services/file-safety/safeWriteText" +import { computeVersionToken } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import type { Task } from "../task/Task" + +// -- Types ------------------------------------------------------------------ + +/** Write kind that drives guard selection. */ +export type GuardedWriteKind = "create" | "update" | "edit" + +/** + * Call-site context the guard needs but cannot derive itself. + */ +export interface GuardedWriteOptions { + /** + * The target sits outside every workspace root and the tool layer obtained an + * approval DECISION for it (the user approved, or the auto-approval policy + * permitted it). Only a tool's post-approval path may set this: it is not the + * same as 'the path is outside the workspace', and an unapproved call keeps the + * containment checks in force. + */ + approvedOutsideWorkspace?: boolean + /** + * The canonical identity of the target, captured BEFORE the approval was asked, + * for an approved outside-workspace write. The guard binds the publish to it, so a + * name repointed between the approval and the publish resolves elsewhere and is + * refused. The guard cannot derive it itself: any lookup it performs happens after + * the approval, and would capture the swapped identity as the baseline. Required + * whenever approvedOutsideWorkspace is set. + */ + approvedCanonicalTarget?: string + /** + * Roots other than task.cwd that a write may be contained in - the other VS Code + * workspace folders. The tool layer classifies paths against ALL workspace folders + * (isPathOutsideWorkspace) and only asks for approval for paths outside every one + * of them, so a guard that knew only task.cwd would reject a write the tool layer + * had already treated as an ordinary in-workspace edit. Core stays host-agnostic: + * the extension-host caller supplies them. + */ + additionalRoots?: string[] +} + +/** Error thrown when a guard rejects a write. Exported so a caller can tell a guard verdict from an unrelated failure. + */ +export class GuardRejectedError extends Error { + constructor( + message: string, + readonly path: string, + ) { + super(message) + this.name = "GuardRejectedError" + } +} + +// -- Per-path tail-promise chain -------------------------------------------- + +/** + * Per-absolute-path FIFO chain of pending guarded writes (tail promise per + * path). Every write enqueues onto the current tail for its path, so + * concurrent writes to the same path run one at a time in submission order. + * + * The chain never leaks a rejection through itself: each link settles, a + * rejected link is skipped by the next writer (a failed write must not block + * later writes to the same path), and every caller receives its own link + * promise to handle. + * + * Settled entries are evicted (below), so a long-lived extension does not + * accumulate a map entry per distinct written path. + */ +const pendingChains = new Map>() + +/** + * Enqueue a write operation on the per-path FIFO chain. + * + * Returns the promise for this link; it always settles. A prior link that + * rejected is skipped, not propagated. The map entry for this link is + * deleted once it settles — but only while it is still the current tail for + * the path, so a replacement enqueued in the meantime keeps ownership. + */ +function enqueue(pathKey: string, fn: () => Promise): Promise { + const prev = pendingChains.get(pathKey) ?? Promise.resolve() + const next = prev.then(fn, fn) + pendingChains.set(pathKey, next) + void next.then( + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + () => { + if (pendingChains.get(pathKey) === next) { + pendingChains.delete(pathKey) + } + }, + ) + return next +} + +// -- Guard primitives -------------------------------------------------------- + +/** + * Extract a Node errno code (e.g. "ENOENT") from a thrown value, or + * undefined when the value carries none. + */ +export function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + +/** + * Cancellation re-check under the publish lock. A write queued on the FIFO + * chain can outlive its task: the caller already reported the write to the model, + * so a task aborted while its link waited must not publish afterwards. + */ +function cancelledBeforePublish(absolutePath: string, displayPath: string, isCancelled?: () => boolean): void { + if (isCancelled?.()) { + throw new GuardRejectedError( + "Task was cancelled before this write published -- nothing was written.", + displayPath, + ) + } +} + +/** True when the path is absent on disk (fs.access reports ENOENT). */ +async function fileIsAbsent(absolutePath: string): Promise { + try { + await fs.access(absolutePath) + return false + } catch (error: unknown) { + return errorCode(error) === "ENOENT" + } +} + +/** + * Publish content only if the target file does not exist. + * + * Rejects with a loud remediation error when the file already exists: the + * write was issued for a file that was never read, so the caller must read + * the file first, then retry. + */ +/** + * Read the on-disk token after a publish, best-effort: a publish that + * succeeded is not undone by a failed stat, so the caller keeps the publish + * and only skips the observation refresh. + */ +async function tokenAfterPublish(absolutePath: string): Promise { + return computeVersionToken(absolutePath).catch(() => undefined) +} + +export async function createIfAbsent( + absolutePath: string, + content: string | Uint8Array, + displayPath: string, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + isCancelled?: () => boolean, + // Re-checked under the lock, immediately before the publish: the path was authorized + // when it was queued, but a symlink can be swapped in while the link waited on the + // FIFO chain or on this lock. + verifyTarget?: () => Promise, +): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + // Bind the publish to the target the authorization check covers: the + // re-check runs first and returns the canonical path it authorized, and + // every step below acts on that fixed target. + const boundTarget = (await verifyTarget?.()) ?? absolutePath + try { + await fs.access(boundTarget) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await safeWriteText(boundTarget, content) + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(boundTarget) + } + + throw new GuardRejectedError( + "File already exists at " + + displayPath + + " and was not read before this write -- read the file first, then retry.", + displayPath, + ) + }) +} + +/** + * Publish content only if the current on-disk version token equals + * expectedVersion (the token observed at read time). + * + * On a match the content is published via the S3 safeWriteText primitive; on + * a mismatch the write is rejected stale with a re-read-then-retry + * remediation suffix. + * + * Atomicity boundary: the check and the publish run inside one acquisition of the + * shared advisory lock, and the post-publish token is read under that same lock, so + * no writer that participates in the protocol can interleave here. Every production + * caller of the publish primitive holds the same canonical key (safeWriteJson + * acquires it before publishing; TaskHistoryStore deletion takes it on the same + * resolved key). The primitive itself cannot re-acquire the lock -- withFileLock must + * not be re-entered inside an operation, and the callers already hold it, so wrapping + * it would deadlock them. A writer that never takes the lock is outside the supported + * threat model: a plain rename has no conditional form, so no advisory mechanism can + * bind a checked version to it. + */ +export async function replaceIfVersion( + absolutePath: string, + expectedVersion: string, + content: string | Uint8Array, + // Re-checked under the lock: a link that waited on the FIFO chain can outlive + // the task that queued it. + displayPath: string, + isCancelled?: () => boolean, + // Re-checked under the lock, immediately before the publish: the path was authorized + // when it was queued, but a symlink can be swapped in while the link waited on the + // FIFO chain or on this lock. + verifyTarget?: () => Promise, +): Promise { + // Lock the key every other writer to this file uses: the resolved publish + // target, so a symlink alias and its referent share one lock. + return withFileLock(await resolveLockKey(absolutePath), async () => { + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + // Bind first: the version comparison and the publish must act on the same + // target the authorization check covered. + const boundTarget = (await verifyTarget?.()) ?? absolutePath + let currentVersion: string + try { + currentVersion = await computeVersionToken(boundTarget) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + // The observed file was deleted after the read: the version recorded + // at read time no longer exists on disk. Normalize the raw ENOENT + // into the guard's re-read-then-retry contract so the caller gets a + // remediation it can act on, not a raw errno. + throw new GuardRejectedError( + "File was deleted after it was read -- the version recorded at read time (" + + expectedVersion + + ") no longer exists; re-read the file, then retry.", + displayPath, + ) + } + // A real I/O failure (EACCES, EIO, ...) -- not a guard verdict. + throw error + } + + // Re-checked after the awaited preflight: the task can be aborted while this + // operation waits, and a publish that starts after that is a write the caller + // has already reported as not performed. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + + if (currentVersion === expectedVersion) { + // Immediately before publication starts. + cancelledBeforePublish(absolutePath, displayPath, isCancelled) + await safeWriteText(boundTarget, content) + // Read the new token under the same lock, otherwise a peer lock-using + // writer can publish in the gap and the caller records that writer's + // token as its own observation. + return tokenAfterPublish(boundTarget) + } + + throw new GuardRejectedError( + "Stale version -- the file changed since you read it (expected " + + expectedVersion + + ", current " + + currentVersion + + "); re-read the file, then retry.", + displayPath, + ) + }) +} + +/** + * Unobserved-edit guard: an edit-style write without a prior observation is + * rejected before any I/O. The literal-match / patch logic stays with the + * tools in S4b; this guard only verifies that a read happened first. + * + * Returns Promise because the rejection is total: this function + * never resolves. + */ +export async function unobservedEditGuard(absolutePath: string, displayPath: string): Promise { + throw new GuardRejectedError("File not read yet -- read the file, then retry.", displayPath) +} + +// -- Public API -------------------------------------------------------------- + +/** + * Resolve a relative or absolute path against task.cwd. + * + * path.resolve also normalizes an already-absolute input (collapsing "." / ".." + * segments and trailing separators), so the key always matches the + * ObservationRegistry key recorded at read time (ReadFileTool observes under + * path.resolve(task.cwd, relPath)) and two spellings of one file share one + * FIFO chain. + */ +function resolveAbsolutePath(task: Task, relPathOrAbsolute: string): string { + return path.resolve(task.cwd, relPathOrAbsolute) +} + +/** + * Reject a target that is not inside the task's workspace, before anything is + * queued or touched. path.resolve keeps an absolute input unchanged and collapses + * ".." segments, so a model-supplied string forwarded verbatim can name any path + * on the machine. The rooignore rules, protected-path rules and the user-approval + * flow live in the tool layer; this is the containment line inside the publish + * helper itself, so a caller that forgets them cannot publish outside the + * workspace by accident. + */ +function isInside(root: string, target: string): boolean { + const relative = path.relative(root, target) + return !(relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative)) +} + +function assertInsideWorkspace(roots: string[], absolutePath: string, displayPath: string): void { + if (!roots.some((root) => isInside(root, absolutePath))) { + throw new GuardRejectedError( + `Path resolves outside the workspace -- write inside the workspace, then retry.`, + displayPath, + ) + } +} + +/** + * The lexical check above cannot see a symlink whose referent leaves the + * workspace, and the publish resolves through that link. Resolve both sides the + * same way and compare again. Only a missing TARGET is walked up to the nearest + * existing ancestor (a create has no target yet); a workspace that cannot be + * resolved - for any reason, ENOENT included - is rejected rather than falling back + * to the lexical decision, because the lexical decision is exactly what a symlink + * defeats. A root that is not on disk is not a container this write can be checked + * against, so the write is refused instead of published on a weaker guarantee. + */ +async function assertCanonicalInsideWorkspace( + roots: string[], + absolutePath: string, + displayPath: string, + // The caller may already hold the canonical target it wants to publish to; + // checking THAT value (rather than resolving the path a second time) keeps the + // containment decision and the bound publish target the same resolution. + preResolvedTarget?: string, +): Promise { + const resolvedRoots: string[] = [] + const unresolvedRoots: string[] = [] + for (const root of roots) { + try { + resolvedRoots.push(await fs.realpath(root)) + } catch { + // A root that cannot be canonicalized is dropped from the ALLOW-LIST rather than + // refusing every write in a multi-root workspace. Containment only gets narrower: + // a target is still accepted only if a root that DID resolve contains it, so the + // broken root never vouches for anything. A write that lives only under the broken + // root falls through to the refusal below. + unresolvedRoots.push(root) + } + } + if (resolvedRoots.length === 0) { + throw new GuardRejectedError( + "Workspace could not be resolved, so this write cannot be checked against it -- retry with a path inside the workspace.", + displayPath, + ) + } + const target = preResolvedTarget ?? (await realpathNearest(absolutePath, displayPath)) + if (!resolvedRoots.some((root) => isInside(root, target))) { + throw new GuardRejectedError( + unresolvedRoots.length > 0 + ? `Path is not inside any workspace folder that could be resolved -- retry with a path inside the workspace.` + : `Path resolves through a link to outside the workspace -- write a real file inside the workspace, then retry.`, + displayPath, + ) + } +} + +async function realpathNearest(target: string, displayPath: string): Promise { + const lexical = path.resolve(target) + try { + return await fs.realpath(lexical) + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") { + throw new GuardRejectedError( + "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", + displayPath, + ) + } + // The leaf itself can be a dangling link: realpath reports ENOENT for it + // exactly as for a not-yet-created file, but the publish resolves the name + // again and would then follow a referent this check never authorized. + const leaf = await fs.lstat(lexical, { bigint: true }).catch((lstatError: unknown) => { + if (errorCode(lstatError) === "ENOENT") { + return undefined + } + throw new GuardRejectedError( + "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", + displayPath, + ) + }) + if (leaf?.isSymbolicLink()) { + throw new GuardRejectedError( + `Path is a link that does not resolve -- retry with a path inside the workspace.`, + displayPath, + ) + } + const missing: string[] = [] + let ancestor = lexical + while (true) { + const parent = path.dirname(ancestor) + if (parent === ancestor) { + return lexical + } + missing.push(path.basename(ancestor)) + ancestor = parent + try { + const real = await fs.realpath(ancestor) + return path.join(real, ...missing.reverse()) + } catch (innerError: unknown) { + if (errorCode(innerError) !== "ENOENT") { + throw new GuardRejectedError( + "Path could not be resolved, so it cannot be checked against the workspace -- retry with a path inside the workspace.", + displayPath, + ) + } + // "Missing" has two meanings and only one of them is benign. A directory + // that has not been created yet is fine - the publish makes it. A component + // that EXISTS as a symlink whose referent is gone is a dangling link: the + // lexical name would be re-joined onto a container that points elsewhere + // (or nowhere), and the publish would then create the file outside the + // directory this check authorized. + const asLink = await fs.lstat(ancestor, { bigint: true }).catch(() => undefined) + if (asLink?.isSymbolicLink()) { + throw new GuardRejectedError( + `Path runs through a link (${ancestor}) that does not resolve -- retry with a path inside the workspace.`, + displayPath, + ) + } + } + } + } +} + +/** + * The canonical identity of a write target, for a caller that needs to bind an + * approval to it. Call this BEFORE asking the user: the guard compares its + * pre-publish resolution against this value, so a name repointed in between is + * refused. A target that does not exist yet is tolerated the way the guard tolerates + * it (a create makes the missing components); any other resolution failure throws a + * GuardRejectedError instead of falling back to the lexical path. + */ +export async function canonicalizeForApproval(target: string, displayPath?: string): Promise { + return realpathNearest(target, displayPath ?? target) +} + +/** + * Guarded write entry point. + * + * 1. Resolves the absolute path against task.cwd. + * 2. Consults the task's S2 observation registry to pick the guard: + * - unobserved + create/update: createIfAbsent (rejects if it exists); + * - observed + create on a file that vanished after the read: recreate; + * - observed otherwise: replaceIfVersion (CAS on the S1 version token); + * - unobserved + edit: unobservedEditGuard. + * 3. A full-file replacement ("update", or a "create" whose target still + * exists) additionally requires a complete observation: a partial read + * (slice, range, truncated, indentation block) authorizes targeted edits + * only, never a full-file overwrite of the existing content. + * 4. Runs the chosen guard on the per-path FIFO chain so concurrent writes to + * the same path are deterministically ordered. + * 5. After a successful publish, refreshes the observation with the new + * on-disk token (complete) so consecutive writes by the same task do not + * fail stale against the version they just published. + */ +export async function guardedWrite( + task: Task, + relPathOrAbsolute: string, + content: string | Uint8Array, + kind: GuardedWriteKind = "update", + // Optional completeness for the refresh. A tool that built its content from a + // view of another file must carry that view's completeness through the publish + // instead of claiming completeness for lines it never read. + completeOverride?: boolean, + options?: GuardedWriteOptions, +): Promise { + const absolutePath = resolveAbsolutePath(task, relPathOrAbsolute) + // Model-facing path: the caller's own spelling, not the resolved absolute + // path. The guard key stays absolute, but a rejection must not put a + // user-specific absolute path into the model's context. + const displayPath = relPathOrAbsolute + + const approvedOutside = options?.approvedOutsideWorkspace === true + // task.cwd is the task's own root; the other workspace folders come from the + // extension-host caller because the tool layer classifies against all of them. + const roots = [path.resolve(task.cwd), ...(options?.additionalRoots ?? []).map((root) => path.resolve(root))] + + // Containment is decided before the link is queued: a write that would land + // outside the workspace must not take a slot on the FIFO chain, take the file + // lock, or touch the filesystem at all. An approved outside-workspace write skips + // containment - the tool layer already put it in front of the user - but not the + // identity re-check below. + if (!approvedOutside) { + assertInsideWorkspace(roots, absolutePath, displayPath) + } + // Bound to the task and the caller's spelling so the guard can re-run the same + // decision under the lock, immediately before the publish. + let authorizedTarget: string | undefined + if (approvedOutside) { + // The identity has to come from the caller, captured before the approval was + // asked. A lookup performed here runs after the approval, so a name repointed in + // between would be captured as the baseline and the swapped write accepted. + if (options?.approvedCanonicalTarget === undefined) { + throw new GuardRejectedError( + "The approved target was not captured before approval, so this write cannot be bound to the path that was approved -- re-approve the write, then retry.", + displayPath, + ) + } + authorizedTarget = options.approvedCanonicalTarget + } + const verifyTarget = async (): Promise => { + // Resolve ONCE and return the canonical target: the containment decision + // below and the publish must act on the same resolution, so the guarded + // writers publish to THIS value instead of handing the caller's alias to + // safeWriteText (which would resolve it a third time - a link swapped in + // after the check would otherwise move the bytes to another file). + const resolved = await realpathNearest(absolutePath, displayPath) + if (!approvedOutside) { + await assertCanonicalInsideWorkspace(roots, absolutePath, displayPath, resolved) + return resolved + } + // For an approved outside-workspace write the re-check is an IDENTITY check: what + // resolves now must still be what was approved. A symlink swapped in - before this + // call or while the link waited on the chain - moves the write somewhere the user + // never saw, so the comparison is against the identity captured before the + // approval, never against a lookup done here. + if (authorizedTarget !== resolved) { + throw new GuardRejectedError( + "The approved target no longer resolves to the path that was approved -- re-approve the write, then retry.", + displayPath, + ) + } + return resolved + } + await verifyTarget() + + return enqueue(absolutePath, async () => { + // Cancellation is checked when the link is dequeued, not when it was enqueued: + // a write queued before an abort can still reach its turn on the chain after the + // task is gone, and the caller has already reported the write to the model. + if (task.abort) { + throw new GuardRejectedError( + "Task was cancelled before this write ran -- the queued publish is not performed.", + displayPath, + ) + } + const obs = task.observationRegistry.get(absolutePath) + // A targeted edit authorizes only the view the model saw, so a partial + // observation stays partial; a full-file publish is complete. + let staysPartial = false + // Token the guard read under the lock, so the refresh records the token + // this write published rather than a peer writer's. + let publishedToken: string | undefined + + // Set when the commit landed and only the durability step failed: the publish is + // kept, the observation is refreshed at the token the error carries, and the + // failure is reported after that refresh so the caller sees both facts. + let committedButNotDurable: PostCommitDurabilityError | undefined + try { + if (kind === "edit") { + // Edit-style writes require a prior read: no observation, no write. + // A targeted edit only authorizes the view the model saw, so a + // partial observation is valid for the edit itself; the version + // check still rejects a file that moved since the read. + if (obs === undefined) { + await unobservedEditGuard(absolutePath, displayPath) + } else { + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + staysPartial = obs.complete === false + } + } else { + // "create" or "update" publish a full file built on the model's + // content. A replacement of an existing target -- an "update", or a + // "create" whose target is still on disk -- therefore requires a + // complete observation: a slice, range, truncated, or + // indentation-block read only authorizes the view the model saw, and + // publishing over the existing file would silently drop everything + // the model never read, so the guard fails closed with a + // re-read-the-whole-file remediation. A fresh create (absent target) + // needs no prior read and stays allowed. + const absent = kind === "create" && (await fileIsAbsent(absolutePath)) + if (!absent && obs !== undefined && obs.complete === false) { + throw new GuardRejectedError( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + displayPath, + ) + } + + if (obs === undefined) { + // Never read: only an absent target may be created. + publishedToken = await createIfAbsent( + absolutePath, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + } else if (absent) { + // A "create" on a file that vanished after the read recreates it. + publishedToken = await createIfAbsent( + absolutePath, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + } else { + // The version recorded at read time must still match the on-disk + // token. + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + verifyTarget, + ) + } + } + } catch (error: unknown) { + // The rename committed; only the parent-directory fsync failed. The content on disk + // is the content this write published, so the token the error carries authorizes the + // same refresh any successful publish gets below, and the durability failure is + // rethrown after it. Withdrawing the observation here would leave the caller holding + // a token for bytes that are no longer on disk and refusing the next edit of a file + // this very write changed. Anything else - a guard refusal, a pre-commit I/O failure - + // unwinds untouched, because there is nothing committed to record. + if (error instanceof PostCommitDurabilityError && typeof error.committedVersion === "string") { + publishedToken = error.committedVersion + committedButNotDurable = error + // The branch that would have set this never ran: an edit built on a partial + // observation stays partial even when the publish is remembered. + staysPartial = kind === "edit" && obs?.complete === false + } else { + throw error + } + } + + // A publish changes the on-disk token (the rename changes ino, size, and + // mtime). The model just wrote the full content, so refresh the + // observation with the new token: a consecutive write by the same task + // must not fail stale against the version it just published. + // The guard already read the token under the lock; a failed stat after a + // successful publish only skips the refresh, it does not undo the publish. + if (publishedToken !== undefined) { + // Refresh with the new token, keeping the completeness the guard + // established: a partial observation that authorized a targeted edit + // must stay partial, otherwise a later full-file replacement would + // publish content built from the slice alone. + task.observationRegistry.observe(absolutePath, publishedToken, completeOverride ?? !staysPartial) + } + + // The refresh above is what keeps the committed content authorized; the durability + // caveat is not swallowed by it. + if (committedButNotDurable !== undefined) { + throw committedButNotDurable + } + }) +} + +/** + * Reset the per-path tail-promise chains (test hook). + * @internal Exported for focused implementation tests. + */ +export function resetChain(): void { + pendingChains.clear() +} diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index ae3d504003..f3714cd1f5 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -861,7 +861,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 96 + "count": 94 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index 3c1d33ae28..4d2cfd7a01 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -1,4 +1,8 @@ import * as vscode from "vscode" + +import { CommittedWriteBufferConflictError } from "./committedWriteBufferConflictError" + +export { CommittedWriteBufferConflictError } import * as path from "path" import * as fs from "fs/promises" import * as diff from "diff" @@ -18,6 +22,11 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import type { FileObservation } from "../../core/task/observationRegistry" +import { versionTokenOfStat } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import { errorCode, guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -39,6 +48,35 @@ export class DiffViewProvider { // it when re-showing the edited file afterward. private documentWasPinned = false private relPath?: string + private teardownInFlight: Promise | undefined + /** + * A cancellation (revertChanges) that arrived while another pass already owned this + * session. The owning pass reads it when its cleanup returns, so a save does not run + * diagnostics or read provider state that a cancellation is closing underneath it. + */ + private teardownCancellationRequested = false + /** + * Whether the teardown pass in flight closes the session itself. revertChanges() sets it + * inside its finalize step; every pass starts with it cleared. A caller that joined a pass + * that already reset must not reset again, while a pass that only closed views and restored + * tabs - a save's post-publish cleanup, a rejected save's discard cleanup - leaves the + * session open for the waiting caller to close. + */ + /** + * Whether this edit session has been closed (its reset has been claimed). A caller that + * waited for someone else's teardown cannot decide this by reading a flag set by the pass it + * joined, nor by reading provider state: the state is exactly what the finalization changes, + * so two callers that both waited on a pass which never resets would each see the session + * still open and each close it. The claim makes "exactly one of us finalizes" a fact. + */ + private sessionFinalizationClaimed = false + private finalizationInFlight: Promise | undefined + // Counts the teardown passes this provider has actually run; a caller that awaited an + // in-flight pass does not count. A save uses it to tell its own post-publish cleanup apart + // from a teardown a cancellation or disposal started underneath it: once any teardown has + // begun the session is being taken down, and a second pass over the same buffers and tabs is + // duplicate cleanup. + private teardownPasses = 0 private newContent?: string private activeDiffEditor?: vscode.TextEditor private fadedOverlayController?: DecorationController @@ -82,6 +120,41 @@ export class DiffViewProvider { viewColumn: vscode.ViewColumn }> = [] private taskRef: WeakRef + /** + * Version token of the empty placeholder open() wrote for a new file (the + * create branch), captured under the S2 stat-matched contract. A rejected + * guarded save removes the placeholder only while its on-disk token still + * equals this value, so a file created by someone else in the meantime is + * never unlinked. + */ + private placeholderVersion: string | undefined = undefined + /** + * The publish this session committed: the target and the exact text that landed. A + * cancellation that reaches revertChanges() after a commit must not resurrect + * originalContent through an unguarded document.save(), and must not unlink a file the + * save had already published: the commit is the point of no return, which is what + * CommittedWriteBufferConflictError already names for the no-diff path. Cleared by + * reset(), so a later session cannot inherit it. + */ + private committedPublish: { path: string; content: string } | undefined + /** + * The observation this path had BEFORE open() recorded the preview's own + * version token: null when there was none, undefined when this provider never + * opened a preview. open() observes the current on-disk version, so a file that + * changed between the tool's read and this preview leaves the preview token as + * the only entry - and a targeted save would then CAS against a version the + * caller never read, publishing stale content over the intervening change. + */ + private preOpenObservation: { version: string; complete: boolean } | null | undefined = undefined + // The entry object this session's preview wrote (path + the exact stored + // object), if any. reset() withdraws only that object: the preview is the + // tool's own look, not a read the model made, and on the exit paths that never + // reach saveChanges' restore (rejected update saves, cancelled tools, reverts) + // a leftover entry would authorize a later targeted edit for a file the model + // never read. Identity, not version/completeness: a model partial read while + // the diff is open replaces the entry with a fresh object at the same token + // and flag, and that read earned its own authorization. + private previewRecordedObservation: { path: string; entry: FileObservation } | undefined = undefined constructor( private cwd: string, @@ -95,6 +168,20 @@ export class DiffViewProvider { const fileExists = this.editType === "modify" const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // A new diff session may reuse this provider after a cancelled one, so the teardown marker + // starts clean: otherwise every later save would report itself cancelled without publishing. + this.teardownPasses = 0 + // A new session has to be finalizable: a provider that was reset once would otherwise + // report its finalization as already claimed. + this.sessionFinalizationClaimed = false + this.finalizationInFlight = undefined + + // Snapshot the authorization as it stands before this preview touches the + // registry; saveChanges(..., "edit") restores it below. + const priorObservation = this.taskRef.deref()?.observationRegistry.get(absolutePath) + this.preOpenObservation = priorObservation + ? { version: priorObservation.version, complete: priorObservation.complete } + : null // Capture the current scroll position before we close the tab so we can // restore it after saving/reverting. @@ -120,7 +207,35 @@ export class DiffViewProvider { this.preDiagnostics = vscode.languages.getDiagnostics() if (fileExists) { + // S4b follow-up (#44 / epic #1375): the preview is a full read of the + // on-disk original. Observe it with the S2 stat-matched contract so the + // accepted save (a full-file replacement) publishes through the guard's + // version check instead of bypassing it; a stat mismatch (or failure) + // leaves the target unobserved and the save fails closed. + const preStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) this.originalContent = await fs.readFile(absolutePath, "utf-8") + const postStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const displayTask = this.taskRef.deref() + // Only record the preview token when the task has no observation for + // this path: a read_file observation recorded the version the model's + // content was built on, and the accept-time guard must compare against + // THAT token. Replacing it with the current on-disk token would blind + // the save to changes that happened between the model's read and this + // preview (e.g. an external editor), letting a v1-based overwrite + // clobber the v2 change. An unread target has no observation, so the + // preview token is recorded (stat-matched) but never as a complete read: + // this is the tool's own preview, not a read the model made, so it must + // not authorize a later full-file replacement. + if (displayTask && preStats && postStats && !displayTask.observationRegistry.has(absolutePath)) { + const displayToken = versionTokenOfStat(preStats) + if (displayToken === versionTokenOfStat(postStats)) { + displayTask.observationRegistry.observe(absolutePath, displayToken, false) + const recorded = displayTask.observationRegistry.get(absolutePath) + if (recorded) { + this.previewRecordedObservation = { path: absolutePath, entry: recorded } + } + } + } } else { this.originalContent = "" } @@ -131,7 +246,63 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { - await fs.writeFile(absolutePath, "") + // Created exclusively. Absence was classified above, and a plain write would silently + // truncate whatever another process created in that window: the placeholder token + // recorded below would then describe the other writer's file, and the guard would + // accept a truncated baseline as the version this edit was built on. A lost race is + // reported, not absorbed. + // Opened exclusively, and held open until its identity has been taken: the version token below + // comes from this handle rather than from a stat of the path, so a rename landing after the + // create cannot make the token describe a different file. The token formula is unchanged + // (versionTokenOfStat over dev:ino:size:mtimeNs:ctimeNs) and the verification is the same S2 + // stat-matched contract as the modify branch above: a mismatched identity or a non-empty read + // means another writer touched the placeholder in the window after the create wrote it, and that + // writer's token must not be observed as complete - observing it would claim we saw content we + // never read, and the token-guarded cleanup would unlink their file - so nothing is recorded and + // the save fails closed instead. When the placeholder is verified empty, its token replaces any + // prior observation for the path: a prior observation describes a file that no longer exists, and + // keeping it would make the accept-time CAS (the placeholder token on disk against the vanished + // file's token) fail every time. The cleanup token is captured whether or not the task is still + // live: a rejected save must not leave the placeholder behind even when the owning task has been + // collected. + let placeholderHandle: Awaited> | undefined + try { + placeholderHandle = await fs.open(absolutePath, "wx") + const placeholderPreStats = await placeholderHandle.stat({ bigint: true }).catch(() => undefined) + if (placeholderPreStats) { + const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) + const placeholderPostStats = await placeholderHandle.stat({ bigint: true }).catch(() => undefined) + const placeholderToken = versionTokenOfStat(placeholderPreStats) + // Stat-matched (S2): the read is trusted only while the bracketing identity reads agree and + // the content is exactly the empty placeholder. + if ( + placeholderPostStats && + placeholderToken === versionTokenOfStat(placeholderPostStats) && + placeholderContent === "" + ) { + // Remember the placeholder token so a rejected save can remove the placeholder only + // while it is still the exact file the create opened. + this.placeholderVersion = placeholderToken + const displayTask = this.taskRef.deref() + if (displayTask) { + displayTask.observationRegistry.observe(absolutePath, placeholderToken, true) + } + } + } + } catch (error: unknown) { + if (errorCode(error) === "EEXIST") { + throw new Error( + `Cannot open ${absolutePath} for editing: the file already exists. ` + + "It was absent when this session classified it and another create won the race; " + + "read the file before editing it.", + ) + } + throw error + } finally { + if (placeholderHandle) { + await placeholderHandle.close().catch(() => {}) + } + } } // If the file was already open, close it (must happen after showing the @@ -323,9 +494,115 @@ export class DiffViewProvider { } } + /** + * The open buffer that must be reconciled after a guarded publish. + * + * A diff editor keeps a virtual document whose fsPath is the file it compares, and + * textDocuments can list that document before the real file buffer, so a first + * match can land on the virtual side. The dirty file buffer then keeps its stale + * bytes and its next save republishes them over the committed content - which is + * what this reconciliation exists to prevent - while the reconciliation itself + * reports success. Prefer the file document; keep the first path match as the + * fallback so a document the host reports under another scheme is reconciled + * rather than skipped. + */ + private findReconciliationDocument(absolutePath: string): vscode.TextDocument | undefined { + const matches = vscode.workspace.textDocuments.filter((document) => + arePathsEqual(document.uri.fsPath, absolutePath), + ) + return matches.find((document) => document.uri.scheme === "file") ?? matches[0] + } + + /** + * Revert one document through the workbench command. The command takes no + * resource argument: when the Open Editors view has focus with a selection it + * force-reverts every selected editor, otherwise the active editor. Activate + * the target first so only this document is reverted, then give the user + * their focus back. + */ + private async revertDocument(document: vscode.TextDocument): Promise { + const previous = vscode.window.activeTextEditor + try { + await vscode.window.showTextDocument(document, { preserveFocus: false, preview: false }) + await vscode.commands.executeCommand("workbench.action.files.revert") + } catch { + // best-effort: a document that cannot be reverted stays dirty + } + if (previous && previous.document !== document) { + // Give the user their focus back. + try { + await vscode.window.showTextDocument(previous.document, { preserveFocus: false, preview: false }) + } catch { + // best-effort: the focus cannot always be restored + } + } + // The caller must know whether the discard actually completed: a document + // that stays dirty can still be saved by VS Code's ordinary file service, + // which would recreate a placeholder the cleanup removed. + return !document.isDirty + } + + /** + * Adopt content that is already on disk as the result of this save. + * + * VS Code's autosave can write the modified side of a diff before the user + * accepts it, which moves the version token without changing the bytes, so + * the compare-and-swap rejects a guard that is already satisfied. The read is + * paired with the bigint stat that produced the token and re-checked after the + * read, so a write landing between the two cannot make an unrelated content + * match look like this publish. The observation keeps the completeness of the + * original read: a caller-side check must not upgrade a partial observation + * into authority for a full-file replacement. + * + * Returns true only when the on-disk bytes are exactly the content this save + * intended to publish. + */ + private async adoptAlreadyPublishedContent( + task: Task, + absolutePath: string, + encodedContent: Uint8Array, + ): Promise { + const before = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!before) { + return false + } + const disk = await fs.readFile(absolutePath).catch(() => undefined) + if (!disk) { + return false + } + const after = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (!after || versionTokenOfStat(before) !== versionTokenOfStat(after)) { + return false + } + if (Buffer.compare(Buffer.from(disk), Buffer.from(encodedContent)) !== 0) { + return false + } + const observation = task.observationRegistry.get(absolutePath) + task.observationRegistry.observe(absolutePath, versionTokenOfStat(after), observation?.complete ?? false) + return true + } + /** + * Roots the guard may contain a write in, beyond the task's own cwd: every other + * VS Code workspace folder. The write tools classify paths against ALL workspace + * folders (isPathOutsideWorkspace) and only ask for approval for paths outside every + * one of them, so a guard that knew only task.cwd would reject a write in a second + * workspace folder that the tool layer had treated as an ordinary in-workspace edit. + */ + private additionalWorkspaceRoots(): string[] { + return (vscode.workspace.workspaceFolders ?? []).map((folder) => folder.uri.fsPath) + } async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + writeKind: GuardedWriteKind = "update", + // The tool layer put this write in front of the user and the user approved it, + // for a target outside every workspace root. Only that post-approval path sets + // it; unapproved saves keep the guard's containment checks. + approvedOutsideWorkspace?: boolean, + // The canonical identity of the approved target, captured by the tool BEFORE it + // asked for approval. Threaded to guardedWrite, which refuses a publish whose name + // no longer resolves to it. + approvedCanonicalTarget?: string, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -339,33 +616,251 @@ export class DiffViewProvider { const updatedDocument = this.activeDiffEditor.document const editedContent = updatedDocument.getText() - if (updatedDocument.isDirty) { - await updatedDocument.save() + // S4b follow-up (#44 / epic #1375): the accepted diff is a full-file + // replacement, so publish it through the guarded-write API instead of + // saving the document raw. open() observed the on-disk version the + // preview was built on; replaceIfVersion rejects the save when the file + // changed after the preview or the target was never observed, with the + // standard re-read-then-retry remediation. + const saveTask = this.taskRef.deref() + let encodedContent: Uint8Array | undefined + try { + if (!saveTask) { + // Fail closed: without the owning task the observation registry is + // unreachable and the save cannot be guarded. The rejection flows + // through the same discard-only cleanup as a guard verdict, so a + // dead task cannot leave the empty placeholder behind either. + throw new Error("Cannot guard the write: the owning task is no longer available") + } + // Publish with the document's own encoding. VS Code's codec covers the + // legacy code pages Node cannot represent (a hand-rolled encoder would + // have to reject them), so encode here and let the guarded write publish + // the bytes unchanged. The write kind comes from the caller: a targeted + // edit after a partial read is authorized by the edit guard, while a + // full-file replacement still needs a complete observation. + encodedContent = await vscode.workspace.encode(editedContent, { + encoding: updatedDocument.encoding, + }) + // The preview must not authorize the save it made unverifiable. Restore + // the observation that existed before open() so the compare-and-swap runs + // against the version the caller's content was built on; when there was + // none, drop the preview's entry so the unobserved-edit guard rejects the + // write and the caller gets the re-read remediation instead. + if (writeKind === "edit" && this.preOpenObservation !== undefined) { + if (this.preOpenObservation) { + saveTask.observationRegistry.observe( + absolutePath, + this.preOpenObservation.version, + this.preOpenObservation.complete, + ) + } else { + saveTask.observationRegistry.forget(absolutePath) + } + } + await guardedWrite(saveTask, this.relPath, encodedContent, writeKind, undefined, { + approvedOutsideWorkspace: approvedOutsideWorkspace === true, + approvedCanonicalTarget, + additionalRoots: this.additionalWorkspaceRoots(), + }) + // From here the session holds a committed write. A cancellation arriving after this + // point reverts the session, not the publish - see revertChanges. + this.committedPublish = { path: absolutePath, content: editedContent } + } catch (error) { + // Autosave can publish the modified side of the diff before the user + // accepts, so the bytes on disk may already be exactly what this save + // intended. The compare-and-swap still rejects because the version token + // moved, and the cleanup below would report a failure for content that is + // already published. When a stat-matched read returns the same bytes the + // write already happened, so adopt that state instead of failing. + if ( + error instanceof GuardRejectedError && + saveTask && + encodedContent && + // An edit with no pre-open observation was rejected for AUTHORIZATION, not for a + // moved token. Adopting the match would report success and record a partial + // observation for a file the model never read, which would then authorize a + // later edit publish - exactly what the unobserved-edit guard exists to prevent. + !(writeKind === "edit" && this.preOpenObservation === null) && + // Only the autosave shape: a clean buffer means its content is what autosave + // already put on disk. A dirty buffer means the disk content came from + // someone else, so the discard cleanup below is still the right outcome. + !updatedDocument.isDirty && + (await this.adoptAlreadyPublishedContent(saveTask, absolutePath, encodedContent)) + ) { + // The publish is already satisfied; fall through to the normal + // post-save flow rather than reporting a rejection. + } else { + // Discard-only failure cleanup. The publish was rejected (stale + // version, unobserved target, a partial-read observation, or an + // unavailable task), so the on-disk content is the newer source of + // truth. Reload it + // into the buffer (discarding the rejected edit), remove the empty + // new-file placeholder while it is still exactly the file open() + // wrote, and close the diff views. Never use revertChanges() here: + // it restores originalContent and saves it, which would overwrite + // the newer disk content that caused the rejection. Best-effort — + // the guard verdict is rethrown below. + try { + // Dispose before any programmatic activation: showTextDocument can + // change the active editor even with preserveFocus, so a listener + // still attached here would record this cleanup as a user touch and + // let the auto-close preferences keep the transient tab open. + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + + await this.runTeardown( + async () => { + let discardSucceeded = !updatedDocument.isDirty + if (updatedDocument.isDirty) { + discardSucceeded = await this.revertDocument(updatedDocument) + } + if (discardSucceeded && this.editType === "create" && this.placeholderVersion) { + // Cleanup has to be serialized with the same resolved-path advisory lock + // every other writer to this file uses. A token check and an unlink in + // separate steps let a peer writer commit in the gap and lose its write. + // A differing token, or a lock that cannot be taken, leaves the file in place. + await withFileLock(await resolveLockKey(absolutePath), async () => { + const placeholderStats = await fs + .stat(absolutePath, { bigint: true }) + .catch(() => undefined) + if ( + !placeholderStats || + versionTokenOfStat(placeholderStats) !== this.placeholderVersion + ) { + return + } + let unlinked = false + try { + await fs.unlink(absolutePath) + unlinked = true + } catch { + // the placeholder vanished or the unlink failed + } + if (!unlinked) { + return + } + // The file is gone, so its tab must go too: closing only the diff + // views would leave a clean plain-text tab for a deleted file. + await this.closeFileTab(absolutePath) + // The directories open() created for the new file must go with it, + // innermost first. rmdir refuses a directory another writer populated + // in the meantime, so the cleanup stops at the first failure. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + try { + await fs.rmdir(this.createdDirs[i]) + } catch { + break + } + } + }) + } + await this.closeOwnDiffView(absolutePath) + }, + // The tail of the pass belongs to the same guard: a caller that joined this teardown + // must not repeat it, and a caller that lands while these steps are still running must + // not start a new pass. + async () => { + // Opening the diff evicted any preview tab the file had. This path closes its own diff + // view and rethrows, so this pass is the only one that can put that preview state back; + // a caller that merely waited for it must not restore the tabs a second time. reset() + // stays with the tool caller's error handling, which owns the provider lifecycle. + await this.restorePreviewTabs() + }, + ) + } catch { + // cleanup is best-effort; the guard verdict below is the outcome + } + throw error + } } + // The publish wrote the buffer's exact content to disk, but the + // document still carries its pre-save dirty flag and the close helpers + // skip dirty tabs. Revert from disk (content is identical — no write, + // no token change) to clear the dirty state before the close logic. + // document.save() would re-publish through the unguarded VS Code file + // service and advance the on-disk token, so the revert is the + // content-safe way to clear it. // Stop tracking touches and cancel any pending scroll-to-diff before any - // programmatic editor activation below. - this.disposeActiveEditorListener() - this.cancelDeferredScroll() + // programmatic editor activation: showTextDocument below can change the + // active editor even with preserveFocus, so a listener still attached would + // record this programmatic revert as a user touch and keep the transient + // tab open against the auto-close preference. + // A cancellation or disposal that reached teardown while the guarded publish was awaiting + // already owns this session: that teardown closed the diff views and applied the auto-close + // preferences. Running this save's own post-publish cleanup on top of it would tear the same + // session down twice, so the save reports that it did not complete a save flow of its own. + if (this.teardownPasses > 0) { + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } + } - await this.closeAllDiffViews() + // The post-publish cleanup is a teardown pass like any other, so it goes through the same + // serialization revertChanges() uses: a cancellation that lands while it runs waits for it + // instead of closing the same tabs underneath it. + await this.runTeardown(async () => { + this.disposeActiveEditorListener() + this.cancelDeferredScroll() + + // Revert only while the buffer is still exactly what the guard published. + // Keystrokes typed during the publish would be discarded by a revert, so a + // buffer that moved on stays dirty: the close helpers skip dirty tabs and + // the user's text survives in the editor. + if (updatedDocument.isDirty && updatedDocument.getText() === editedContent) { + const reconciled = await this.revertDocument(updatedDocument) + if (!reconciled) { + // The verdict is load-bearing here for the same reason it is in saveDirectly below: + // a buffer that stays dirty holds the bytes the guard just published, and an ordinary + // save - VS Code's own file service, or a later write - can put them back over a newer + // file. Close and reopen from the published bytes; if that still leaves the buffer + // dirty, report the committed-write conflict instead of proceeding into the save-over + // and returning ordinary success, so the caller cannot treat a committed publish as an + // edit it is free to re-apply. + await this.closeFileTab(absolutePath) + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) + const reopened = this.findReconciliationDocument(absolutePath) + if (reopened?.isDirty) { + throw new CommittedWriteBufferConflictError( + absolutePath, + "saveChanges: the diff-view save committed its content but could not clear the open buffer.", + ) + } + } + } - // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const saveTask = this.taskRef.deref() - const saveState = await saveTask?.providerRef.deref()?.getState() - - await this.keepOrCloseEditedFile( - absolutePath, - this.userTouchedDiffEditor, - saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - saveState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, - ) + // A save belongs to one task, so its post-publish cleanup closes only this provider's + // diff tab. closeAllDiffViews() would also close a concurrent task's clean diff view + // while that task still holds its activation listener and deferred scroll timer against + // a tab that is gone - the same reason the failure teardown above and reset() scope the + // close to this provider's own file. + await this.closeOwnDiffView(absolutePath) + + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset (saveTask was resolved above + // for the guarded publish). + const saveState = await saveTask?.providerRef.deref()?.getState() + + await this.keepOrCloseEditedFile( + absolutePath, + this.userTouchedDiffEditor, + saveState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + saveState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + saveState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + + // Restore any preview tabs the diff evicted, reconstructing the user's + // prior not-yet-edited tab state. + await this.restorePreviewTabs() + }) - // Restore any preview tabs the diff evicted, reconstructing the user's - // prior not-yet-edited tab state. - await this.restorePreviewTabs() + if (this.teardownCancellationRequested) { + // A cancellation or disposal reached revertChanges() while this save owned the post-publish + // pass. The publish is on disk, but the session is closing: the waiting caller finalizes + // it, and diagnostics or the EOL/patch tail below read provider state (newContent, + // relPath) that the finalization clears. Report no completed save instead. + this.teardownCancellationRequested = false + return { newProblemsMessage: undefined, userEdits: undefined, finalContent: undefined } + } // Getting diagnostics before and after the file edit is a better approach than // automatically tracking problems in real-time. This method ensures we only @@ -527,63 +1022,173 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - if (!fileExists) { - if (updatedDocument.isDirty) { - await updatedDocument.save() - } - - await this.closeAllDiffViews() - // The file was newly created for this edit; close its transiently - // opened tab before deleting it from disk. - await this.closeFileTab(absolutePath) - await fs.unlink(absolutePath) - - // Remove only the directories we created, in reverse order. - for (let i = this.createdDirs.length - 1; i >= 0; i--) { - await fs.rmdir(this.createdDirs[i]) - } - } else { - // Revert document. - const edit = new vscode.WorkspaceEdit() - - const fullRange = new vscode.Range( - updatedDocument.positionAt(0), - updatedDocument.positionAt(updatedDocument.getText().length), - ) - - edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + // A publish that already committed is not undone by a cancellation. The modify branch below + // would put originalContent back through an unguarded document.save() - the one writer left + // using the call the comment above saveChanges says every other path avoids - and the create + // branch would unlink a file the save had just published, which also discards whatever a + // third party built on it. Same name for the same state as the no-diff path uses: the commit + // is the point of no return, and the caller that asked for the undo is told so. + const committedAfterPublish = + this.committedPublish !== undefined && arePathsEqual(this.committedPublish.path, absolutePath) + const committedContent = this.committedPublish?.content + + // Set inside the pass when the committed buffer survives every attempt to clear it; read by the + // report after the pass. A local, because only this cancellation's teardown can establish it. + let committedBufferUncleared = false + const ownedTeardown = await this.runTeardown( + async () => { + if (committedAfterPublish) { + // Clear the dirty flag from the published bytes rather than writing over them, and only + // while the buffer still holds exactly what landed: text typed after the accept survives. + if (updatedDocument.isDirty && updatedDocument.getText() === committedContent) { + const reconciled = await this.revertDocument(updatedDocument) + if (!reconciled) { + // The verdict is load-bearing here for the same reason it is in the save teardown above + // (:811-:826): a buffer that stays dirty holds the bytes the guard published, and an + // ordinary save - VS Code's own file service, or a later write - can put them back over a + // newer file. Close and reopen from the published bytes, which is the same second chance + // that path gives, and remember whether it left the buffer dirty. + // + // The reason is recorded rather than thrown here: the tab work, the preview restore and + // the reset still have to run, and the report below is already placed after the pass for + // that reason. Throwing from inside the pass would leave a cancellation waiting on a + // teardown that had stopped half-way through the tabs. + // Fail-closed on the reopen itself. A second chance that could not run left the buffer + // uncleared just as surely as one that ran and came back dirty, and nothing here can + // prove otherwise, so the flag is set before the attempt rather than only after it. + committedBufferUncleared = true + try { + await this.closeFileTab(absolutePath) + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) + committedBufferUncleared = + this.findReconciliationDocument(absolutePath)?.isDirty === true + } catch { + // best-effort: the flag already says the only thing the failure proves, and the pass + // has to go on to the tab work, the preview restore and the reset. A teardown that + // stopped at its own reconciliation would abandon a session whose caller is waiting + // for it to finalize. + } + } + } + await this.closeOwnDiffView(absolutePath) + // The tab rules are the modify branch's, because the tab situation is the same: a + // committed file the user may or may not have touched. + const committedState = await this.taskRef.deref()?.providerRef.deref()?.getState() + await this.keepOrCloseEditedFile( + absolutePath, + false, + committedState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + committedState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + committedState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + return + } + if (!fileExists) { + if (updatedDocument.isDirty) { + await updatedDocument.save() + } - // Apply the edit and save, since contents shouldn't have changed - // this won't show in local history unless of course the user made - // changes and saved during the edit. - await vscode.workspace.applyEdit(edit) - await updatedDocument.save() + // A rejection belongs to one task, so this teardown closes only this provider's + // diff tab rather than every clean diff view in the workbench. + await this.closeOwnDiffView(absolutePath) + // The file was newly created for this edit; close its transiently + // opened tab before deleting it from disk. + await this.closeFileTab(absolutePath) + await fs.unlink(absolutePath) + + // Remove only the directories we created, in reverse order. + for (let i = this.createdDirs.length - 1; i >= 0; i--) { + await fs.rmdir(this.createdDirs[i]) + } + } else { + // Revert document. + const edit = new vscode.WorkspaceEdit() - await this.closeAllDiffViews() + const fullRange = new vscode.Range( + updatedDocument.positionAt(0), + updatedDocument.positionAt(updatedDocument.getText().length), + ) - // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const revertTask = this.taskRef.deref() - const revertState = await revertTask?.providerRef.deref()?.getState() + edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + + // Apply the edit and save, since contents shouldn't have changed + // this won't show in local history unless of course the user made + // changes and saved during the edit. + await vscode.workspace.applyEdit(edit) + await updatedDocument.save() + + // Same scoping as the create branch above: the revert is this task's, so only this + // provider's diff tab goes. + await this.closeOwnDiffView(absolutePath) + + // Read auto-close preferences from state; fall back to defaults that + // preserve the existing behavior when unset. + const revertTask = this.taskRef.deref() + const revertState = await revertTask?.providerRef.deref()?.getState() + + await this.keepOrCloseEditedFile( + absolutePath, + false, + revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, + revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? + DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, + revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + ) + } + }, + // The tail of the pass belongs to the same guard: a caller that joined this teardown + // must not repeat it, and a caller that lands while these steps are still running must + // not start a new pass. + async () => { + // Another teardown started first and already ran the pass over this session's buffers, + // tabs and preview state. Restoring preview tabs or resetting here again would be that + // same work a second time, and the reset would clear state whose closing this provider + // does not own. The pass that started the teardown finalizes the session. + // Restore any preview tabs the diff evicted, reconstructing the user's + // prior not-yet-edited tab state. + // Edit is done. + // The claim, not a flag read back afterwards, is what keeps a caller that joined this + // pass from closing the session a second time. + await this.restorePreviewTabs() + await this.finalizeSession(() => this.reset()) + }, + ) - await this.keepOrCloseEditedFile( + if (!ownedTeardown) { + // The pass that started the teardown owns the tab work, so this caller must not repeat + // it. A pass that carries its own finalization (revertChanges passes do) has already + // claimed the close. A pass without one - a save's post-publish cleanup, or a rejected + // save's discard cleanup - leaves the session open, and the tool caller whose error + // handling owns that reset may never come back after a cancellation or disposal. Whoever + // gets here first claims it; a second waiter joins the claim instead of resetting again. + await this.finalizeSession(() => this.reset()) + } + if (committedAfterPublish) { + // Reported after the pass so the tab work, the preview restore and the reset all still + // happen: a cancellation that lands on a committed publish closes the session, it just + // cannot roll the content back. Both the owner and any caller that joined the pass see the + // same signal, because both were waiting on this outcome. + throw new CommittedWriteBufferConflictError( absolutePath, - false, - revertState?.autoCloseZooOpenedFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES, - revertState?.autoCloseZooOpenedFilesAfterUserEdited ?? - DEFAULT_AUTO_CLOSE_ZOO_OPENED_FILES_AFTER_USER_EDITED, - revertState?.autoCloseZooOpenedNewFiles ?? DEFAULT_AUTO_CLOSE_ZOO_OPENED_NEW_FILES, + // The detail says which of the two things happened: the cancellation arriving late, or the + // buffer surviving the attempt to clear it. They are different states and the caller's next + // step differs - one is settled content the caller must not reapply, the other is settled + // content with a live buffer that can still be saved over it. + committedBufferUncleared + ? "revertChanges: the publish committed and the open buffer could not be cleared from its content." + : "revertChanges: a cancellation arrived after the guarded publish had committed.", ) } - - // Restore any preview tabs the diff evicted, reconstructing the user's - // prior not-yet-edited tab state. - await this.restorePreviewTabs() - - // Edit is done. - await this.reset() } + /** + * Close every clean Zoo diff tab in the workbench. After the per-task teardowns were + * scoped, only the fallback in reset() still calls this, for the case where the provider + * no longer knows which file it was editing; every teardown that has a path in scope + * closes just its own view, so one task's rejected or saved edit cannot disturb another + * task's open diff view. + */ private async closeAllDiffViews(): Promise { const closeOps = vscode.window.tabGroups.all .flatMap((group) => group.tabs) @@ -618,6 +1223,152 @@ export class DiffViewProvider { await Promise.all(closeOps) } + /** + * Close only this provider's diff tab. closeAllDiffViews() closes every clean + * diff tab in the workbench, so a rejected save would also close another task's + * diff view while that task's provider still holds its activation listener and + * deferred scroll timer against a tab that is gone. A rejection belongs to one + * task, so the cleanup must stay inside that task's view. + */ + private async closeOwnDiffView(absolutePath: string): Promise { + const target = path.resolve(absolutePath) + const tabs = vscode.window.tabGroups.all + .flatMap((group) => group.tabs) + .filter((tab) => { + if (tab.isDirty) { + return false + } + if (tab.input instanceof vscode.TabInputTextDiff) { + // Only Zoo's own diff tabs, not a Source Control diff the user has open + // for the same file. + return ( + // The comparison is the shared one, not a spelling compare: openDiffEditor() reuses a + // tab through arePathsEqual, so a tab it handed this provider can come back under the + // other casing on Windows and still have to be found here. + tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && + arePathsEqual(tab.input.modified.fsPath, target) + ) + } + // A diff tab for a file that was already open is identified by its label + // rather than by the URI scheme. A basename alone cannot tell two tasks in + // different directories apart, so the label only counts when the tab's own + // URI points at this provider's target. + const uri = (tab.input as { uri?: { fsPath?: string } })?.uri + return ( + // The tab's own URI decides, through the same comparison the opener used to pick this + // tab up: arePathsEqual normalizes and, on Windows, folds case - which is how a tab + // ends up carrying a different spelling of the file the provider is tracking. The + // closer gets no independent case rule: it may not be more eager than the opener. + typeof uri?.fsPath === "string" && + arePathsEqual(uri.fsPath, target) && + typeof tab.label === "string" && + tab.label.startsWith(`${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES}`) + ) + }) + for (const tab of tabs) { + try { + await vscode.window.tabGroups.close(tab) + } catch { + // best-effort: the tab stays open + } + } + } + /** + * Only one teardown path may act on a document at a time. A cancellation can reach + * revertChanges() while a rejected save is already discarding the same buffer, and + * both would edit the document and close the same tabs. The second caller awaits the + * cleanup already in flight instead of repeating it. + */ + private async runTeardown( + cleanup: () => Promise, + // Steps that belong to the same teardown pass but run after the cleanup callback: the + // preview-tab restore and reset(). They have to sit inside the tracked promise, or a + // cancellation landing after the callback settled and before these finished would see + // no teardown in flight and run the whole pass - document revert, tab closing, restore, + // reset - a second time. + finalize?: () => Promise, + ): Promise { + if (this.teardownInFlight !== undefined) { + // Record the cancellation before waiting: the pass that owns the session has to know a + // cancellation is waiting on it, and the waiting caller must not leave the session + // mid-edit if that pass turns out not to finalize it. + this.teardownCancellationRequested = true + await this.teardownInFlight + // A rejection here is the owning pass telling this caller its cleanup failed: the + // content it was waiting behind may still be dirty and its tab still open, so this + // caller must not claim the close as though the session had been tidied. + // Not ours: the caller that started this teardown owns everything that + // belongs to it, including the steps that follow the callback. + return false + } + // A gate, not the cleanup promise itself: the tracked promise stays in place until + // the finalization settles, so a late caller waits for the whole pass. + // A pass starts clean: a cancellation recorded for an earlier pass must not leak into + // this one. + this.teardownCancellationRequested = false + // The gate carries the pass's outcome, not only its completion. A caller that joined a + // pass whose cleanup threw has to learn that the buffer may still be dirty and the tab + // still open, instead of resuming as though the session had been tidied and claiming the + // close of a session it never tidied. + let release!: () => void + let failGate!: (error: unknown) => void + const tracked = new Promise((resolve, reject) => { + release = resolve + failGate = reject + }) + // The pass that owns the gate never awaits it, so a failed pass with no waiter would + // leave this rejection unhandled. Attaching a handler here does not change what a + // waiter sees: they await `tracked` itself, and it still rejects for them. + tracked.catch(() => {}) + this.teardownInFlight = tracked + this.teardownPasses++ + let failure: unknown + let cleanedUp = false + try { + await cleanup() + if (finalize) { + await finalize() + } + cleanedUp = true + } catch (error) { + failure = error + throw error + } finally { + this.teardownInFlight = undefined + if (cleanedUp) { + release() + } else { + failGate(failure) + } + } + return true + } + + /** + * Run the session's finalization exactly once. The first caller claims it and runs it; + * anyone who arrives while it is running awaits that attempt rather than starting their own, + * and anyone who arrives after it is done does nothing. A pass that owns its own teardown and + * a caller that merely waited for one go through here, which is what keeps "only one caller + * owns reset()" true regardless of how many cancellations piled onto the same pass. + */ + private async finalizeSession(finalize: () => Promise): Promise { + if (this.sessionFinalizationClaimed) { + return + } + if (this.finalizationInFlight) { + await this.finalizationInFlight + return + } + const inFlight = finalize() + this.finalizationInFlight = inFlight + try { + await inFlight + this.sessionFinalizationClaimed = true + } finally { + this.finalizationInFlight = undefined + } + } + // Stop tracking user activation of the target file. Called before any // programmatic showTextDocument so our own re-show never counts as a "touch". private disposeActiveEditorListener(): void { @@ -1101,6 +1852,27 @@ export class DiffViewProvider { } async reset(): Promise { + // A reset closes the session whoever calls it through, so the finalization is claimed + // here as well: a teardown waiter that arrives afterwards must not run a second one. + this.sessionFinalizationClaimed = true + + // Withdraw a preview observation that no save path restored. Every caller + // reaches reset() - tool catch blocks call it, and revertChanges arrives + // through finalizeSession - so this single hook covers the exits that never + // touch saveChanges' restore. The entry is dropped only while it still + // holds the exact entry object it wrote: any later observe() - a successful + // publish, a model read (even a partial one at the same token and flag) - + // replaces the entry with a fresh object, so earned observations survive. + const previewRecorded = this.previewRecordedObservation + this.previewRecordedObservation = undefined + if (previewRecorded) { + const previewTask = this.taskRef.deref() + // Ownership by object identity: the stored object still being the one + // this preview wrote means no read has claimed the entry since. + if (previewTask && previewTask.observationRegistry.get(previewRecorded.path) === previewRecorded.entry) { + previewTask.observationRegistry.forget(previewRecorded.path) + } + } // Dispose touch listeners and cancel any pending deferred scroll BEFORE any // async editor manipulation. closeAllDiffViews() awaits tab-close operations, // so leaving listeners/timers live across that await could let a stale handler @@ -1109,7 +1881,15 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - await this.closeAllDiffViews() + // A reset belongs to one task. Closing every clean diff tab would close another + // task's view while that task's provider still holds its activation listener and + // deferred scroll timer against a tab that is gone, so the cleanup stays inside + // this provider's view whenever it knows which file it was editing. + if (this.relPath) { + await this.closeOwnDiffView(path.resolve(this.cwd, this.relPath)) + } else { + await this.closeAllDiffViews() + } this.editType = undefined this.isEditing = false this.originalContent = undefined @@ -1128,6 +1908,9 @@ export class DiffViewProvider { this.userTouchedDocument = false this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] + this.placeholderVersion = undefined + this.preOpenObservation = undefined + this.committedPublish = undefined } /** @@ -1137,6 +1920,10 @@ export class DiffViewProvider { * @param relPath - Relative path to the file * @param content - Content to write to the file * @param openFile - Whether to show the file in editor (false = open in memory only for diagnostics) + * @param writeKind - Guarded-write kind that selects the S4a guard for this publish. + * Defaults to "create" because this method always publishes a complete file + * content: an unobserved target may only be created when absent, and an + * observed target must still carry the current observation token. * @returns Result of the save operation including any new problems detected */ async saveDirectly( @@ -1145,6 +1932,16 @@ export class DiffViewProvider { openFile: boolean = true, diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + writeKind: GuardedWriteKind = "create", + // Completeness the caller earned elsewhere; a move carries the source's + // view through the publish instead of claiming completeness for lines it never read. + completeOverride?: boolean, + // Approved by the user for a target outside every workspace root (see saveChanges). + approvedOutsideWorkspace?: boolean, + // The canonical identity of the approved target, captured by the tool BEFORE it + // asked for approval. Threaded to guardedWrite, which refuses a publish whose name + // no longer resolves to it. + approvedCanonicalTarget?: string, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1155,9 +1952,80 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() - // Write the content directly to the file - await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + // Publish through the S4 guarded-write API (epic #1375): an unobserved + // write to an existing file and a stale observed version are rejected + // with a re-read-then-retry remediation instead of overwriting the file. + // Concurrent in-process writes to the same path are already ordered by + // the guard's per-path FIFO chain, so no additional locking is added here. + const task = this.taskRef.deref() + if (!task) { + // Fail closed: without the owning task the observation registry is + // unreachable and the write cannot be guarded. + throw new Error("Cannot guard the write: the owning task is no longer available") + } + // No pre-guard mkdir: safeWriteText creates missing parent directories at publish + // time, so a rejected guard leaves NO directories behind - including outside the + // workspace, where a rejected write must not leave a trace. + await guardedWrite(task, relPath, content, writeKind, completeOverride, { + approvedOutsideWorkspace: approvedOutsideWorkspace === true, + approvedCanonicalTarget, + additionalRoots: this.additionalWorkspaceRoots(), + }) + // The publish committed: a later revertChanges on this session reports the committed-write + // conflict instead of rolling the content back. + this.committedPublish = { path: absolutePath, content } + + // The guarded publish wrote the bytes to disk directly. If the target + // document is already open, its buffer still holds the pre-publish text: + // a dirty buffer would republish those stale bytes over what the guard + // committed through VS Code's unguarded save path, and a clean one only + // catches up when the file watcher fires. Reconcile it before returning - + // reverting reloads the published bytes and clears the dirty flag - so no + // open buffer is left whose text differs from the publish. Reverting a + // document that already matches the disk is a no-op. + const openTarget = this.findReconciliationDocument(absolutePath) + if (openTarget !== undefined) { + // A dirty buffer holds edits the user has not saved. The guarded publish wrote the + // accepted content to disk, so reverting would discard those edits. Only a clean + // buffer (or one whose text already equals the published content) may be reloaded; + // a buffer with different unsaved edits is retained and the conflict is reported. + if (openTarget.isDirty && openTarget.getText() !== content) { + // The publish already committed: report the committed-write buffer conflict. + // A generic error would tell the model to retry a non-idempotent edit + // (SearchReplaceTool/EditTool would re-read and duplicate the change). + throw new CommittedWriteBufferConflictError( + absolutePath, + "saveDirectly: the buffer held unsaved edits that differ from the content the guard published.", + ) + } + const reconciled = await this.revertDocument(openTarget).catch(() => false) + if (!reconciled) { + // The revert failed or the buffer stayed dirty: its stale bytes could + // still be republished by VS Code's ordinary save path over what the + // guard committed. Close the tab and reopen the document from disk so + // no dirty buffer survives; if the reopened buffer is still dirty, + // fail the operation rather than reporting success over it - the + // diagnostics/open path must not run while the target is dirty. + try { + await this.closeFileTab(absolutePath) + } catch { + // best-effort close: the reopened-document check below decides + } + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) + const reopened = this.findReconciliationDocument(absolutePath) + if (reopened?.isDirty) { + // Same error the branch above uses: the publish committed, so what the caller must not + // do is re-apply the edit. The stale-bytes hazard is named in the detail line, and the + // no-retry guidance comes from the shared error. + throw new CommittedWriteBufferConflictError( + absolutePath, + `saveDirectly: the open document at ${absolutePath} is still dirty after the guarded publish -- ` + + "reverting and reopening it did not reconcile the buffer, so its stale bytes could overwrite " + + "the published content on the next save.", + ) + } + } + } // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory @@ -1168,13 +2036,11 @@ export class DiffViewProvider { preserveFocus: true, }) } else { - // Just open the document in memory to trigger diagnostics without showing it - const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) - - // Save the document to ensure VSCode recognizes it as saved and triggers diagnostics - if (doc.isDirty) { - await doc.save() - } + // Just open the document in memory to trigger diagnostics without showing it. + // Do not save here: the guarded publish already committed the accepted content, + // and saving a dirty buffer would republish its stale bytes through VS Code's + // unguarded save path, over what the guard wrote. + await vscode.workspace.openTextDocument(vscode.Uri.file(absolutePath)) // Force a small delay to ensure diagnostics are triggered await new Promise((resolve) => setTimeout(resolve, 100)) diff --git a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts index e94a66e1db..5eb648683d 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -1,13 +1,29 @@ -import { DiffViewProvider, DIFF_VIEW_URI_SCHEME, DIFF_VIEW_LABEL_CHANGES } from "../DiffViewProvider" +import { + CommittedWriteBufferConflictError, + DiffViewProvider, + DIFF_VIEW_URI_SCHEME, + DIFF_VIEW_LABEL_CHANGES, +} from "../DiffViewProvider" import { DecorationController } from "../DecorationController" -import type { Task } from "../../../core/task/Task" import type { Mock } from "vitest" import * as vscode from "vscode" +import * as path from "path" import delay from "delay" import { DEFAULT_WRITE_DELAY_MS } from "@roo-code/types" import { makePosition, makeRange, makeTextDocument, makeTextEditor, makeUri } from "../../../test-utils/vscode" +import * as fs from "fs/promises" + +import { computeVersionToken, versionTokenOfStat } from "../../../utils/versionToken" +import type { BigIntStats } from "fs" +import { safeWriteText } from "../../../services/file-safety/safeWriteText" +import { withFileLock } from "../../../utils/fileLock" +import { createDirectoriesForFile } from "../../../utils/fs" +import { ObservationRegistry } from "../../../core/task/observationRegistry" +import { arePathsEqual } from "../../../utils/path" +import type { Task } from "../../../core/task/Task" + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -18,23 +34,95 @@ vi.mock("fs/promises", () => ({ readFile: vi.fn().mockResolvedValue("file content"), writeFile: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + // The S4b follow-up (#44) preview observation stats the target before and + // after reading it; undefined stats leave the target unobserved (fail closed). + stat: vi.fn().mockResolvedValue(undefined), + mkdir: vi.fn().mockResolvedValue(undefined), + rename: vi.fn().mockResolvedValue(undefined), + unlink: vi.fn().mockResolvedValue(undefined), + rmdir: vi.fn().mockResolvedValue(undefined), + // guardedWrite resolves the workspace root (and the nearest existing ancestor of the + // target) before publishing. The double returns the path unchanged so the containment + // check sees the same spelling the rest of the test uses. + open: vi.fn(async (targetPath: string) => { + const mocked = await import("fs/promises") + return { + stat: (options?: Parameters[1]) => mocked.stat(targetPath, options), + close: () => Promise.resolve(), + } + }), + realpath: vi.fn(async (p: string) => p), })) +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", async (importOriginal) => { + // guardedWrite matches the durability branch by name, so the class has to come from + // the real module: a stand-in would let rows pass against a shape production does not + // build. The publish primitive stays a stub. + const actual = await importOriginal() + return { + ...actual, + safeWriteText: vi.fn(), + resolveLockKey: vi.fn(async (p: string) => p), + } +}) + +// Mock the S1 version token (used by the S4 guarded write); the real +// computeVersionToken needs fs.stat, which is not part of the fs/promises mock above. +// Keep the real versionTokenOfStat: DiffViewProvider.open() (S4b follow-up #44) +// derives the preview token from its synthetic stat mock with that pure function. +vi.mock("../../../utils/versionToken", async () => { + const actual = await vi.importActual("../../../utils/versionToken") + return { + computeVersionToken: vi.fn(), + versionTokenOfStat: actual.versionTokenOfStat, + } +}) + // Mock utils +// Mock the shared advisory lock that the guarded-write path uses; the real +// proper-lockfile would try to create a lock directory on the mocked fs. +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(async (filePath: string, operation: (p: string) => Promise) => operation(filePath)), +})) + vi.mock("../../../utils/fs", () => ({ createDirectoriesForFile: vi.fn().mockResolvedValue([]), })) // Mock path vi.mock("path", () => ({ - resolve: vi.fn((cwd, relPath) => `${cwd}/${relPath}`), + resolve: vi.fn((cwd: string, relPath?: string) => (relPath === undefined ? cwd : `${cwd}/${relPath}`)), + isAbsolute: vi.fn((p: string) => p.startsWith("/")), basename: vi.fn((path) => path.split("/").pop()), + dirname: vi.fn((path) => path.split("/").slice(0, -1).join("/") || "/"), + join: (...args: string[]) => args.join("/"), + sep: "/", + // guardedWrite's workspace containment compares paths lexically, so the double needs + // a POSIX relative() that matches the resolve()/join() doubles above. + relative: (from: string, to: string) => { + const f = from.split("/").filter(Boolean) + const t = to.split("/").filter(Boolean) + let i = 0 + while (i < f.length && i < t.length && f[i] === t[i]) i++ + return [...Array.from({ length: f.length - i }, () => ".."), ...t.slice(i)].join("/") + }, })) // Mock vscode vi.mock("vscode", () => ({ workspace: { applyEdit: vi.fn(), + // VS Code's own codec. The double returns bytes that differ from the plain + // UTF-8 encoding of the same text, so an assertion can prove the publish + // writes the codec's output rather than re-encoding the string itself. + encode: vi.fn((content: string, options: { encoding: string }) => + Promise.resolve( + options.encoding === "utf8bom" + ? Buffer.concat([Buffer.from("\uFEFF"), Buffer.from(content)]) + : Buffer.from(content), + ), + ), onDidOpenTextDocument: vi.fn(() => ({ dispose: vi.fn() })), openTextDocument: vi.fn().mockResolvedValue({ isDirty: false, @@ -47,6 +135,7 @@ vi.mock("vscode", () => ({ }, window: { createTextEditorDecorationType: vi.fn(), + activeTextEditor: undefined as unknown, showTextDocument: vi.fn(), onDidChangeVisibleTextEditors: vi.fn(() => ({ dispose: vi.fn() })), onDidChangeActiveTextEditor: vi.fn(() => ({ dispose: vi.fn() })), @@ -199,14 +288,20 @@ describe("DiffViewProvider", () => { }) // Create a mock Task instance; the double provides providerRef.deref() -> - // getState() with provider settings (see makeTaskDouble below). - mockTask = makeTaskDouble({ - // Auto-closing edited tabs is opt-in by default; the legacy - // "close/keep behavior" suite below asserts the close path, so - // enable it here. The opt-in default itself is covered by the - // dedicated "auto-close settings decision table" suite. - autoCloseZooOpenedFiles: true, - }) + // getState() with provider settings (see makeTaskDouble below). The guarded + // write (S4b) consults the task's S2 observation registry, so the double + // carries a real (in-memory) registry instance. + mockTask = { + ...makeTaskDouble({ + // Auto-closing edited tabs is opt-in by default; the legacy + // "close/keep behavior" suite below asserts the close path, so + // enable it here. The opt-in default itself is covered by the + // dedicated "auto-close settings decision table" suite. + autoCloseZooOpenedFiles: true, + }), + cwd: mockCwd, + observationRegistry: new ObservationRegistry(), + } as unknown as Task // the double is partial; cast matches makeTaskDouble's own diffViewProvider = new DiffViewProvider(mockCwd, mockTask) // Mock the necessary properties and methods @@ -225,6 +320,15 @@ describe("DiffViewProvider", () => { addLines: vi.fn(), clear: vi.fn(), } as unknown as DecorationController + + // S4b follow-up (#44): saveChanges publishes the accepted diff through the + // guarded write, which requires the target to be observed at the previewed + // on-disk version. Seed the preconditions for the relPaths the suites below + // use; the guarded-write suites override or clear as needed. + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + mockTask.observationRegistry.observe(`${mockCwd}/mock-target-file.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") }) describe("update method", () => { @@ -709,6 +813,47 @@ describe("DiffViewProvider", () => { expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.pinEditor") }) }) + // Installs one of this provider's Zoo diff tabs in the mocked workbench and spies the + // real tab-close call, optionally holding that close open on a gate. Tests use it to + // wait for, and count, a teardown pass by the effect that matters - the tab actually + // going away - rather than by the name of the private helper that closes it. + const installDiffTabSpy = (absolutePath: string, hold?: Promise) => { + const tab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: absolutePath }, + }, + label: absolutePath + ": changes (Editable)", + isDirty: false, + } + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + // A second, foreign diff view sits in the same workbench so a teardown that sweeps every + // clean diff view is visible to the caller rather than invisible. + const otherTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: absolutePath.replace(/([^/]+)$/, "other-task.ts") }, + }, + label: "other-task.ts: changes (Editable)", + isDirty: false, + } + Object.setPrototypeOf(otherTab.input, vscode.TabInputTextDiff.prototype) + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [tab, otherTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation(async (closed) => { + closedTabs.push(closed) + if (hold) { + await hold + } + return true + }) + return { tab, otherTab, closedTabs } + } describe("closeAllDiffViews method", () => { it("should close diff views including those identified by label", async () => { @@ -769,11 +914,365 @@ describe("DiffViewProvider", () => { }) }) + // A rejected save belongs to one task. closeAllDiffViews() closes every clean + // diff tab in the workbench, so it would close another task's diff view while + // that task's provider still holds its activation listener and deferred scroll + // timer against a tab that is gone. + describe("closeOwnDiffView method", () => { + it("closes only this provider's tab and leaves another task's diff view open", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) + + it("leaves another task's tab when the same basename sits in another directory", async () => { + // Two tasks can edit files with the same name. Matching by basename alone + // would close the other task's clean tab, so the tab's own URI has to decide. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const sameNameOtherDir = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: "/other-cwd/test.ts" }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + // A pre-opened file's tab is identified by its label, so the URI check is the + // only thing that can tell the two apart here. + const labelOnlyOtherDir = { + input: { uri: { fsPath: "/other-cwd/test.ts" } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const labelOnlyOwn = { + input: { uri: { fsPath: `${mockCwd}/test.ts` } }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, sameNameOtherDir]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, sameNameOtherDir, labelOnlyOtherDir, labelOnlyOwn] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab, labelOnlyOwn]) + }) + + it("leaves a Source Control diff the user has open for the same file", async () => { + // A git diff of the same file is the user's tab, not this task's, so a reset + // must not close it. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const gitDiffTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: "git" }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, gitDiffTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, gitDiffTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toEqual([ownTab]) + }) + + // The closer has to recognise the tab the opener handed it. openDiffEditor() reuses a diff tab + // through arePathsEqual while closeOwnDiffView() compared spellings, so on Windows the opener + // could pick up a tab the closure then missed, leaving it open after reset() cleared the + // provider state. Which two spellings count as one file is a property of the platform's path + // comparison, so the expectation states agreement with that comparison rather than hard-coding + // what Windows does: the Windows lane requires the fold, a case-sensitive lane requires the + // closer not to be more eager than the opener. + const targetOf = (): string => path.resolve(path.join(mockCwd, "test.ts")) + const otherCasingOf = (p: string): string => p.replace(/([^\\/]+)$/, (last) => last.toUpperCase()) + const makeOwnDiffTabForClosure = (modifiedFsPath: string) => { + const tab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: modifiedFsPath }, + }, + isDirty: false, + } + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + return tab + } + + it("closes its own tab whichever casing of the target that tab carries", async () => { + const target = targetOf() + const sameSpellingTab = makeOwnDiffTabForClosure(target) + const otherSpellingTab = makeOwnDiffTabForClosure(otherCasingOf(target)) + const otherTaskTab = makeOwnDiffTabForClosure(path.join(path.dirname(target), "other-task.ts")) + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [sameSpellingTab, otherSpellingTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((closed) => { + closedTabs.push(closed) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs).toContain(sameSpellingTab) + // Another task's file is still another task's file, casing or no casing. + expect(closedTabs).not.toContain(otherTaskTab) + expect(closedTabs.includes(otherSpellingTab)).toBe( + arePathsEqual(otherSpellingTab.input.modified.fsPath, target), + ) + }) + + it("recognises the label-identified tab through the same comparison it uses for scheme tabs", async () => { + const target = targetOf() + const ownLabel = `${path.basename(target)}: ${DIFF_VIEW_LABEL_CHANGES} (Editable)` + const labelTab = { + input: { uri: { fsPath: path.join(otherCasingOf(path.dirname(target)), path.basename(target)) } }, + label: ownLabel, + isDirty: false, + } + const otherTaskLabelTab = { + input: { uri: { fsPath: target } }, + label: `other-task.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [labelTab, otherTaskLabelTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((closed) => { + closedTabs.push(closed) + return Promise.resolve(true) + }) + + await diffViewProvider["closeOwnDiffView"](path.join(mockCwd, "test.ts")) + + expect(closedTabs.includes(labelTab)).toBe(arePathsEqual(labelTab.input.uri.fsPath, target)) + // The label still gates: sharing the comparison does not turn every tab that points at the + // target into this provider's tab. + expect(closedTabs).not.toContain(otherTaskLabelTab) + }) + }) + + it("reset() closes only this provider's tab, not another task's", async () => { + // A guard rejection belongs to one task, and every tool caller resets that + // task's provider in its catch block, so the teardown must stay inside this + // provider's view. + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + label: `other-task.ts: ${DIFF_VIEW_LABEL_CHANGES} (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + + diffViewProvider["relPath"] = "test.ts" + await diffViewProvider.reset() + + expect(closedTabs).toEqual([ownTab]) + }) + + // The flow-level version of the tab-scoping check above: a rejected edit is one task's + // business, so its teardown must not reach into another task's diff view. Asserted + // through revertChanges() and the tabs themselves rather than through the private + // helper the teardown now calls, so it stays true whichever helper is used. + it("revertChanges() leaves another task's diff view open", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + label: `test.ts: changes (Editable)`, + isDirty: false, + } + const otherTaskTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + label: `other-task.ts: changes (Editable)`, + isDirty: false, + } + for (const tab of [ownTab, otherTaskTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTaskTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "test.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("content"), + positionAt: vi.fn().mockReturnValue({ line: 0, character: 0 }), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } as unknown as vscode.TextEditor + // The tail steps are not what this assertion is about: stub them so the spy counts + // only the revert pass's own tab work. + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["reset"] = vi.fn().mockResolvedValue(undefined) + + await diffViewProvider.revertChanges() + + expect(closedTabs).toEqual([ownTab]) + }) + + // The one closeAllDiffViews() call site that survives is reset()'s fallback for when + // the provider no longer knows which file it was editing. That site is chosen, not + // overlooked: with no path to scope to, sweeping every clean diff view is all that is + // left, and this test is what says so out loud. + it("reset() without a relPath still closes every clean diff view", async () => { + const ownTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/test.ts` }, + }, + isDirty: false, + } + const otherTab = { + input: { + constructor: { name: "TabInputTextDiff" }, + original: { scheme: DIFF_VIEW_URI_SCHEME }, + modified: { fsPath: `${mockCwd}/other-task.ts` }, + }, + isDirty: false, + } + for (const tab of [ownTab, otherTab]) { + Object.setPrototypeOf(tab.input, vscode.TabInputTextDiff.prototype) + } + Object.defineProperty(vscode.window.tabGroups, "all", { + get: () => [{ tabs: [ownTab, otherTab] }], + configurable: true, + }) + const closedTabs: unknown[] = [] + vi.mocked(vscode.window.tabGroups.close).mockImplementation((tab) => { + closedTabs.push(tab) + return Promise.resolve(true) + }) + diffViewProvider["relPath"] = undefined + + await diffViewProvider["reset"]() + + expect(closedTabs).toEqual([ownTab, otherTab]) + }) + describe("saveDirectly method", () => { beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + + // Baseline for the single-writer flow these tests encode: the file was read + // before the write, so the observation registry holds the version token the + // guarded write recomputes and compares, and the target exists on disk. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") + vi.mocked(fs.access).mockResolvedValue(undefined) }) it("should write content directly to file without opening diff view", async () => { @@ -782,9 +1281,9 @@ describe("DiffViewProvider", () => { const result = await diffViewProvider.saveDirectly("test.ts", "new content", true, true, 2000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was opened without focus expect(vscode.window.showTextDocument).toHaveBeenCalledWith( @@ -805,14 +1304,238 @@ describe("DiffViewProvider", () => { it("should not open file when openWithoutFocus is false", async () => { await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify file was NOT opened expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + it("publishes an approved outside-workspace target", async () => { + // The tool layer classified this path as outside every workspace folder, put it in + // front of the user, and the user approved. The guard must not then reject the + // write it was told about. + vi.mocked(fs.access).mockRejectedValue({ code: "ENOENT" }) + vi.mocked(computeVersionToken).mockResolvedValue("v1") + + await diffViewProvider.saveDirectly( + "../outside.ts", + "new content", + false, + true, + 1000, + "create", + undefined, + true, + path.resolve(mockCwd, "../outside.ts"), + ) + + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/../outside.ts`, "new content") + }) + + it("rejects the same escape when no approval was obtained", async () => { + // The flag is what carries the approval; without it the containment line inside the + // publish helper still holds. + vi.mocked(fs.access).mockRejectedValue({ code: "ENOENT" }) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect( + diffViewProvider.saveDirectly("../outside.ts", "new content", false, true, 1000, "create"), + ).rejects.toThrow("Path resolves outside the workspace") + + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("does not save a dirty buffer in the memory-only diagnostics path", async () => { + // The guarded publish already committed the accepted content. Saving a dirty + // buffer here would republish its stale bytes through VS Code's unguarded save + // path, over what the guard wrote. + const dirtyDoc = { + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + } as unknown as vscode.TextDocument + vi.mocked(vscode.workspace.openTextDocument).mockResolvedValue(dirtyDoc) + + await diffViewProvider.saveDirectly("test.ts", "new content", false, true, 0) + + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ fsPath: `${mockCwd}/test.ts` }), + ) + expect(dirtyDoc.save).not.toHaveBeenCalled() + }) + + it("reconciles an already-open target document after the guarded publish", async () => { + // guardedWrite wrote the published bytes to disk directly; the open + // buffer still holds the pre-publish text, and its dirty state means + // VS Code's ordinary save path would republish those stale bytes over + // what the guard committed. saveDirectly must reconcile the buffer + // before returning: reverting reloads the published bytes and clears + // the dirty flag. + const openDirtyDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + // The buffer already holds the published content, so reloading it loses no edits. + getText: () => "new content", + save: vi.fn().mockResolvedValue(undefined), + } + const docDouble = openDirtyDoc as unknown as vscode.TextDocument // Structural double: the revert path only reads uri and dirty state. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(docDouble) + try { + // The revert command reloads the published bytes: the double's + // dirty flag clears when the revert runs, as it does in the editor. + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command: string) => { + if (command === "workbench.action.files.revert") { + openDirtyDoc.isDirty = false + } + }) + + await diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0) + + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.files.revert") + // The reconcile is a revert, not a save: saving would republish the + // stale buffer bytes over the publish. + expect(openDirtyDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(docDouble), 1) + vi.mocked(vscode.commands.executeCommand).mockReset() + } + }) + + it("reconciles the file document when a virtual document with the same fsPath is listed first", async () => { + // A diff editor lists its virtual side under the fsPath of the file it is + // comparing, and textDocuments can report that document before the real + // buffer. The reconciliation has to pick the file document: aimed at the + // virtual side it reverts a document that was never stale, leaves the dirty + // buffer holding the pre-publish bytes, and reports success - and the buffer's + // next save republishes those stale bytes over what the guard committed. + const virtualDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "diff" }, + isDirty: false, + getText: () => "virtual side of the diff", + save: vi.fn().mockResolvedValue(undefined), + } + const openDirtyDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + getText: () => "new content", + save: vi.fn().mockResolvedValue(undefined), + } + const virtualDouble = virtualDoc as unknown as vscode.TextDocument // Structural double + const fileDouble = openDirtyDoc as unknown as vscode.TextDocument // Structural double + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(virtualDouble, fileDouble) + try { + // The revert command has no resource argument, so the document the + // reconciliation activates is the document that gets reverted. Clearing the + // dirty flag only for the document that was activated is what makes an aimed + // -at-the-virtual-side reconciliation visible in the assertions below. + const activated: unknown[] = [] + vi.mocked(vscode.window.showTextDocument).mockImplementation(async (document) => { + activated.push(document) + // Structural double: saveDirectly awaits the activation and never reads the + // returned editor, so the object carries only the document it activated. + return { document } as unknown as vscode.TextEditor + }) + vi.mocked(vscode.commands.executeCommand).mockImplementation(async (command: unknown) => { + if (command === "workbench.action.files.revert" && activated[activated.length - 1] === fileDouble) { + openDirtyDoc.isDirty = false + } + }) + + await diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0) + + expect(vscode.window.showTextDocument).toHaveBeenCalledWith( + fileDouble, + expect.objectContaining({ preserveFocus: false }), + ) + expect(vscode.commands.executeCommand).toHaveBeenCalledWith("workbench.action.files.revert") + // The stale buffer is the one that was reconciled. + expect(openDirtyDoc.isDirty).toBe(false) + expect(virtualDoc.isDirty).toBe(false) + expect(virtualDoc.save).not.toHaveBeenCalled() + expect(openDirtyDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(virtualDouble), 1) + documents.splice(documents.indexOf(fileDouble), 1) + vi.mocked(vscode.window.showTextDocument).mockReset() + vi.mocked(vscode.commands.executeCommand).mockReset() + } + }) + it("keeps a dirty buffer with different unsaved edits and reports the conflict", async () => { + // The user has unsaved edits in the open target that differ from what the guard + // published. Reverting would discard them, so the buffer is retained and the + // conflict is reported instead; the published content stays on disk. + const userEditedDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + getText: () => "user unsaved edit", + save: vi.fn().mockResolvedValue(undefined), + } + const docDouble = userEditedDoc as unknown as vscode.TextDocument + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(docDouble) + try { + const conflict = await diffViewProvider + .saveDirectly("test.ts", "new content", false, false, 0) + .catch((error: unknown) => error) + expect(conflict).toBeInstanceOf(CommittedWriteBufferConflictError) + expect((conflict as Error).name).toBe("CommittedWriteBufferConflictError") + // The model must be told the write is committed and must not retry the edit. + expect((conflict as Error).message).toContain("write is already committed") + expect((conflict as Error).message).toContain("do not retry") + expect((conflict as Error).message).not.toContain("then retry") + + // No revert ran: the user's edits are still in the buffer. + expect(vscode.commands.executeCommand).not.toHaveBeenCalledWith("workbench.action.files.revert") + expect(documents).toContain(docDouble) + expect(userEditedDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(docDouble), 1) + } + }) + + it("fails the write when the reconcile leaves the target dirty", async () => { + // The revert command rejects and the buffer stays dirty: reporting + // success would leave VS Code's ordinary save path free to republish + // the stale buffer over the guarded bytes. saveDirectly must close the + // tab and reopen the document from disk, and when the reopened buffer + // is still dirty it must fail instead of reporting success - the + // diagnostics/open path must not run over a dirty target. + const staleDoc = { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + isDirty: true, + getText: () => "new content", + save: vi.fn().mockResolvedValue(undefined), + } + const docDouble = staleDoc as unknown as vscode.TextDocument // Structural double: the reconcile path only reads uri and dirty state. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.push(docDouble) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + try { + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("revert command failed")) + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", false, false, 0)).rejects.toThrow( + "still dirty after the guarded publish", + ) + + // The stale buffer is taken out of the save path: the tab was closed + // and the document reopened from disk before the failure surfaced, + // so a later save reads the published bytes, not the stale ones. + expect(closeFileTab).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith( + expect.objectContaining({ fsPath: `${mockCwd}/test.ts` }), + ) + expect(staleDoc.save).not.toHaveBeenCalled() + } finally { + documents.splice(documents.indexOf(docDouble), 1) + vi.mocked(vscode.commands.executeCommand).mockReset() + } + }) + it("should skip diagnostics when diagnosticsEnabled is false", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() @@ -820,9 +1543,9 @@ describe("DiffViewProvider", () => { await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 1000) - // Verify file was written - const fs = await import("fs/promises") - expect(fs.writeFile).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content", "utf-8") + // Verify file was written via safeWriteText + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") // Verify delay was NOT called expect(mockDelay).not.toHaveBeenCalled() @@ -849,267 +1572,3073 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["relPath"]).toBe("test.ts") expect(diffViewProvider["newContent"]).toBe("new content") }) - }) - describe("saveChanges method with diagnostic settings", () => { - beforeEach(() => { - // Setup common mocks for saveChanges tests - diffViewProvider["relPath"] = "test.ts" - diffViewProvider["newContent"] = "new content" - diffViewProvider["activeDiffEditor"] = makeTextEditor({ - document: makeTextDocument({ getText: vi.fn().mockReturnValue("new content") }), - }) - diffViewProvider["preDiagnostics"] = [] + describe("guarded write (S4b, epic #1375)", () => { + const enoent = () => Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) - // Mock vscode functions - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) - vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) - }) + it("rejects an unobserved write to an existing file with the read-first remediation", async () => { + mockTask.observationRegistry.clear() - it("should apply diagnostic delay when diagnosticsEnabled is true", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) - // Mock closeAllDiffViews - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + it("creates an unobserved file when the target is absent", async () => { + mockTask.observationRegistry.clear() + vi.mocked(fs.access).mockRejectedValue(enoent()) - const result = await diffViewProvider.saveChanges(true, 3000) + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - // Verify delay was called with correct duration - expect(mockDelay).toHaveBeenCalledWith(3000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) - it("should skip diagnostics when diagnosticsEnabled is false", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + it("leaves no directories behind when the guard rejects the write", async () => { + // A rejected guard must not touch the filesystem at all. Creating the parent + // directories before the guard runs would leave empty directories behind - and + // for an outside-workspace target, outside the workspace. safeWriteText creates + // missing parents itself at publish time. + mockTask.observationRegistry.clear() + vi.mocked(createDirectoriesForFile).mockClear() - // Mock closeAllDiffViews - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + await expect( + diffViewProvider.saveDirectly("nested/dir/test.ts", "new content", true, false, 0), + ).rejects.toThrow("File already exists at nested/dir/test.ts") - const result = await diffViewProvider.saveChanges(false, 2000) + expect(createDirectoriesForFile).not.toHaveBeenCalled() + expect(safeWriteText).not.toHaveBeenCalled() + }) - // Verify delay was NOT called and diagnostics were NOT checked - expect(mockDelay).not.toHaveBeenCalled() - expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + it("rejects an observed write whose version token is stale", async () => { + // The file changed on disk after the read that recorded "v1". + vi.mocked(computeVersionToken).mockResolvedValue("v2") - it("should use default values when no parameters provided", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() + const result = diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) - // Mock closeAllDiffViews - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + await expect(result).rejects.toThrow("Stale version") + await expect(result).rejects.toThrow("re-read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + }) - const result = await diffViewProvider.saveChanges() + it("rejects an unobserved edit-kind write before any I/O", async () => { + mockTask.observationRegistry.clear() - // Verify default behavior (enabled=true, delay falls back to DEFAULT_WRITE_DELAY_MS) - expect(mockDelay).toHaveBeenCalledWith(0) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") - }) + await expect( + diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0, "edit"), + ).rejects.toThrow("File not read yet -- read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + expect(fs.access).not.toHaveBeenCalled() + }) - it("pins the default write delay to zero", () => { - // The tests above compare against the constant, so they would still pass if the - // default were changed back to 1000. Pin the value the write path actually uses. - expect(DEFAULT_WRITE_DELAY_MS).toBe(0) - }) + it("recreates an observed file that vanished after the read", async () => { + vi.mocked(fs.access).mockRejectedValue(enoent()) + + await diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, "new content") + }) + + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await expect(diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + }) + }) + + describe("saveChanges guarded publish (S4b follow-up #44)", () => { + // Synthetic stat the preview observation tokenizes with the real (unmocked) + // versionTokenOfStat. Cast: the mock only implements the members the tool + // and versionToken read. + const previewStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + + // Structural TextEditor double for the open()/saveChanges flow: only the + // members they touch. One documented unknown cast stands in for the full + // vscode.TextEditor type (avoids any casts; see AGENTS.md). + const mockTextEditor = (fsPath: string, text = ""): vscode.TextEditor => + ({ + document: { + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(text), + lineCount: 0, + encoding: "utf8", + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + }) as unknown as vscode.TextEditor + + // Structural TextDocument double for the onDidOpenTextDocument callback. + const mockTextDocument = (fsPath: string): vscode.TextDocument => + ({ uri: { fsPath, scheme: "file" } }) as unknown as vscode.TextDocument + // The workbench revert clears the model's dirty flag; the double must model + // that so the cleanup can tell a completed discard from a failed one. + const revertClearsDirty = (document: { isDirty: boolean }, onRevert?: () => void): void => { + vi.mocked(vscode.commands.executeCommand).mockImplementation((command: string) => { + if (command === "workbench.action.files.revert") { + document.isDirty = false + onRevert?.() + } + return Promise.resolve(undefined) + }) + } + + beforeEach(() => { + // Private members are set via bracket notation (spec convention). + // Reset the focus the revert helper reads: a test that sets it must not leak + // into the next one, where cleanup could restore a stale editor. + vi.mocked(vscode.window).activeTextEditor = undefined + diffViewProvider["relPath"] = "test.ts" + diffViewProvider["newContent"] = "new content" + diffViewProvider["activeDiffEditor"] = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["preDiagnostics"] = [] + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockTextEditor(`${mockCwd}/test.ts`)) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + }) + + it("open() observes the previewed version of an existing file as a partial read, not a model read", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/observed.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + // The preview is the tool's own read, not a read the model made, so it must + // not claim completeness for content the model never saw. + expect(obs!.complete).toBe(false) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/observed.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/observed.ts`, { bigint: true }) + }) + + it("open() records no observation when the post-read stat rejects", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/observed.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/observed.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" })) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("observed.ts") + + expect(mockTask.observationRegistry.has(`${mockCwd}/observed.ts`)).toBe(false) + // The accepted save is a full-file replacement, so an unobserved target still + // fails closed instead of publishing content built on a preview that could not + // be tied to a version token. + await expect( + diffViewProvider.saveDirectly("observed.ts", "new content", false, false, 0, "update"), + ).rejects.toThrow( + "File already exists at observed.ts and was not read before this write -- read the file first, then retry.", + ) + }) + it("open() observes the empty placeholder of a new file so the accepted save can be guarded", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/brand-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/brand-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("brand-new.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/brand-new.ts`) + expect(obs).toBeDefined() + expect(obs!.version).toBe(versionTokenOfStat(previewStats)) + expect(obs!.complete).toBe(true) + // The create path stat-matches the placeholder (pre + post read), so + // both calls carry the bigint requirement, and the verification read + // uses utf-8. + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/brand-new.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.readFile)).toHaveBeenCalledWith(`${mockCwd}/brand-new.ts`, "utf-8") + }) + + it("open() leaves the target unobserved when the pre/post stat mismatch (mid-preview mutation)", async () => { + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated.ts`)), 0) + return { dispose: vi.fn() } + }) + const mockEditor = mockTextEditor(`${mockCwd}/mutated.ts`) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("mutated.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated.ts`)).toBeUndefined() + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(1, `${mockCwd}/mutated.ts`, { bigint: true }) + expect(vi.mocked(fs.stat)).toHaveBeenNthCalledWith(2, `${mockCwd}/mutated.ts`, { bigint: true }) + }) + + it("open() leaves the target unobserved when the pre-read stat fails (stat gap)", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + // The pre-read stat fails and only the post-read stat resolves: the + // on-disk version the preview is built on is unproven, so open() must + // leave the target unobserved even though a post stat is available. + vi.mocked(fs.stat) + .mockRejectedValueOnce(new Error("EPERM: operation not permitted")) + .mockResolvedValueOnce(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("gap.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/gap.ts`)).toBeUndefined() + }) + + it("open() leaves a new file unobserved when the placeholder stat fails", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/gap-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/gap-create.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockRejectedValue(new Error("EPERM: operation not permitted")) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("gap-create.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/gap-create.ts`)).toBeUndefined() + }) + + it("publishes the accepted content through the guarded write (safeWriteText)", async () => { + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(result.newProblemsMessage).toBe("") + }) + + const openPreview = async () => { + // Enough of the editor double for open() to find the diff editor again. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 1, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(editor as unknown as vscode.TextEditor) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.window).visibleTextEditors = [editor as unknown as vscode.TextEditor] + // openDiffEditor resolves from the document-open event, so fire it. + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => { + callback({ + uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" }, + } as unknown as vscode.TextDocument) + }, 0) + return { dispose: vi.fn() } + }) + // fs/promises is mocked; open() only reads these BigIntStats fields when it + // stat-matches the preview read. + vi.mocked(fs.stat).mockResolvedValue({ + dev: 1n, + ino: 2n, + size: 5n, + mtimeNs: 100n, + ctimeNs: 100n, + } as unknown as BigIntStats) + ;(diffViewProvider as unknown as { editType: string }).editType = "modify" + await diffViewProvider.open("test.txt") + ;(diffViewProvider as unknown as { newContent?: string }).newContent = "new content" + } + + it("does not let the preview's own observation authorize a targeted edit", async () => { + // The tool read never observed this path, so the only entry the save could + // point at is the one open() records for the preview. Authorizing from that + // entry publishes the tool's content over anything that changed between the + // read and the preview, so the save must fall back to the unobserved-edit + // guard and be rejected with the re-read remediation. + mockTask.observationRegistry.clear() + await openPreview() + // The preview did record an observation - that is the entry under test. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow(/File not read yet/) + expect(safeWriteText).not.toHaveBeenCalled() + // The preview's authorization was withdrawn rather than left behind. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) + }) + + it("withdraws the preview observation on a rejected update save once reset runs", async () => { + // The full trigger the review row spells out: the model never read the + // file, open() records the preview entry (complete: false), and + // saveChanges(..., "update") is rejected as only-partially-read. The + // discard cleanup reloads the buffer; nothing wrote, so the token is + // unchanged and nothing forgets the entry. reset() - reached by every + // tool catch and by revertChanges through finalizeSession - must + // withdraw it, or a later apply_diff is authorized by a look the model + // never made (the edit-path test above covers only the first attempt). + mockTask.observationRegistry.clear() + await openPreview() + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + await expect(diffViewProvider.saveChanges(false, 0, "update")).rejects.toThrow(/only partially read/) + // Step 2 of the trigger: the rejected update leaves the preview entry in + // place - this is the leftover the fix must catch. + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(true) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.has(`${mockCwd}/test.txt`)).toBe(false) + }) + + it("keeps an observation the model earned at the same token after the preview", async () => { + // A read_file after the preview records complete: true at the same + // version token (nothing wrote in between). The withdrawal must not + // touch it: the model did read the file, and its earned authorization + // outlives the preview. + mockTask.observationRegistry.clear() + await openPreview() + const previewEntry = mockTask.observationRegistry.get(`${mockCwd}/test.txt`) + expect(previewEntry).toBeDefined() + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, previewEntry!.version, true) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.txt`)?.complete).toBe(true) + }) + + it("keeps a model partial read that replaced the preview entry at the same token", async () => { + // A partial read_file while the diff is open replaces the preview + // observation with a fresh entry at the SAME token and the SAME + // incomplete flag - indistinguishable by content, but a different + // owner. The withdrawal is by entry identity, so the model's earned + // partial authorization survives the reset and a later targeted edit + // stays authorized. + mockTask.observationRegistry.clear() + await openPreview() + const previewEntry = mockTask.observationRegistry.get(`${mockCwd}/test.txt`) + expect(previewEntry).toBeDefined() + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, previewEntry!.version, false) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.txt`)).toBeDefined() + }) + + it("keeps an entry whose token moved on - the preview no longer owns it", async () => { + // A landed publish (or a later session) replaced the entry: the version + // no longer names the preview's token, so the withdrawal leaves it + // alone even though the flag still reads incomplete. + mockTask.observationRegistry.clear() + await openPreview() + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "landed-token", false) + + await diffViewProvider.reset() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.txt`)?.version).toBe("landed-token") + }) + + it("still publishes a targeted edit authorized by a pre-preview observation", async () => { + // The same preview must not break the legitimate case: the model read the + // file (partially) and nothing changed before the preview, so the restored + // pre-open observation authorizes the targeted edit. + mockTask.observationRegistry.observe(`${mockCwd}/test.txt`, "v1", false) + await openPreview() + + await diffViewProvider.saveChanges(false, 0, "edit") + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.txt`, Buffer.from("new content")) + }) + + it("publishes the accepted content in the document's own encoding", async () => { + // A utf8bom document: getText() returns the text without the BOM, so the + // publish must go through VS Code's codec for the document's own + // encoding rather than re-encoding the text as plain UTF-8. + const bomEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8bom", + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = bomEditor + + await diffViewProvider.saveChanges(false) + + expect(vi.mocked(vscode.workspace.encode)).toHaveBeenCalledWith("new content", { + encoding: "utf8bom", + }) + expect(safeWriteText).toHaveBeenCalledWith( + `${mockCwd}/test.ts`, + Buffer.concat([Buffer.from("\uFEFF"), Buffer.from("new content")]), + ) + }) + + it("rejects the accepted save when the file changed after the preview (stale version)", async () => { + vi.mocked(computeVersionToken).mockResolvedValue("v2") + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Stale version -- the file changed since you read it (expected v1, current v2); re-read the file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects the accepted save when the target was never observed (fail closed)", async () => { + mockTask.observationRegistry.clear() + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File already exists at test.ts and was not read before this write -- read the file first, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects the accepted save when the observation was only a partial read", async () => { + // A partial observation (slice/range/truncated/indentation read) must not + // authorize the full-file replacement the accept path performs, even when + // the version is current. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "File was only partially read (line slice, range, truncated view, or indentation block) -- " + + "a full-file replacement needs the complete content; re-read the whole file, then retry.", + ) + expect(safeWriteText).not.toHaveBeenCalled() + }) + it("publishes a targeted edit that a partial observation authorizes", async () => { + // The same partial observation rejects a full-file replacement but + // authorizes the targeted edit the tool performed, so the write kind + // the tool passed must reach the guard. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, "v1", false) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + + await diffViewProvider.saveChanges(false, 0, "edit") + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + }) + + it("fails closed when the owning task has been collected", async () => { + // A real WeakRef cannot be forced to deref to undefined deterministically + // (GC timing), so a structural stub stands in for the collected reference. + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + + // Nothing may be published for a collected task, and the discard-only + // cleanup still runs before the error is rethrown. + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + expect(safeWriteText).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalledTimes(1) + }) + + it("open() keeps the model's existing observation instead of replacing it with the preview token", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/t3-modify.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-modify.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider.editType = "modify" + mockTask.observationRegistry.clear() + // The model read the file before the preview: its observation must + // survive so the accept-time guard compares against the version the + // model's content was built on, not the on-disk version at preview + // time. + mockTask.observationRegistry.observe(`${mockCwd}/t3-modify.ts`, "model-token", true) + + await diffViewProvider.open("t3-modify.ts") + + const obs = mockTask.observationRegistry.get(`${mockCwd}/t3-modify.ts`) + expect(obs?.version).toBe("model-token") + expect(obs?.version).not.toBe(versionTokenOfStat(previewStats)) + // the stat-matched pair is still taken; only the observation is kept + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + }) + + it("open() records the placeholder token for a create even when the model read the file before it vanished", async () => { + // The vanished file's old observation must NOT win here: it describes + // a file that no longer exists, and keeping it would make the + // accept-time CAS (placeholder token on disk vs. the vanished file's + // token) fail every time, so recreating the file would always fail + // and the placeholder would leak. The placeholder token is the + // correct baseline for the new file. + const mockEditor = mockTextEditor(`${mockCwd}/t3-create.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-create.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/t3-create.ts`, "model-token", true) + + await diffViewProvider.open("t3-create.ts") + + const placeholderToken = versionTokenOfStat(previewStats) + // the placeholder is written, stat-matched, observed (replacing the + // vanished file's stale token), and remembered for cleanup + // Created exclusively (#new row 4507851689): a plain write would truncate a file another + // process created after the absence was classified. + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "wx") + expect(vi.mocked(fs.stat)).toHaveBeenCalledTimes(2) + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-create.ts`)?.version).toBe(placeholderToken) + expect(diffViewProvider["placeholderVersion"]).toBe(placeholderToken) + }) + + it("open() on a create with a collected task writes the placeholder but tracks nothing", async () => { + // The task has been collected (dead WeakRef): the placeholder is still + // written (the file must exist to open the diff), but there is no live + // task to observe - the later save fails closed through the taskRef + // fail-closed path. + const mockEditor = mockTextEditor(`${mockCwd}/t3-dead-task.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/t3-dead-task.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + + await diffViewProvider.open("t3-dead-task.ts") + + // Created exclusively (#new row 4507851689): a plain write would truncate a file another + // process created after the absence was classified. + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "wx") + // no live task: nothing observed, but the cleanup token is still + // captured (provider state) so the fail-closed save rejection can + // remove the placeholder instead of leaking it + expect(mockTask.observationRegistry.get(`${mockCwd}/t3-dead-task.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(previewStats)) + }) + + it("open() does not record a placeholder another writer touched after the write", async () => { + // CR d037753 finding: a writer that touched the placeholder between + // open()'s fs.writeFile() and the observation would have its token + // recorded as a complete observation of content open() never read. + // The stat-matched verification must reject it: no observation (the + // prior observation stays untouched), no cleanup token (so a rejected + // save cannot unlink the writer's file), and the save fails closed. + const mockEditor = mockTextEditor(`${mockCwd}/contested.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/contested.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("external content") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + mockTask.observationRegistry.observe(`${mockCwd}/contested.ts`, "model-token", true) + + await diffViewProvider.open("contested.ts") + + // Created exclusively (#new row 4507851689): a plain write would truncate a file another + // process created after the absence was classified. + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "wx") + // nothing recorded, and the vanished file's prior observation was + // not replaced with the writer's token + expect(mockTask.observationRegistry.get(`${mockCwd}/contested.ts`)?.version).toBe("model-token") + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + // Row 4507851689 (measured): the placeholder is a create, and the absence above is a + // classification, not a reservation. A plain write would truncate a file that won the race, + // and the stat-matched observation below would then describe content open() never wrote. + it("open() creates the placeholder exclusively and reports a lost create race", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/race.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/race.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + const create = vi.mocked(fs.open) + create.mockRejectedValueOnce( + Object.assign(new Error(`EEXIST: file already exists, open '${mockCwd}/race.ts'`), { code: "EEXIST" }), + ) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + const failure = await diffViewProvider.open("race.ts").catch((error: unknown) => error) + expect(create).toHaveBeenCalledWith(`${mockCwd}/race.ts`, "wx") + expect(failure).toBeInstanceOf(Error) + expect((failure as Error).message).toContain("another create won the race") + // Nothing was observed and no cleanup token was taken, so a later rejected save cannot + // unlink the winner's file. + expect(mockTask.observationRegistry.get(`${mockCwd}/race.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the post-stat fails after the write", async () => { + // The bracketing stats must both succeed: a failed post-stat means + // the read is not trustworthy, so nothing is recorded. + const mockEditor = mockTextEditor(`${mockCwd}/statfail.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/statfail.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat) + .mockResolvedValueOnce(previewStats) + .mockRejectedValueOnce(Object.assign(new Error("ENOENT: no such file"), { code: "ENOENT" })) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("statfail.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/statfail.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() does not record the placeholder when the bracketing stats disagree (mid-preview mutation)", async () => { + // A token change between the bracketing stats means the placeholder + // was replaced or rewritten while open() was reading it - same S2 + // rule as the modify branch: nothing is recorded. + const mutatedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(2), + size: BigInt(301), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const mockEditor = mockTextEditor(`${mockCwd}/mutated-new.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/mutated-new.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(mutatedStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + + await diffViewProvider.open("mutated-new.ts") + + expect(mockTask.observationRegistry.get(`${mockCwd}/mutated-new.ts`)).toBeUndefined() + expect(diffViewProvider["placeholderVersion"]).toBeUndefined() + }) + + it("open() records the placeholder token from the handle it created, not from the path it stats", async () => { + // Where the identity comes from, pinned. The doubles make the two sources disagree: the handle + // reports the identity of the file the exclusive create opened, and a path stat reports another file + // - which is the state a rename landing between the create and the identity read produces on a real + // filesystem. The token formula is unchanged (versionTokenOfStat over dev:ino:size:mtimeNs:ctimeNs); + // only its source moves. Nothing here compares a path spelling, because this path hands canonical + // paths to the lock, so a spelling pin would be a pin on the mock rather than on the file. + const handleStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(77), + size: BigInt(0), + mtimeNs: BigInt(4_000_000_000n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const mockEditor = mockTextEditor(`${mockCwd}/handle-identity.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/handle-identity.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + vi.mocked(fs.open).mockResolvedValue({ + stat: vi.fn().mockResolvedValue(handleStats), + close: vi.fn().mockResolvedValue(undefined), + // structural double; the cast matches the placeholder rows above it + } as unknown as Awaited>) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + await diffViewProvider.open("handle-identity.ts") + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(handleStats)) + expect(diffViewProvider["placeholderVersion"]).not.toBe(versionTokenOfStat(previewStats)) + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/handle-identity.ts`, "wx") + }) + + // The control: with the handle and the path naming the same file, the token is the same one the + // placeholder rows above already pin, so the row above is sensitive to the source of the identity and + // not to an unrelated property of the double. + it("the placeholder token is unchanged when the handle and the path name the same file", async () => { + const mockEditor = mockTextEditor(`${mockCwd}/same-file.ts`) + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/same-file.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + vi.mocked(fs.open).mockResolvedValue({ + stat: vi.fn().mockResolvedValue(previewStats), + close: vi.fn().mockResolvedValue(undefined), + // structural double; the cast matches the placeholder rows above it + } as unknown as Awaited>) + diffViewProvider.editType = "create" + mockTask.observationRegistry.clear() + await diffViewProvider.open("same-file.ts") + expect(diffViewProvider["placeholderVersion"]).toBe(versionTokenOfStat(previewStats)) + expect(vi.mocked(fs.open)).toHaveBeenCalledWith(`${mockCwd}/same-file.ts`, "wx") + }) + + it("saveChanges() accepts a recreate after a prior read - the accept-time CAS checks the placeholder token", async () => { + // The exact recreate-always-failed trace: the model read the file + // (observed "v1" by the outer beforeEach), the file then vanished, + // open() wrote the placeholder, and the accept must succeed against + // the placeholder token (not the vanished file's stale token). + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + // prior read observation (the file has since vanished) + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe("v1") + + await diffViewProvider.open("test.ts") + + // the placeholder is untouched on disk: the accept-time token matches + // the placeholder token open() recorded + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(result.newProblemsMessage).toBe("") + }) + + it("saveChanges() checks the placeholder token and unlinks inside the same lock", async () => { + // A peer writer that commits between the token check and the unlink would lose + // its write, so the cleanup runs under the same resolved-path advisory lock every + // other writer to this file uses. A token that moved inside the lock window means + // the file is no longer the placeholder and must be left in place. + const mockEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + vi.mocked(vscode.commands.executeCommand).mockResolvedValue(undefined) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(mockTextDocument(`${mockCwd}/test.ts`)), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("") + diffViewProvider.editType = "create" + + await diffViewProvider.open("test.ts") + + const lockKeys: string[] = [] + vi.mocked(withFileLock).mockImplementation(async (lockKey, operation) => { + lockKeys.push(lockKey) + if (lockKeys.length === 2) { + // the peer committed while the cleanup waited for the lock + vi.mocked(fs.stat).mockResolvedValue({ ...previewStats, size: 9999n, mtimeNs: 5n, ctimeNs: 6n }) + } + return operation(lockKey) + }) + + // The publish is rejected against the peer's token, so the discard cleanup runs. + vi.mocked(computeVersionToken).mockResolvedValue("peer-token") + // Deterministic: open() observed the placeholder token and + // computeVersionToken answers the peer's token, so the guard takes the + // stale-version branch - pin it, not the unobserved-create alternative. + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow(/^Stale version -- the file changed/) + + // One acquisition for the guarded publish, one for the cleanup. + expect(lockKeys).toEqual([`${mockCwd}/test.ts`, `${mockCwd}/test.ts`]) + expect(vi.mocked(fs.unlink)).not.toHaveBeenCalled() + }) + + it("clears the dirty buffer via a disk revert after a successful guarded publish", async () => { + // The user edited the buffer before accepting, so the document is + // dirty: the publish wrote the exact buffer content, and the dirty + // flag must be cleared by reverting from disk rather than saving the + // buffer (which would republish through the unguarded file service). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + const result = await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + // the revert activates the exact document with the exact options: + // preserveFocus keeps the user's focus, preview: false pins the tab + // the target is activated so the revert command is scoped to this document + // (no active editor to restore in this case) + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { + preserveFocus: false, + preview: false, + }) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(dirtyEditor.document.save).not.toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("discards the dirty buffer and removes the new-file placeholder after a guarded rejection, then rethrows", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + // open() wrote and observed the empty placeholder; the on-disk token + // then moved past it, so the guard rejects the publish. + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + // discard-only cleanup: the newer disk content is reloaded into the + // buffer (never re-saved from originalContent), and the placeholder + // is unlinked while it is still exactly the file open() wrote + expect(vi.mocked(vscode.window.showTextDocument)).toHaveBeenCalledWith(dirtyEditor.document, { + preserveFocus: false, + preview: false, + }) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // the placeholder stat uses the bigint stat options (the version + // token requires the full-precision fields) + // The read must be attributed to the bigint stat that produced the token: + expect(vi.mocked(fs.stat).mock.calls[0][1]).toEqual({ bigint: true }) + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("disposes the active-editor listener before the programmatic revert", async () => { + // showTextDocument can change the active editor even with + // preserveFocus, so the listener must be gone before the revert + // activates the document: otherwise this programmatic activation is + // recorded as a user touch and the auto-close preference is overridden. + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) + + await diffViewProvider.saveChanges(false) + + expect(order).toEqual(["dispose", "revert"]) + }) + + it("disposes the listener before the discard revert and closes the deleted file's tab after a successful unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["disposeActiveEditorListener"] = vi.fn(() => { + order.push("dispose") + }) + diffViewProvider["cancelDeferredScroll"] = vi.fn(() => { + order.push("cancel") + }) + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + revertClearsDirty(dirtyEditor.document, () => order.push("revert")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The tab for the file that was just unlinked is closed, and only + // after the revert; closing only the diff views would leave a clean + // plain-text tab for a deleted file behind. + expect(order).toEqual(["dispose", "cancel", "revert", "closeTab"]) + }) + + it("does not close the file tab when the placeholder unlink fails", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EACCES: permission denied, unlink")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // Nothing was deleted, so there is no deleted-file tab to close. + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("removes the directories open() created, innermost first, after the placeholder unlink", async () => { + const order: string[] = [] + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/new`, `${mockCwd}/new/dir`] + diffViewProvider["closeFileTab"] = vi.fn().mockImplementation(() => { + order.push("closeTab") + return Promise.resolve() + }) + vi.mocked(fs.rmdir).mockImplementation(async (p) => { + order.push("rmdir:" + p) + }) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // The empty directories open() made for the rejected new file go with the + // placeholder, innermost first, and only after the unlink succeeded. + expect(order).toEqual(["closeTab", "rmdir:" + `${mockCwd}/new/dir`, "rmdir:" + `${mockCwd}/new`]) + }) + + it("stops removing created directories when one cannot be removed", async () => { + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + diffViewProvider["createdDirs"] = [`${mockCwd}/a`, `${mockCwd}/a/b`, `${mockCwd}/a/b/c`] + vi.mocked(fs.rmdir).mockRejectedValueOnce(new Error("ENOTEMPTY: directory not empty")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + // A directory another writer populated in the meantime must not be removed, + // and the best-effort cleanup must still finish so the guard verdict stays + // the outcome. + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(`${mockCwd}/a/b/c`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not revert a buffer the user changed during the publish", async () => { + // The publish captured the buffer text before awaiting the write; a keystroke + // typed during that wait is newer than the published bytes, so the buffer must + // stay dirty instead of being reverted to disk. + const getText = vi.fn() + getText.mockReturnValueOnce("new content").mockReturnValueOnce("new content typed during the publish") + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText, + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalledWith("workbench.action.files.revert") + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + }) + + it("activates the document before the revert so the command is scoped to it, then restores the focus", async () => { + // workbench.action.files.revert takes no resource argument: with the Open + // Editors view focused it force-reverts every selected editor, otherwise the + // active editor. Activating the target keeps the revert scoped to this + // document, and the user's previous focus is given back afterwards. + const previousEditor = mockTextEditor(`${mockCwd}/other.ts`, "other") + vi.mocked(vscode.window).activeTextEditor = previousEditor + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + const calls = vi.mocked(vscode.window.showTextDocument).mock.calls + expect(calls[0]).toEqual([dirtyEditor.document, { preserveFocus: false, preview: false }]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(calls[1]).toEqual([previousEditor.document, { preserveFocus: false, preview: false }]) + }) + + it("does not restore focus when the user had no active editor", async () => { + vi.mocked(vscode.window).activeTextEditor = undefined + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + + await diffViewProvider.saveChanges(false) + + // Only the activation happened; there was no focus to give back. + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [dirtyEditor.document, { preserveFocus: false, preview: false }], + ]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + }) + + it("does not restore focus when the active editor is already the target document", async () => { + // The user was already looking at this document, so there is nothing to give + // back: re-showing it would be a redundant activation. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + vi.mocked(vscode.window).activeTextEditor = editor + diffViewProvider["activeDiffEditor"] = editor + revertClearsDirty(editor.document) + + await diffViewProvider.saveChanges(false) + + // Only the activation happened: the focus was already on the target, so there + // was nothing to give back. + expect(vi.mocked(vscode.window.showTextDocument).mock.calls).toEqual([ + [editor.document, { preserveFocus: false, preview: false }], + ]) + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + }) + it("keeps the placeholder when the discard fails, so a later save cannot recreate the rejected content", async () => { + // The revert command can fail (a locked or orphaned model). While the buffer + // is still dirty, VS Code's ordinary file service can save it back to the path, + // so the placeholder open() wrote must survive and the tab must stay open. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + vi.mocked(vscode.commands.executeCommand).mockRejectedValue(new Error("revert failed")) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved-past-placeholder") + diffViewProvider["placeholderVersion"] = placeholderToken + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(fs.unlink).not.toHaveBeenCalled() + expect(closeFileTab).not.toHaveBeenCalled() + }) + + it("does not reload a clean buffer when the guard rejects - only dirty buffers are discarded", async () => { + // The buffer was never touched (isDirty is falsy): there is nothing + // to discard, so the failure cleanup must not activate the document + // or run the revert command. + const cleanEditor = mockTextEditor(`${mockCwd}/test.ts`, "new content") + diffViewProvider["activeDiffEditor"] = cleanEditor + vi.mocked(computeVersionToken).mockResolvedValue("v2") + // The placeholder on disk is still exactly what open() wrote, so the cleanup + // can still remove it. + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(safeWriteText).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + expect(vi.mocked(vscode.commands.executeCommand)).not.toHaveBeenCalled() + // A clean buffer has nothing that can be saved back, so the placeholder + // cleanup still runs even though no revert was needed. + expect(fs.unlink).toHaveBeenCalledWith(`${mockCwd}/test.ts`) + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not unlink the placeholder when the edit type is not create - the outer gate short-circuits", async () => { + // placeholderVersion is remembered (open() took the placeholder path) + // but the edit type is not create: the outer gate must short-circuit + // before statting or unlinking, so the placeholder on disk is left + // untouched. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "modify" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + vi.mocked(fs.stat).mockResolvedValue(previewStats) // placeholder still on disk + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.unlink).not.toHaveBeenCalled() + // the dirty discard and the view close still ran + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("adopts content autosave already published instead of reporting a stale rejection", async () => { + // Autosave wrote the buffer before acceptance: the document is clean and the + // disk already holds exactly the bytes this save intended, but the version + // token moved, so the compare-and-swap still rejects. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + // Run the real close helpers and watch the tab itself, so the claim below does + // not depend on which private helper the post-save cleanup happens to call. The + // plain-file close and the preview restore are not this test's subject, so they + // stay stubbed. + Reflect.deleteProperty(diffViewProvider, "closeAllDiffViews") + Reflect.deleteProperty(diffViewProvider, "closeOwnDiffView") + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const { tab: diffTab, closedTabs } = installDiffTabSpy(`${mockCwd}/test.ts`) + diffViewProvider.editType = "modify" + // A partial observation must stay partial: adopting content that is already on + // disk cannot upgrade it into authority for a full-file replacement. + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), false) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + + // The observation now points at the state that already matches, keeping the + // completeness of the original read. + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(false) + // Nothing was clobbered, so the buffer is not reloaded and no placeholder is + // unlinked; the normal post-save close flow still ran. + expect(fs.unlink).not.toHaveBeenCalled() + expect(vi.mocked(vscode.window.showTextDocument)).not.toHaveBeenCalled() + // Proven on the tab itself: the diff tab this save had is gone. Asserting on the + // close call rather than on the private helper keeps the claim true whichever + // helper the post-save cleanup uses. + expect(closedTabs).toEqual([diffTab]) + }) + + it("does not adopt an autosaved match for an edit that was never authorized", async () => { + // Same autosave shape, different verdict: with no observation from before open() + // the guard rejects for AUTHORIZATION. Adopting the byte match would report a + // modified result and record a partial observation for a file the model never + // read, which would then authorize a later targeted publish. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + diffViewProvider["preOpenObservation"] = null + mockTask.observationRegistry.clear() + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false, 0, "edit")).rejects.toThrow(/File not read yet/) + + // No adoption read, and no observation granted. + expect(fs.readFile).not.toHaveBeenCalled() + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)).toBeUndefined() + }) + + it("still rejects when the disk content does not match what the save intended", async () => { + // Same autosave shape, different bytes: the guard verdict stands. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("someone else") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + // The observation is left at the state the read saw, not upgraded to the + // autosaved content the save did not author. + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + // The rejection still stands: the buffer is discarded and the views close, + // so the caller sees the guard verdict, not a silent success. + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + it("keeps the rejection when the buffer is still dirty even though the disk matches", async () => { + // A dirty buffer means the disk content came from someone else, so the + // discard cleanup is still the outcome even when the bytes happen to match. + // Without the clean-document gate this test would adopt the match and skip + // the discard. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(vi.mocked(vscode.commands.executeCommand)).toHaveBeenCalledWith("workbench.action.files.revert") + // The clean-document gate short-circuits before the adoption check, so the + // rejection path never stats or reads the file. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("keeps the rejection when the file moved between the stat and the read", async () => { + // Same bytes, but the second stat differs: the read is not attributable to + // the state the token describes, so a match cannot be adopted. + const movedStats = { + isDirectory: () => false, + dev: BigInt(1), + ino: BigInt(9), + size: BigInt(300), + mtimeNs: BigInt(4_000_000_001n), + ctimeNs: BigInt(5_000_000_000n), + } as unknown as BigIntStats + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValueOnce(previewStats).mockResolvedValueOnce(movedStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps a complete observation complete when adopting an autosaved match", async () => { + // The completeness of the original read must survive the adoption unchanged: + // a complete read stays complete, so the fallback default must not silently + // downgrade it. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const observation = mockTask.observationRegistry.get(`${mockCwd}/test.ts`) + expect(observation?.version).toBe(versionTokenOfStat(previewStats)) + expect(observation?.complete).toBe(true) + // The stat that pairs with the read must be the bigint form, otherwise the + // token is built from truncated fields. + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + }) + + it("keeps the rejection when the stat paired with the read is unavailable", async () => { + // There is no version to attribute the read to, so a match cannot be adopted. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockRejectedValueOnce(new Error("stat failed")) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + expect(mockTask.observationRegistry.get(`${mockCwd}/test.ts`)?.version).toBe( + versionTokenOfStat(previewStats), + ) + }) + + it("keeps the rejection when the paired read fails", async () => { + // The bytes cannot be compared, so a match cannot be assumed. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockRejectedValueOnce(new Error("read failed")) + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + }) + + it("records an incomplete observation when the path was never observed", async () => { + // Nothing was read in full, so the adopted state must be recorded as an + // incomplete observation rather than dereferencing a missing entry. A path the + // registry has never seen keeps the case independent of earlier tests. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/never-observed.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["relPath"] = "never-observed.ts" + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + const freshObservation = mockTask.observationRegistry.get(`${mockCwd}/never-observed.ts`) + expect(freshObservation?.version).toBe(versionTokenOfStat(previewStats)) + expect(freshObservation?.complete).toBe(false) + }) + it("keeps a non-guard write failure a failure even when the disk already matches", async () => { + // The guard passed and the write itself failed. The disk happens to hold the + // same bytes, but this save did not publish them, so the failure must not be + // reinterpreted as a success. + const cleanEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = cleanEditor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue(versionTokenOfStat(previewStats)) + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") + vi.mocked(safeWriteText).mockRejectedValueOnce(new Error("EACCES: permission denied")) + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("EACCES: permission denied") + // No adoption read, so the guard verdict is the outcome. + expect(fs.readFile).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + it("does not adopt content when the failure is not a guard verdict", async () => { + // The bytes match and the buffer is clean, but the failure is the dead-task + // error, not a guard rejection: adoption must not turn an unrelated failure + // into a success. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + diffViewProvider["taskRef"] = { deref: () => undefined } as unknown as WeakRef + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + "Cannot guard the write: the owning task is no longer available", + ) + // The adoption check never runs, so the file is neither stat-ed nor read. + expect(fs.stat).not.toHaveBeenCalled() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("pairs the read with the bigint form on both sides of the comparison", async () => { + // A non-bigint stat truncates the fields the version token is built from, so + // both stats around the read must request the bigint form. + const editor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = editor + diffViewProvider.editType = "modify" + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, versionTokenOfStat(previewStats), true) + vi.mocked(computeVersionToken).mockResolvedValue("moved") + vi.mocked(fs.stat).mockResolvedValue(previewStats) + vi.mocked(fs.readFile).mockResolvedValue("new content") + + await expect(diffViewProvider.saveChanges(false)).resolves.toMatchObject({ newProblemsMessage: "" }) + expect(vi.mocked(fs.stat).mock.calls.map((call) => call[1])).toEqual([{ bigint: true }, { bigint: true }]) + }) + it("does not unlink when the placeholder stat is unavailable and still closes the diff views", async () => { + // The placeholder vanished between open() and the rejected save: the + // stat guard must short-circuit BEFORE the token comparison (no + // unlink) and the best-effort cleanup must not skip the view close. + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the placeholder vanished: stat rejects and the guard's .catch + // normalizes it to undefined stats + vi.mocked(fs.stat).mockRejectedValue(new Error("ENOENT: no such file or directory")) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + + it("does not unlink a placeholder whose content changed after open() - the token gate refuses", async () => { + // The placeholder is still on disk, but its stats no longer match the + // token open() recorded: another writer touched the file, so the + // cleanup must refuse to unlink (it would destroy the other + // writer's content). + const dirtyEditor = { + document: { + uri: { fsPath: `${mockCwd}/test.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue("new content"), + lineCount: 0, + encoding: "utf8", + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = dirtyEditor + revertClearsDirty(dirtyEditor.document) + diffViewProvider.editType = "create" + const placeholderToken = versionTokenOfStat(previewStats) + mockTask.observationRegistry.observe(`${mockCwd}/test.ts`, placeholderToken, true) + // the on-disk placeholder moved on since open(): same identity, new + // size -> a different token than the one open() recorded + const movedStats = { ...previewStats, size: BigInt(301) } as unknown as BigIntStats + vi.mocked(fs.stat).mockResolvedValue(movedStats) + vi.mocked(computeVersionToken).mockResolvedValue("moved") // stale rejection + diffViewProvider["placeholderVersion"] = placeholderToken + + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("Stale version") + + expect(fs.stat).toHaveBeenCalledWith(`${mockCwd}/test.ts`, { bigint: true }) + // token mismatch -> the placeholder is NOT ours anymore: no unlink + expect(fs.unlink).not.toHaveBeenCalled() + expect(diffViewProvider["closeOwnDiffView"]).toHaveBeenCalled() + }) + }) + + describe("saveChanges method with diagnostic settings", () => { + beforeEach(() => { + // Setup common mocks for saveChanges tests + diffViewProvider["relPath"] = "test.ts" + diffViewProvider["newContent"] = "new content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ getText: vi.fn().mockReturnValue("new content") }), + }) + diffViewProvider["preDiagnostics"] = [] + + // Mock vscode functions + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) + vi.mocked(vscode.languages.getDiagnostics).mockReturnValue([]) + }) + + it("should apply diagnostic delay when diagnosticsEnabled is true", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges(true, 3000) + + // Verify delay was called with correct duration + expect(mockDelay).toHaveBeenCalledWith(3000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should skip diagnostics when diagnosticsEnabled is false", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges(false, 2000) + + // Verify delay was NOT called and diagnostics were NOT checked + expect(mockDelay).not.toHaveBeenCalled() + expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should use default values when no parameters provided", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges() + + // Verify default behavior (enabled=true, delay falls back to DEFAULT_WRITE_DELAY_MS) + expect(mockDelay).toHaveBeenCalledWith(0) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("pins the default write delay to zero", () => { + // The tests above compare against the constant, so they would still pass if the + // default were changed back to 1000. Pin the value the write path actually uses. + expect(DEFAULT_WRITE_DELAY_MS).toBe(0) + }) it("should handle custom delay values", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() - // Mock closeAllDiffViews + // Mock closeAllDiffViews + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + + await diffViewProvider.saveChanges(true, 5000) + + // Verify custom delay was used + expect(mockDelay).toHaveBeenCalledWith(5000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + }) + }) + + describe("preEditScrollLine capture and restore", () => { + it("should capture scroll line from visible editor at open() time", async () => { + const mockEditor = makeTextEditor({ + document: makeTextDocument({ uri: makeUri(`${mockCwd}/scroll.ts`) }), + visibleRanges: [makeRange(42)], + }) + + vi.mocked(vscode.window).visibleTextEditors = [mockEditor] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(makeTextDocument({ uri: makeUri(`${mockCwd}/scroll.ts`) })), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + diffViewProvider.editType = "modify" + + await diffViewProvider.open("scroll.ts") + + expect(diffViewProvider["preEditScrollLine"]).toBe(42) + }) + + it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { + const mockEditorNoRanges = makeTextEditor({ + document: makeTextDocument({ uri: makeUri(`${mockCwd}/new.ts`) }), + // No visibleRanges, so the capture in open() yields undefined. + visibleRanges: [], + }) + + vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback(makeTextDocument({ uri: makeUri(`${mockCwd}/new.ts`) })), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + diffViewProvider.editType = "modify" + + await diffViewProvider.open("new.ts") + + expect(diffViewProvider["preEditScrollLine"]).toBeUndefined() + }) + + it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = makeTextEditor({ revealRange: mockRevealRange }) + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["documentWasOpen"] = true + diffViewProvider["preEditScrollLine"] = 30 + // saveChanges early-exits without relPath, newContent, activeDiffEditor + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ getText: vi.fn().mockReturnValue("content") }), + }) + + await diffViewProvider.saveChanges(false) + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 30, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + + it("saveChanges() does NOT call revealRange when preEditScrollLine is undefined", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = makeTextEditor({ revealRange: mockRevealRange }) + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["documentWasOpen"] = true + diffViewProvider["preEditScrollLine"] = undefined + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ getText: vi.fn().mockReturnValue("content") }), + }) + + await diffViewProvider.saveChanges(false) + + expect(mockRevealRange).not.toHaveBeenCalled() + }) + + it("saveChanges() cancels a pending deferred scroll so it cannot fight scroll-restore", async () => { + // With auto-approve, saveChanges runs immediately after scrollToFirstDiff. + // A late deferred reveal must not fire after the viewport is restored. + vi.useFakeTimers() + try { + const deferredReveal = vi.fn() + const liveEditor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(`${mockCwd}/race.ts`), + getText: vi.fn().mockReturnValue("a\nCHANGED\nc\nd\n"), + lineCount: 5, + }), + visibleRanges: [makeRange()], + revealRange: deferredReveal, + }) + diffViewProvider.originalContent = "a\nb\nc\nd\n" + diffViewProvider["activeDiffEditor"] = liveEditor + vi.mocked(vscode.window).visibleTextEditors = [liveEditor] + + // Schedule the deferred reveal, then accept the edit before it fires. + diffViewProvider.scrollToFirstDiff() + expect(deferredReveal).toHaveBeenCalledTimes(1) + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["documentWasOpen"] = true + diffViewProvider["preEditScrollLine"] = 0 + diffViewProvider["newContent"] = "a\nCHANGED\nc\nd\n" + + await diffViewProvider.saveChanges(false) + + // The timer was cancelled; advancing past it produces no extra reveal. + vi.advanceTimersByTime(100) + expect(deferredReveal).toHaveBeenCalledTimes(1) + expect(diffViewProvider["deferredScrollTimer"]).toBeUndefined() + } finally { + vi.useRealTimers() + } + }) + + it("revertChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { + const mockRevealRange = vi.fn() + const mockSavedEditor = makeTextEditor({ revealRange: mockRevealRange }) + + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["documentWasOpen"] = true + diffViewProvider["preEditScrollLine"] = 15 + diffViewProvider.editType = "modify" + diffViewProvider.originalContent = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(`${mockCwd}/test.txt`), + getText: vi.fn().mockReturnValue("modified"), + positionAt: vi.fn().mockReturnValue(makePosition()), + }), + }) + + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + + await diffViewProvider.revertChanges() + + expect(mockRevealRange).toHaveBeenCalledWith( + expect.objectContaining({ start: { line: 15, character: 0 } }), + vscode.TextEditorRevealType.AtTop, + ) + }) + }) + + describe("userTouchedDocument close/keep behavior", () => { + const mockTargetPath = `${mockCwd}/mock-target-file.ts` + + const buildActiveDiffEditor = () => + makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + positionAt: vi.fn().mockReturnValue(makePosition()), + }), + }) + + it("saveChanges() closes the file tab when the file was not open and untouched", async () => { + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["documentWasOpen"] = false + diffViewProvider["userTouchedDocument"] = false + diffViewProvider["preEditScrollLine"] = undefined + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = buildActiveDiffEditor() + + await diffViewProvider.saveChanges(false) + + expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + + it("revertChanges() holds the teardown through its finalization steps, not just the cleanup callback", async () => { + // The guard has to stay held until the owner's finalization - the preview-tab restore + // and reset() - settles as well. A cancellation that lands after the cleanup callback + // but before those steps finished sees no teardown in flight and runs the whole pass a + // second time: the document is reverted again, the same tabs are closed again, and the + // tabs are restored and the provider reset twice. + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + const { closedTabs } = installDiffTabSpy(mockTargetPath) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + const editor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + diffViewProvider["activeDiffEditor"] = editor + // The finalization is held open on a gate: that is exactly the window a real + // cancellation lands in - the cleanup callback has settled, the tabs are not back yet. + let releaseFinalization!: () => void + const finalizationGate = new Promise((resolve) => { + releaseFinalization = resolve + }) + const restorePreviewTabs = vi.fn().mockImplementation(async () => { + await finalizationGate + }) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + + const first = diffViewProvider.revertChanges() + // Join only once the owner is INSIDE its finalization, so the second caller is + // testing the window the guard used to release early on. + for (let i = 0; i < 100 && restorePreviewTabs.mock.calls.length === 0; i++) { + await new Promise((resolve) => setImmediate(resolve)) + } + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + const second = diffViewProvider.revertChanges() + releaseFinalization() + await Promise.all([first, second]) + + expect(applyEdit).toHaveBeenCalledTimes(1) + // One pass over the tabs across both callers: counted on the tab actually + // closing, not on the helper the pass happens to call. + expect(closedTabs).toHaveLength(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + }) + + it("revertChanges() does not run a second teardown while one is already in flight", async () => { + // Cancellation can reach revertChanges() while a rejected save is still + // discarding the same buffer. Both paths acting on the document and the same + // tabs is duplicate cleanup, so the second caller waits for the first. + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + const { closedTabs } = installDiffTabSpy(mockTargetPath) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + const editor = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + diffViewProvider["activeDiffEditor"] = editor + + const first = diffViewProvider.revertChanges() + const second = diffViewProvider.revertChanges() + await Promise.all([first, second]) + + // Only this provider's tab is touched, whichever caller ran the pass. + expect(new Set(closedTabs).size).toBe(1) + expect(applyEdit).toHaveBeenCalledTimes(1) + }) + // The save's post-publish cleanup touches the same buffers and tabs a cancellation's + // teardown does, so it has to be one serialized pass - and a save whose session was torn + // down while the guarded publish was still awaiting must not run a cleanup of its own. + it("saveChanges() skips its post-publish cleanup when a teardown began during the publish", async () => { + // A cancellation can reach revertChanges() while the publish is awaiting. That teardown + // already closed the diff views and applied the auto-close preferences; a second pass + // would close the same tabs and re-run the same document revert. + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const { closedTabs } = installDiffTabSpy(mockTargetPath) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + // The cancellation lands inside the guarded publish. The implementation has to be + // restored afterwards: clearAllMocks() drops call records but keeps queued + // implementations, and a leaked publish would cancel every later save in this file. + vi.mocked(safeWriteText).mockImplementation(async () => { + await diffViewProvider.revertChanges() + }) + + let result: Awaited> | undefined + try { + result = await diffViewProvider.saveChanges(false) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(result).toEqual({ + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: undefined, + }) + // Exactly one teardown ran - the cancellation's - and the save added no cleanup of its + // own on top of it. + expect(applyEdit).toHaveBeenCalledTimes(1) + // Only this provider's tab is touched, by either caller. + expect(new Set(closedTabs).size).toBe(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) + + it("saveChanges() serializes its post-publish cleanup with a revertChanges() that lands during it", async () => { + // The cleanup closes the same tabs a cancellation would close. Going through the shared + // teardown state means the cancellation waits for the save instead of racing it. + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const save = diffViewProvider.saveChanges(false) + while (closedTabs.length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + + // The revert has not touched the document: it is waiting for the save's cleanup. + expect(applyEdit).not.toHaveBeenCalled() + + release() + // The publish had already committed when this cancellation arrived, so the revert now + // reports the committed-write conflict instead of restoring originalContent. Held rather + // than swallowed: the serialization property below is about who touched the tabs. + const settled = await Promise.allSettled([save, revert]) + expect(settled[0].status).toBe("fulfilled") + expect(settled[1].status).toBe("rejected") + const rejection = (settled[1] as PromiseRejectedResult).reason as CommittedWriteBufferConflictError + expect(rejection).toBeInstanceOf(CommittedWriteBufferConflictError) + // Not merely that something rejected: this is the window the row describes - a + // cancellation that arrived after the guarded publish had committed. + expect(rejection.message).toContain( + "revertChanges: a cancellation arrived after the guarded publish had committed.", + ) + + // One pass over the tabs, and the cancellation did not repeat the document revert. + // Only this provider's tab is touched, by either caller. + expect(new Set(closedTabs).size).toBe(1) + expect(applyEdit).not.toHaveBeenCalled() + }) + + // The pass that started the teardown owns the tab work, so a waiting caller must not + // restore the preview tabs twice. It still has to leave the session closed: the owning + // pass here is a save's post-publish cleanup, which has no finalize step of its own. + it("revertChanges() restores no preview tabs twice but finalizes the session the owning pass left open", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const save = diffViewProvider.saveChanges(false) + while (closedTabs.length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + + // The revert waits for the save's pass instead of starting one of its own. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + // Same post-commit cancellation as the row above: the conflict report is awaited, not + // swallowed, so this row keeps testing tab and reset counts on its own. + const settled = await Promise.allSettled([save, revert]) + expect(settled[0].status).toBe("fulfilled") + const rejection = (settled[1] as PromiseRejectedResult).reason as CommittedWriteBufferConflictError + expect(rejection).toBeInstanceOf(CommittedWriteBufferConflictError) + // Not merely that something rejected: this is the window the row describes - a + // cancellation that arrived after the guarded publish had committed. + expect(rejection.message).toContain( + "revertChanges: a cancellation arrived after the guarded publish had committed.", + ) + + // The owning pass restored the preview tabs exactly once, and the waiting caller + // finalized the session exactly once - the pass that ran the tabs does not reset, so + // the cancellation that waited on it closes the session instead of leaving it active. + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + expect(applyEdit).not.toHaveBeenCalled() + }) + + // The pre-merge row this covers: a cancellation or disposal that lands while the save owns + // its post-publish teardown used to leave the provider mid-edit. The save's pass closes the + // views and restores the tabs but never resets, the waiting revertChanges() returned without + // finalizing, and Task.disposeOnce() only awaits the reversion promise - so isEditing and the + // active editor survived the task that owned them. + it("saveChanges() finalizes the session when a cancellation lands during its post-publish teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + const listener = { dispose: vi.fn() } + diffViewProvider["activeEditorListener"] = listener + diffViewProvider["deferredScrollTimer"] = setTimeout(() => {}, 10_000) + + const save = diffViewProvider.saveChanges(false) + while (closedTabs.length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } + + // Task.disposeOnce() reaches revertChanges() while the save's pass owns the session. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + // The publish committed before Task.disposeOnce() reached revertChanges(), so the + // cancellation closes the session and reports the committed-write conflict instead of + // writing originalContent back over it. + const settled = await Promise.allSettled([save, revert]) + + // The session is closed: no edit flag, no retained editor, listeners and timer gone. + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + expect(listener.dispose).toHaveBeenCalledTimes(1) + expect(diffViewProvider["activeEditorListener"]).toBeUndefined() + expect(diffViewProvider["deferredScrollTimer"]).toBeUndefined() + + // One pass over the tabs and buffers: the waiting cancellation repeated neither the + // document revert nor the preview-tab restoration. + expect(applyEdit).not.toHaveBeenCalled() + // Both the save pass and the cancellation's session finalization close this + // provider's own tab; what must not happen is either of them touching another + // task's view. + expect(new Set(closedTabs).size).toBe(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + + // A session a cancellation is closing reports no completed save, so no caller can present + // diagnostics or user-edit output for provider state that is already gone. + expect(settled[0].status).toBe("fulfilled") + expect( + (settled[0] as PromiseFulfilledResult>>).value, + ).toEqual({ + newProblemsMessage: undefined, + userEdits: undefined, + finalContent: undefined, + }) + // The caller that asked for the undo is told which of the two it got. + expect(settled[1].status).toBe("rejected") + const rejection = (settled[1] as PromiseRejectedResult).reason as CommittedWriteBufferConflictError + expect(rejection).toBeInstanceOf(CommittedWriteBufferConflictError) + // Not merely that something rejected: this is the window the row describes - a + // cancellation that arrived after the guarded publish had committed. + expect(rejection.message).toContain( + "revertChanges: a cancellation arrived after the guarded publish had committed.", + ) + }) - await diffViewProvider.saveChanges(true, 5000) + // Row 4507760734 (measured): the guarded path treats the commit as the point of no return, + // which is why CommittedWriteBufferConflictError exists at all. The modify branch's + // updatedDocument.save() is the one writer left that republishes through VS Code's unguarded + // file service - the call the comment above saveChanges says every other path avoids - so a + // cancellation that lands after the publish has to leave the published bytes and say so. + it("revertChanges() after a committed publish leaves the published content and reports the conflict", async () => { + const saveDoc = vi.fn().mockResolvedValue(undefined) + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: saveDoc, + }) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) - // Verify custom delay was used - expect(mockDelay).toHaveBeenCalledWith(5000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + applyEdit.mockClear() + vi.mocked(fs.unlink).mockClear() + + const outcome = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(outcome).toBeInstanceOf(CommittedWriteBufferConflictError) + // No originalContent write, and no unguarded document.save() over the commit. + expect(applyEdit).not.toHaveBeenCalled() + expect(saveDoc).not.toHaveBeenCalled() + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["committedPublish"]).toBeUndefined() }) - }) - describe("preEditScrollLine capture and restore", () => { - it("should capture scroll line from visible editor at open() time", async () => { - const mockEditor = makeTextEditor({ - document: makeTextDocument({ uri: makeUri(`${mockCwd}/scroll.ts`) }), - visibleRanges: [makeRange(42)], + // The other direction, without which the fix above could quietly delete the restore: + // a cancellation that lands before any publish still puts the file back. + it("revertChanges() before any publish still restores the original content", async () => { + const saveDoc = vi.fn().mockResolvedValue(undefined) + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: saveDoc, }) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + expect(diffViewProvider["committedPublish"]).toBeUndefined() + + await expect(diffViewProvider.revertChanges()).resolves.toBeUndefined() + expect(applyEdit).toHaveBeenCalledTimes(1) + expect(saveDoc).toHaveBeenCalledTimes(1) + expect(diffViewProvider.isEditing).toBe(false) + }) - vi.mocked(vscode.window).visibleTextEditors = [mockEditor] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor) - vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback(makeTextDocument({ uri: makeUri(`${mockCwd}/scroll.ts`) })), 0) - return { dispose: vi.fn() } + // Row 4507851696 (measured): the create branch's fs.unlink is the delete that can take a + // newly published file with it - including whatever another writer built on that path in + // the meantime, since the unlink is not version-checked. + it("revertChanges() after a committed create does not unlink the published file", async () => { + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - diffViewProvider.editType = "modify" + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) - await diffViewProvider.open("scroll.ts") + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + vi.mocked(fs.unlink).mockClear() - expect(diffViewProvider["preEditScrollLine"]).toBe(42) + const outcome = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(outcome).toBeInstanceOf(CommittedWriteBufferConflictError) + expect(fs.unlink).not.toHaveBeenCalledWith(mockTargetPath) + expect(diffViewProvider.isEditing).toBe(false) }) - it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { - const mockEditorNoRanges = makeTextEditor({ - document: makeTextDocument({ uri: makeUri(`${mockCwd}/new.ts`) }), - // No visibleRanges, so the capture in open() yields undefined. - visibleRanges: [], + // Row 4507764622 (measured): the post-publish cleanup discarded revertDocument's verdict. + // A buffer that stays dirty after the reopen still holds the bytes the guard published, so + // the save reports the committed-write conflict instead of proceeding into the save-over + // and returning ordinary success - the same name the no-diff path uses for the same state. + it("saveChanges() reports the committed-write conflict when it cannot clear the buffer it published", async () => { + const document = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: true, + save: vi.fn().mockResolvedValue(undefined), }) + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + const failure = await diffViewProvider.saveChanges(false).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + // The detail names this window; the guidance is the shared one, not a new vocabulary. + expect((failure as Error).message).toContain("saveChanges: the diff-view save committed its content") + expect((failure as Error).message).toContain("do not retry") + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + expect(diffViewProvider["closeFileTab"]).toHaveBeenCalledTimes(1) + documents.length = 0 + }) - vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges) - vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback(makeTextDocument({ uri: makeUri(`${mockCwd}/new.ts`) })), 0) - return { dispose: vi.fn() } + // The same verdict on the other teardown owner. This branch called revertDocument and threw the + // answer away: a buffer that stays dirty after the revert still holds the bytes the guard + // published, and the row below is the save teardown's version of the same state. The teardown + // now gives the buffer the same second chance the save teardown gives - close, reopen from the + // bytes) and reports which of the two outcomes it ended in, because the caller's next step + // differs: settled content with a live buffer still needs the buffer saved or discarded. + it("revertChanges() after a committed create reports the buffer it could not clear", async () => { + // A structural double: the buffer holds exactly the bytes the guard published, and the rows + // below make it dirty the way the state they describe does. The factory's object carries a + // mutable isDirty; the interface declares it read-only, so the double is held under the one + // property the rows write and cast to the interface once, at the boundary. + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - diffViewProvider.editType = "modify" - - await diffViewProvider.open("new.ts") + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + // The revert command ran and the buffer came back dirty: the double answers with the verdict + // revertDocument reads off isDirty, and the fixture keeps isDirty true so the two agree. + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + reopen.mockResolvedValue(document) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) - expect(diffViewProvider["preEditScrollLine"]).toBeUndefined() + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one, which + // is the state this branch is entered with. + buffer.isDirty = true + vi.mocked(fs.unlink).mockClear() + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + // The detail names the state this teardown ended in; the guidance stays the shared one. + expect((failure as Error).message).toContain("could not be cleared") + expect((failure as Error).message).toContain("do not retry") + // The reconciliation ran: the buffer was closed and reopened from the published bytes + // before the teardown decided what to report. + expect(reopen).toHaveBeenCalledTimes(1) + expect(diffViewProvider["closeFileTab"]).toHaveBeenCalled() + // The published file survives the cancellation, and the session still finalizes. + expect(fs.unlink).not.toHaveBeenCalledWith(mockTargetPath) + expect(diffViewProvider.isEditing).toBe(false) + documents.length = 0 }) - it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { - const mockRevealRange = vi.fn() - const mockSavedEditor = makeTextEditor({ revealRange: mockRevealRange }) - - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor) - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) - diffViewProvider["documentWasOpen"] = true - diffViewProvider["preEditScrollLine"] = 30 - // saveChanges early-exits without relPath, newContent, activeDiffEditor - diffViewProvider["newContent"] = "content" - diffViewProvider["activeDiffEditor"] = makeTextEditor({ - document: makeTextDocument({ getText: vi.fn().mockReturnValue("content") }), + // The other answer the verdict can produce. The reopen came back clean, so the buffer is no + // longer live over the published bytes and the only thing left to say is what the cancellation + // always means after a commit: the content is on disk and the edit must not be reapplied. + it("revertChanges() after a committed create reports the late cancellation when the reopen came back clean", async () => { + // A structural double: the buffer holds exactly the bytes the guard published, and the rows + // below make it dirty the way the state they describe does. The factory's object carries a + // mutable isDirty; the interface declares it read-only, so the double is held under the one + // property the rows write and cast to the interface once, at the boundary. + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + // Closing the tab discards the buffer, so the reopened document is the file's own clean + // buffer. This is the case the reconciliation exists to produce. + const closeFileTab = vi.fn().mockImplementation(async () => { + buffer.isDirty = false }) + diffViewProvider["closeFileTab"] = closeFileTab + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + reopen.mockResolvedValue(document) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one. + buffer.isDirty = true + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + expect((failure as Error).message).toContain( + "a cancellation arrived after the guarded publish had committed", + ) + expect((failure as Error).message).not.toContain("could not be cleared") + expect(reopen).toHaveBeenCalledTimes(1) + documents.length = 0 + }) - expect(mockRevealRange).toHaveBeenCalledWith( - expect.objectContaining({ start: { line: 30, character: 0 } }), - vscode.TextEditorRevealType.AtTop, + // And the case where the verdict says the buffer is already clear. The second chance is a + // response to a refusal, not a step every cancellation takes: nothing was closed and reopened + // here, and the report is the ordinary one. + it("revertChanges() does not reopen the buffer the revert already cleared", async () => { + // A structural double: the buffer holds exactly the bytes the guard published, and the rows + // below make it dirty the way the state they describe does. The factory's object carries a + // mutable isDirty; the interface declares it read-only, so the double is held under the one + // property the rows write and cast to the interface once, at the boundary. + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + // The revert succeeded, which in revertDocument's terms means the buffer is no longer dirty. + diffViewProvider["revertDocument"] = vi.fn().mockImplementation(async () => { + buffer.isDirty = false + return true + }) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + reopen.mockResolvedValue(document) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) + + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one. + buffer.isDirty = true + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + expect(reopen).not.toHaveBeenCalled() + expect((failure as Error).message).toContain( + "a cancellation arrived after the guarded publish had committed", ) + expect((failure as Error).message).not.toContain("could not be cleared") + documents.length = 0 }) - it("saveChanges() does NOT call revealRange when preEditScrollLine is undefined", async () => { - const mockRevealRange = vi.fn() - const mockSavedEditor = makeTextEditor({ revealRange: mockRevealRange }) + // The second chance failing on its own. This is a VS Code call inside a teardown whose whole + // job is to leave the session finalized, so a refusal here must not abort the pass. The flag is + // set before the attempt rather than only after it: a second chance that could not run left the + // buffer uncleared just as surely as one that ran and came back dirty. + it("revertChanges() reports the buffer it could not clear when the reopen itself is refused", async () => { + const buffer: { isDirty: boolean } = makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }) + const document = buffer as unknown as vscode.TextDocument // Structural double: the teardown reads getText, isDirty and save only. + const documents = vscode.workspace.textDocuments as vscode.TextDocument[] + documents.length = 0 + documents.push(document) + diffViewProvider["revertDocument"] = vi.fn().mockResolvedValue(false) + const closeFileTab = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = closeFileTab + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + const reopen = vi.mocked(vscode.workspace.openTextDocument) + reopen.mockClear() + // Once, so the module double's own default is what the rest of the file keeps getting. + reopen.mockRejectedValueOnce( + Object.assign(new Error("EACCES: permission denied, open 'mock-target-file.ts'"), { code: "EACCES" }), + ) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "create" + diffViewProvider["originalContent"] = "" + diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document }) - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor) + await diffViewProvider.saveChanges(false) + expect(diffViewProvider["committedPublish"]?.content).toBe("content") + // The save met a clean buffer; the cancellation meets the published bytes in a dirty one. + buffer.isDirty = true + // saveChanges ran its own teardown on the way in, so the counts below are taken from the + // point the cancellation pass starts at. + closeFileTab.mockClear() + restorePreviewTabs.mockClear() + reopen.mockClear() + + const failure = await diffViewProvider.revertChanges().catch((error: unknown) => error) + // The report is the committed-write conflict, not the reopen's refusal: the pass survived its + // own reconciliation and still told the caller what it knows. + expect(failure).toBeInstanceOf(CommittedWriteBufferConflictError) + expect((failure as Error).message).toContain("could not be cleared") + expect((failure as Error).message).not.toContain("EACCES") + expect(reopen).toHaveBeenCalledTimes(1) + expect(closeFileTab).toHaveBeenCalled() + // The tab work and the finalization still ran rather than being abandoned mid-pass. + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(diffViewProvider.isEditing).toBe(false) + documents.length = 0 + }) + + // The same gap on the other teardown owner: a rejected publish's discard cleanup never resets + // (the tool caller's error handling owns that), so a cancellation that only waited for it left + // the session mid-edit with nobody finalizing it. + it("a cancellation that waits for a rejected save's discard cleanup still finalizes the session", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) - diffViewProvider["documentWasOpen"] = true - diffViewProvider["preEditScrollLine"] = undefined + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" diffViewProvider["newContent"] = "content" + diffViewProvider.isEditing = true diffViewProvider["activeDiffEditor"] = makeTextEditor({ - document: makeTextDocument({ getText: vi.fn().mockReturnValue("content") }), + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), }) - await diffViewProvider.saveChanges(false) + // Restore the publish mock afterwards: a queued rejection would fail every later save. + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + type SaveResult = Awaited> + let settled: PromiseSettledResult[] | undefined + try { + const save: Promise = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + settled = await Promise.allSettled([save, revert]) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } - expect(mockRevealRange).not.toHaveBeenCalled() + // The guard verdict still surfaces... + expect(settled?.[0].status).toBe("rejected") + // ...and the session that cancellation was waiting on is finalized. + expect(diffViewProvider.isEditing).toBe(false) + expect(diffViewProvider["activeDiffEditor"]).toBeUndefined() + // The waiting cancellation did not repeat the document revert. + expect(applyEdit).not.toHaveBeenCalled() }) - it("saveChanges() cancels a pending deferred scroll so it cannot fight scroll-restore", async () => { - // With auto-approve, saveChanges runs immediately after scrollToFirstDiff. - // A late deferred reveal must not fire after the viewport is restored. - vi.useFakeTimers() - try { - const deferredReveal = vi.fn() - const liveEditor = makeTextEditor({ - document: makeTextDocument({ - uri: makeUri(`${mockCwd}/race.ts`), - getText: vi.fn().mockReturnValue("a\nCHANGED\nc\nd\n"), - lineCount: 5, - }), - visibleRanges: [makeRange()], - revealRange: deferredReveal, - }) - diffViewProvider.originalContent = "a\nb\nc\nd\n" - diffViewProvider["activeDiffEditor"] = liveEditor - vi.mocked(vscode.window).visibleTextEditors = [liveEditor] + // A rejected publish closes its own diff view and rethrows, so the preview tabs the diff + // evicted are restored by that pass - and only by that pass. + it("saveChanges() restores preview tabs when a rejected publish tears the session down", async () => { + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const closeOwnDiffView = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) - // Schedule the deferred reveal, then accept the edit before it fires. - diffViewProvider.scrollToFirstDiff() - expect(deferredReveal).toHaveBeenCalledTimes(1) + // The publish is rejected, so the discard-only cleanup runs and the guard verdict is + // rethrown. Restore the publish mock afterwards: a queued rejection would fail every + // later save in this file. + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + try { + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow("guard rejected for test") + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(makeTextEditor()) - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) - diffViewProvider["documentWasOpen"] = true - diffViewProvider["preEditScrollLine"] = 0 - diffViewProvider["newContent"] = "a\nCHANGED\nc\nd\n" + expect(closeOwnDiffView).toHaveBeenCalledTimes(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + }) - await diffViewProvider.saveChanges(false) + // The rejected save's discard cleanup restores the tabs once, and it never resets - the + // tool caller's error handling owns that reset. A cancellation that only waited for that + // pass still has to leave the session closed, so it performs the finalization. + it("saveChanges() restores preview tabs once and the waiting revert finalizes the session it left open", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) - // The timer was cancelled; advancing past it produces no extra reveal. - vi.advanceTimersByTime(100) - expect(deferredReveal).toHaveBeenCalledTimes(1) - expect(diffViewProvider["deferredScrollTimer"]).toBeUndefined() + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + // The revert joins the rejected save's pass instead of starting its own. + const revert = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await revert } finally { - vi.useRealTimers() + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) } - }) - it("revertChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { - const mockRevealRange = vi.fn() - const mockSavedEditor = makeTextEditor({ revealRange: mockRevealRange }) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + }) - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor) - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) - diffViewProvider["documentWasOpen"] = true - diffViewProvider["preEditScrollLine"] = 15 - diffViewProvider.editType = "modify" - diffViewProvider.originalContent = "original" + it("revertChanges() finalizes the session once when two cancellations wait on the same pass", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + let cleanupEntered = false + const closeOwnDiffView = vi.fn().mockImplementation(async () => { + cleanupEntered = true + await gate + }) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = closeOwnDiffView + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["newContent"] = "content" diffViewProvider["activeDiffEditor"] = makeTextEditor({ document: makeTextDocument({ - uri: makeUri(`${mockCwd}/test.txt`), - getText: vi.fn().mockReturnValue("modified"), - positionAt: vi.fn().mockReturnValue(makePosition()), + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), }), }) + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + while (!cleanupEntered) { + await new Promise((resolve) => setImmediate(resolve)) + } + // Two cancellations - a tool caller and a disposal, say - both join the pass that is + // already running. Neither owns it, and the owning pass never finalizes the session, + // so exactly one of them may close it. + const first = diffViewProvider.revertChanges() + const second = diffViewProvider.revertChanges() + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await Promise.all([first, second]) + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) + expect(reset).toHaveBeenCalledTimes(1) + }) + + it("revertChanges() does not finalize a session a second time after the first cancellation closed it", async () => { + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + diffViewProvider["reset"] = reset + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + // Two cancellations one after the other: the first owns its pass and closes the session, + // the second starts a pass of its own and must still not close the session a second time. + await diffViewProvider.revertChanges() await diffViewProvider.revertChanges() - expect(mockRevealRange).toHaveBeenCalledWith( - expect.objectContaining({ start: { line: 15, character: 0 } }), - vscode.TextEditorRevealType.AtTop, - ) + expect(restorePreviewTabs).toHaveBeenCalledTimes(2) + expect(reset).toHaveBeenCalledTimes(1) }) - }) - - describe("userTouchedDocument close/keep behavior", () => { - const mockTargetPath = `${mockCwd}/mock-target-file.ts` - const buildActiveDiffEditor = () => - makeTextEditor({ + it("revertChanges() does not finalize a session that a direct reset already closed", async () => { + const realReset = diffViewProvider["reset"].bind(diffViewProvider) + let resetCalls = 0 + diffViewProvider["reset"] = async () => { + resetCalls++ + await realReset() + } + diffViewProvider["closeOwnDiffView"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + vi.mocked(vscode.workspace.applyEdit).mockResolvedValue(true) + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ document: makeTextDocument({ uri: makeUri(mockTargetPath), getText: vi.fn().mockReturnValue("content"), - positionAt: vi.fn().mockReturnValue(makePosition()), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), }), }) - it("saveChanges() closes the file tab when the file was not open and untouched", async () => { - const closeFileTab = vi.fn().mockResolvedValue(undefined) - diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) - diffViewProvider["closeFileTab"] = closeFileTab + // A reset outside the teardown paths closes the session for everyone, so the claim has to + // come from the reset itself and not only from the caller that went through the helper. + await diffViewProvider["reset"]() + await diffViewProvider.revertChanges() + + expect(resetCalls).toBe(1) + }) + + // The other direction: the rejected save is the one that joins an existing pass. It then + // runs no cleanup of its own, and the pass that started the teardown restores the tabs once. + it("saveChanges() restores no preview tabs when a revert already owns the teardown", async () => { + let release: () => void = () => {} + const gate = new Promise((resolve) => { + release = resolve + }) + const { closedTabs } = installDiffTabSpy(mockTargetPath, gate) + const restorePreviewTabs = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = restorePreviewTabs + // reset() also closes this provider's diff tab; stub it so the close spy counts + // only the pass under test. + diffViewProvider["reset"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) diffViewProvider["relPath"] = "mock-target-file.ts" - diffViewProvider["documentWasOpen"] = false - diffViewProvider["userTouchedDocument"] = false - diffViewProvider["preEditScrollLine"] = undefined + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" diffViewProvider["newContent"] = "content" - diffViewProvider["activeDiffEditor"] = buildActiveDiffEditor() + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) - await diffViewProvider.saveChanges(false) + // The revert starts its pass first and is held inside it. + const revert = diffViewProvider.revertChanges() + while (closedTabs.length === 0) { + await new Promise((resolve) => setImmediate(resolve)) + } - expect(closeFileTab).toHaveBeenCalledWith(mockTargetPath) - expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + vi.mocked(safeWriteText).mockRejectedValue(new Error("guard rejected for test")) + let save: Promise = Promise.resolve() + try { + save = diffViewProvider.saveChanges(false) + await new Promise((resolve) => setImmediate(resolve)) + release() + await expect(save).rejects.toThrow("guard rejected for test") + await revert + } finally { + vi.mocked(safeWriteText).mockReset() + vi.mocked(safeWriteText).mockResolvedValue(undefined) + } + + // The save's cleanup never ran, so it restored nothing; the owning revert pass did the + // restoring exactly once. + // The rejected save ran no cleanup of its own: the only tab close in the whole + // exchange is the owning revert pass's own view going away. + expect(closedTabs).toHaveLength(1) + expect(restorePreviewTabs).toHaveBeenCalledTimes(1) }) it("saveChanges() keeps the file open when the user touched it", async () => { @@ -1207,6 +4736,45 @@ describe("DiffViewProvider", () => { expect(diffViewProvider["userTouchedDocument"]).toBe(true) }) + // A cancellation that joins a teardown waits behind a pass it does not own. When that + // pass's cleanup throws, the buffer can still be dirty and this provider's tab still + // open, so the waiter has to hear about the failure instead of resuming as though the + // session had been tidied - and it must not close a session it never tidied. + it("revertChanges() carries a failed cleanup to the caller that joined the pass", async () => { + const applyEdit = vi.mocked(vscode.workspace.applyEdit) + applyEdit.mockResolvedValue(true) + diffViewProvider["closeFileTab"] = vi.fn().mockResolvedValue(undefined) + diffViewProvider["restorePreviewTabs"] = vi.fn().mockResolvedValue(undefined) + const cleanupFailure = new Error("tab close rejected mid-teardown") + // The owning pass fails partway through its cleanup: the tab is still open and the + // document still holds the edit when the pass ends. + diffViewProvider["closeOwnDiffView"] = vi.fn().mockRejectedValue(cleanupFailure) + const reset = vi.fn().mockResolvedValue(undefined) + diffViewProvider["reset"] = reset + diffViewProvider["relPath"] = "mock-target-file.ts" + diffViewProvider["editType"] = "modify" + diffViewProvider["originalContent"] = "original" + diffViewProvider["activeDiffEditor"] = makeTextEditor({ + document: makeTextDocument({ + uri: makeUri(mockTargetPath), + getText: vi.fn().mockReturnValue("content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }), + }) + + const owner = diffViewProvider.revertChanges() + const joiner = diffViewProvider.revertChanges() + + await expect(owner).rejects.toThrow("tab close rejected mid-teardown") + // The gate carries the pass's outcome, so the waiter receives the same failure the + // owner received instead of a clean resume. + await expect(joiner).rejects.toThrow(cleanupFailure) + // Resetting here would clear the state of a session that is still mid-edit: the pass + // that owns the session has to answer for it, which it does through its own caller. + expect(reset).not.toHaveBeenCalled() + expect(diffViewProvider["sessionFinalizationClaimed"]).toBe(false) + }) }) describe("userTouchedDiffEditor keep/close behavior", () => { @@ -1604,7 +5172,19 @@ describe("DiffViewProvider", () => { }) const setupProvider = (stateOverrides: Record = {}) => { - const provider = new DiffViewProvider(mockCwd, makeTaskDouble(stateOverrides)) + const task = { + ...makeTaskDouble(stateOverrides), + cwd: mockCwd, + // S4b follow-up (#44): saveChanges publishes through the guarded write, + // which resolves the path against task.cwd and consults the task's + // observation registry - both must be present on this suite's mock task. + observationRegistry: new ObservationRegistry(), + } as unknown as Task // the double is partial; cast matches makeTaskDouble's own + const provider = new DiffViewProvider(mockCwd, task) + // The preview observation + matching version token let the guarded save + // proceed so these tests stay focused on the auto-close decision table. + task.observationRegistry.observe(`${mockTargetPath}`, "v1") + vi.mocked(computeVersionToken).mockResolvedValue("v1") provider["relPath"] = "auto-close-test.ts" provider["newContent"] = "content" provider["activeDiffEditor"] = buildActiveDiffEditor() diff --git a/src/integrations/editor/committedWriteBufferConflictError.ts b/src/integrations/editor/committedWriteBufferConflictError.ts new file mode 100644 index 0000000000..61ac8a6da7 --- /dev/null +++ b/src/integrations/editor/committedWriteBufferConflictError.ts @@ -0,0 +1,28 @@ +/** + * The guarded publish committed the accepted content to disk, but an open buffer + * with different unsaved edits prevented reconciling the editor. The write is + * committed: callers must not retry the same (possibly non-idempotent) edit, + * because re-reading and re-applying it would duplicate the change. + * + * Each site adds a `detail` line naming the window it hit - the save that could not + * clear a dirty buffer, the cancellation that arrived after a commit, the cancellation whose + * revert left the buffer dirty after the reopen, the move whose source unlink never ran - + * while the guidance below stays identical everywhere, so one name and one set of + * instructions covers every committed-write conflict. + * name and one set of instructions covers every committed-write conflict. + * + * Kept in its own vscode-free module so tool-level tests can construct it + * without loading the DiffViewProvider's editor dependencies. + */ +export class CommittedWriteBufferConflictError extends Error { + constructor(absolutePath: string, detail?: string) { + super( + (detail ? `${detail} ` : "") + + `The guarded publish committed the accepted content to ${absolutePath}, but an open buffer holds ` + + "unsaved edits that differ from it; the buffer was kept so those edits are not lost. " + + "The write is already committed to disk -- do not retry this edit. Save or discard the buffer, " + + "then read the file again before making a separate edit.", + ) + this.name = "CommittedWriteBufferConflictError" + } +} diff --git a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts index bfc397cf09..fe0e095a85 100644 --- a/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader-unicode.spec.ts @@ -74,6 +74,8 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 3, returnedLines: 1, wasTruncated: true, + // This unit's read-scope work reports whether any returned line was clipped. + hasClippedLines: true, }) expect(Buffer.from(result.content).toString("utf8")).toBe(result.content) }) @@ -115,6 +117,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 1, wasTruncated: false, + hasClippedLines: true, }) expect(readWithSlice("")).toEqual({ content: "1 | ", @@ -122,6 +125,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 1, wasTruncated: false, + hasClippedLines: false, }) }) @@ -132,6 +136,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, }) expect(readWithIndentation(longLine, { anchorLine: 0 })).toEqual({ content: "Error: anchor_line 0 is out of range (1-1)", @@ -139,6 +144,7 @@ describe("Unicode clipping through the existing readers", () => { totalLines: 1, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, }) }) }) diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index a092dff8fc..2a2efcb4b4 100644 --- a/src/integrations/misc/__tests__/indentation-reader.spec.ts +++ b/src/integrations/misc/__tests__/indentation-reader.spec.ts @@ -1,4 +1,5 @@ import { describe, it, expect } from "vitest" +import { MAX_LINE_LENGTH } from "../../../core/prompts/tools/native-tools/read_file" import { parseLines, formatWithLineNumbers, @@ -278,11 +279,45 @@ describe("readWithSlice", () => { expect(result.wasTruncated).toBe(true) }) + it("reports a clipped line separately from omitted lines", () => { + // Every line is returned, but formatWithLineNumbers clips a line longer + // than MAX_LINE_LENGTH, so the model did not see the whole file. + const lines = ["x".repeat(MAX_LINE_LENGTH + 10), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(true) + }) + + it("keeps a slice complete when a line is exactly at the length cap", () => { + // formatWithLineNumbers clips only lines strictly longer than the cap, so a + // line at exactly MAX_LINE_LENGTH is shown in full and the read is complete. + const lines = ["x".repeat(MAX_LINE_LENGTH), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.wasTruncated).toBe(false) + expect(result.hasClippedLines).toBe(false) + }) + + it("flags clipping when any line is clipped, not only when every line is", () => { + // The first line is clipped and the second is shown in full: some lines are + // a partial view even though every line was returned. + const lines = ["y".repeat(MAX_LINE_LENGTH + 1), "short"].join("\n") + const result = readWithSlice(lines, 0, 10) + + expect(result.returnedLines).toBe(2) + expect(result.hasClippedLines).toBe(true) + }) + it("should handle offset beyond file end", () => { const result = readWithSlice(SIMPLE_CODE, 1000, 10) expect(result.returnedLines).toBe(0) expect(result.content).toContain("Error: offset 1001 is beyond file end") + // No line was returned, so nothing could have been clipped. + expect(result.hasClippedLines).toBe(false) }) it("should handle negative offset", () => { @@ -296,6 +331,14 @@ describe("readWithSlice", () => { // ─── readWithIndentation Tests ──────────────────────────────────────────────── describe("readWithIndentation", () => { + it("reports an out-of-range anchor as an error with no clipping", () => { + const result = readWithIndentation(SIMPLE_CODE, { anchorLine: 1000 }) + + expect(result.content).toContain("out of range") + expect(result.returnedLines).toBe(0) + expect(result.hasClippedLines).toBe(false) + }) + describe("basic block extraction", () => { it("should extract content around the anchor line", () => { const result = readWithIndentation(PYTHON_CODE, { diff --git a/src/integrations/misc/indentation-reader.ts b/src/integrations/misc/indentation-reader.ts index 43e86a47ad..21aa6777b5 100644 --- a/src/integrations/misc/indentation-reader.ts +++ b/src/integrations/misc/indentation-reader.ts @@ -58,8 +58,10 @@ export interface IndentationReadResult { totalLines: number /** Lines actually returned */ returnedLines: number - /** Whether output was truncated due to limit */ + /** Whether output was truncated because lines were omitted */ wasTruncated: boolean + /** Whether any returned line was clipped by the per-line length cap */ + hasClippedLines?: boolean } // ─── Constants ──────────────────────────────────────────────────────────────── @@ -244,6 +246,7 @@ export function readWithIndentation(content: string, options: IndentationReadOpt totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -386,6 +389,7 @@ export function readWithSlice( totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -393,6 +397,11 @@ export function readWithSlice( const endIdx = Math.min(offset + limit, totalLines) const selectedLines = lines.slice(offset, endIdx) const wasTruncated = endIdx < totalLines + // A returned line can still be a partial view: formatWithLineNumbers clips a + // line longer than MAX_LINE_LENGTH, so a slice that returned every line may + // still hide content. Clipping is reported separately from omission so the + // caller does not suggest a next offset that is beyond the file. + const hasClippedLines = selectedLines.some((line) => line.content.length > MAX_LINE_LENGTH) // Format output const formattedContent = formatWithLineNumbers(selectedLines) @@ -403,5 +412,6 @@ export function readWithSlice( totalLines, returnedLines: selectedLines.length, wasTruncated, + hasClippedLines, } } diff --git a/src/services/file-safety/__tests__/safeWriteText.spec.ts b/src/services/file-safety/__tests__/safeWriteText.spec.ts new file mode 100644 index 0000000000..45080a17eb --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,1825 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import { execFile } from "child_process" +import type { ChildProcess } from "child_process" +import * as path from "path" + +import { + LockKeyWalkIncompleteError, + PostCommitDurabilityError, + resolveLockKey, + safeWriteText, + StagingPathError, +} from "../safeWriteText" + +// Full mock for fs/promises — all methods are vi.fn() stubs +vi.mock("fs/promises", () => ({ + copyFile: vi.fn(), + chmod: vi.fn(), + mkdir: vi.fn(), + access: vi.fn(), + rename: vi.fn(), + unlink: vi.fn(), + rmdir: vi.fn(), + realpath: vi.fn(), + lstat: vi.fn(), + readlink: vi.fn(), + // Production reads the two directories through fs.stat (bigint) when the caller's directory spelling + // differs from the target's, so the mock has to carry the method. The default reports "not there", which + // is what a mocked path outside the target's directory already meant for these rows. + stat: vi.fn(), +})) + +// Full mock for fs — all sync methods are vi.fn() stubs. Stats is a bare +// class stub so tests can build minimal Stats stand-ins via its prototype. +vi.mock("fs", () => ({ + openSync: vi.fn(), + writeSync: vi.fn(), + closeSync: vi.fn(), + mkdirSync: vi.fn(), + fsyncSync: vi.fn(), + chmodSync: vi.fn(), + fchmodSync: vi.fn(), + fstatSync: vi.fn(), + statSync: vi.fn(), + Stats: class Stats {}, +})) + +// Mock child_process.execFile (callback-based — must invoke callback to resolve) +vi.mock("child_process", () => ({ + execFile: vi.fn((cmd, args, opts, cb) => { + if (typeof cb === "function") cb(null) + }), +})) + +// Minimal stand-in for the ChildProcess that callback-form execFile returns. +const fakeChild = { kill: () => true } as unknown as ChildProcess + +// Helper that mirrors safeWriteText's path resolution exactly +function _resolvedTarget(filePath: string): string { + return path.resolve(filePath) +} +function _dirPath(filePath: string): string { + return path.dirname(_resolvedTarget(filePath)) +} +// Minimal Stats stand-in: the SUT only reads `.mode` from it. +// Async lstat stand-in: the SUT only asks whether the path is a link or a file. +// Built on the Stats prototype so the mock value still satisfies fsSync.Stats. +function _fileStats(isLink: boolean): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + s.isSymbolicLink = () => isLink + s.isFile = () => !isLink + return s +} + +// fs.BigIntStats is a type-only export (fs.BigIntStats is undefined at runtime), so the stand-in is +// a Stats object carrying bigint ino/dev - exactly what fs.lstat(path, { bigint: true }) hands +// back at runtime. +function _fileStatsWithIdentity(ino: bigint, dev: bigint): fsSync.BigIntStats { + // Double assertion: the runtime value is the Stats stand-in, the type is the bigint variant. + const s = _fileStats(false) as unknown as fsSync.BigIntStats + s.ino = ino + s.dev = dev + return s +} + +function mockDefaults(): void { + vi.resetAllMocks() + // After resetAllMocks, vi.fn() returns undefined — restore promise defaults. + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.access).mockResolvedValue(undefined) + vi.mocked(fs.rename).mockResolvedValue(undefined) + vi.mocked(fs.unlink).mockResolvedValue(undefined) + vi.mocked(fs.rmdir).mockResolvedValue(undefined) + // Existing-target default: a regular 0o644 file. + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o644)) + // Staged-file default: a regular file, not a link, so a caller-supplied + // tempPath passes the location and file-type check by default. + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + // The caller-staged branch re-confirms the file its fd landed on through fs.fstatSync. The + // default carries no bigint identity, which is the shape production never hands this compare + // (it asks for { bigint: true }), so the guard declines to read it as a swap and the rows below + // keep the meaning they had before the re-confirm existed. Rows that care about identity say so + // by handing both sides one. + vi.mocked(fsSync.fstatSync).mockReturnValue(_fileStats(false)) + vi.mocked(fs.stat).mockRejectedValue(Object.assign(new Error("no such directory"), { code: "ENOENT" })) +} +function _stats(mode: number): fsSync.Stats { + const s = Object.create(fsSync.Stats.prototype) as fsSync.Stats + Object.assign(s, { mode }) + return s +} + +// ── Test 1: staging file created then cleaned after success ──────────────── + +describe("safeWriteText", () => { + beforeEach(() => { + mockDefaults() + // Default sync-write behaviour: report that all requested bytes were + // written. The Buffer overload passes (fd, buffer, offset, length), + // so the fourth argument is the requested length. + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + typeof args[3] === "number" ? args[3] : 0, + ) + }) + + describe("staging and cleanup", () => { + // The staging directory is private, and it must stay the only thing that is. Created inside + // a parent that does not exist yet, the recursive mkdirSync that makes it also makes that + // parent -- with the staging mode -- so the target ends up published into an owner-only + // directory it never asked for. What matters is the order of the two creations and the + // mode each one carries, recorded across both filesystem APIs this write uses. + it("creates the target's parent directory before the private staging directory", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const parentDir = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const creations: Array<{ path: string; mode?: number }> = [] + try { + vi.mocked(fs.mkdir).mockImplementation(async (p: Parameters[0]) => { + creations.push({ path: String(p) }) + return undefined + }) + const syncMkdir = vi.mocked(fsSync.mkdirSync) + syncMkdir.mockImplementation(((p: string | Buffer | URL, options?: unknown) => { + creations.push({ + path: String(p), + mode: (options as { mode?: number }).mode, + }) + }) as unknown as typeof fsSync.mkdirSync) + + await safeWriteText(targetPath, "hello world", { platform: "linux" }) + + const parent = creations.findIndex((c) => c.path === parentDir) + const staging = creations.findIndex((c) => c.path.includes(".file-safety-staging")) + expect(parent).toBeGreaterThanOrEqual(0) + expect(staging).toBeGreaterThanOrEqual(0) + // The parent is made first, by the plain recursive mkdir, which carries no mode of + // its own and therefore gets the directory default. + expect(staging).toBeGreaterThan(parent) + expect(creations[parent].mode).toBeUndefined() + // The private mode is still applied -- to the staging directory alone. + expect(creations[staging].mode).toBe(0o700) + } finally { + vi.mocked(fs.mkdir).mockReset() + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fsSync.mkdirSync).mockReset() + } + }) + + it("creates a temp file in the staging dir, fsyncs it, renames to target, and cleans up on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) // fd=1 + vi.mocked(fsSync.closeSync).mockReturnValue(undefined) + + await safeWriteText(targetPath, "hello world", { platform: "linux" }) + + // staging dir was created with private permissions — use + // stringContaining to handle Windows path resolution + expect(fsSync.mkdirSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), { + recursive: true, + mode: 0o700, + }) + // a pre-existing staging dir is repaired to private permissions too + expect(fsSync.chmodSync).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), 0o700) + + // temp file was opened for writing with the existing target's mode + // (default 0o644 from the statSync default mock) + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + + // content was written as a buffer (partial-write loop, full write) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("hello world", "utf8"), 0, 11) + + // fsync (sync form) was called on the fd + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // file was closed + expect(fsSync.closeSync).toHaveBeenCalledWith(1) + + // atomic rename happened — realpath mock returns targetPath, so that's the dest + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink of temp (it's now the committed file; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + + it("removes the now-empty staging directory after a successful self-staged commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux" }) + + // the staging subdir is removed best-effort after the commit rename + // (stringContaining: the SUT and the test helper resolve Windows + // drive-relative paths differently, as in the existing staging tests) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + // the win32 DACL restore gate must stay closed on other platforms: + // no icacls save or restore is attempted + expect(execFile).not.toHaveBeenCalled() + }) + + it("still removes the staging directory when no options are supplied at all", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // options is undefined: the self-staged check and the optional-chained + // DACL runner lookup must not dereference it + await expect(safeWriteText(targetPath, "hello")).resolves.toBeUndefined() + + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + if (process.platform === "win32") { + // default platform is win32: the DACL save + restore still ran + // through the default icacls path (options?.execFileRunner must + // not throw when options is undefined) + expect(vi.mocked(execFile)).toHaveBeenCalledTimes(2) + expect(vi.mocked(fs.unlink)).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) + } + }) + + it("does not remove the staging directory when the caller supplies its own tempPath", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }) + + // the caller owns its temp file's directory; safeWriteText must not + // rmdir a directory it did not create + expect(fs.rmdir).not.toHaveBeenCalled() + }) + + it("a failed staging-dir removal never fails the committed write", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rmdir).mockRejectedValue(Object.assign(new Error("ENOTEMPTY"), { code: "ENOTEMPTY" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).resolves.toBeUndefined() + + // the commit rename still happened and the rmdir error was swallowed + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(fs.rmdir).toHaveBeenCalledTimes(1) + expect(fs.rmdir).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging")) + }) + + it("gives each self-staged write its own staging directory so a concurrent write cannot remove it", async () => { + const targetA = "/tmp/test-dir/target-a.txt" + const targetB = "/tmp/test-dir/target-b.txt" + vi.mocked(fs.realpath).mockImplementation((p) => Promise.resolve(p as string)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetA, "a", { platform: "linux" }) + await safeWriteText(targetB, "b", { platform: "linux" }) + + // Two self-staged writes in the same directory must not share one staging + // directory: the first write's best-effort rmdir would otherwise delete the + // directory the second write had created but not yet opened (ENOENT on openSync). + const created = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + const staging = created.filter((p) => p.includes(".file-safety-staging_")) + expect(staging).toHaveLength(2) + expect(staging[0]).not.toBe(staging[1]) + // Uniqueness comes from the documented name shape + // /.file-safety-staging__: pinning the shape + // keeps the separator and the random suffix meaningful, not just the prefix. + for (const dir of staging) { + expect(dir).toMatch(/\.file-safety-staging_\d+_[a-z0-9]+$/) + } + const removed = vi.mocked(fs.rmdir).mock.calls.map((c) => String(c[0])) + expect(removed).toEqual([staging[0], staging[1]]) + }) + + it("removes its own staging directory when a self-staged write fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect(safeWriteText(targetPath, "hello", { platform: "linux" })).rejects.toThrow("EACCES") + + // The failed write's temp file is unlinked, then the directory it + // created is removed — a failed write must not leave an empty + // .file-safety-staging directory behind. + // mkdirSync created this write's staging directory; the temp file lives + // inside it, so the unlink targets a path under that directory. + const staging = vi.mocked(fsSync.mkdirSync).mock.calls.map((c) => String(c[0])) + expect(staging).toHaveLength(1) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining(staging[0])) + expect(fs.rmdir).toHaveBeenCalledWith(staging[0]) + // The directory is only empty after its temp file is gone, so the + // unlink must happen before the rmdir. + expect(vi.mocked(fs.unlink).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(fs.rmdir).mock.invocationCallOrder[0], + ) + }) + + it("does not remove a staging directory it did not create when a caller-staged write fails", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const callerTemp = "/tmp/test-dir/caller-staged.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect( + safeWriteText(targetPath, "hello", { platform: "linux", tempPath: callerTemp }), + ).rejects.toThrow("EACCES") + + // The caller owns that directory: only the caller's temp file is cleaned, + // never a rmdir of a directory safeWriteText never created. + expect(fs.unlink).toHaveBeenCalledWith(callerTemp) + expect(fs.rmdir).not.toHaveBeenCalled() + }) + }) + + it("win32: a rejecting async onWarning does not abort the write or leak an unhandled rejection", async () => { + // TypeScript accepts an async sink where a void callback is expected, so the + // wrapper has to attach a handler to the returned promise: an unhandled + // rejection can end the process under Node's default mode, after a write that + // already succeeded. + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {}) + + await expect( + safeWriteText(targetPath, "data", { + platform: "win32", + onWarning: async () => { + throw new Error("async sink down") + }, + }), + ).resolves.toBeUndefined() + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + // The rejection is reported through the fallback sink rather than surfacing as an + // unhandled rejection. + expect(consoleWarn).toHaveBeenCalledWith(expect.stringContaining("onWarning callback rejected")) + consoleWarn.mockRestore() + }) + + // ── Test 2: fsync ordering ─────────────────────────────────────────────── + + describe("fsync ordering", () => { + it("calls fsync on the fd before close, and rename after close", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Verify call order: openSync(temp) → writeSync → fsyncSync(temp) + // → closeSync(temp) → rename. On POSIX the parent directory is then + // opened and fsynced after the commit rename, so openSync/fsyncSync/ + // closeSync each have a second (directory) call. + expect(vi.mocked(fsSync.openSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.writeSync).mock.calls.length).toBe(1) + expect(vi.mocked(fsSync.fsyncSync).mock.calls.length).toBe(2) + expect(vi.mocked(fsSync.closeSync).mock.calls.length).toBe(2) + + // the temp file was fully closed before the commit rename + expect(vi.mocked(fsSync.closeSync).mock.calls[0][0]).toBe(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + // The title promises the order, so compare the invocations rather than + // only count them: a rename before closeSync, or a close before fsync, + // would not be a durable commit. + const fsyncOrder = vi.mocked(fsSync.fsyncSync).mock.invocationCallOrder[0] + const closeOrder = vi.mocked(fsSync.closeSync).mock.invocationCallOrder[0] + const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0] + expect(fsyncOrder).toBeLessThan(closeOrder) + expect(closeOrder).toBeLessThan(renameOrder) + }) + }) + + // ── Test 3: simulated failure between write and rename leaves target intact ── + + describe("crash/torn-write safety", () => { + it("simulated failure between fsync and rename leaves the target byte-identical and no temp left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + + await expect(safeWriteText(targetPath, "new data", { platform: "linux" })).rejects.toThrow("ENOSPC") + + // rename was attempted (the failure point) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // temp file was cleaned up on failure + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + + // backup was NOT created (backup:false by default), so target is untouched + // The only rename call was temp→target, not a rollback rename + expect(fs.rename).toHaveBeenCalledTimes(1) + }) + + it("a post-commit backup cleanup failure is non-fatal: the target stays committed and no temp is left behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The post-commit backup unlink (SUT step 6) fails — the write must + // still succeed; an orphaned backup is the documented acceptable + // outcome, so the failure is swallowed instead of rolling back. + vi.mocked(fs.unlink).mockRejectedValueOnce(new Error("EPERM")) + + await safeWriteText(targetPath, "data", { backup: true, platform: "linux" }) + + // the commit rename (temp -> target) still happened; it is the only rename + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // the failing cleanup was the post-commit backup unlink + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + + // the staging temp was already committed by the rename; nothing + // temp-shaped is unlinked afterwards + expect(fs.unlink).not.toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("a failed post-commit directory fsync does not roll the backup back over the published content", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // The file fd opens normally; the parent-directory open after the commit + // rename fails, which is the post-commit durability failure. + vi.mocked(fsSync.openSync).mockImplementation((target) => { + if (String(target) === dirPath) throw new Error("EBADF") + return 1 + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + PostCommitDurabilityError, + ) + + // The commit rename already published the new content, and the backup was only + // ever a copy: the target was never moved, so there is nothing to rename back. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // The durability failure is reported, not swallowed. The backup is NOT deleted: + // after the commit the target holds the NEW content, and this copy is the only + // one known to hold the previous content - the recovery copy the contract + // promises for exactly this path. + const backupUnlinks = vi.mocked(fs.unlink).mock.calls.filter(function (call: unknown[]) { + return String(call[0]).includes("safeWriteText.bak_") + }) + expect(backupUnlinks).toEqual([]) + // The staged temp is still cleaned up. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("reports the retained backup path on a post-commit durability failure", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockImplementation((target) => { + if (String(target) === dirPath) { + throw new Error("EBADF") + } + return 1 + }) + + const error = await safeWriteText(targetPath, "new data", { + backup: true, + platform: "linux", + }).catch(function (caught: unknown) { + return caught + }) + + // The backup is kept on this path, and its name is a hidden random string. Without + // the field the caller is told a recovery copy exists and given no way to find it. + expect(error).toBeInstanceOf(PostCommitDurabilityError) + const retained = (error as PostCommitDurabilityError).backupPath + expect(typeof retained).toBe("string") + expect(String(retained)).toContain("safeWriteText.bak_") + // The reported path is exactly the copy that was made, so a caller can open it. + const copiedBackups = vi + .mocked(fs.copyFile) + .mock.calls.map(function (call: unknown[]) { + return String(call[1]) + }) + .filter(function (p: string) { + return p.includes("safeWriteText.bak_") + }) + expect(copiedBackups).toContain(retained) + }) + + it("reports a null backup path when no backup was taken", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const dirPath = path.dirname(targetPath) + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockImplementation((target) => { + if (String(target) === dirPath) { + throw new Error("EBADF") + } + return 1 + }) + + const error = await safeWriteText(targetPath, "new data", { + platform: "linux", + }).catch(function (caught: unknown) { + return caught + }) + + expect(error).toBeInstanceOf(PostCommitDurabilityError) + expect((error as PostCommitDurabilityError).backupPath).toBeNull() + }) + }) + + // ── Test 4: backup:true keeps old safeWriteJson semantics, copy-based ── + + describe("backup:true", () => { + it("copies target -> backup before commit without moving the target, deletes the copy on success", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "new data", { backup: true }) + + // target was accessed (exists check) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // The backup is a copy: the canonical target is never moved away, so readers + // never see a missing file and no later step can clobber a concurrent publish. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.rename).not.toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + + // the only rename is the atomic commit temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // backup copy was deleted on success + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + + it("a failed commit does not move the target, so nothing has to be rolled back", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The commit rename is the only rename in the flow and it fails. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow("ENOSPC") + + // The target never left its path, so there is no restore rename and the + // pre-write content is still what a reader sees at targetPath. + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + + // Both the backup copy and the staging temp are cleaned up on failure. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("creates the backup privately before its content exists, then fsyncs it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "new data", { backup: true }) + + // The destination must exist with a private mode before copyFile writes anything + // into it: copyFile chooses the destination mode itself, so a restrictive target + // could otherwise leave a group/world-readable copy that a later chmod cannot + // undo. "wx" also means a pre-existing path is never silently reused. + const seedOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) { + return String(call[0]).includes("safeWriteText.bak_") && call[1] === "wx" + }) + expect(seedOpen).toBeDefined() + expect(seedOpen?.[2]).toBe(0o600) + const seedOrder = vi.mocked(fsSync.openSync).mock.invocationCallOrder[ + vi.mocked(fsSync.openSync).mock.calls.indexOf(seedOpen!) + ] + expect(seedOrder).toBeLessThan(vi.mocked(fs.copyFile).mock.invocationCallOrder[0]) + + // The chmod keeps a copied read-only attribute (Windows) from breaking the fsync + // open, and keeps a backup of a permissive file private. + expect(fs.copyFile).toHaveBeenCalledWith(targetPath, expect.stringContaining("safeWriteText.bak_")) + expect(fs.chmod).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_"), 0o600) + expect(vi.mocked(fs.chmod).mock.invocationCallOrder[0]).toBeGreaterThan( + vi.mocked(fs.copyFile).mock.invocationCallOrder[0], + ) + + // The copy is then opened for fsync with the writable flag. + const backupOpen = vi.mocked(fsSync.openSync).mock.calls.find(function (call) { + return String(call[0]).includes("safeWriteText.bak_") && call[1] === "r+" + }) + expect(backupOpen).toBeDefined() + }) + + it("a failed backup flush is reported and leaves no partial backup behind", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The copy lands, but the fsync of the copy fails: the retained content is not + // known to be durable, so the write must not proceed on a half-written backup. + // The staged temp is fsynced earlier with a different handle, so target the + // backup's fd specifically. + vi.mocked(fsSync.openSync).mockImplementation((p: unknown) => + String(p).includes("safeWriteText.bak_") ? 7 : 1, + ) + vi.mocked(fsSync.fsyncSync).mockImplementation((fd: unknown) => { + if (fd === 7) { + throw new Error("EIO") + } + }) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + "EIO", + ) + + // Nothing was published, and the incomplete copy is removed rather than left + // next to the target looking like a usable backup. + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("a backup copy that fails part-way is removed, not left as a usable-looking backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The destination is seeded with openSync("wx") BEFORE any content exists at it, + // and the copy then fails part-way: the name is there, holding a partial copy of + // nothing usable. Cleanup has to key off the attempt, not off a copy that + // succeeded, or this file outlives the failed write beside the target. + vi.mocked(fs.copyFile).mockRejectedValue(Object.assign(new Error("EIO"), { code: "EIO" })) + + await expect(safeWriteText(targetPath, "new data", { backup: true, platform: "linux" })).rejects.toThrow( + "EIO", + ) + + // Nothing was published, and the half-written copy is removed rather than left + // next to the target looking like a backup someone could restore. + expect(fs.rename).not.toHaveBeenCalled() + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + }) + + it("backup:true when target does not exist: no backup created, just commit", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // fs.access resolves for dirPath check, but rejects for target check (backup path) + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + }) + + await safeWriteText(targetPath, "new data", { backup: true, platform: "linux" }) + + // no backup rename (target didn't exist) + expect(fs.access).toHaveBeenCalledWith(targetPath) + + // only one rename: temp -> target + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + + // no unlink (no backup to delete; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 5: win32 DACL path ────────────────────────────────────────────── + + describe("win32 DACL", () => { + // The case hands in the platform it wants the writer to believe, so it does not need a + // Windows host: the neighbouring cases in this describe already run on every lane. + it("copies target DACL onto staging file via icacls before rename on Windows", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls dump + restore were called (execFile is callback-based mock) + expect(execFile).toHaveBeenCalledTimes(2) + }) + + it("non-win32: DACL path is unreachable when platform is not win32", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // icacls was NOT called on non-win32 + expect(execFile).not.toHaveBeenCalled() + }) + + it("win32 DACL failure falls back to plain rename (never fails the write)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls dump fails — the callback-based mock must invoke cb with an error. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write succeeded despite icacls failure (fallback to plain rename) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + // one icacls attempt only: a failed DACL apply must not try to restore + expect(execFile).toHaveBeenCalledTimes(1) + }) + + it("win32: reports that access rights may change when the DACL cannot be saved", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls error"), "", "") + return fakeChild + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + // The write still commits - a failing icacls must not leave the user unable to save - + // but the caller is told the replacement may not carry the old ACL. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) + }) + + // The platform here is an argument, not the host: this asserts what every icacls call is + // bounded by while the write is open, and the neighbouring cases below hand in the same + // option and already run on every lane, so skipping it off Windows only hid coverage. + it("bounds every icacls call with a finite timeout under the lock staleness window", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + const calls = vi.mocked(execFile).mock.calls + expect(calls).toHaveLength(2) + for (const call of calls) { + expect(call[0]).toBe("icacls") + // An execFile without a timeout waits for its child forever. While it waits, + // this write is open and - for a safeWriteJson caller - the per-file advisory + // lock is held while every other writer of the same file burns its retries. + const timeout = Number((call[2] as { timeout?: number } | undefined)?.timeout) + expect(Number.isFinite(timeout)).toBe(true) + expect(timeout).toBeGreaterThan(0) + // Below LOCK_STALE_MS (src/utils/fileLock.ts) so a hung helper cannot outlive + // the staleness window the lock protocol is written around. + expect(timeout).toBeLessThan(31_000) + } + }) + + it("win32: a timed-out icacls degrades to the designed skip-DACL path and still commits", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // What execFile hands back when its timeout fires: an error whose code is ETIMEDOUT. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") { + cb(Object.assign(new Error("spawnSync icacls ETIMEDOUT"), { code: "ETIMEDOUT" }), "", "") + } + return fakeChild + }) + const warnings: string[] = [] + + await expect( + safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }), + ).resolves.toBeUndefined() + + expect(execFile).toHaveBeenCalledTimes(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(warnings.filter((m) => m.includes("different access rights"))).toHaveLength(1) + }) + + it("win32: reports when the target cannot be checked for DACL preservation", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The target exists but is not readable: that is not "absent", and skipping DACL + // preservation has to be visible. + vi.mocked(fs.access).mockImplementation(async (p) => { + if (String(p) === targetPath) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + expect(execFile).not.toHaveBeenCalled() + expect(warnings.filter((m) => m.includes("Could not check"))).toHaveLength(1) + }) + + it("win32 DACL: a partial dump left by a failed save is removed and never restored", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // icacls save fails — a real icacls may have written a partial dump + // before erroring, so the dump path must be cleaned up and must never + // be used for a restore. + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + if (typeof cb === "function") cb(new Error("icacls save error"), "", "") + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // write committed; only the save was attempted (no restore from a failed dump) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) + expect(execFile).toHaveBeenCalledTimes(1) + const saveArgs = vi.mocked(execFile).mock.calls[0]?.[1] + expect(saveArgs?.[1]).toBe("/save") + // the dump path (possibly partially created by icacls) was unlinked + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) + }) + + it("win32 DACL save args are [targetPath, /save, dumpPath, /T] before backup rename", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + // icacls was called twice (save + restore) + expect(execFile).toHaveBeenCalledTimes(2) + + // First call: save DACL from target before backup rename + const firstCall = vi.mocked(execFile).mock.calls[0] + expect(firstCall[0]).toBe("icacls") + expect(firstCall[1]).toEqual([targetPath, "/save", expect.stringContaining("safeWriteText.acl"), "/T"]) + + // Second call: restore DACL onto directory after commit rename + const secondCall = vi.mocked(execFile).mock.calls[1] + expect(secondCall[0]).toBe("icacls") + expect(secondCall[1]).toEqual([ + expect.stringContaining("/tmp/test-dir"), + "/restore", + expect.stringContaining("safeWriteText.acl"), + ]) + + // dump file was unlinked after restore + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) + }) + + it("win32 DACL save runs before the backup copy, not after it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // The title is about order, so assert the order the mocks were actually + // called in. If the save ran after the backup copy the dump could describe a + // file that a concurrent publish had already replaced. + await safeWriteText(targetPath, "data", { backup: true, platform: "win32" }) + + const callOrder = vi.mocked(execFile).mock.invocationCallOrder + const copyOrder = vi.mocked(fs.copyFile).mock.invocationCallOrder + const renameOrder = vi.mocked(fs.rename).mock.invocationCallOrder + const saveCall = callOrder[0] + const restoreCall = callOrder[1] + const backupCopy = copyOrder[0] + const commitRename = renameOrder[0] + + expect(saveCall).toBeLessThan(backupCopy) + expect(backupCopy).toBeLessThan(commitRename) + expect(commitRename).toBeLessThan(restoreCall) + }) + + it("win32 DACL: a failed restore is reported and the dump is still unlinked", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}) + + // icacls save succeeds, restore fails + let callCount = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + callCount++ + if (typeof cb === "function") { + cb(callCount === 1 ? null : new Error("icacls restore error"), "", "") + } + return fakeChild + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // The content did commit: failing here would break every publish on a machine + // where icacls cannot reapply the saved ACEs. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + expect(fs.rename).toHaveBeenCalledTimes(1) + + // The changed access rights are reported instead of being swallowed. + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("could not be restored")) + warnSpy.mockRestore() + + // dump file was still unlinked in finally + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.acl")) + }) + + it("win32 DACL: when target does not exist, no save/restore/dump", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + // fs.access rejects for targetPath (ENOENT), but resolves for dirPath + vi.mocked(fs.access).mockImplementation(async (p) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw { code: "ENOENT" } + return undefined + }) + + await safeWriteText(targetPath, "data", { platform: "win32" }) + + // icacls was NOT called (target absent → skip DACL entirely) + expect(execFile).not.toHaveBeenCalled() + + // no dump file created or unlinked + expect(fs.unlink).not.toHaveBeenCalled() + }) + }) + + // ── Test 6: pre-written temp path (tempPath option) ────────────────────── + + describe("pre-written temp path", () => { + it("uses the provided tempPath, fsyncs it, and renames to target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + + // platform:linux skips DACL entirely so this test focuses on tempPath only + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // openSync was called on the custom temp path (r+ mode for fsync) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + + // fsync was called + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + + // rename happened — realpath mock returns targetPath + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + + // no unlink of custom temp (caller's concern; DACL skipped via platform:linux) + expect(fs.unlink).not.toHaveBeenCalled() + + // a caller-supplied tempPath must not create the staging directory + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + }) + + it("applies the existing target's mode to a caller-supplied tempPath before publishing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // the caller-staged temp is fchmod'd to the restrictive target mode so + // the atomic rename cannot widen a 0o600 target (CWE-732 regression) + expect(fsSync.fchmodSync).toHaveBeenCalledWith(2, 0o600) + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("keeps the temp's default mode when the target does not exist yet (ENOENT)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // no existing target, so nothing to preserve and no fchmod on the temp + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("propagates a non-ENOENT stat failure rather than defaulting the mode (caller-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eacces + }) + vi.mocked(fsSync.openSync).mockReturnValue(2) + + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + + // A target that cannot be stat'd is not a fresh target: publishing with + // the default mode would widen a restrictive target through the rename. + await expect( + safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow("EACCES") + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("propagates a non-ENOENT stat failure rather than defaulting the mode (self-staged)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const eio = Object.assign(new Error("EIO: i/o error"), { code: "EIO" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw eio + }) + + // The mode is read before the temp is opened, so a real I/O failure stops + // the write before anything is staged. + await expect(safeWriteText(targetPath, "hello world", { platform: "linux" })).rejects.toThrow("EIO") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("opens the temp before applying a read-only target's mode (0o444 does not block the open)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o444)) + vi.mocked(fsSync.openSync).mockReturnValue(3) + + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + + await safeWriteText(targetPath, "", { tempPath: customTempPath, platform: "linux" }) + + // a 0o444 target must not make openSync(tempPath, "r+") fail: the mode + // is applied with fchmodSync on the already-open fd, after the open + expect(fsSync.openSync).toHaveBeenCalledWith(customTempPath, "r+") + expect(fsSync.fchmodSync).toHaveBeenCalledWith(3, 0o444) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + }) + + it("applies the existing target's exact mode to the self-staged temp (umask must not narrow it)", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o664)) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // openSync's creation mode is narrowed by the process umask (0o664 -> 0o644 with + // the common 0o022), and the rename publishes the temp's mode onto the target, + // so the existing target's mode must be applied on the fd before the commit. + expect(fsSync.fchmodSync).toHaveBeenCalledWith(1, 0o664) + const openIdx = vi.mocked(fsSync.openSync).mock.invocationCallOrder[0] + const fchmodIdx = vi.mocked(fsSync.fchmodSync).mock.invocationCallOrder[0] + expect(openIdx).toBeLessThan(fchmodIdx) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("does not fchmod the self-staged temp for a fresh target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw enoent + }) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // Nothing exists to preserve: the default creation mode is the intended one. + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + }) + }) + + // ── Test 7: symlink handling (Finding 4 regression test) ───────────────── + + describe("symlink handling", () => { + it("a write through a symlink commits onto the resolved referent, never the link path", async () => { + const linkPath = "/tmp/links/link.txt" + const referentPath = "/tmp/targets/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(referentPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(linkPath, "new-content", { platform: "linux" }) + + // The commit rename must target the realpath result (the referent), never the link itself — + // that is what guarantees a write through a symlink replaces the referent's content + // and preserves the link. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), referentPath) + expect(fs.rename).not.toHaveBeenCalledWith(expect.anything(), linkPath) + }) + + it("when realpath reports ENOENT (target absent), uses the given path as-is", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + // lstat reports the path itself as absent, so this is a new target and + // the fallback is allowed. + vi.mocked(fs.lstat).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // rename still happened with the fallback path (path.resolve on /tmp → C:\tmp) + const resolvedFallback = _resolvedTarget(targetPath) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), resolvedFallback) + }) + + it("propagates a dangling symlink instead of writing through the link path", async () => { + // realpath resolves the referent, so a link whose target is missing reports + // ENOENT. Falling back to the link path would replace the symlink with a + // regular file, so the error must propagate and nothing may be committed. + const linkPath = "/tmp/test-dir/dangling-link.txt" + vi.mocked(fs.realpath).mockRejectedValue(Object.assign(new Error("ENOENT"), { code: "ENOENT" })) + const linkStats = Object.create(fsSync.Stats.prototype) as fsSync.Stats + linkStats.isSymbolicLink = () => true + vi.mocked(fs.lstat).mockResolvedValue(linkStats) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await expect(safeWriteText(linkPath, "data", { platform: "linux" })).rejects.toThrow("ENOENT") + + expect(fs.rename).not.toHaveBeenCalled() + }) + }) + + // ── Test 8: review fixes (permissions, partial writes, resolution, durability) ── + + describe("review fixes", () => { + it("preserves the target's restrictive mode and tolerates a failed staging-dir permission repair", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + // a pre-existing staging dir may fail its best-effort permission repair + vi.mocked(fsSync.chmodSync).mockImplementationOnce(() => { + throw new Error("EACCES") + }) + + await safeWriteText(targetPath, "secret", { platform: "linux" }) + + // the staging file inherits the target's 0o600 mode and the write commits + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o600) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("falls back to the 0o644 default when the target does not exist yet", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + vi.mocked(fsSync.statSync).mockImplementation(() => { + throw Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + }) + + await safeWriteText(targetPath, "fresh", { platform: "linux" }) + + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), "w", 0o644) + }) + + it("loops on short writes until the full content is durable before fsync", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const content = "0123456789" // 10 bytes + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + const buffer = Buffer.from(content, "utf8") + // first write (offset 0) reports 4 bytes (short write); the loop continues + vi.mocked(fsSync.writeSync).mockImplementation((...args: unknown[]) => + args[2] === 0 ? 4 : typeof args[3] === "number" ? args[3] : 0, + ) + + await safeWriteText(targetPath, content, { platform: "linux" }) + + // [0,10) reports 4 bytes, then [4,10) writes the remaining 6 + expect(fsSync.writeSync).toHaveBeenCalledTimes(2) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(1, 1, buffer, 0, 10) + expect(fsSync.writeSync).toHaveBeenNthCalledWith(2, 1, buffer, 4, 6) + expect(fsSync.fsyncSync).toHaveBeenCalledWith(1) + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("fsyncs the parent directory after the commit rename on POSIX", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // temp fd=1 then parent-dir fd=2 - distinct fds prove the ordering + vi.mocked(fsSync.openSync).mockReturnValueOnce(1).mockReturnValue(2) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // the directory fsync (fd 2) happens only after the file fsync (fd 1); + // the dir path assertion is path-agnostic (stringContaining) because + // path.dirname renders the same input differently on Windows + expect(fsSync.openSync).toHaveBeenCalledWith(expect.stringContaining("test-dir"), "r") + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(1, 1) + expect(fsSync.fsyncSync).toHaveBeenNthCalledWith(2, 2) + expect(fsSync.closeSync).toHaveBeenCalledWith(2) + }) + + it("reports a failed parent-directory fsync instead of claiming a durable write", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockReturnValueOnce(1) + .mockImplementationOnce(() => { + throw new Error("EBADF") + }) + + // The content rename committed, so the caller can still find the data at + // the target; what the write cannot claim is that the directory entry + // reached the disk. Returning success here would claim durability the + // filesystem did not grant. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toThrow( + PostCommitDurabilityError, + ) + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + // Row 4507851715: the guidance this error gives - do not re-issue the write - is only + // checkable against a version, so the error has to name the version it committed. + it("carries the version the commit landed so the caller can say what is on disk", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockReturnValueOnce(1) + .mockImplementationOnce(() => { + throw new Error("EBADF") + }) + // A BigIntStats stand-in carrying the five fields the version token renders. + // Double assertion: the runtime value is the stand-in, the type is the bigint variant. + const committed = Object.create(fsSync.Stats.prototype) as unknown as fsSync.BigIntStats + committed.dev = 7n + committed.ino = 8n + committed.size = 4n + committed.mtimeNs = 9n + committed.ctimeNs = 10n + vi.mocked(fs.stat).mockResolvedValue(committed) + + let thrown: unknown + try { + await safeWriteText(targetPath, "data", { platform: "linux" }) + } catch (error: unknown) { + thrown = error + } + + expect(thrown).toBeInstanceOf(PostCommitDurabilityError) + const failure = thrown as PostCommitDurabilityError + // dev:ino:size:mtimeNs:ctimeNs, the same derivation every guard compares against. + expect(failure.committedVersion).toBe("7:8:4:9:10") + expect(failure.message).toContain("version 7:8:4:9:10") + expect(failure.message).toContain("committed, not durable") + expect(failure.message).toContain("do not re-issue the write") + expect(failure.targetPath).toBe(targetPath) + }) + + it("still says not to re-issue when the committed version cannot be read", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync) + .mockReturnValueOnce(1) + .mockImplementationOnce(() => { + throw new Error("EBADF") + }) + // fs.stat defaults to ENOENT here, which is the shape of a target that vanished + // between the commit and the version read. + + let thrown: unknown + try { + await safeWriteText(targetPath, "data", { platform: "linux" }) + } catch (error: unknown) { + thrown = error + } + + expect(thrown).toBeInstanceOf(PostCommitDurabilityError) + const failure = thrown as PostCommitDurabilityError + expect(failure.committedVersion).toBeUndefined() + expect(failure.message).toContain("committed, not durable: do not re-issue it") + expect(failure.message).not.toContain("undefined") + // The commit is not undone by the failed read: the content is still at the target. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), targetPath) + }) + + it("propagates realpath errors (EACCES and code-less) instead of the fallback path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValueOnce(eacces) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + + const plain = new Error("resolution failed") + vi.mocked(fs.realpath).mockRejectedValueOnce(plain) + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(plain) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("backup:true propagates access errors (EACCES and code-less) instead of skipping the backup", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const eacces = Object.assign(new Error("EACCES"), { code: "EACCES" }) + const plain = new Error("access failed") + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // each write accesses dirPath then target; only the target access rejects + const rejectTarget = (error: Error) => async (p: unknown) => { + if (typeof p === "string" && p.endsWith("target.txt")) throw error + } + vi.mocked(fs.access) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(eacces)) + .mockImplementationOnce(rejectTarget(plain)) + .mockImplementationOnce(rejectTarget(plain)) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toEqual( + expect.objectContaining({ code: "EACCES" }), + ) + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow( + "access failed", + ) + expect(fs.rename).not.toHaveBeenCalled() + }) + }) + describe("content bytes", () => { + const targetPath = "/tmp/enc-dir/target.txt" + + beforeEach(() => { + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + }) + + it("stages UTF-8 bytes for string content", async () => { + await safeWriteText(targetPath, "héllo", { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, Buffer.from("héllo", "utf8"), 0, 6) + }) + + it("publishes caller-supplied bytes unchanged instead of re-encoding them", async () => { + // The extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages and BOMs Node cannot represent, and hands + // the result over: those bytes must reach the commit rename exactly as + // they were given. + const bytes = Buffer.from([0x00, 0x68, 0x00, 0x69]) + await safeWriteText(targetPath, bytes, { platform: "linux" }) + expect(fsSync.writeSync).toHaveBeenCalledWith(1, bytes, 0, 4) + }) + }) +}) + +// ── Test 12: lock key, staging path, and post-commit durability ───────────── + +describe("resolveLockKey", () => { + beforeEach(() => mockDefaults()) + + it("canonicalizes the parent directory, not just the file", async () => { + vi.mocked(fs.realpath).mockImplementation(async (target) => { + const key = String(target) + if (key === "/tmp/linkdir/file.json") return "/real/dir/file.json" + if (key === "/real/dir") return "/real/dir" + return key + }) + + // The key is the canonical directory plus the basename, so a symlinked + // ancestor and its referent share one lock. + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe(path.join("/real/dir", "file.json")) + }) + + it("computes a key for a dangling link, which resolvePublishTarget refuses", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + // Only the link path is a symlink: the referent it names is answered as the plain file it + // is. Answering every readlink call - as a blanket mockResolvedValue did - made the walk + // re-read its own resolved key as another link, so this test ran the bounded-cycle path + // (eight readlink calls and the depth limit) instead of the dangling-link path it is named + // for, and could not tell a correct walk from one that only stops because it gives up. + const enotlink = Object.assign(new Error("EINVAL: invalid argument, not a symlink"), { code: "EINVAL" }) + vi.mocked(fs.readlink).mockImplementation(async (target) => + // Compared on separators, not through path.join: the argument is the caller's spelling, + // which on Windows is "/tmp/linkdir/file.json" while path.join would render it with + // backslashes - and a mock that never recognises the link answers every call as "not a + // link", which is a different scenario from the one this test names. + String(target).replace(/\\/g, "/") === "/tmp/linkdir/file.json" + ? "referent.json" + : Promise.reject(enotlink), + ) + + // Mid-commit a peer writer renames the referent away and back, so the key + // must still be computable while the link dangles. + await expect(resolveLockKey("/tmp/linkdir/file.json")).resolves.toBe( + path.resolve(path.join("/tmp/linkdir", "referent.json")), + ) + // Two readlink calls, and that count is the proof of the normal exit: one from + // resolvePublishTarget on the link, one from the fallback walk on the referent, which is + // answered as "not a link" and ends the walk. The bounded walk would have asked eight times + // - what the cycle test below asserts - so this test now fails if the walk ever mistakes the + // referent for another link and runs to the depth limit instead. + expect(fs.readlink).toHaveBeenCalledTimes(2) + }) + + it("refuses a two-link cycle instead of handing back the key the walk stopped at", async () => { + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + // Every readlink answers with another link, so an unbounded walk would never end and the + // bound is what stops it. What the bound must not do is hand back the spelling it stopped at + // as if it were the answer: that key comes from the caller's own path after the filesystem + // stopped answering about it, which is the divergence a lock key exists to remove. + vi.mocked(fs.readlink).mockImplementation(async () => "a.json") + + const failure = await resolveLockKey("/tmp/linkdir/a.json").then( + () => undefined, + (error: unknown) => error as Error, + ) + expect(failure).toBeInstanceOf(LockKeyWalkIncompleteError) + if (!(failure instanceof LockKeyWalkIncompleteError)) { + throw new Error("the cycle must reject with LockKeyWalkIncompleteError, not " + String(failure)) + } + // The bound and the spelling the walk stopped at travel with the refusal, so a caller can + // report which chain gave up without re-walking it. + expect(failure.bound).toBe(8) + expect(failure.absoluteFilePath).toBe("/tmp/linkdir/a.json") + expect(failure.lastKey).toBe(path.resolve(path.join("/tmp/linkdir", "a.json"))) + expect(fs.readlink).toHaveBeenCalledTimes(8) + }) +}) + +describe("caller-supplied staging path", () => { + beforeEach(() => mockDefaults()) + + it("rejects a staging file outside the target's directory before writing anything", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + + // A rename across filesystems fails with EXDEV, and a path elsewhere lets + // a caller publish an unrelated file onto the target. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("accepts a staging file spelled through an alias of the target's directory", async () => { + const aliasTarget = "/srv/alias/target.txt" + // An alias root: canonicalDirKey canonicalizes through realpath, so the write resolves to + // the referent while the caller still holds the alias spelling of the same directory. + vi.mocked(fs.realpath).mockResolvedValue("/srv/referent/target.txt") + vi.mocked(fsSync.openSync).mockReturnValue(1) + const directoryStats = _fileStatsWithIdentity(1234n, 9n) + vi.mocked(fs.stat).mockResolvedValue(directoryStats) + + await safeWriteText(aliasTarget, "data", { tempPath: "/srv/alias/x.tmp", platform: "linux" }) + + // The staging file was published, not refused: containment is about which directory it + // sits in, and these two spellings name one directory. + expect(fs.rename).toHaveBeenCalledWith("/srv/alias/x.tmp", "/srv/referent/target.txt") + // Both directories are read by identity, bigint for the same reason as the file stats. + const directoryLookups = vi.mocked(fs.stat).mock.calls.filter((c) => c[1] !== undefined) + expect(directoryLookups.length).toBe(2) + for (const c of directoryLookups) { + expect(c[1]).toEqual({ bigint: true }) + } + }) + + it("rejects when a staging directory stat fails for a reason other than it being absent", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // The spelling comparison is the fallback for a directory that is not there. An EACCES says + // nothing about containment, so it is reported instead of being read as a match or a + // mismatch: a guard that decides from an error it could not interpret has stopped guarding. + vi.mocked(fs.stat).mockRejectedValue(Object.assign(new Error("EACCES"), { code: "EACCES" })) + + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(/could not be compared/) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + it("creates no directory when a supplied staging path is rejected", async () => { + const targetPath = "/tmp/test-dir/nested-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + + // A rejected write has to leave the filesystem as it found it. mkdir -p ran ahead of + // these checks, so a caller could create directories in the target's tree - and learn + // about them only from a StagingPathError that never mentioned them. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/other-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + + expect(fs.mkdir).not.toHaveBeenCalled() + expect(fs.access).not.toHaveBeenCalled() + expect(fsSync.mkdirSync).not.toHaveBeenCalled() + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("still creates the parent directory for a staging path it accepts", async () => { + const targetPath = "/tmp/test-dir/nested-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/nested-dir/x.tmp", platform: "linux" }) + + expect(fs.mkdir).toHaveBeenCalledWith(path.dirname(targetPath), { recursive: true }) + expect(fs.rename).toHaveBeenCalledWith("/tmp/test-dir/nested-dir/x.tmp", targetPath) + }) + + it("rejects a staging path that is a symlink", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(true)) + + // Renaming a link over the target publishes whatever the link points at. + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/x.tmp", platform: "linux" }), + ).rejects.toThrow(StagingPathError) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("rejects a staging path that is the target itself", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // Same inode and device for the supplied staging path and the target: the + // failure handler would unlink the only copy of the content, so a failed + // write would delete the file it was meant to protect. + const stats = _fileStatsWithIdentity(42n, 7n) + vi.mocked(fs.lstat).mockResolvedValue(stats) + + await expect(safeWriteText(targetPath, "data", { tempPath: targetPath, platform: "linux" })).rejects.toThrow( + StagingPathError, + ) + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + // The comparison is only sound when both stats are read as bigint: on NTFS/ReFS the file + // identifiers exceed Number.MAX_SAFE_INTEGER. + // Filter on the options, not the spelling: path.resolve prefixes a drive letter on Windows, + // so the two identity reads are the calls that asked for options at all. + const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined) + expect(identityLookups.length).toBeGreaterThanOrEqual(2) + for (const c of identityLookups) { + expect(c[1]).toEqual({ bigint: true }) + } + expect(fs.unlink).not.toHaveBeenCalled() + }) + + it("rejects when the target identity cannot be compared for a reason other than a missing target", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + // A hard-linked staging file shares the target's inode, so the identity comparison is the only thing + // between this write and a rename onto the very file the guard protects. An EACCES from the target + // lstat must not be mistaken for "there is no target". + const stagingStats = _fileStatsWithIdentity(42n, 7n) + vi.mocked(fs.lstat).mockImplementation(async (p) => { + if (String(p) === targetPath) { + throw Object.assign(new Error("EACCES"), { code: "EACCES" }) + } + return stagingStats + }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: "/tmp/test-dir/hardlink.txt", platform: "linux" }), + ).rejects.toThrow("Staging file could not be compared with the target") + expect(fsSync.openSync).not.toHaveBeenCalled() + expect(fs.rename).not.toHaveBeenCalled() + // Both identity reads must ask for bigint stats, or the comparison silently falls + // back to rounded numbers on NTFS/ReFS. + const identityLookups = vi.mocked(fs.lstat).mock.calls.filter((c) => c[1] !== undefined) + expect(identityLookups).toHaveLength(2) + for (const c of identityLookups) { + expect(c[1]).toEqual({ bigint: true }) + } + }) +}) + +describe("cleanup when a backed-up write fails before commit", () => { + beforeEach(() => mockDefaults()) + + it("releases the staged file, its copy and its own staging directory before throwing", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The commit rename is the only rename in this flow and it fails. + vi.mocked(fs.rename).mockRejectedValue(new Error("ENOSPC")) + + await expect(safeWriteText(targetPath, "data", { backup: true, platform: "linux" })).rejects.toThrow("ENOSPC") + + // The staging file and this write's own directory must not leak, and neither may + // the backup copy: the target still holds the pre-write content on disk. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_")) + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + const stagingDirs = vi.mocked(fsSync.mkdirSync).mock.calls.map((call) => String(call[0])) + expect(stagingDirs.length).toBe(1) + expect(fs.rmdir).toHaveBeenCalledWith(stagingDirs[0]) + + const failingRenameOrder = vi.mocked(fs.rename).mock.invocationCallOrder[0] + const unlinkOrder = vi.mocked(fs.unlink).mock.invocationCallOrder[0] + const rmdirOrder = vi.mocked(fs.rmdir).mock.invocationCallOrder[0] + expect(unlinkOrder).toBeGreaterThan(failingRenameOrder) + expect(rmdirOrder).toBeGreaterThan(failingRenameOrder) + }) +}) + +describe("resolvePublishTarget", () => { + beforeEach(() => mockDefaults()) + + it("propagates an lstat failure that is not ENOENT instead of falling back to the link path", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + const eacces = Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockRejectedValue(eacces) + + // A failed lstat says nothing about whether the path is a link, so the + // fallback would publish through a link we were not allowed to inspect. + await expect(safeWriteText(targetPath, "data", { platform: "linux" })).rejects.toBe(eacces) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("still falls back to the given path when lstat also reports the path as absent", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.realpath).mockRejectedValue(enoent) + vi.mocked(fs.lstat).mockRejectedValue(enoent) + vi.mocked(fsSync.openSync).mockReturnValue(1) + + await safeWriteText(targetPath, "data", { platform: "linux" }) + + // The fallback is the resolved path, not the string that was handed in. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining("safeWriteText_"), path.resolve(targetPath)) + }) +}) +describe("resolveLockKey when the parent directory does not exist yet", () => { + beforeEach(() => mockDefaults()) + + it("canonicalizes through the nearest existing ancestor instead of the literal parent", async () => { + // Two writers must take ONE lock: the one whose parent directory is already there, + // and the one racing to create it. Resolving only the immediate parent and falling + // back to its literal spelling on ENOENT gave them different keys whenever an + // ancestor was a symlink or a Windows short name, so a read-modify-write under the + // advisory lock lost one side. + const aliasDir = path.resolve("/tmp/alias-parent") + const canonicalDir = path.resolve("/tmp/real-parent") + const nested = path.join(aliasDir, "nested") + const target = path.join(nested, "history_item.json") + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + vi.mocked(fs.realpath).mockImplementation(async (p) => { + const s = String(p) + if (s === target || s === nested) { + throw Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + } + if (s === aliasDir) { + return canonicalDir + } + return s + }) + + expect(await resolveLockKey(target)).toBe(path.join(canonicalDir, "nested", "history_item.json")) + }) + + it("propagates a realpath failure that is not ENOENT instead of guessing a key", async () => { + // A realpath that fails for another reason says nothing about the canonical form; + // returning a literal key would silently put this writer on a different lock. + const target = path.join(path.resolve("/tmp/test-dir"), "history_item.json") + vi.mocked(fs.lstat).mockResolvedValue(_fileStats(false)) + // Not a link: the walk must reach the canonicalization step, which is where the + // non-ENOENT failure has to surface. + vi.mocked(fs.readlink).mockRejectedValue( + Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }), + ) + vi.mocked(fs.realpath).mockImplementation(async (p) => { + if (String(p) === target) return target + throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }) + }) + + await expect(resolveLockKey(target)).rejects.toThrow("EACCES") + }) +}) +describe("caller-staged file protected through publication", () => { + beforeEach(() => mockDefaults()) + + // After validation the guard reads the staging path twice more: through the fd it just opened, + // and immediately before the commit rename. Reporting a different identity at one of those + // looks is how these rows say "the path was replaced after that check" - a real filesystem + // offers no way to schedule a swap between two calls inside one function, and replacing the + // whole call would not show which look the refusal came from. + function _staged( + customTempPath: string, + targetPath: string, + looks: { open?: fsSync.BigIntStats; commit?: fsSync.BigIntStats | Error }, + ): void { + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(5) + const stagingIdentity = _fileStatsWithIdentity(8n, 3n) + const targetIdentity = _fileStatsWithIdentity(11n, 3n) + let stagingLooks = 0 + vi.mocked(fs.lstat).mockImplementation(async (p) => { + const s = String(p) + if (s.endsWith("target.txt")) { + return targetIdentity + } + // The first look is the validation; the second is the one right before the rename. The + // two arrive under different spellings (the resolved one and the caller's own), which is + // why the match is on the basename and not on a path prefix. + stagingLooks += 1 + if (stagingLooks === 1) { + return stagingIdentity + } + const next = looks.commit + if (next instanceof Error) { + throw next + } + return next ?? stagingIdentity + }) + vi.mocked(fsSync.fstatSync).mockReturnValue(looks.open ?? stagingIdentity) + } + + it("refuses when the fd names a different file than the one validation checked", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // Validation saw (8,3); the fd came back naming (9,3) - the path was swapped for a link or + // another file in between. openSync on a link follows it, so the fchmod below would change + // the permissions of a file this write never staged, and the rename would publish it. + _staged(customTempPath, targetPath, { open: _fileStatsWithIdentity(9n, 3n) }) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/replaced between validation and open/) + // The refusal came before fchmodSync, so no permission was changed on the substituted file. + expect(fsSync.fchmodSync).not.toHaveBeenCalled() + // Nothing was published. + expect(fs.rename).not.toHaveBeenCalled() + // The fd read asked for bigint stats: on NTFS/ReFS an identity can exceed + // Number.MAX_SAFE_INTEGER and a rounded number makes two files look alike. + const fdLooks = vi.mocked(fsSync.fstatSync).mock.calls + expect(fdLooks).toHaveLength(1) + expect(fdLooks[0]?.[1]).toEqual({ bigint: true }) + }) + + it("accepts a staging file the open confirmed, and still publishes it", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // The ordinary case: the same file at every look. A guard that refused this one would stop + // every caller-staged write. + _staged(customTempPath, targetPath, {}) + vi.mocked(fsSync.statSync).mockReturnValue(_stats(0o600)) + + await safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }) + + expect(fsSync.fchmodSync).toHaveBeenCalledWith(5, 0o600) + expect(fs.rename).toHaveBeenCalledWith(customTempPath, targetPath) + // Two bigint reads of the staging path: the validation and the one immediately before the + // commit. The second is what this commit adds, so it is asserted as a call, not only as a + // decision. + const stagingLooks = vi + .mocked(fs.lstat) + .mock.calls.filter((c) => String(c[0]).endsWith("custom-temp.tmp") && c[1] !== undefined) + expect(stagingLooks).toHaveLength(2) + for (const c of stagingLooks) { + expect(c[1]).toEqual({ bigint: true }) + } + }) + + it("refuses when the staging path was replaced after the file was opened", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // The open confirmed (8,3); the look immediately before the rename reports (9,3). Without + // that look the rename publishes whatever the path came to name, not the content staged + // here. + _staged(customTempPath, targetPath, { commit: _fileStatsWithIdentity(9n, 3n) }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/replaced between validation and the commit/) + expect(fs.rename).not.toHaveBeenCalled() + // The failure handler still unlinks the path it was given. Whether that is the right + // cleanup for a path someone else replaced is a separate question from publishing it, and + // this commit does not change the answer. + expect(fs.unlink).toHaveBeenCalledWith(customTempPath) + }) + + it("refuses when the pre-commit look fails with something other than the file being gone", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + _staged(customTempPath, targetPath, { commit: Object.assign(new Error("EACCES"), { code: "EACCES" }) }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/could not be re-checked before the commit \(EACCES\)/) + expect(fs.rename).not.toHaveBeenCalled() + }) + + it("refuses when the staging file is gone by the time the commit would run", async () => { + const targetPath = "/tmp/test-dir/target.txt" + const customTempPath = "/tmp/test-dir/custom-temp.tmp" + // Missing is the one answer a stat can give that means "do not publish": there is nothing + // at the path this write staged its content into. The spelling would be the fallback for a + // directory comparison; here it would be a rename that cannot happen. + _staged(customTempPath, targetPath, { commit: Object.assign(new Error("ENOENT"), { code: "ENOENT" }) }) + + await expect( + safeWriteText(targetPath, "data", { tempPath: customTempPath, platform: "linux" }), + ).rejects.toThrow(/could not be re-checked before the commit \(ENOENT\)/) + expect(fs.rename).not.toHaveBeenCalled() + }) +}) diff --git a/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts new file mode 100644 index 0000000000..a845f77ba7 --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.stagingAlias.spec.ts @@ -0,0 +1,134 @@ +import * as fsSync from "fs" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { safeWriteText, StagingPathError } from "../safeWriteText" + +// Regression coverage for the caller-supplied staging guard (row 4507642578). The guard used to +// compare the supplied staging file's directory with the target's directory as two strings, and a +// directory alias makes those two strings differ for one directory: canonicalDirKey canonicalizes +// through fs.realpath, so a write whose target is reached via an alias root gets dirPath in the +// referent's spelling while the caller still holds its own, and the string compare refused a +// staging file that was physically inside the target's directory. + +// The alias is created with whatever primitive the platform provides, so the hosted Windows lane +// and the hosted Linux lane both run this file: a junction on win32 (a reparse point that Node can +// create without developer-mode privileges), a symlinked directory on POSIX. Both produce the same +// divergence, because realpath resolves either one. +const ALIAS_TYPE: "junction" | "dir" = process.platform === "win32" ? "junction" : "dir" + +function createAlias(referent: string, alias: string): void { + fsSync.symlinkSync(referent, alias, ALIAS_TYPE) +} + +// Removing the alias must not follow it: rmdir drops a junction without touching what it points at, +// and unlink drops a symlink. +function removeAlias(alias: string): void { + if (process.platform === "win32") { + fsSync.rmdirSync(alias) + } else { + fsSync.unlinkSync(alias) + } +} + +interface AliasFixture { + root: string + referent: string + alias: string + target: string +} + +function makeFixture(initialContent: string): AliasFixture { + const root = fsSync.mkdtempSync(path.join(os.tmpdir(), "fws-alias-")) + const referent = path.join(root, "referent") + const alias = path.join(root, "alias") + fsSync.mkdirSync(referent) + fsSync.writeFileSync(path.join(referent, "settings.json"), initialContent, "utf8") + createAlias(referent, alias) + return { root, referent, alias, target: path.join(alias, "settings.json") } +} + +function disposeFixture(fixture: AliasFixture): void { + try { + removeAlias(fixture.alias) + } catch { + // The alias is already gone; nothing to release. + } + fsSync.rmSync(fixture.root, { recursive: true, force: true }) +} + +describe("caller-supplied staging path through a directory alias (" + ALIAS_TYPE + ")", () => { + it("accepts a staging file spelled through the alias of the target's directory", async () => { + const fixture = makeFixture("initial") + try { + // The staging file sits in the alias spelling of the directory the write resolves to the + // referent of. Identity says one directory, and the guard has to agree. + const stagingPath = path.join(fixture.alias, ".staged.json") + await fs.writeFile(stagingPath, "aliased staging", "utf8") + await safeWriteText(fixture.target, "aliased staging", { tempPath: stagingPath }) + const published = await fs.readFile(path.join(fixture.referent, "settings.json"), "utf8") + expect(published).toBe("aliased staging") + // The commit consumed the staging file: it is the target now, wherever it is spelled. + const stagingRemains = await fs + .stat(stagingPath) + .then(() => true) + .catch(() => false) + expect(stagingRemains).toBe(false) + } finally { + disposeFixture(fixture) + } + }, 120000) + + it("still refuses a staging file that sits in a different directory", async () => { + const fixture = makeFixture("initial") + try { + const elsewhere = path.join(fixture.root, "elsewhere") + fsSync.mkdirSync(elsewhere) + const stagingPath = path.join(elsewhere, "unrelated.json") + await fs.writeFile(stagingPath, "unrelated payload", "utf8") + await expect(safeWriteText(fixture.target, "new content", { tempPath: stagingPath })).rejects.toThrow( + StagingPathError, + ) + // The guard still guards: containment was relaxed for aliases, not for distance, and an + // unrelated file must not be published onto the target. + const targetContent = await fs.readFile(path.join(fixture.referent, "settings.json"), "utf8") + expect(targetContent).toBe("initial") + const unrelatedContent = await fs.readFile(stagingPath, "utf8") + expect(unrelatedContent).toBe("unrelated payload") + } finally { + disposeFixture(fixture) + } + }, 120000) + + it("names the target's directory when it refuses", async () => { + const fixture = makeFixture("initial") + try { + const elsewhere = path.join(fixture.root, "elsewhere") + fsSync.mkdirSync(elsewhere) + const stagingPath = path.join(elsewhere, "unrelated.json") + await fs.writeFile(stagingPath, "unrelated payload", "utf8") + // A resolved write is the failure this row is about, so the error is collected rather than + // awaited into a union of "nothing" and "the guard's error". + let failure: StagingPathError | undefined + try { + await safeWriteText(fixture.target, "new content", { tempPath: stagingPath }) + } catch (error: unknown) { + failure = error as StagingPathError + } + expect(failure).toBeInstanceOf(StagingPathError) + // The message quotes the referent spelling, which is the directory the write resolves to - + // the reason the old compare read an alias as containment failure. The directory is + // canonicalized before it is compared, because the row is about which directory the + // message names, not about which spelling the operating system handed the test: on the + // hosted Windows runner os.tmpdir() is an 8.3 short name (C:\Users\RUNNER~1\AppData\ + // Local\Temp) while canonicalDirKey reports the long spelling through realpath, and the + // two spellings name one directory. Comparing the raw mkdtemp output against the message + // made that row fail on a spelling. + expect(String(failure?.message)).toContain(await fs.realpath(fixture.referent)) + expect(failure?.stagingPath).toBe(path.resolve(stagingPath)) + } finally { + disposeFixture(fixture) + } + }, 120000) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..9115854370 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,856 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" + +import { computeVersionToken } from "../../utils/versionToken" +import { execFile } from "child_process" + +export interface SafeWriteTextOptions { + /** + * When true, keep the old-file semantics without ever removing the target: the + * previous content is copied to a hidden backup path and flushed before the + * commit rename, the commit rename atomically replaces the target, and on success + * the backup copy is deleted. A failure before the commit leaves the target + * untouched (there is nothing to roll back) and removes the backup copy. When + * false (default) the atomic rename simply replaces the target. + */ + backup?: boolean + + /** + * Platform override for testing. When omitted the real process.platform + * value is used. Set to "win32" or "linux" / "darwin" from tests so that + * both branches are reachable without needing a real Windows runner. + */ + platform?: string + + /** + * Custom execFile runner for testing (e.g. vi.fn). When omitted the real + * child_process.execFile is used. + */ + execFileRunner?: typeof execFile + + /** + * Sink for non-fatal safety notices. A Windows DACL that could not be captured means the + * committed file may inherit different access rights: the write still proceeds (a missing or + * failing icacls must not block saving), but the caller is told instead of the change being + * silent. Defaults to console.warn. + */ + onWarning?: (message: string) => void + + /** + * Pre-written temp path to use for the commit phase. When provided, + * safeWriteText skips creating its own staging file and uses this path + * instead (it still fsyncs before rename). Useful when a caller has + * already written data to a temp file via a custom stream. + */ + tempPath?: string +} + +/** + * A caller-supplied staging path that is not a file this write may publish: it + * sits outside the target's directory (so the commit rename would cross + * filesystems) or is not a regular file. Rejecting it before any write keeps the + * target from being replaced by whatever the path points at. + */ +export class StagingPathError extends Error { + readonly stagingPath: string + + constructor(message: string, stagingPath: string) { + super(message) + this.name = "StagingPathError" + this.stagingPath = stagingPath + } +} + +/** + * The commit rename succeeded but the parent-directory fsync did not, so the + * directory entry is not known to be durable. The content is at the target; the + * caller cannot assume it survives a crash. Reported as its own error so a + * successful return never claims durability the filesystem did not grant. + * + * It also carries the version token the commit landed. The guidance this error gives + * - do not re-issue the write - is only checkable against a version, and a guarded + * write that wants to keep authorizing the content it just published has to record + * the version that is on disk rather than the one its read observed. + */ +export class PostCommitDurabilityError extends Error { + readonly targetPath: string + /** + * The old-content copy retained for recovery on this path, or null when backup + * mode was off or the target did not exist before the write. The name is a hidden + * random path, so without this field no caller could find the copy the catch block + * deliberately keeps. + */ + readonly backupPath: string | null + /** + * The version token of the content the commit landed, derived the way every guard + * derives one (`computeVersionToken`), read best-effort after the commit. Undefined + * when the post-commit stat failed: the commit stands either way, and a caller that + * cannot name the version says so rather than guessing. + */ + readonly committedVersion: string | undefined + + constructor(targetPath: string, cause: unknown, backupPath: string | null = null, committedVersion?: string) { + super( + "The rename committed but the parent directory could not be fsynced -- the content is at the target path reported on this error, and the directory entry may not be durable. " + + (committedVersion !== undefined + ? `The content this write committed is version ${committedVersion} -- committed, not durable: do not re-issue the write; read the file again before editing it.` + : "This write is committed, not durable: do not re-issue it; read the file again before editing it."), + { cause }, + ) + this.name = "PostCommitDurabilityError" + this.targetPath = targetPath + this.backupPath = backupPath + this.committedVersion = committedVersion + } +} + +/** + * The lock-key walk followed links as far as it was allowed to and never received an answer: + * every step reported another link, so the chain was either longer than the bound or circular. + * Reported as its own error because the operation has not been refused by a guard - it never got + * as far as a guard - and because the caller's spelling is not a lock key: the writer that would + * have taken this lock is the one whose key the filesystem refused to name. + */ +export class LockKeyWalkIncompleteError extends Error { + readonly absoluteFilePath: string + readonly bound: number + /** The last key the walk reached before it stopped. Carried because the caller can report which + * spelling it stopped at without re-walking the chain. */ + readonly lastKey: string + + constructor(absoluteFilePath: string, bound: number, lastKey: string) { + super( + `Lock key for ${absoluteFilePath} did not resolve within ${bound} symlink steps (last key ${lastKey}); refusing to take a lock derived from the spelling the walk stopped at.`, + ) + this.name = "LockKeyWalkIncompleteError" + this.absoluteFilePath = absoluteFilePath + this.bound = bound + this.lastKey = lastKey + } +} +// -- helpers --------------------------------------------------------------- + +/** Generate a unique temp file name in the given directory. */ +function _tempName(dir: string, prefix: string): string { + return path.join(dir, "." + prefix + "_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp") +} + +/** Create a private per-write staging sub-directory inside *dir*. The name is + * unique per write, so concurrent writes never collide on their temp names and + * never remove a staging directory another write is still using: with one shared + * name, one write's best-effort rmdir could delete the directory another write + * had just created but not yet opened, failing its openSync with ENOENT. */ +function _stagingDir(dir: string): string { + const sd = path.join(dir, ".file-safety-staging_" + Date.now() + "_" + Math.random().toString(36).substring(2)) + // mode:0o700 protects a freshly created staging dir; the best-effort chmod + // repairs a pre-existing one (mkdirSync with recursive:true never chmods an + // existing directory), so staged temp files are never group/world readable. + fsSync.mkdirSync(sd, { recursive: true, mode: 0o700 }) + try { + fsSync.chmodSync(sd, 0o700) + } catch { + // best-effort: chmod denied or unavailable; a fresh dir was still + // created with the requested mode + } + return sd +} + +function _fsyncFile(fd: number): void { + fsSync.fsyncSync(fd) +} + +/** + * Upper bound for the Windows DACL child processes, in milliseconds. execFile without a timeout + * waits for the child indefinitely: a icacls blocked on a reparse point, a network share, or an + * interception filter would hold the caller's write open forever, and for a safeWriteJson caller it + * would hold the per-file advisory lock while every other writer of the same file exhausts its + * retries (src/utils/fileLock.ts: 5 retries, 100ms..1s backoff) and fails. A timeout lands on the + * path this module already has for a failing icacls - skip DACL handling, commit the write, report + * the warning - and it stays below LOCK_STALE_MS (31_000) so a hung helper cannot outlive the + * staleness window the lock protocol is written around. + */ +// How far resolveLockKey will follow links when the path is not there yet. Two links that +// point at each other never answer "not there" or "not a link", so the walk needs a bound; it is +// a limit on the work, not a claim about how deep a real chain goes. +const LOCK_KEY_WALK_BOUND = 8 +const DACL_COMMAND_TIMEOUT_MS = 20_000 + +/** Save the DACL of *srcPath* to a dump file on Windows. + * Returns true when the dump was written successfully; false otherwise. + * Never throws — callers treat failure as "skip DACL handling". */ +async function _saveDaclWindows(srcPath: string, dumpPath: string, execFileRunner?: typeof execFile): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner( + "icacls", + [srcPath, "/save", dumpPath, "/T"], + { timeout: DACL_COMMAND_TIMEOUT_MS, windowsHide: true }, + (err) => (err ? reject(err) : resolve()), + ) + }) + return true + } catch { + return false + } +} + +/** Restore a DACL dump onto *dirPath* on Windows. + * Returns whether icacls succeeded; the caller reports a failure. */ +async function _restoreDaclWindows( + dirPath: string, + dumpPath: string, + execFileRunner?: typeof execFile, +): Promise { + const runner = execFileRunner ?? execFile + try { + await new Promise((resolve, reject) => { + runner( + "icacls", + [dirPath, "/restore", dumpPath], + { timeout: DACL_COMMAND_TIMEOUT_MS, windowsHide: true }, + (err) => (err ? reject(err) : resolve()), + ) + }) + return true + } catch { + return false + } +} + +// -- public API ------------------------------------------------------------ + +/** + * Resolve the publish target: the symlink referent when the given path is an + * existing symlink, the path itself otherwise. Only ENOENT (target absent yet) + * may fall back to the given path; any other resolution error (EACCES, EIO, ...) + * propagates so a broken or unreadable symlink is never written through its + * link path. Callers that stage a temp file themselves must stage it beside + * the resolved path: the commit is a rename onto the referent, and a rename + * across filesystems fails with EXDEV. + */ +export async function resolvePublishTarget(absoluteFilePath: string): Promise { + return fs.realpath(absoluteFilePath).catch(async (error: unknown) => { + if (errorCode(error) !== "ENOENT") throw error + // ENOENT also covers a dangling symlink, which must never be written through. + // Only a lstat that also reports the path as absent may fall back to the + // given path; a real lstat failure (EACCES, EIO) says nothing about whether + // the path is a link, so falling back would write through a link we were + // simply not allowed to inspect. + const linkStat = await fs.lstat(absoluteFilePath).catch((lstatError: unknown) => { + if (errorCode(lstatError) === "ENOENT") return undefined + throw lstatError + }) + if (linkStat?.isSymbolicLink()) throw error + return absoluteFilePath + }) +} + +/** + * Stat a directory for the staging-containment comparison. undefined means the directory is not + * there, which is the one failure that carries information about containment: a supplied staging + * file cannot sit in a directory that does not exist, and the caller's spelling comparison decides. + * Any other failure is the guard losing its ability to decide, so it is reported as its own refusal + * rather than being read as containment. + */ +async function _statDirectoryForContainment( + directoryPath: string, + supplied: string, +): Promise { + try { + return await fs.stat(directoryPath, { bigint: true }) + } catch (error: unknown) { + if (errorCode(error) === "ENOENT") { + return undefined + } + throw new StagingPathError( + `Staging directory could not be compared with the target's directory (${directoryPath})`, + supplied, + ) + } +} + +/** + * Whether the two stats name DIFFERENT files, judged the way the compares around it judge + * one directory: as bigints, because on NTFS and ReFS a file identity can exceed + * Number.MAX_SAFE_INTEGER and a rounded value makes two files look alike or two spellings of + * one file look different. The helper reports a swap only when both stats actually carry a + * bigint identity - production asks for { bigint: true } at every call site that feeds it, so + * a stat without one is a double that was never asked to answer identity, and calling that a + * swap would refuse every staged write for a shape no real filesystem produces. A stat that + * fails outright is handled by its call site, not here: this helper only ever answers + * "different" or "not shown to be different". + */ +function _stagingIdentityDiffers(checked: fsSync.BigIntStats, current: fsSync.BigIntStats): boolean { + return ( + typeof checked.ino === "bigint" && + typeof current.ino === "bigint" && + typeof checked.dev === "bigint" && + typeof current.dev === "bigint" && + (checked.ino !== current.ino || checked.dev !== current.dev) + ) +} + +/** + * Distinguish "the target does not exist" from a real I/O failure (EACCES, + * EIO, ...). The mode-preservation path may only fall back to the fresh-file + * default on ENOENT; any other failure is propagated, otherwise a restrictive + * target (0o600) would be published with the default 0o644 through the rename. + */ +function errorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? String((error as { code: unknown }).code) + : undefined +} + +/** + * Canonicalize the parent directory and re-join the basename. fs.realpath + * canonicalizes every component, including a symlinked ancestor directory or a + * Windows 8.3 short name, so a lock key must be canonical even when the file + * itself is not there yet -- otherwise the key for one file depends on whether + * the file exists when the key is computed, and two writers take two locks. + */ + +async function canonicalDirKey(absoluteFilePath: string): Promise { + const dirPath = path.dirname(absoluteFilePath) + // Walk up to the nearest ancestor that EXISTS, canonicalize that, and re-join the + // components that are not there yet. Falling back to the unresolved spelling of the + // whole parent - what a single realpath(...).catch(() => dirPath) used to do - makes + // the key depend on whether the directory happens to exist: a writer whose parent is + // already there canonicalizes through a symlinked ancestor (or a short name) while a + // writer racing to create the same directory gets the literal spelling, so the two + // take different locks for one file and a read-modify-write loses one side. + // A realpath failure that is not "not there yet" says nothing about the canonical + // form, so it is propagated rather than papered over with a key that may be wrong. + let cursor = dirPath + const missing: string[] = [] + for (;;) { + const canonical = await fs.realpath(cursor).catch((error: unknown) => { + if (errorCode(error) === "ENOENT") return undefined + throw error + }) + if (canonical !== undefined) { + return path.join(canonical, ...missing.reverse(), path.basename(absoluteFilePath)) + } + missing.push(path.basename(cursor)) + const parent = path.dirname(cursor) + if (parent === cursor) { + // Every component up to the root is missing: there is nothing to canonicalize + // against, and the literal path is the only key left. + return path.join(dirPath, path.basename(absoluteFilePath)) + } + cursor = parent + } +} + +/** + * Lock key for a publish target: the symlink referent when the path is an + * existing symlink, the path itself otherwise. Unlike resolvePublishTarget this + * tolerates a dangling link, because the lock key has to be computable while a + * peer writer is mid-commit (a publish renames the staged file onto the referent, + * and backup mode keeps a copy beside it). + * + * It is fail-closed about the other reason a resolution can end without an answer. + * The fallback walk runs only for ENOENT - the path is not there yet, which is also + * what a dangling link reports - because that is the one case where a key can still + * be derived without guessing. A failure that says the filesystem refused to answer + * (EACCES on a link the caller may not read, ENOTDIR, EIO, or the ELOOP that + * resolvePublishTarget reports for a cycle) propagates: a key built from the caller's + * own spelling after a refusal is the same divergence class this function exists to + * remove, and locking the wrong file is worse than refusing the operation. Reaching + * the walk's depth bound is the third way an answer fails to arrive - the chain went + * as far as it was allowed to and stopped - so it rejects rather than returning the + * prefix the walk happened to stop at. + * Every key this function returns is canonicalized through canonicalDirKey. + */ +export async function resolveLockKey(absoluteFilePath: string): Promise { + try { + return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath)) + } catch (error: unknown) { + // Only a missing path falls back. Everything else is a refusal, and a refusal is not + // evidence about what the path refers to - see the note above. + if (errorCode(error) !== "ENOENT") { + throw error + } + // A real readlink also answers for cases that are not failures: "not there" and "not a + // link" (EINVAL, which is what a plain file and a Windows junction report) end the walk + // at the key it reached, which is the answer for a dangling link and for an alias that + // lands on a real file. Two links that point at each other answer neither, so the walk is + // bounded, and the bound is a refusal rather than an answer. + let key = absoluteFilePath + for (let depth = 0; depth < LOCK_KEY_WALK_BOUND; depth++) { + const target = await fs.readlink(key).catch((readlinkError: unknown) => { + const code = errorCode(readlinkError) + if (code === "ENOENT" || code === "EINVAL") { + return undefined + } + throw readlinkError + }) + if (target === undefined) { + return await canonicalDirKey(key) + } + key = await canonicalDirKey(path.resolve(path.dirname(key), target)) + } + throw new LockKeyWalkIncompleteError(absoluteFilePath, LOCK_KEY_WALK_BOUND, key) + } +} + +export async function safeWriteText( + filePath: string, + content: string | Uint8Array, + options?: SafeWriteTextOptions, +): Promise { + const absoluteFilePath = path.resolve(filePath) + + // Resolve the symlink referent (see resolvePublishTarget). + const targetPath = await resolvePublishTarget(absoluteFilePath) + const dirPath = path.dirname(targetPath) + + // Create the staging directory only when we generate the temp file there; + // callers supplying their own tempPath (e.g. safeWriteJson) must not be left + // with an empty .file-safety-staging directory behind. Track the directory this + // write created so its cleanup removes its own directory, not a shared one. + let stagingDir: string | null = null + let tempPath: string + // Non-null only for a caller-supplied staging file: the identity the validation above + // reported, plus the spelling a refusal should quote back. The open and the commit rename + // each re-confirm the path against it, because validation is a statement about one moment. + let validatedStaging: { stats: fsSync.BigIntStats; supplied: string } | null = null + if (options?.tempPath) { + // A caller-supplied staging file is only safe when it is the file this + // write is staging, not an arbitrary path. Two properties are checked: + // it must sit beside the resolved target (a rename across filesystems + // fails with EXDEV, and a path elsewhere lets a caller publish an + // unrelated file onto the target), and it must be a regular file rather + // than a link — renaming a link over the target publishes whatever the + // link points at, which is the same trust problem as writing through a + // dangling symlink in resolvePublishTarget. + const supplied = path.resolve(options.tempPath) + const suppliedDir = path.dirname(supplied) + const targetDir = path.resolve(dirPath) + // Containment is a claim about directories, not about strings. An alias root - a junction on + // Windows, a symlinked directory on POSIX - makes canonicalDirKey hand dirPath in the referent's + // spelling while the caller still holds its own, and a string compare of the two then refuses a + // staging file that sits physically inside the target's directory. Compare the directories by + // identity, through fs.stat with bigint: true for the same reason the two lstats below use it: + // on NTFS and ReFS a file identity can exceed Number.MAX_SAFE_INTEGER, and a rounded number makes + // two directories look different or two different directories look alike. The spelling compare is + // kept as the fast path in front of it and as the answer when a stat cannot be made; a stat that + // fails for any reason other than the directory not being there propagates, because a guard that + // reads "could not tell" as "contained" has stopped guarding. + if (suppliedDir !== targetDir) { + const suppliedDirStat = await _statDirectoryForContainment(suppliedDir, supplied) + const targetDirStat = await _statDirectoryForContainment(targetDir, supplied) + const sameDirectory = + suppliedDirStat !== undefined && + targetDirStat !== undefined && + suppliedDirStat.dev === targetDirStat.dev && + suppliedDirStat.ino === targetDirStat.ino + if (!sameDirectory) { + throw new StagingPathError( + `Staging file must sit in the target's directory (${dirPath}), got ${supplied}`, + supplied, + ) + } + } + // BigInt stats: on NTFS/ReFS the file identity can exceed Number.MAX_SAFE_INTEGER, and + // a rounded number makes two different files look identical (rejecting a valid staging + // file) or hides a real alias. + const stagingStat = await fs.lstat(supplied, { bigint: true }) + if (stagingStat.isSymbolicLink() || !stagingStat.isFile()) { + throw new StagingPathError( + `Staging file must be a regular file, not ${stagingStat.isSymbolicLink() ? "a symlink" : "another file type"}`, + supplied, + ) + } + // A staging path that is the target would be unlinked by the failure handler + // while it still holds the only copy of the content, so a failed write would + // delete the file it was meant to protect. Compare identities, not spellings: + // an alias of the target is the same hazard. + // Only a missing target may be skipped: an EACCES/ELOOP/ENOTDIR here means the + // identity comparison could not be made, and treating that as "no target" would let a + // staging alias reach the commit and let cleanup delete the file it was meant to + // protect. + const targetStat = await fs.lstat(targetPath, { bigint: true }).catch((error: unknown) => { + if (errorCode(error) !== "ENOENT") { + throw new StagingPathError("Staging file could not be compared with the target", supplied) + } + return null + }) + if ( + targetStat && + typeof stagingStat.ino === "bigint" && + typeof targetStat.ino === "bigint" && + stagingStat.ino === targetStat.ino && + stagingStat.dev === targetStat.dev + ) { + throw new StagingPathError("Staging file must not be the target itself", supplied) + } + // The caller's own path is used as given; only the check is canonical. + // Everything above is a statement about the path at one moment. Keep the identity that + // was checked so the open and the commit rename can each re-confirm it. + validatedStaging = { stats: stagingStat, supplied } + tempPath = options.tempPath + } else { + // The staging directory lives inside the target's own parent, so the parent has to exist + // before _stagingDir runs: mkdirSync's recursive creation applies its mode to every + // directory it creates and never chmods one that already exists, so a self-staged write + // into a missing parent would publish the target into a directory left at the private + // staging mode (0o700) instead of the directory default. Nothing validates a self-staged + // path ahead of this, so creating the parent here cannot create a directory on behalf of + // a write that is about to be rejected -- and it is the order safeWriteJson already uses + // for its own staging directory. + await fs.mkdir(dirPath, { recursive: true }) + stagingDir = _stagingDir(dirPath) + tempPath = _tempName(stagingDir, "safeWriteText") + } + + // A caller-supplied staging path defers this until that path has been accepted (mirrors + // safeWriteJson behaviour): mkdir -p ahead of the caller-supplied checks let a *rejected* + // write create a directory in the target's own tree before anything was validated. For + // the self-staged branch above this re-runs on a directory that already exists -- a + // recursive mkdir never chmods an existing directory, so it cannot repair a mode inherited + // from the staging creation -- and fs.access still confirms the directory is usable. + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + + let backupPath: string | null = null + let releaseBackupOnSuccess = false + // Whether the commit rename ran. A failure after it is a durability problem + // with the published file, not a pre-commit failure, and the backup is then + // the only known-good copy of the previous content. + let committed = false + // Non-null only when the win32 step-2 block saved a successful DACL dump: + // it gates the step-5 restore and is tracked for the cleanup unlinks. + let daclDumpPath: string | null = null + try { + // -- Step 1: write content to staging temp file ------------------- + if (!options?.tempPath) { + // Preserve the existing target's permissions: the staging file must + // not be published wider than the file it replaces (a 0o600 target + // must not become 0o644 through the atomic rename). + // Encode before opening the staging file: an encoding Node cannot + // represent must not leave a half-written temp file behind. + // A string is encoded as UTF-8; bytes handed in by the caller (the + // extension host encodes a document with VS Code's own codec, which + // covers the legacy code pages Node cannot represent) are published + // unchanged. + const buffer = Buffer.from(content) + let targetMode = 0o644 // default for a fresh target + let targetExists = false + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + targetExists = true + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error + // target does not exist yet - keep the default + } + // openSync's creation mode is narrowed by the process umask, so an + // existing 0o664 target would be published as 0o644 through the + // rename. Apply the existing target's exact mode on the fd, as the + // caller-staged branch does; a fresh target keeps the default mode. + const fd = fsSync.openSync(tempPath, "w", targetMode) + try { + if (targetExists) { + fsSync.fchmodSync(fd, targetMode) + } + // Loop until every byte is written: writeSync can report a short + // (partial) write, and publishing a truncated staging file would + // commit corrupt content. + let offset = 0 + while (offset < buffer.length) { + offset += fsSync.writeSync(fd, buffer, offset, buffer.length - offset) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } else { + // Preserve the existing target's mode (CWE-732): the caller-staged + // temp carries its own creation mode, and publishing it as-is would + // widen a restrictive target (e.g. 0o600 -> 0o644) through rename. + // The mode is applied with fchmodSync on the open fd (AFTER openSync): + // chmodSync on the path before the open would make a read-only target + // (0o400/0o444) fail openSync(tempPath, "r+") with EACCES. + let targetMode: number | null = null + try { + targetMode = fsSync.statSync(targetPath).mode & 0o777 + } catch (error: unknown) { + if (errorCode(error) !== "ENOENT") throw error + // target does not exist yet - keep the temp's default mode + } + const fd = fsSync.openSync(tempPath, "r+") + try { + if (validatedStaging) { + // Re-confirm the file the open actually landed on, BEFORE the chmod below: an fd + // opened on a path that was swapped for a link in the meantime would change the + // permissions of somebody else's file, and a swap to a different file would let the + // commit publish that file instead of the content staged here. A fstatSync that + // fails propagates: the fd is ours, and a stat we cannot take is not evidence that + // the path still names our file. + const opened = fsSync.fstatSync(fd, { bigint: true }) + if (_stagingIdentityDiffers(validatedStaging.stats, opened)) { + throw new StagingPathError( + "Staging file was replaced between validation and open", + validatedStaging.supplied, + ) + } + } + if (targetMode !== null) { + fsSync.fchmodSync(fd, targetMode) + } + _fsyncFile(fd) + } finally { + fsSync.closeSync(fd) + } + } + + // -- Step 2 (win32): save DACL BEFORE the backup copy ----------- + const platform = options?.platform ?? process.platform + // Warning delivery must never abort the write: the notices below describe a + // committed-but-imperfect publish, and a caller whose callback throws (a UI sink, + // a logger that is mid-restart) must not turn that into a failed save. + const warn = (message: string) => { + const report = (label: string, error: unknown) => { + console.warn( + `safeWriteText: onWarning callback ${label}: ${error instanceof Error ? error.message : String(error)}`, + ) + } + try { + const sink = options?.onWarning ?? ((m: string) => console.warn(m)) + const result: unknown = sink(message) + // A sink may be async - TypeScript accepts a value-returning callback where + // a void one is expected. Awaiting it would let warning delivery delay a + // write that has already committed (and hang it if the sink never settles), + // while leaving the promise unhandled turns a rejection into an unhandled + // rejection, which under Node's default mode can end the process after a + // successful write. Attach a handler without awaiting. + if (result instanceof Promise) { + result.catch((error: unknown) => report("rejected", error)) + } + } catch (error: unknown) { + report("failed", error) + } + } + if (platform === "win32") { + let accessError: unknown = null + try { + await fs.access(targetPath) // target exists? + } catch (error: unknown) { + accessError = error + } + if (accessError === null) { + const dumpPath = _tempName(dirPath, "safeWriteText.acl") + const saved = await _saveDaclWindows(targetPath, dumpPath, options?.execFileRunner) + if (saved) { + // Only a successfully saved dump may be restored onto the + // committed file (step 5). + daclDumpPath = dumpPath + } else { + // A failed icacls may have left a partial dump behind; + // remove it now (best-effort) so no partial dump survives and + // no later step can restore from it. + await fs.unlink(dumpPath).catch(() => {}) + // The target exists and its DACL could not be captured, so the commit rename + // replaces it with a file that inherits different access rights. The write still + // proceeds - a missing or failing icacls must not leave the user unable to save - + // but the replacement is no longer ACL-identical and that has to be visible + // instead of silent. + warn( + `Could not save the DACL of ${targetPath}; the replacement may inherit different access rights.`, + ) + } + } else if (errorCode(accessError) !== "ENOENT") { + // Not "absent": the target is there but could not be checked (EACCES, ...), so + // DACL preservation was skipped for a reason the caller cannot infer from the + // successful write alone. + warn( + `Could not check ${targetPath} for DACL preservation (${errorCode(accessError) ?? "unknown error"}); the replacement may inherit different access rights.`, + ) + } + } + try { + // -- Step 3 (backup:true): durable copy target -> backup ---- + if (options?.backup) { + try { + await fs.access(targetPath) + backupPath = _tempName(dirPath, "safeWriteText.bak") + // Copy, never move. Renaming the target away leaves the canonical path absent for + // the whole commit window: readers see a missing file, and a concurrent + // writer can create a new target that a later rollback would destroy. A copy + // keeps the target present, so the step 4 rename is the only change to the + // canonical path. The copy is flushed so the retained content survives a crash. + try { + // Create the destination BEFORE any content exists at it, with the mode fixed + // at open time. fs.copyFile picks the destination mode itself (the platform + // creation mask subject to umask on some platforms, the source's mode - or its + // read-only attribute - on others), so letting it create the file would either + // leave a restrictive target's bytes briefly readable to others, or leave the + // copy unwritable so the fsync open below fails with EACCES. open() ignores its + // mode argument for an existing file, so this 0o600 survives the copy on POSIX; + // the chmod afterwards is what clears a copied read-only attribute on Windows + // and keeps a backup of a permissive file private. + const seedFd = fsSync.openSync(backupPath, "wx", 0o600) + fsSync.closeSync(seedFd) + await fs.copyFile(targetPath, backupPath) + await fs.chmod(backupPath, 0o600) + // "r+" not "r": fsync on a read-only handle is EPERM on Windows, and the same + // flag the staged temp file uses above. + const backupFd = fsSync.openSync(backupPath, "r+") + try { + _fsyncFile(backupFd) + } finally { + fsSync.closeSync(backupFd) + } + } catch (backupError: unknown) { + // A partial backup must not outlive this attempt: it is not a complete copy + // of anything, and once the write fails nothing else removes it. + await fs.unlink(backupPath).catch(() => {}) + backupPath = null + throw backupError + } + releaseBackupOnSuccess = true + } catch (err: unknown) { + if (errorCode(err) !== "ENOENT") throw err + } + } + + // -- Step 4: atomic rename temp -> target --------------------- + // The rename publishes whatever the staging path names at the moment it runs. One step + // before it, confirm the path still names the file this write staged and chmodded: a + // path swapped for a link or for another file in the meantime would otherwise be + // published in place of the content written here. A stat that fails - the file gone, or + // a failure that is not about it being gone - is a refusal, because the rename cannot + // then be shown to be publishing our content. The window between this look and the + // rename is narrowed, not closed: a rename has no handle to go through, so this is the + // last look, not a lock. + if (validatedStaging) { + const beforeCommit = await fs.lstat(tempPath, { bigint: true }).catch((error: unknown) => { + throw new StagingPathError( + `Staging file could not be re-checked before the commit (${errorCode(error) ?? "unknown error"}): ${tempPath}`, + validatedStaging.supplied, + ) + }) + if (_stagingIdentityDiffers(validatedStaging.stats, beforeCommit)) { + throw new StagingPathError( + "Staging file was replaced between validation and the commit", + validatedStaging.supplied, + ) + } + } + await fs.rename(tempPath, targetPath) + committed = true + + // -- Step 4b (POSIX): fsync the parent directory so the directory entry + // changed by the commit rename is durable, not just the file content. + if (platform !== "win32") { + try { + const dirFd = fsSync.openSync(dirPath, "r") + try { + _fsyncFile(dirFd) + } finally { + fsSync.closeSync(dirFd) + } + } catch (error: unknown) { + // The content rename committed, but the directory entry that + // points at it is not known to be durable. Reporting success + // here would let a caller believe the write survives a crash, + // so the failure is surfaced as its own error: the caller can + // still find the content at the target, it just cannot rely on + // the directory entry having reached the disk. + // The catch block keeps the backup on this path, so hand the caller the + // path of the copy it is keeping - it is a hidden random name and is + // otherwise unrecoverable. + // Read the version the commit landed, the same derivation every guard compares + // against, so a caller can record which content is on disk and a later write can + // be authorized against it. Best-effort: a failed stat does not undo the commit. + const committedVersion = await computeVersionToken(targetPath).catch(() => undefined) + throw new PostCommitDurabilityError( + targetPath, + error, + releaseBackupOnSuccess ? backupPath : null, + committedVersion, + ) + } + } + + // -- Step 5 (win32): restore DACL AFTER commit rename --------- + // daclDumpPath is non-null only when the win32 step-2 block saved a + // successful dump, so this gate is closed on every other platform + // and on every failed save. + if (daclDumpPath !== null) { + const restoredDir = path.dirname(targetPath) + const restored = await _restoreDaclWindows(restoredDir, daclDumpPath, options?.execFileRunner) + if (!restored) { + // The content is committed, but the published file may carry a different DACL + // from the one that was saved. Failing the write here would break every + // publish on machines where icacls cannot reapply the saved ACEs (a plain + // temp directory restore fails with "Not all privileges or groups referenced + // are assigned to the caller"), so the change of access rights is reported + // rather than thrown. + warn( + `Content committed at ${targetPath}, but the saved DACL could not be restored from ${daclDumpPath}; the file may carry different access rights than the one it replaced.`, + ) + } + } + + // -- Step 6 (backup:true): delete backup on success ----------- + if (releaseBackupOnSuccess && backupPath) { + try { + await fs.unlink(backupPath) + } catch { + // non-fatal — orphaned backup is acceptable + } + } + } finally { + // Unlink DACL dump regardless of success/failure in this span. + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + } + + // tempPath is now the committed file; no cleanup needed. + + // Best-effort: remove the now-empty staging directory. Self-staged + // writes only, and only this write's own directory: a per-write directory + // cannot be the one another concurrent write is still using. A failure must + // never un-commit a published file, so the removal swallows all errors. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + } catch (originalError: unknown) { + // The backup is never restored: it is a copy, and the target already holds + // either the pre-write content (before the commit) or the published content. + // Only a pre-commit failure may discard the backup. After the commit the target + // holds the NEW content, and when the failure is a post-commit durability error the + // backup is the only copy known to hold the previous content - deleting it here + // would destroy the recovery copy the contract promises. + if (backupPath && releaseBackupOnSuccess && !committed) { + // Nothing to restore: the backup is a copy, so the target still holds whatever + // the commit left there. Before the commit that is the pre-write content, and + // the copy beside it is redundant; it must not be left where no caller can + // find it. + await fs.unlink(backupPath).catch(() => {}) + backupPath = null + } + try { + await fs.unlink(tempPath).catch(() => {}) + } catch { + // cleanup failure is non-fatal + } + + // A failed self-staged write must not leave its staging directory behind. + // Only the directory this write created, and only after its temp file is + // gone, so the directory is empty and the removal stays best-effort. + if (stagingDir) { + await fs.rmdir(stagingDir).catch(() => {}) + } + + if (daclDumpPath !== null) { + await fs.unlink(daclDumpPath).catch(() => {}) + } + + throw originalError + } +} diff --git a/src/utils/__tests__/fileLock.test.ts b/src/utils/__tests__/fileLock.test.ts index 92637fe7a0..f937e290a3 100644 --- a/src/utils/__tests__/fileLock.test.ts +++ b/src/utils/__tests__/fileLock.test.ts @@ -1,5 +1,8 @@ +import * as fsp from "fs/promises" +import * as os from "os" +import * as path from "path" import * as lockfile from "proper-lockfile" -import { acquireFileLock, LOCK_STALE_MS, withFileLock } from "../fileLock" +import { acquireFileLock, LOCK_STALE_MS, lockPathFor, withFileLock } from "../fileLock" vi.mock("proper-lockfile", () => ({ lock: vi.fn() })) @@ -15,6 +18,10 @@ describe("file lock protocol", () => { stale: LOCK_STALE_MS, update: 10000, realpath: false, + // The drift guard in safeWriteJson answers "is this the same lock?" by comparing + // the two artefacts lockPathFor names, which is only sound while this module + // locks exactly that location. + lockfilePath: lockPathFor("history.json"), retries: { retries: 35, factor: 1, minTimeout: 1000, maxTimeout: 1000 }, }), ) @@ -31,4 +38,93 @@ describe("file lock protocol", () => { ).rejects.toBe(error) expect(release).toHaveBeenCalledOnce() }) + + // The lock artefact sits beside the file it guards, and proper-lockfile's own mkdir of + // that artefact is not recursive. A write into a directory that did not exist yet failed + // here with ENOENT - before the publish primitive, and so before any rollback it would + // have owned - and left the empty tree behind. These rows run against the real filesystem + // for the directory behaviour; the lock itself stays stubbed because the question here is + // which directories exist afterwards, not who holds what. + it("creates the directory the lock artefact needs before taking the lock", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-chain-")) + const target = path.join(base, "missing", "nested", "history.json") + const release = vi.fn(async () => {}) + vi.mocked(lockfile.lock).mockResolvedValue(release) + try { + await acquireFileLock(target) + expect((await fsp.stat(path.join(base, "missing", "nested"))).isDirectory()).toBe(true) + // The artefact did not move to the ancestor that happened to exist: every writer to + // this path, arriving before or after the directory, is locking the same location. + expect(lockfile.lock).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ lockfilePath: lockPathFor(target) }), + ) + await release() + // Releasing does not undo the creation: the write the lock was taken for follows it. + expect((await fsp.stat(path.join(base, "missing", "nested"))).isDirectory()).toBe(true) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) + + it("removes only the directories it created when the lock cannot be acquired", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-rollback-")) + const existing = path.join(base, "existing") + await fsp.mkdir(existing) + // A directory somebody else already made is not this call's to remove, even though it + // sits inside the chain this call was walking. + const madeByAnotherWriter = path.join(existing, "one") + await fsp.mkdir(madeByAnotherWriter) + const target = path.join(madeByAnotherWriter, "two", "history.json") + const lockFailure = Object.assign(new Error("EEXIST: file already exists"), { code: "EEXIST" }) + vi.mocked(lockfile.lock).mockRejectedValue(lockFailure) + try { + // A rollback that reported its own failure instead would hide which of the two + // failures the caller has to act on. + await expect(acquireFileLock(target)).rejects.toBe(lockFailure) + expect((await fsp.stat(existing)).isDirectory()).toBe(true) + expect((await fsp.stat(madeByAnotherWriter)).isDirectory()).toBe(true) + await expect(fsp.stat(path.join(madeByAnotherWriter, "two"))).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) + + // The contract that exists on the release path, pinned rather than assumed. A cancellation that surfaces as + // a rejection after acquisition does release the lock (fileLock.ts:181-:186), and the caller keeps their + // own error even when the release behind it fails: :183-:185 logs the release failure and rethrows the + // operation's, which is the half a well-meaning fix flips by accident. The directory chain this call + // made stays, because it belongs to the artefact rather than to the outcome - :153-:155 owns created + // directories only when acquisition itself failed - and the next acquisition reuses them instead of + // paying for them twice. The window nothing in this module observes is an abort between lockfile.lock + // resolving and the caller observing the returned release function; that one is covered by the + // staleness contract, and no row here should pretend the release path closes it. + it("releases the lock after a cancelled operation, keeps the cancellation as the caller's error, and leaves the chain the lock owned", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-cancel-")) + const target = path.join(base, "missing", "nested", "history.json") + const releaseFailure = Object.assign(new Error("EPERM: operation not permitted, unlock"), { code: "EPERM" }) + const release = vi.fn(async () => { + throw releaseFailure + }) + vi.mocked(lockfile.lock).mockResolvedValue(release) + const cancellation = Object.assign(new Error("The task was cancelled"), { name: "AbortError" }) + // A failed release is reported, not rethrown. Captured so the row can say which of the two it is. + // Restored inline: this file has no afterEach to do it, and a leaked spy would mute the next row's + // real logging. + const reported = vi.spyOn(console, "error").mockImplementation(() => {}) + try { + await expect( + withFileLock(target, async () => { + throw cancellation + }), + ).rejects.toBe(cancellation) + expect(release).toHaveBeenCalledOnce() + expect(reported).toHaveBeenCalledWith(expect.stringContaining("Failed to release lock for"), releaseFailure) + // The chain the lock was taken for is still there, including the level the artefact needed. + expect((await fsp.stat(path.join(base, "missing", "nested"))).isDirectory()).toBe(true) + } finally { + reported.mockRestore() + await fsp.rm(base, { recursive: true, force: true }) + } + }) }) diff --git a/src/utils/__tests__/fileLockChain.test.ts b/src/utils/__tests__/fileLockChain.test.ts new file mode 100644 index 0000000000..05aa0bfccb --- /dev/null +++ b/src/utils/__tests__/fileLockChain.test.ts @@ -0,0 +1,76 @@ +import * as fsp from "fs/promises" +import * as path from "path" +import * as lockfile from "proper-lockfile" +import { acquireFileLock } from "../fileLock" + +// Only the two filesystem calls this module makes are doubled, and the lock itself: an +// unexpected fs.stat call would fail the row instead of quietly resolving, which matters +// because the directory question is the one the task-persistence suites forbid asking. +vi.mock("fs/promises", () => ({ mkdir: vi.fn(), rmdir: vi.fn() })) +vi.mock("proper-lockfile", () => ({ lock: vi.fn() })) + +// The doubles hold Node's recursive-mkdir contract, measured on this host rather than +// inferred from the docs: the call resolves with the first directory it actually created - +// on Windows reported with an extended-length \\?\ prefix - and with nothing when every +// level already existed. That answer is the only thing this module uses to decide what a +// failed acquisition may give back, so these rows keep the answer fixed and read the calls +// the module made, instead of asking a real filesystem to lose a race on schedule. +describe("what a failed lock acquisition gives back", () => { + const lockRefused = (): Error => + Object.assign(new Error("EEXIST: file already exists, lock held"), { code: "EEXIST" }) + + beforeEach(() => vi.clearAllMocks()) + + it("claims nothing when every level of the chain already existed", async () => { + const refusal = lockRefused() + vi.mocked(fsp.mkdir).mockResolvedValue(undefined) + vi.mocked(lockfile.lock).mockRejectedValue(refusal) + await expect(acquireFileLock(path.join("C:", "tree", "state.json"))).rejects.toBe(refusal) + // Nothing was created, so nothing may be removed: the directories here belong to + // whoever put them there, and one of them may be somebody else's working tree. + expect(fsp.rmdir).not.toHaveBeenCalled() + }) + + // Row 4507646158 follow-up. The real-filesystem rows cannot pin this: whether the writer that + // created the directory is also the writer that loses the lock is a race, and a populated + // directory is protected by ENOTEMPTY whichever way the rollback asks for it. So the claim - a + // rollback that cannot remove a directory leaves it, and does not report its own refusal over + // the failure that caused it - is made here, where the create answer and the rmdir answer are + // both fixed. + it("leaves a created directory another writer populated and still reports the lock refusal", async () => { + const refusal = lockRefused() + const createdLevel = path.resolve(path.join("tree", "one")) + vi.mocked(fsp.mkdir).mockResolvedValue("\\\\?\\" + createdLevel) + vi.mocked(lockfile.lock).mockRejectedValue(refusal) + // The directory this call created is not empty by the time the rollback looks: another writer + // has put something in it, and rmdir refuses a directory in use. + vi.mocked(fsp.rmdir).mockRejectedValue( + Object.assign(new Error("ENOTEMPTY: directory not empty, " + createdLevel), { code: "ENOTEMPTY" }), + ) + const target = path.join(createdLevel, "state.json") + // The caller gets the lock refusal it asked about. A rollback failure on the way out must not + // arrive dressed as the error that caused the rollback. + await expect(acquireFileLock(target)).rejects.toBe(refusal) + // The populated directory is asked for once and left there, not retried into its parent. + expect(vi.mocked(fsp.rmdir).mock.calls.map((call) => String(call[0]))).toEqual([createdLevel]) + }) + + it("gives back only the levels the create reported, deepest first", async () => { + const refusal = lockRefused() + // Resolved rather than spelled out: the fixture has to be an absolute path on every lane, and + // "C:" is absolute only on Windows. The report below is still prefixed, which is the half of + // the row that is about Windows - normalising it has to land back on the absolute path the call + // already knows, on either platform. + const firstLevel = path.resolve(path.join("tree", "one")) + // Node's answer on Windows arrives prefixed; the module normalises it before comparing. + vi.mocked(fsp.mkdir).mockResolvedValue("\\\\?\\" + firstLevel) + vi.mocked(lockfile.lock).mockRejectedValue(refusal) + const target = path.join(firstLevel, "two", "state.json") + await expect(acquireFileLock(target)).rejects.toBe(refusal) + expect(vi.mocked(fsp.mkdir).mock.calls.map((call) => String(call[0]))).toEqual([path.join(firstLevel, "two")]) + expect(vi.mocked(fsp.rmdir).mock.calls.map((call) => String(call[0]))).toEqual([ + path.join(firstLevel, "two"), + firstLevel, + ]) + }) +}) diff --git a/src/utils/__tests__/fileLockContention.test.ts b/src/utils/__tests__/fileLockContention.test.ts new file mode 100644 index 0000000000..939e57f294 --- /dev/null +++ b/src/utils/__tests__/fileLockContention.test.ts @@ -0,0 +1,61 @@ +import * as fsp from "fs/promises" +import * as os from "os" +import * as path from "path" +import { acquireFileLock, lockPathFor } from "../fileLock" + +// These rows run against the real proper-lockfile on real temporary directories. The +// property is which artefact two writers reach, and a stubbed lock cannot answer it: +// a stub reports whatever shape the test hands it, including a shape production does +// not build. +describe("file lock contention where the directory has to be created first", () => { + it("a writer that arrives after the directory exists contends with the writer that made it", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-contend-")) + const target = path.join(base, "missing", "nested", "state.json") + try { + const releaseFirst = await acquireFileLock(target) + expect((await fsp.stat(path.dirname(target))).isDirectory()).toBe(true) + // The second writer arrives while the first still holds and after the directory + // appeared. Under ancestor-first locking it would have derived a different + // artefact and walked straight in; the artefact is derived from the guarded path, + // so it contends. + // proper-lockfile reports a held artefact as its own error rather than the raw + // mkdir collision, so the row names what the caller actually sees. + await expect(acquireFileLock(target, { immediate: true })).rejects.toThrow(/already being held/) + await releaseFirst() + const releaseSecond = await acquireFileLock(target, { immediate: true }) + expect((await fsp.stat(lockPathFor(target))).isDirectory()).toBe(true) + await releaseSecond() + await expect(fsp.stat(lockPathFor(target))).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) + + it("two writers racing into the same missing directory leave exactly one holding the path", async () => { + const base = await fsp.mkdtemp(path.join(os.tmpdir(), "filelock-race-")) + const target = path.join(base, "missing", "nested", "state.json") + const attempts = await Promise.allSettled([ + acquireFileLock(target, { immediate: true }), + acquireFileLock(target, { immediate: true }), + ]) + const holding = attempts.filter((attempt) => attempt.status === "fulfilled") + const refused = attempts.filter((attempt) => attempt.status === "rejected") + try { + expect(holding).toHaveLength(1) + expect(refused).toHaveLength(1) + expect((refused[0] as PromiseRejectedResult).reason).toBeInstanceOf(Error) + expect(String((refused[0] as PromiseRejectedResult).reason)).toMatch(/already being held/) + // One artefact, held by one writer: the refused attempt creates no lock of its own + // and leaks nothing behind. The refused attempt has already run its rollback here, and one of + // the two created the directory the artefact stands in: the directory and the live artefact + // both have to outlive that rollback, and the holder has to be able to release. (Row + // 4507646158 follow-up: this is the window the reviewer's rollback claim is about.) + expect((await fsp.stat(path.dirname(target))).isDirectory()).toBe(true) + expect((await fsp.stat(lockPathFor(target))).isDirectory()).toBe(true) + await holding[0]!.value() + await expect(fsp.stat(lockPathFor(target))).rejects.toMatchObject({ code: "ENOENT" }) + } finally { + await fsp.rm(base, { recursive: true, force: true }) + } + }) +}) diff --git a/src/utils/__tests__/jsonFileRecovery.test.ts b/src/utils/__tests__/jsonFileRecovery.test.ts index 1bd90d8935..c2dc4726b4 100644 --- a/src/utils/__tests__/jsonFileRecovery.test.ts +++ b/src/utils/__tests__/jsonFileRecovery.test.ts @@ -1,10 +1,12 @@ import * as fs from "fs/promises" import * as os from "os" import * as path from "path" +import * as fsSync from "fs" import { acquireFileLock } from "../fileLock" import { setPersistenceTraceSink } from "../../core/task-persistence/persistenceTrace" import { deleteJsonFile, recoverMissingJsonFile } from "../jsonFileRecovery" +import { LockKeyWalkIncompleteError, resolveLockKey } from "../../services/file-safety/safeWriteText" import { readFileWithMissingRetry } from "../../core/task-persistence/readFileWithMissingRetry" vi.mock("fs/promises", async () => { @@ -101,6 +103,172 @@ describe("reader recovery", () => { await expect(fs.access(backup)).resolves.toBeUndefined() }) + // The three rows below put resolveLockKey's fail-closed rule in front of a caller rather than + // asserting it at the function: this file's own delete, which computes the key before it takes + // the lock and so is the operation that either refuses or proceeds on a key nobody agreed to. + // The link geometry they need - a referent that is not there, a readlink that is refused, a + // chain that never answers - is supplied by spies, because this lane cannot build a file symlink + // for real (fs.symlink answers EPERM here, and the alias rows above say so). Everything else + // runs on the real filesystem: the lock machinery, the canonicalization inside the walk, and + // the files the rows assert on. + it("refuses the delete when the filesystem will not say what the path refers to", async () => { + await fs.writeFile(target, JSON.stringify({ committed: true })) + // This spec's unlink double is a vi.fn shared across rows and afterEach restores spies without + // resetting vi.fn history, so the row clears it before asking whether the delete touched a file. + vi.mocked(fs.unlink).mockClear() + const refusal = Object.assign(new Error("EACCES: permission denied, realpath '" + target + "'"), { + code: "EACCES", + }) + const realpathSpy = vi.spyOn(fs, "realpath").mockRejectedValueOnce(refusal) + try { + await expect(deleteJsonFile(target)).rejects.toBe(refusal) + // The refusal was the first question the resolution asked, and it is the error the caller + // receives rather than a wrapped one, so a caller that logs the code still sees EACCES. + // A restored spy keeps no call history, so the count is read while it is installed. + expect(realpathSpy).toHaveBeenCalledTimes(1) + } finally { + realpathSpy.mockRestore() + } + // The key is computed before the lock is taken, so the delete stops before it starts instead + // of locking a key derived from the caller's own spelling. Nothing was unlinked. + expect(fs.unlink).not.toHaveBeenCalled() + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + expect(JSON.parse(await fs.readFile(backup, "utf8"))).toEqual({ committed: true }) + }) + + it("refuses the delete when reading the link itself is refused", async () => { + await fs.writeFile(target, JSON.stringify({ committed: true })) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + vi.mocked(fs.unlink).mockClear() + // The referent is not there - the window a peer's commit rename opens - so the resolution + // falls to the walk, and the walk's first readlink is refused. The refusal used to be read as + // "not a link", which ends the walk and hands back whatever spelling it reached. + const linkStats = Object.assign(Object.create(fsSync.Stats.prototype) as fsSync.Stats, { + isSymbolicLink: () => true, + }) + const refusal = Object.assign(new Error("EACCES: permission denied, readlink '" + target + "'"), { + code: "EACCES", + }) + const realpathSpy = vi.spyOn(fs, "realpath").mockRejectedValueOnce(enoent) + const lstatSpy = vi.spyOn(fs, "lstat").mockResolvedValueOnce(linkStats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockRejectedValueOnce(refusal) + try { + await expect(deleteJsonFile(target)).rejects.toBe(refusal) + // One readlink, and it is the one that was refused: the walk stopped because the filesystem + // refused to answer, not because it decided the answer was "not a link". Read while the + // spy is still installed - restoring it clears the call history. + expect(readlinkSpy).toHaveBeenCalledTimes(1) + } finally { + readlinkSpy.mockRestore() + lstatSpy.mockRestore() + realpathSpy.mockRestore() + } + expect(fs.unlink).not.toHaveBeenCalled() + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + }) + + it("refuses the delete when the link chain stops at the walk's depth bound", async () => { + await fs.writeFile(target, JSON.stringify({ committed: true })) + const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + const linkStats = Object.assign(Object.create(fsSync.Stats.prototype) as fsSync.Stats, { + isSymbolicLink: () => true, + }) + vi.mocked(fs.unlink).mockClear() + const realpathSpy = vi.spyOn(fs, "realpath").mockRejectedValueOnce(enoent) + const lstatSpy = vi.spyOn(fs, "lstat").mockResolvedValueOnce(linkStats) + const readlinkSpy = vi.spyOn(fs, "readlink").mockImplementation(async () => "alias.json") + let failure: unknown + try { + failure = await deleteJsonFile(target).then( + () => undefined, + (error: unknown) => error, + ) + // Eight links answered and none of them was an answer. The count is the seam running: a + // shorter count would mean the row never reached the bound it names. Read before the + // restores below, which clear the spy's call history. + expect(readlinkSpy).toHaveBeenCalledTimes(8) + } finally { + readlinkSpy.mockRestore() + lstatSpy.mockRestore() + realpathSpy.mockRestore() + } + expect(failure).toBeInstanceOf(LockKeyWalkIncompleteError) + if (!(failure instanceof LockKeyWalkIncompleteError)) { + throw new Error("the depth bound must reject, not resolve :: " + String(failure)) + } + expect(failure.bound).toBe(8) + expect(failure.absoluteFilePath).toBe(path.resolve(target)) + expect(path.basename(failure.lastKey)).toBe("alias.json") + expect(fs.unlink).not.toHaveBeenCalled() + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + }) + + // The three rows below need a file symlink. `fs.symlink` on a file answers EPERM on the Windows + // lane, which has neither privilege nor Developer Mode, and no other Windows spelling carries the + // meaning: a hard link has no referent for resolveLockKey to follow, and a junction links + // directories. So these run on the non-Windows lanes - executed there by the ubuntu coverage run, + // not verified locally - and the claim they pin is a POSIX claim about what an alias delete does. + const itPosix = it.runIf(process.platform !== "win32") + + itPosix("contends with the writer holding the referent lock when asked for through an alias", async () => { + const alias = path.join(dir, "history_item_alias.json") + await fs.writeFile(target, JSON.stringify({ committed: true })) + await fs.symlink(target, alias) + // The row is only about the key. If the two spellings did not resolve to the same lock, the + // delete below would be contending for a name no writer ever takes, and the refusal would + // prove nothing about the alignment. + expect(await resolveLockKey(alias)).toBe(await resolveLockKey(target)) + const release = await acquireFileLock(target) + try { + await expect(deleteJsonFile(alias)).rejects.toMatchObject({ code: "ELOCKED" }) + // Refused, so it touched nothing: not the referent, and not the backup beside it. + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + expect(JSON.parse(await fs.readFile(backup, "utf8"))).toEqual({ committed: true }) + expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true) + } finally { + await release() + } + // Once the writer is gone the same call goes through, and it removes the referent. + await deleteJsonFile(alias) + await expect(fs.stat(target)).rejects.toMatchObject({ code: "ENOENT" }) + }) + + itPosix("removes the referent an alias names, leaving the link standing and unreadable", async () => { + const alias = path.join(dir, "history_item_alias.json") + await fs.writeFile(target, JSON.stringify({ committed: true })) + await fs.symlink(target, alias) + await deleteJsonFile(alias) + // This is what the alignment costs, and it is a change from unlinking the name the caller + // passed: the spelling the caller asked about is still there, and reading through it now + // answers ENOENT because what it pointed at is gone. The leftover prefixes come from the + // referent's basename too, so the backup is removed under either spelling. + expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true) + await expect(fs.stat(alias)).rejects.toMatchObject({ code: "ENOENT" }) + await expect(fs.readFile(alias, "utf8")).rejects.toMatchObject({ code: "ENOENT" }) + await expect(fs.access(backup)).rejects.toMatchObject({ code: "ENOENT" }) + }) + + itPosix("asks the referent-side delete for the same lock it asked for before", async () => { + const alias = path.join(dir, "history_item_alias.json") + await fs.writeFile(target, JSON.stringify({ committed: true })) + await fs.symlink(target, alias) + // Asked for by the referent's own name nothing here is new: that delete already contended + // with the referent lock, because its key was always the referent. The alignment is only + // visible from the alias side, which is why the row asserts both halves - the contention that + // was already there, and the outcome that is unchanged by the change. + const release = await acquireFileLock(target) + try { + await expect(deleteJsonFile(target)).rejects.toMatchObject({ code: "ELOCKED" }) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ committed: true }) + } finally { + await release() + } + await deleteJsonFile(target) + await expect(fs.stat(target)).rejects.toMatchObject({ code: "ENOENT" }) + expect((await fs.lstat(alias)).isSymbolicLink()).toBe(true) + await expect(fs.readFile(alias, "utf8")).rejects.toMatchObject({ code: "ENOENT" }) + }) + it("does not create a missing task directory", async () => { await fs.rm(dir, { recursive: true }) await expect(recoverMissingJsonFile(target)).resolves.toBe(false) diff --git a/src/utils/__tests__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts new file mode 100644 index 0000000000..a68d922c19 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -0,0 +1,757 @@ +// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts + +import * as os from "os" +import path from "path" +import { type BigIntStats, closeSync, mkdtempSync, openSync, readdirSync, rmSync, writeFileSync } from "fs" +import * as fs from "fs/promises" +import { acquireFileLock } from "../fileLock" +import { safeWriteJson } from "../safeWriteJson" +import { lockPathFor } from "../fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import { getErrorCode } from "../errorCode" + +vi.mock("../fileLock", async () => { + // The drift guard asks whether two keys name one lock, which is a question about the + // artefact location lockPathFor describes. Mocking this module must not replace that + // one pure function, or a row would be comparing a location of its own invention. + const actual = await vi.importActual("../fileLock") + return { ...actual, acquireFileLock: vi.fn(async () => async () => {}) } +}) + +vi.mock("fs/promises", async () => { + const actual = await vi.importActual("fs/promises") + return { + ...actual, + realpath: vi.fn(), + lstat: vi.fn(), + readlink: vi.fn(), + // stat stays a pass-through by default. The acceptance rows read the artefact identity back + // through it, and the one row that overrides it puts this back when it finishes. + stat: vi.fn(actual.stat), + } +}) +const mockedRealpath = vi.mocked(fs.realpath) +const mockedLstat = vi.mocked(fs.lstat) +const mockedReadlink = vi.mocked(fs.readlink) +const mockedAcquireFileLock = vi.mocked(acquireFileLock) +const mockedStat = vi.mocked(fs.stat) + +const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + +// "Not a link" is not a bare error. readlink reports it as EINVAL, and the lock-key walk now has +// to tell that answer apart from a refusal, so the double carries the code the real call carries. +// It used not to, and nothing noticed: the walk swallowed every readlink failure, so a codeless +// rejection read the same as the answer "this is not a link". +const notALink = (): Error => Object.assign(new Error("EINVAL: invalid argument, not a symlink"), { code: "EINVAL" }) + +// Whether this volume answers a name with a different spelling than the one its directory stores. +// The gate further down needs the capability rather than the platform label, so it is measured +// from a real directory: write one file, ask whether a differently-cased name opens, and require +// the directory to list exactly one spelling of it. A failure that is not "not there" throws and +// stops the file from collecting - a probe that defaulted on an unexpected error would report the +// capability as absent and turn the gated row into a skip that reads like a pass. The row that +// gates on this value re-measures the same property at run time through the promise surface and +// compares, so a wrong answer here fails loudly rather than quietly reducing coverage. +const CASE_FOLDING_VOLUME = (() => { + const dir = mkdtempSync(path.join(os.tmpdir(), "lockkey-foldprobe-")) + try { + const stored = path.join(dir, "probe.json") + writeFileSync(stored, "{}") + // The probe's own easy question first: a volume that will not open the file under the name it + // was written with is not in a position to answer the hard one. + closeSync(openSync(stored, "r")) + const listed = readdirSync(dir) + if (listed.length !== 1) { + throw new Error("case-folding probe directory holds " + String(listed.length) + " entries, expected 1") + } + let opensUnderOtherCase = false + try { + closeSync(openSync(path.join(dir, "PROBE.JSON"), "r")) + opensUnderOtherCase = true + } catch (error: unknown) { + const code = getErrorCode(error) + // "Not there" is the verdict this gate wants: a volume that stores one spelling and does + // not find the other is case-sensitive. Anything else is a question the filesystem refused + // to answer, which is not an answer. + if (code !== "ENOENT") { + throw new Error("case-folding probe could not ask the filesystem: " + String(code ?? error)) + } + } + return opensUnderOtherCase + } finally { + rmSync(dir, { recursive: true, force: true }) + } +})() + +// Each test creates a real temp directory so the real fs calls still work, +// and clears the module-level doubles between tests so an implementation from one test cannot carry over. +const createdDirs: string[] = [] +async function makeDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)) + createdDirs.push(dir) + return dir +} + +beforeEach(() => { + mockedRealpath.mockReset() + mockedLstat.mockReset() + mockedReadlink.mockReset() + mockedAcquireFileLock.mockReset() +}) + +afterEach(async () => { + for (const dir of createdDirs) { + await fs.rm(dir, { recursive: true, force: true }).catch(() => undefined) + } + createdDirs.length = 0 +}) + +// Only isSymbolicLink() is consulted by the guard, so the double carries just +// that method. The mocks reject asynchronously: a synchronous throw would bypass +// resolvePublishTarget's catch and skip the ENOENT/symlink branch under test. +const symlinkStat = (target: unknown) => + ({ + isSymbolicLink: () => target === currentLink, + // The staging-path check in safeWriteText also asks whether the path is a + // regular file, so the double carries that predicate as well. + isFile: () => target !== currentLink, + }) as unknown as BigIntStats +let currentLink = "" + +/** + * safeWriteJson stages as ..new__.tmp - createTempFilePath at + * safeWriteJson.ts:192-197, the template main carries at its :54 - so a staged leftover never + * starts with ".new_": it starts with the target's own name. Matching the infix is what lets + * the "nothing left staged" assertions below see a leak at all; the startsWith form matches + * only safeWriteText's own ".new__.tmp" names (safeWriteText.ts:92-94), which this + * file never stages. safeWriteJson.interruption.test.ts:196 already filters by infix. + */ +const stagedFile = (name: string): boolean => name.includes(".new_") + +describe("safeWriteJson lock key under a peer commit", () => { + it("waits for the peer instead of rejecting, and locks the referent", async () => { + const order: string[] = [] + const dir = await makeDir("lockkey-") + const referent = path.join(dir, "history_item.json") + currentLink = path.join(dir, "link.json") + + // The peer writer has renamed the referent away and has not committed yet, + // so the first resolution fails with ENOENT while lstat still reports a + // symbolic link. A strict resolve here rejects the caller before it can ever + // queue behind the peer, and the caller's delta write is lost. + mockedRealpath + .mockImplementationOnce(async () => { + order.push("resolve-failed") + throw enoent + }) + .mockImplementation(async (target) => { + order.push("resolve") + // The second call happens under the lock, where the peer has committed. + return target === currentLink ? referent : String(target) + }) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(notALink()), + ) + mockedAcquireFileLock.mockImplementation(async () => { + order.push("lock") + return async () => {} + }) + + await safeWriteJson(currentLink, { id: "task-1" }) + + // The lock key is the key every other writer to this file uses, so the caller + // queued behind the peer instead of failing before the lock. + expect(mockedAcquireFileLock).toHaveBeenCalledWith(referent) + // After the lock: the strict publish resolution, the key/publish-target agreement check + // After the lock: the strict publish resolution, the key/publish-target agreement check + // (resolve of the publish target plus canonicalization of its directory), and the + // re-resolution of the staging directory - the approval and the re-verification + // before anything is staged. None of them changes which lock the caller queued behind. + expect(order).toEqual([ + "resolve-failed", + "lstat", + "resolve", + "resolve", + "lock", + "resolve", + "resolve", + "resolve", + "resolve", + "resolve", + ]) + // The commit rename lands on the requested (alias) path: under the replacement + // protocol the alias itself is replaced, so the write arrives there. + expect(JSON.parse(await fs.readFile(currentLink, "utf8"))).toEqual({ id: "task-1" }) + }) + + it("refuses a resolution that lands in another directory even when its lock artefact is reachable", async () => { + const dir = await makeDir("lockkey-diralias-") + const referent = await makeDir("lockkey-referent-") + const alias = path.join(dir, "alias") + const peerFile = path.join(referent, "settings.json") + await fs.mkdir(alias) + const target = path.join(alias, "settings.json") + + // realpath and lstat delegate to the real filesystem: the two directories below are + // directories the operating system created, which is the only way to show the guard + // refusing a destination that is genuinely elsewhere. + const real = await vi.importActual("fs/promises") + mockedRealpath.mockImplementation(async (p) => await real.realpath(String(p))) + mockedLstat.mockImplementation(async (p) => await real.lstat(String(p), { bigint: true })) + mockedReadlink.mockRejectedValue(enoent) + + let acquiredKey: string | undefined + mockedAcquireFileLock.mockImplementation(async (file) => { + acquiredKey = String(file) + // This writer holds the lock for the key it computed, at the artefact + // acquireFileLock creates for it. The peer publishes the file under its own lock in + // another directory, moves the directory this writer's key names out of the way, and + // re-keys that name onto the peer's directory. The writer's artefact spelling now + // resolves through the alias onto the PEER's artefact: an identity match there would + // name a lock this writer never took. + await fs.mkdir(lockPathFor(String(file)), { recursive: true }) + await fs.writeFile(peerFile, JSON.stringify({ mcpServers: { peer: true } })) + await fs.mkdir(lockPathFor(peerFile), { recursive: true }) + await fs.rename(alias, path.join(dir, "stale")) + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + return async () => {} + }) + + const error = await safeWriteJson(target, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + expect(String(error && error.name)).toBe("LockKeyDriftError") + expect(String(acquiredKey)).toContain(path.join("alias", "settings.json")) + // Nothing staged or published: the referent directory holds only the peer's file and + // its own artefact, and the moved-aside directory holds only this writer's. + expect((await fs.readdir(referent)).sort()).toEqual(["settings.json", "settings.json.lock"]) + expect(JSON.parse(await fs.readFile(peerFile, "utf8"))).toEqual({ mcpServers: { peer: true } }) + expect((await fs.readdir(path.join(dir, "stale"))).sort()).toEqual(["settings.json.lock"]) + }) + + it("accepts the name the filesystem itself answers for the spelling the key was computed from", async () => { + const dir = await makeDir("lockkey-casefold-") + // The fixture's own geometry, recorded during acquisition rather than assumed: the key the product + // hands to acquireFileLock is the spelling its pre-lock resolution answered with, and whether the + // writer's own spelling exists by the time acquisition returns is the far half of the divergence the + // guard is then asked about. Both rows assert these before asserting anything about the guard, so a + // platform that stops building the pair fails here instead of somewhere harder to read. + const acquisitionFacts: Array<{ key: string; spellingPresentDuringAcquisition: boolean }> = [] + const lower = path.join(dir, "settings.json") + const upper = path.join(dir, "SETTINGS.JSON") + const real = await vi.importActual("fs/promises") + // The reviewer's case turns on realpath handing back the name a directory stores rather + // than the spelling it was given - which is what NTFS's GetFinalPathNameByHandle does. + // This double models exactly that, and is the identity function wherever the two already + // agree. Where the filesystem does the restating and where the double does it is what the + // probe below measures, because the subject of this row is a pair that differs by basename + // case inside one directory, and _locksAreOneLock (safeWriteJson.ts:119-:124) puts its + // question to no other pair. A case-only pair is something only a case-folding filesystem + // produces on its own; on ext4 the two spellings are two entries and the guard is inert + // there by design, so the double supplies the restating and the row asserts the identity + // and propagation half of what the code promises on that platform. + const restatingRealpath = async (target: string): Promise => { + const resolved = await real.realpath(target) + const parent = path.dirname(resolved) + const name = path.basename(resolved) + const listed = await real.readdir(parent).catch(() => [] as string[]) + // Prefer the entry whose name differs from the request in case alone. On a folding + // filesystem that is the one name the directory stores; on a case-sensitive one the + // directory holds both spellings and the match has to be pinned to the other one, or the + // divergence this row is built on would depend on readdir order. + const stored = listed.find((entry: string) => entry !== name && entry.toLowerCase() === name.toLowerCase()) + return stored === undefined ? resolved : path.join(parent, stored) + } + const probeLower = path.join(dir, "probe.json") + await real.writeFile(probeLower, "{}") + const foldsCase = await restatingRealpath(path.join(dir, "PROBE.JSON")).then( + async (resolved: string) => path.basename(resolved) === path.basename(probeLower), + () => false, + ) + await real.rm(probeLower, { force: true }) + mockedRealpath.mockImplementation(async (p) => await restatingRealpath(String(p))) + mockedLstat.mockImplementation(async (p) => await real.lstat(String(p), { bigint: true })) + mockedReadlink.mockImplementation(async (p) => await real.readlink(String(p))) + + mockedAcquireFileLock.mockImplementation(async (file) => { + // The key was computed from the upper-case spelling while nothing of that name existed, + // so it kept the literal spelling. The file this write publishes to appears during + // acquisition, under the name the filesystem reports. + const ownArtefact = lockPathFor(String(file)) + await fs.writeFile(ownArtefact, "") + await fs.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) + if (!foldsCase) { + // A case-sensitive filesystem cannot produce the name pair on its own, so the entry in + // the writer's spelling is made here and the two artefact names are joined by a hard + // link. Hard link rather than symlink, deliberately: a hard link is one object under + // two names on NTFS and on ext4 alike, it asks for no privilege on either hosted lane, + // and the thing the guard answers from is object identity, which a hard link supplies + // and a link that resolves somewhere else only imitates. + await fs.link(lower, upper) + await fs.link(ownArtefact, lockPathFor(lower)) + } + acquisitionFacts.push({ + key: String(file), + spellingPresentDuringAcquisition: await fs.lstat(upper).then( + () => true, + () => false, + ), + }) + return async () => {} + }) + + const error = await safeWriteJson(upper, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + // The claim is the guard's answer, and it is the same claim on either filesystem: the two + // names reach one artefact, so this writer holds the lock the other spelling would take and + // the write is not refused. Where the two platforms differ is in how many names exist, which + // is a property of the directory and not of the guard, so only the counts branch. + // Geometry before verdict. + expect( + acquisitionFacts.map((fact: { key: string }) => path.basename(fact.key)), + "the lock key handed to acquisition was not this writer's spelling, so no divergence reached the guard", + ).toEqual(["SETTINGS.JSON"]) + expect( + acquisitionFacts.map( + (fact: { spellingPresentDuringAcquisition: boolean }) => fact.spellingPresentDuringAcquisition, + ), + "the writer's spelling never appeared during acquisition, so the under-lock resolution had nothing to restate", + ).toEqual([true]) + expect(String(error && error.name)).toBe("undefined") + expect(JSON.parse(await fs.readFile(upper, "utf8"))).toEqual({ mcpServers: { mine: true } }) + if (!foldsCase) { + // The commit rename lands on the path the caller was given (safeWriteJson.ts:483), and on + // a case-sensitive filesystem that names the other entry: the referent keeps the peer's + // content and the writer's own spelling becomes a regular file. That is what the shipped + // order does, so the row states it instead of asserting a result the platform never gives. + expect(JSON.parse(await fs.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) + } + const entries = await fs.readdir(dir) + // A case-folding filesystem holds one file and one artefact under the two spellings, so it + // lists two entries; a case-sensitive one holds two distinct files and two distinct + // artefacts, and lists four. Names are compared case-insensitively because on a folding + // filesystem the commit rename itself restates which spelling the file carries. + expect(entries.map((name: string) => name.toLowerCase()).sort()).toEqual( + foldsCase + ? ["settings.json", "settings.json.lock"] + : ["settings.json", "settings.json", "settings.json.lock", "settings.json.lock"], + ) + expect(entries.length).toBe(foldsCase ? 2 : 4) + // The guard answered from object identity, so the identity is read back off the filesystem + // rather than taken on trust from the fixture: two names, one artefact, on either platform. + const ownArtefactRead = await fs.stat(lockPathFor(upper)) + const peerArtefactRead = await fs.stat(lockPathFor(lower)) + expect(peerArtefactRead.dev).toBe(ownArtefactRead.dev) + expect(peerArtefactRead.ino).toBe(ownArtefactRead.ino) + }) + + it("does not stage or publish while the lock acquisition has not returned", async () => { + const dir = await makeDir("lockkey-pending-") + const target = path.join(dir, "settings.json") + + // Staging uses the ..new__.tmp infix and the publish renames + // onto the target, both inside this directory, so the directory listing taken + // while acquisition is pending is the whole proof. + // ..new__.tmp infix and the publish renames onto the target, + // both inside this directory, so an empty directory is the whole proof. + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockRejectedValue(enoent) + mockedReadlink.mockRejectedValue(notALink()) + + let acquiredKey: string | undefined + let duringAcquisition: string[] | undefined + let releaseAcquisition: () => void = () => {} + const acquisition = new Promise((resolve) => { + releaseAcquisition = resolve + }) + mockedAcquireFileLock.mockImplementation(async (file) => { + acquiredKey = String(file) + // The acquisition has been called and has not returned. Let the caller run + // in that window: if it stages or publishes before holding the lock, that + // work lands in this directory and the snapshot below catches it. A caller + // that waits for the lock has nothing to do here but wait. + await new Promise((resolve) => setTimeout(resolve, 25)) + duringAcquisition = await fs.readdir(dir) + await acquisition + return async () => {} + }) + + const write = safeWriteJson(target, { mcpServers: {} }) + releaseAcquisition() + await write + + expect(acquiredKey).toBe(target) + expect(duringAcquisition).toEqual([]) + expect(await fs.readdir(dir)).toEqual(["settings.json"]) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ mcpServers: {} }) + }) + + it("releases the lock when the resolution under the lock rejects", async () => { + const order: string[] = [] + let released = false + const dir = await makeDir("lockkey-") + const referent = path.join(dir, "history_item.json") + currentLink = path.join(dir, "link.json") + + // A real dangling link: the walk tolerates it so the caller can queue behind + // the peer, but once the lock is held the strict rejection still applies. A + // rejection outside the protected block would leave the lock held until the + // stale timeout for every other writer to the same file. + mockedRealpath.mockImplementation(async () => { + throw enoent + }) + mockedLstat.mockImplementation(async (target) => { + order.push("lstat") + return symlinkStat(target) + }) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(notALink()), + ) + mockedAcquireFileLock.mockImplementation(async () => { + order.push("lock") + return async () => { + order.push("release") + released = true + } + }) + + await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow(enoent) + expect(released).toBe(true) + // The strict rejection is reached through the ENOENT + symlink branch, not + // through a synchronous throw that skips it. + expect(order).toEqual(["lstat", "lock", "lstat", "release"]) + }) + + it("refuses to publish through a lock that no longer names the target", async () => { + const order: string[] = [] + let released = false + const dir = await makeDir("lockkey-") + const keyedFile = path.join(dir, "history_item.json") + const movedFile = path.join(dir, "elsewhere.json") + currentLink = path.join(dir, "link.json") + + // The key is resolved from the alias as it looks before the lock; the publish target is + // resolved again under it. Repoint the alias in between - a peer committing, or a planted + // link swap - and this writer would commit onto a file whose lock it never took, while + // another writer can hold that file's lock and commit onto it too. + let referent = keyedFile + mockedRealpath.mockImplementation(async (target) => (target === currentLink ? referent : String(target))) + mockedLstat.mockImplementation(async (target) => symlinkStat(target)) + mockedReadlink.mockImplementation(async (target) => + target === currentLink ? referent : Promise.reject(notALink()), + ) + mockedAcquireFileLock.mockImplementation(async (lockedPath) => { + order.push("lock:" + path.basename(lockedPath)) + referent = movedFile + return async () => { + order.push("release") + released = true + } + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(currentLink, { id: "task-1" })).rejects.toThrow("the advisory lock was taken for") + + expect(order).toEqual(["lock:history_item.json", "release"]) + expect(released).toBe(true) + // Nothing was staged or published: neither the keyed file nor the file the alias moved to. + for (const candidate of [keyedFile, movedFile]) { + const present = await fs.access(candidate).then( + () => true, + () => false, + ) + expect(present).toBe(false) + } + consoleError.mockRestore() + }) + + it("refuses to stage when the target's parent no longer names that directory", async () => { + let released = false + const dir = await makeDir("lockkey-") + const target = path.join(dir, "history_item.json") + const outside = path.join(dir, "elsewhere") + currentLink = "" + + // The staging location is chosen from the parent directory resolved under the lock, and the + // staged file is written here in safeWriteJson before safeWriteText runs. Replace that directory + // with a link pointing outside it between the guard's approval of it and its re-verification, + // and the staged JSON lands outside the directory this write was approved for - safeWriteText + // can still reject the publish, but the stray file already exists. Canonicalizing the parent is + // the only thing that can see the swap, and it sees it as the re-verification landing somewhere + // the approval did not. (A swap planted before the first reading is invisible to this guard by + // construction: both readings then agree, and safeWriteText's own beside-the-target check is + // what rejects it.) + let readings = 0 + mockedRealpath.mockImplementation(async (t) => { + const key = String(t) + if (key !== dir) { + return key + } + // Readings 1 and 2 are the lock-key canonicalization before and after the lock, reading 3 + // is the guard approving the directory as it currently resolves, and reading 4 is the guard + // re-verifying it - the one a planted swap has to move. This case used to pass off the guard's + // separate existence probe instead: its stub named a path that never existed, so the guard's + // fs.stat of the resolution failed and the write was rejected for "no existing directory", + // which was never evidence about a re-point. The readings below are what make it evidence. + readings += 1 + return readings <= 3 ? dir : outside + }) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) + mockedAcquireFileLock.mockImplementation(async () => async () => { + released = true + }) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow( + "the directory this write was approved for", + ) + expect(readings).toBe(4) // the guard really did take its second reading of the approved directory + + expect(released).toBe(true) + // Nothing was staged: no target, and no .new_*.tmp left behind in the directory. + await expect(fs.readFile(target, "utf8")).rejects.toThrow() + expect((await fs.readdir(dir)).filter(stagedFile)).toEqual([]) + consoleError.mockRestore() + }) + + it("locks and publishes the same file when the alias does not move", async () => { + // The guard has to stay quiet on the normal path: key and publish target resolve through + // the same alias, and the write goes ahead under one lock. + const dir = await makeDir("lockkey-") + const target = path.join(dir, "history_item.json") + currentLink = "" + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await safeWriteJson(target, { id: "task-1" }) + + expect(mockedAcquireFileLock).toHaveBeenCalledWith(target) + expect(JSON.parse(await fs.readFile(target, "utf8"))).toEqual({ id: "task-1" }) + consoleError.mockRestore() + }) + + it("canonicalizes the parent directory when the file itself is not there yet", async () => { + // fs.realpath canonicalizes every component, including a symlinked ancestor + // directory or a Windows 8.3 short name. If the fallback returns the alias + // directory, the key depends on whether the file exists at the moment the key + // is computed, and a writer that resolved the canonical directory takes a + // different lock for the same file. + const aliasDir = path.join(os.tmpdir(), "alias-dir") + const canonicalDir = path.join(os.tmpdir(), "canonical-dir") + const file = path.join(aliasDir, "history_item.json") + mockedRealpath.mockImplementation(async (target) => { + if (target === file) throw enoent + return canonicalDir + }) + mockedLstat.mockImplementation( + async () => ({ isSymbolicLink: () => false, isFile: () => true }) as unknown as BigIntStats, + ) + + expect(await resolveLockKey(file)).toBe(path.join(canonicalDir, "history_item.json")) + }) + it("measures the same case-folding answer at run time that the collection-time probe reported", async () => { + // This row is the probe's control rather than another assertion about the writer. The gate on + // the row below skips work on the strength of a value computed while this file was collected, + // and a probe that reported the capability as absent would turn that row into a skip that + // reads like a pass. Re-measuring at run time, through the promise surface, in a directory + // this row creates, means a wrong probe answer shows up here as a failure instead of as an + // absence. Its own control is hard-coding CASE_FOLDING_VOLUME to false at collection time: + // on a folding volume this row reddens, which is the direction that could otherwise go quiet. + const dir = await makeDir("lockkey-foldcheck-") + const real = await vi.importActual("fs/promises") + const stored = path.join(dir, "probe.json") + await real.writeFile(stored, "{}") + const ownName = await real.open(stored, "r").catch(() => undefined) + await ownName?.close() + expect(ownName, "the file this row created did not open under its own name").not.toBeUndefined() + expect(await real.readdir(dir)).toHaveLength(1) + const otherCase = await real.open(path.join(dir, "PROBE.JSON"), "r").then( + async (opened: Awaited>) => { + await opened.close() + return true + }, + (error: unknown) => { + const code = getErrorCode(error) + // Same rule the probe follows: absence is an answer, anything else is a measurement + // that did not happen and must not be recorded as one. + if (code !== "ENOENT") { + throw new Error( + "run-time case-folding check could not ask the filesystem: " + String(code ?? error), + ) + } + return false + }, + ) + expect(otherCase, "the run-time measurement and the collection-time probe disagree about this volume").toBe( + CASE_FOLDING_VOLUME, + ) + }) + + // The gate below is a measured capability rather than a platform label: this row can only drive + // the guard's identity question on a volume where one file answers under two spellings, and + // that is a property of the volume, not of the runner's name. CASE_FOLDING_VOLUME is computed + // at collection time from a real directory and throws when the measurement cannot be trusted, + // instead of defaulting, because a probe that quietly answered "no" would make this row a skip + // that reads like a pass. What the gate does not claim: on a case-sensitive volume the artefact + // the acquisition builds and the artefact the guard computes from the key are one name, so + // there is no divergence for the guard to ask about and no artefact stat to fail. The gate + // skips a row that cannot drive the guard there; it does not skip a question about EACCES + // propagation, because on such a volume there is no such propagation to cover. + it.runIf(CASE_FOLDING_VOLUME)( + "propagates a lock-artefact stat that fails for a reason other than the artefact being absent", + async () => { + const dir = await makeDir("lockkey-artefactstat-") + const lower = path.join(dir, "settings.json") + const upper = path.join(dir, "SETTINGS.JSON") + const real = await vi.importActual("fs/promises") + // fs.stat is overloaded - bigint stats are their own overload - so the pass-through and the + // injected variant are built from one rest signature here instead of being asserted at each + // use. Arguments and results go straight through in both directions. + const realStatPassThrough = async ( + ...args: Parameters + ): Promise>> => await real.stat(...args) + const injectArtefactStat = + (fail: () => never) => + async (...args: Parameters): Promise>> => { + if (String(args[0]).endsWith(".lock")) { + fail() + } + return await real.stat(...args) + } + const restatingRealpath = async (target: string): Promise => { + const resolved = await real.realpath(target) + const parent = path.dirname(resolved) + const name = path.basename(resolved) + const listed = await real.readdir(parent).catch(() => [] as string[]) + // Prefer the entry whose name differs from the request in case alone. On a folding + // filesystem that is the one name the directory stores; on a case-sensitive one the + // directory holds both spellings and the match has to be pinned to the other one, or the + // divergence this row is built on would depend on readdir order. + const stored = listed.find( + (entry: string) => entry !== name && entry.toLowerCase() === name.toLowerCase(), + ) + return stored === undefined ? resolved : path.join(parent, stored) + } + const probeLower = path.join(dir, "probe.json") + await real.writeFile(probeLower, "{}") + const foldsCase = await restatingRealpath(path.join(dir, "PROBE.JSON")).then( + async (resolved: string) => path.basename(resolved) === path.basename(probeLower), + () => false, + ) + await real.rm(probeLower, { force: true }) + + // The instrument this row owes its reader. It used to install its own fs and fileLock mocks with + // vi.doMock and re-import safeWriteJson through vi.resetModules, and it was green on a + // case-folding filesystem while reporting zero artefact stats on a case-sensitive one. The + // reason was neither the guard nor the platform: asserted from inside the acquisition double it + // thought it had installed, the capture came back empty, which means the re-import never used + // that double and the row was being served by this file's module-level doubles - NTFS's own + // case restating supplying the divergence, and a real stat supplying the zeros. A row that + // injects a seam has to assert that the seam ran, because a green row that measures nothing is + // indistinguishable from a passing test, and that is what happened here. The doubles are the + // module-level ones now, re-armed for this row, with no resetModules and no re-import, and the + // capture is asserted first below so a scoping regression cannot go silent again. + const acquisitionFacts: Array<{ key: string; artefactBuilt: boolean }> = [] + let statCalls = 0 + const refusal = Object.assign(new Error("EACCES: permission denied, stat"), { code: "EACCES" }) + mockedRealpath.mockImplementation( + async (target: Parameters[0]) => await restatingRealpath(String(target)), + ) + // lstat and readlink pass through for this row. resolvePublishTarget follows aliases through + // them, and an un-armed double would answer undefined where this row needs the real answer. + // They reject the way the filesystem rejects, which is the half the comment at the top of this + // module warns about. + mockedLstat.mockImplementation( + async (target: Parameters[0]) => await real.lstat(String(target)), + ) + mockedReadlink.mockImplementation( + async (target: Parameters[0]) => await real.readlink(String(target)), + ) + mockedAcquireFileLock.mockImplementation(async (file) => { + // The divergence the guard is asked about, reached the way a writer reaches it: this writer's + // key keeps the spelling it was computed from, the name the canonicalizer answers for the + // file appears under it while the lock is held, and the two artefact names are one object by + // hard link on either lane, so the stat below has a comparison to fail from on either lane. + // Hard link rather than symlink, for the reason given in the acceptance row above: one object + // under two names on NTFS and on ext4 alike, with no privilege asked for. + const ownArtefact = lockPathFor(String(file)) + await real.writeFile(ownArtefact, "") + await real.writeFile(lower, JSON.stringify({ mcpServers: { peer: true } })) + if (!foldsCase) { + await real.link(lower, upper) + await real.link(ownArtefact, lockPathFor(lower)) + } + acquisitionFacts.push({ + key: String(file), + artefactBuilt: await real.stat(ownArtefact).then( + () => true, + () => false, + ), + }) + return async () => {} + }) + // Only a lock artefact fails, so the EACCES this row reports is the guard's comparison and + // nowhere else: the write path's own stat calls, which preserve the target mode, keep working. + mockedStat.mockImplementation( + injectArtefactStat(() => { + statCalls++ + throw refusal + }), + ) + + let error: Error | undefined + try { + error = await safeWriteJson(upper, { mcpServers: { mine: true } }).then( + () => undefined, + (caught: unknown) => caught as Error, + ) + } finally { + // Restored whatever the row concludes, so the injected failure cannot reach another row. + mockedStat.mockImplementation(realStatPassThrough) + mockedRealpath.mockReset() + mockedLstat.mockReset() + mockedReadlink.mockReset() + mockedAcquireFileLock.mockReset() + } + + // The seam ran, and it was handed this writer's spelling. Both facts are about the fixture + // rather than about the guard, which is why they come before the guard's answer: if a later + // change stops this row from driving acquisition, it has to say so here instead of turning + // the count back into a zero that reads like a verdict about the code. On a case-folding + // filesystem the key arrives restated by the filesystem itself and the writer's spelling is the + // name the directory already stores; on a case-sensitive one the key keeps the spelling this + // writer asked in with, and the peer's file is what sits under the other name. + expect( + acquisitionFacts.length, + "the acquisition seam this row injects never ran, so nothing below it was driven by the fixture", + ).toBe(1) + expect(acquisitionFacts[0].artefactBuilt).toBe(true) + expect( + acquisitionFacts.map((fact: { key: string }) => path.basename(fact.key)), + "acquisition was not asked to lock this writer's spelling, so no divergence reached the guard", + ).toEqual([path.basename(upper)]) + expect( + statCalls, + "the guard never put its identity question to the filesystem on this platform", + ).toBeGreaterThan(0) + expect(String(error && (error as { code?: string }).code)).toBe("EACCES") + expect(String(error && error.name)).not.toBe("LockKeyDriftError") + // Fail closed: the writer published nothing, and the file it would have replaced still + // holds the peer's content. + expect(JSON.parse(await real.readFile(lower, "utf8"))).toEqual({ mcpServers: { peer: true } }) + expect((await real.readdir(dir)).length).toBe(foldsCase ? 2 : 4) + }, + ) +}) diff --git a/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts new file mode 100644 index 0000000000..b45c3821d6 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.stagingDirectory.spec.ts @@ -0,0 +1,313 @@ +import * as os from "os" +import * as path from "path" + +import { safeWriteJson } from "../safeWriteJson" + +// Capture the actual implementation before the mock factory wraps it, so a wrapped realpath can +// delegate to the real one - and run a real filesystem side effect at the moment the guard asks - +// without recursing into itself. +// Typed by the hoisted callback's return type rather than by a cast on undefined: the target type +// is an overloaded function type, and casting undefined straight to it is what tsc rejects (TS2352). +const actuals = vi.hoisted((): { realpath?: (typeof import("fs/promises"))["realpath"] } => ({ + realpath: undefined, +})) + +vi.mock("fs/promises", async () => { + const actual = await vi.importActual("fs/promises") + actuals.realpath = actual.realpath + // Only realpath is wrapped. The staging guard does not consult stat at all: a probe that cannot + // answer is not an answer, and nothing here has to stub stat for the guard to do its job. + return { ...actual, realpath: vi.fn(actual.realpath) } +}) + +import * as fs from "fs/promises" + +/** + * Controls on the staging-directory drift guard in safeWriteJson: the re-verification that runs + * between approving a staging directory beside the publish target and streaming into it. The guard + * may act on one thing only - a re-resolution that succeeds and lands on a different directory - + * and it must not reject an ordinary write because a probe could not answer, because the approved + * path is spelled differently from the way the same directory is reported, or because a directory + * that really is gone is reported as missing. The alias below is the portable form of the Windows + * case that broke the windows test:misc lane: on a GitHub runner the temp root arrives spelled with + * its 8.3 short name (C:\Users\RUNNER~1\AppData\Local\Temp), resolvePublishTarget returns that + * lexical path for a target that does not exist yet, and realpath reports the long name for that + * very same directory. + */ +/** + * safeWriteJson stages as ..new__.tmp - createTempFilePath at + * safeWriteJson.ts:192-197, the template main carries at its :54 - so a staged leftover never + * starts with ".new_": it starts with the target's own name. Matching the infix is what lets + * the "nothing left staged" assertions below see a leak at all; the startsWith form matches + * only safeWriteText's own ".new__.tmp" names (safeWriteText.ts:92-94), which this + * file never stages. safeWriteJson.interruption.test.ts:196 already filters by infix. + */ +const stagedFile = (name: string): boolean => name.includes(".new_") + +describe("safeWriteJson staging directory drift", () => { + let tmpRoot = "" + + beforeEach(async () => { + tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "swj-staging-drift-")) + }) + + afterEach(async () => { + vi.mocked(fs.realpath).mockReset() + vi.mocked(fs.realpath).mockImplementation(async (...args) => await actuals.realpath!(...args)) + if (tmpRoot) { + await fs.rm(tmpRoot, { recursive: true, force: true }) + tmpRoot = "" + } + }) + + /** + * Run a real filesystem side effect at the guard's second reading of the approved directory. The + * guard canonicalizes the approved directory and then re-resolves that canonical path; every + * other realpath this write performs is passed the file itself or the alias spelling, because the + * lock key and the staging directory are both derived from the directory as the caller spelled + * it. So "the argument is already the canonical referent" identifies the second reading without + * counting calls. Returns whether the effect ever ran, which every case asserts so a guard that + * stopped asking is caught rather than silently passed. + + * What the guard may act on is a single signal: the re-resolution of the approved directory + * succeeds and lands on a different directory. A probe that cannot answer - ENOENT, EACCES, EPERM, + * ELOOP, or an fs double that reports the path as missing - carries no information about + * replacement, so it must not reject the write; a directory that really is gone then fails at the + * staging write itself, which is still fail-closed with nothing created outside the approved + * directory. + */ + function actAtRewalk(canonicalReferent: string, act: () => Promise): () => boolean { + let acted = false + vi.mocked(fs.realpath).mockImplementation(async (...args) => { + const lexical = path.resolve(String(args[0])) + if (!acted && lexical === canonicalReferent) { + acted = true + await act() + } + return await actuals.realpath!(...args) + }) + return () => acted + } + + it("writes through an alias directory when the target does not exist yet", async () => { + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const target = path.join(alias, "settings.json") + + await safeWriteJson(target, { preserved: true }) + + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + // Nothing may be left staged on either spelling of the same directory. + expect((await fs.readdir(referent)).filter(stagedFile)).toEqual([]) + expect((await fs.readdir(alias)).filter(stagedFile)).toEqual([]) + }) + + it("stages anyway when the guard's probe cannot answer", async () => { + // A probe that cannot answer is not an answer. The filesystem refuses to canonicalize the + // directory this write was approved for - here with EACCES, which is the failure the walk + // cannot absorb into a retry from an existing ancestor - and refusal says nothing about the + // directory having been replaced, so the write proceeds. This is the case that reddens when + // the narrowing is reverted: without it the guard is back to treating "I could not look" as + // evidence of drift, and an ordinary write into a directory it cannot stat is rejected. + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const referentReal = await actuals.realpath!(referent) + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + const refused = new Error("EACCES: permission denied, realpath") as NodeJS.ErrnoException + refused.code = "EACCES" + throw refused + }) + + await safeWriteJson(target, { preserved: true }) + + expect(acted()).toBe(true) // the guard really asked, and really was not answered + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + expect((await fs.readdir(referent)).filter(stagedFile)).toEqual([]) + }) + + it("stages anyway under the task persistence suite's blanket missing stat", async () => { + // The shape that made CI red: core/task/__tests__/Task.spec.ts mocks fs/promises with + // stat: vi.fn().mockRejectedValue({ code: "ENOENT" }) for every path, and its task directory + // (a Windows temp path under \\test-storage\\tasks\\, created moments earlier by + // fsReal.mkdirSync) demonstrably existed at the instant the guard asked - both directory + // resolutions returned it and the unmocked fs.statSync reported the same inode. Measured on + // the old guard, exactly one fs.stat call was enough to reject that write as drift. Here the + // same blanket double sits over a write whose directory really is there: the guard must not + // consult it about the directory, and this case reddens if it ever starts to; the one stat call + // the write is allowed to make is its own publish-target mode read, counted below. + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const target = path.join(alias, "settings.json") + let statPaths: string[] = [] + const statSpy = vi.spyOn(fs, "stat").mockRejectedValue({ code: "ENOENT" }) + let failure: unknown + try { + await safeWriteJson(target, { preserved: true }) + } catch (error) { + failure = error + } finally { + statPaths = statSpy.mock.calls.map((call) => String(call[0])) + statSpy.mockRestore() + } + expect(failure).toBeUndefined() + // Nothing asked fs.stat about the approved directory, which is the question this row pins. The + // one call that was made named the publish target: it is the mode read (_readPublishTargetMode + // in safeWriteJson.ts) that row 4507642592 added, and the same blanket double answers it here + // with "not there", which the mode read reads as "no mode to carry over" rather than as a + // reason to refuse the write. + const directoryQuestions = statPaths.filter((p) => path.basename(p) !== "settings.json") + expect(directoryQuestions.length).toBe(0) + expect(statPaths.length).toBe(1) + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + }) + + it("stages anyway when the re-walk reports the directory missing mid-replacement", async () => { + // The other reading of "cannot answer": the directory is there, but a component is being + // swapped and realpath reports ENOENT for one instant. _resolveScopeRoot already tolerates + // that - it retries from its nearest existing ancestor and rejoins the same spelling - so + // this case is absorbed by the walk rather than by the guard, and it is NOT the control for + // the narrowing: the EACCES case above is, because that failure is the one the walk rethrows. + const referent = path.join(tmpRoot, "referent-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const referentReal = await actuals.realpath!(referent) + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + const missing = new Error("ENOENT: no such file or directory, realpath") as NodeJS.ErrnoException + missing.code = "ENOENT" + throw missing + }) + + await safeWriteJson(target, { preserved: true }) + + expect(acted()).toBe(true) + const written = await fs.readFile(path.join(referent, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + }) + + it("still rejects when the approved directory is replaced by a junction before staging", async () => { + // The fail-closed side, decided by a real filesystem rather than by a stubbed answer: the + // directory the write was approved for is renamed aside and a junction is planted in its place + // pointing at a different directory, at the instant the guard takes its second reading of it. + // The junction is the portable twin of the POSIX symlink case (a Windows runner cannot create + // symlinks in an unprivileged checkout but can create junctions, and the first case above + // already plants one). realpath is left real: the wrapper decides only WHEN the plant happens, + // and the answer the guard reads comes from the OS. + const referent = path.join(tmpRoot, "approved-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const elsewhere = path.join(tmpRoot, "elsewhere") + await fs.mkdir(elsewhere, { recursive: true }) + const referentReal = await actuals.realpath!(referent) + const elsewhereReal = await actuals.realpath!(elsewhere) + const moved = path.join(tmpRoot, "approved-dir-moved") + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + await fs.rename(referent, moved) + await fs.symlink(elsewhereReal, referent, process.platform === "win32" ? "junction" : "dir") + }) + + await expect(safeWriteJson(target, { preserved: true })).rejects.toThrow(/Refusing to stage/) + + expect(acted()).toBe(true) + // Rejected before staging: the planted destination got nothing, and the directory this write + // was approved for - now spelled by where it was moved to - got no staged file and no published + // target. (Its advisory lock directory is still there: the lock lives beside the target, and the + // plant moved the target out from under the held lock, which is the state this case creates.) + expect(await fs.readdir(elsewhere)).toEqual([]) + expect((await fs.readdir(moved)).filter((name) => name === "settings.json" || stagedFile(name))).toEqual([]) + }) + + it("fails as a failed write when the approved directory is really gone", async () => { + // The case the removed stat probe used to decide by asserting drift: the directory is deleted + // for real at the guard's second reading. Nothing is created outside the directory the write + // was approved for and nothing is published, so the write is still fail-closed - it simply + // fails as the failed write it is instead of claiming to be evidence of replacement. This case + // is NOT a control for the narrowing: the deletion is keyed to the guard's second reading, so + // the old guard had already cleared its existence probe before the directory went, and it + // passes on both guards. The two cannot-answer cases above are the load-bearing controls. + const referent = path.join(tmpRoot, "doomed-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const referentReal = await actuals.realpath!(referent) + const target = path.join(alias, "settings.json") + const acted = actAtRewalk(referentReal, async () => { + await fs.rm(referent, { recursive: true, force: true }) + }) + + await expect(safeWriteJson(target, { preserved: true })).rejects.toThrow(/ENOENT/) + + expect(acted()).toBe(true) + // The alias now dangles, nothing was published through it, and the only thing left under the + // temp root is the alias itself: no staged file, no lock directory, no stray destination. + const published = await fs.readFile(target, "utf8").catch(() => null) + expect(published).toBeNull() + expect((await fs.readdir(tmpRoot)).sort()).toEqual(["alias-dir"]) + }) + it("follows the approved pathname when the directory is replaced before the guard reads it", async () => { + // The ordering this guard cannot see, recorded rather than wished away: the approved directory + // is renamed aside and replaced by a junction onto a different directory BEFORE the guard + // takes its first reading, so both readings agree on the foreign directory and the drift + // signal stays silent. Neither layer rejects the write - not the drift guard (both readings + // land on the same place) and not safeWriteText's beside-the-target check (its tempPath and + // its re-resolved publish target are both spelled through the same alias, and + // resolvePublishTarget returns the path it was given while the target file is still absent). + // The write therefore completes at wherever the approved pathname now points, with no staged + // file left anywhere: measured identical before and after the narrowing, so this is a property + // of the guard's design, not a change in behavior. What it does mean is that the file can be + // published into a directory the writer's lock - which stayed with the renamed directory - + // does not name, the same hazard the lock-key drift check covers for a target alias that + // moves, and a reader who wants that ordering rejected has to reject it in safeWriteText. + const referent = path.join(tmpRoot, "approved-dir") + await fs.mkdir(referent, { recursive: true }) + const alias = path.join(tmpRoot, "alias-dir") + await fs.symlink(referent, alias, process.platform === "win32" ? "junction" : "dir") + const elsewhere = path.join(tmpRoot, "elsewhere") + await fs.mkdir(elsewhere, { recursive: true }) + const elsewhereReal = await actuals.realpath!(elsewhere) + const moved = path.join(tmpRoot, "approved-dir-moved") + const aliasSpelling = path.resolve(alias) + const target = path.join(alias, "settings.json") + let readings = 0 + let planted = false + vi.mocked(fs.realpath).mockImplementation(async (...args) => { + const lexical = path.resolve(String(args[0])) + // Readings 1 and 2 are the lock-key canonicalization before and after the lock; reading 3 + // is the guard's approval of the staging directory, and the swap is planted as that reading + // is taken, so the approval already sees the foreign directory. + if (lexical === aliasSpelling) { + readings += 1 + if (readings === 3 && !planted) { + planted = true + await fs.rename(referent, moved) + await fs.symlink(elsewhereReal, referent, process.platform === "win32" ? "junction" : "dir") + } + } + return await actuals.realpath!(...args) + }) + + await safeWriteJson(target, { preserved: true }) + + expect(readings).toBe(3) + expect(planted).toBe(true) + const written = await fs.readFile(path.join(elsewhere, "settings.json"), "utf8") + expect(JSON.parse(written)).toEqual({ preserved: true }) + // Nothing stranded: the renamed directory holds only the advisory lock directory that lives + // beside the target, and neither spelling of it nor the new location holds a staged file. + expect((await fs.readdir(moved)).filter((name) => name === "settings.json" || stagedFile(name))).toEqual([]) + expect((await fs.readdir(elsewhere)).filter(stagedFile)).toEqual([]) + }) +}) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 30ac500212..f6eeaa4631 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -3,7 +3,7 @@ import { Writable } from "stream" import * as path from "path" import * as os from "os" -import { safeWriteJson } from "../safeWriteJson" +import { ConfinedPathEscapeError, safeWriteJson } from "../safeWriteJson" // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when @@ -14,6 +14,14 @@ const fsPromisesActuals = vi.hoisted(() => ({ writeFile: undefined as (typeof import("fs/promises"))["writeFile"] | undefined, })) +// The fs mock delegates createWriteStream to the implementation captured here, so a test can +// replace the mock for one call and still let the write reach the real filesystem. +const fsSyncActuals = vi.hoisted(() => ({ + createWriteStream: undefined as (typeof import("fs"))["createWriteStream"] | undefined, + openSync: undefined as (typeof import("fs"))["openSync"] | undefined, + fchmodSync: undefined as (typeof import("fs"))["fchmodSync"] | undefined, +})) + vi.mock("fs/promises", async () => { const actual = await vi.importActual("fs/promises") fsPromisesActuals.rename = actual.rename @@ -42,9 +50,17 @@ vi.mock("fs/promises", async () => { // Mock the 'fs' module for fsSync.createWriteStream vi.mock("fs", async () => { const actualFs = await vi.importActual("fs") + fsSyncActuals.createWriteStream = actualFs.createWriteStream + // openSync and fchmodSync are doubled for the same reason createWriteStream is: the mode rows + // below read what production passed, and the default delegates to the real call so the write + // still lands on disk. + fsSyncActuals.openSync = actualFs.openSync + fsSyncActuals.fchmodSync = actualFs.fchmodSync return { ...actualFs, // Spread actual implementations createWriteStream: vi.fn(actualFs.createWriteStream), // Default to actual, but mockable + openSync: vi.fn(actualFs.openSync), + fchmodSync: vi.fn(actualFs.fchmodSync), } }) @@ -140,6 +156,207 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual(newData) }) + // Row 4507642592: the commit rename publishes the staging inode, so the mode the staging file is + // created with becomes the target's mode. These rows pin that the staging file is created as + // open or as private as the file it replaces, instead of with the process default. + describe("staging file mode", () => { + // Production states the mode twice, in the two places it has to be stated: the open, whose + // creation mode the process umask narrows, and one fchmod on the descriptor, which is what + // lands the mode exactly. Node's WriteStream also fills a missing mode in on the options + // object it is handed, so a call log read afterwards shows a normalized object rather than + // what was passed; both arguments here are recorded at the moment production passes them and + // then handed to the real call, so the write still lands on disk. -1 in the open recording + // stands for "no mode was passed"; no entry in the fchmod recording stands for "there was + // no publish target to copy a mode from". + type StagingModeCalls = { openedWith: number[]; chmoddedTo: number[] } + function captureStagingModeCalls(): StagingModeCalls { + const openedWith: number[] = [] + const chmoddedTo: number[] = [] + const realOpenSync = fsSyncActuals.openSync! + const realFchmodSync = fsSyncActuals.fchmodSync! + vi.mocked(fsSyncActual.openSync).mockImplementation(function (filePath, flags, mode) { + const passed: unknown = mode + openedWith.push( + passed === undefined + ? -1 + : typeof passed === "string" + ? Number.parseInt(passed, 8) + : Number(passed), + ) + return realOpenSync( + filePath as Parameters[0], + flags as Parameters[1], + mode, + ) + }) + vi.mocked(fsSyncActual.fchmodSync).mockImplementation(function (filePath, mode) { + chmoddedTo.push(Number(mode)) + return realFchmodSync(filePath, mode) + }) + return { openedWith, chmoddedTo } + } + + it("opens the staging file at the publish target's mode and chmods it to exactly that", async () => { + await fs.chmod(currentTestFilePath, 0o600) + // Read back the mode the filesystem reports for the target instead of asserting a literal: + // Windows carries per-user rights in the DACL and reports a coarse mode, and the claim under + // test is that the staging file is brought to the target's mode, on every platform. + const targetMode = (await fs.stat(currentTestFilePath)).mode & 0o777 + const modeCalls = captureStagingModeCalls() + + await safeWriteJson(currentTestFilePath, { updated: "content" }) + + expect(modeCalls.openedWith.length).toBe(1) + expect(modeCalls.openedWith[0]).toBe(targetMode) + // The fchmod is the half that survives the umask: the open alone can only ask for this + // mode, and the filesystem answers at most what the mask leaves. One call, on the + // descriptor the write is about to use. + expect(modeCalls.chmoddedTo).toEqual([targetMode]) + expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) + }) + + it("opens the staging file with the creation default when the target does not exist yet", async () => { + const missingTarget = path.join(tempDir, "not-written-yet.json") + const modeCalls = captureStagingModeCalls() + + await safeWriteJson(missingTarget, { created: "content" }) + + // Nothing is being replaced, so there is no mode to carry over, and a mode invented here + // would be this write deciding how private a file it has not created yet should be. No + // publish target means no fchmod either, which is the same rule safeWriteText applies to a + // fresh target. + expect(modeCalls.openedWith.length).toBe(1) + expect(modeCalls.openedWith[0]).toBe(-1) + expect(modeCalls.chmoddedTo).toEqual([]) + expect(await fileExists(missingTarget)).toBe(true) + }) + + // Row 4507855186. The target-mode read is the one stat in this writer whose failure has to stop the + // write. A target that is not there yet has no mode to carry over, so absence is a verdict; any other + // failure means the mode of the file being replaced is unknown, and defaulting on it publishes a + // private target with the creation default. These rows inject the two non-absence codes at the + // publish target itself - the writer stats the resolved path, so the match is made against both + // spellings - and delegate every other stat to the real filesystem. + async function blockTargetModeRead(code: string): Promise<{ + failure: Error + statPaths: string[] + restore: () => void + }> { + const actual = await vi.importActual("fs/promises") + const statPaths: string[] = [] + const spellings = new Set([currentTestFilePath]) + try { + spellings.add(await actual.realpath(currentTestFilePath)) + } catch { + // A target that cannot be realpath'd is matched by its own spelling only. + } + const failure = Object.assign(new Error(code + ": the target-mode stat could not read the mode"), { + code, + }) as Error + const realStat = actual.stat + vi.mocked(fs.stat).mockImplementation(async (...args: Parameters) => { + const keyed = String(args[0]) + statPaths.push(keyed) + if (spellings.has(keyed)) { + throw failure + } + return (realStat as typeof fs.stat)(...args) + }) + // restoreAllMocks does not bring back an implementation that a test installed over a factory + // double, so the delegation back to the real stat is put back here rather than left to the hook. + return { + failure, + statPaths, + restore: () => { + vi.mocked(fs.stat).mockImplementation(((...args: Parameters) => + Reflect.apply(actual.stat, actual, args)) as typeof fs.stat) + }, + } + } + + async function runAgainstUnreadableMode(code: string): Promise { + const injected = await blockTargetModeRead(code) + // Call counts are read as a delta: this suite restores mock implementations between rows but + // the doubles keep the history of earlier rows, so an absolute not-to-have-been-called would be a + // claim about the whole file rather than about this write. + const streamsBefore = vi.mocked(fsSyncActual.createWriteStream).mock.calls.length + const renamesBefore = vi.mocked(fs.rename).mock.calls.length + const before = await readFileContent(currentTestFilePath) + const stages: string[] = [] + let failure: unknown + try { + await safeWriteJson( + currentTestFilePath, + { updated: "content" }, + { + trace: (event: { stage: string }) => { + stages.push(event.stage) + }, + }, + ) + } catch (error: unknown) { + failure = error + } finally { + injected.restore() + } + expect(failure).toBe(injected.failure) + // The stage list records where the writer stopped. It announces the write before reading the mode, + // so write-start is present; the load-bearing facts are that the staging stream is never opened + // and the target is never renamed onto. The stat list is the proof that the injected failure landed + // on the target-mode read and not on some other stat: that read is the only one this writer + // makes before it fails, and it is the read that threw, because only a stat of the publish + // target raises the injected error. The spelling is deliberately not asserted - on the hosted + // lanes the temporary directory is reported under a different name by realpath than os.tmpdir + // returned, which is a fact about the runner and not about this writer. + expect(stages).toEqual(["write-start"]) + expect(injected.statPaths.length).toBe(1) + expect(vi.mocked(fsSyncActual.createWriteStream).mock.calls.length).toBe(streamsBefore) + expect(vi.mocked(fs.rename).mock.calls.length).toBe(renamesBefore) + expect(await readFileContent(currentTestFilePath)).toEqual(before) + } + + it("propagates an EACCES on the target-mode stat and publishes nothing", async () => { + await runAgainstUnreadableMode("EACCES") + }) + + it("propagates an EIO on the target-mode stat and publishes nothing", async () => { + await runAgainstUnreadableMode("EIO") + }) + + // The end-to-end mode assertion is made where modes mean what the assertion says they mean. + it.runIf(process.platform !== "win32")("keeps a 0o600 file private through an update", async () => { + await fs.chmod(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { updated: "content" }) + + const published = await fs.stat(currentTestFilePath) + expect(published.mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) + }) + // Row 4507851727. The direction the row above cannot see: a mask only takes bits away, so a + // restrictive target lands unchanged whether or not the mode was applied exactly, and the + // assertion that separates the two shapes is a group-writable target under a narrowing mask. + // The mask is set for the row rather than relied on, because the value that narrows 0o664 to + // 0o644 is the process's own and not something this row should inherit from a runner. + // Executed by the ubuntu coverage lane, not verified locally: on Windows the creation mode + // carries only the read-only bit, so 0o664 is not a mode a landed file can report and the + // row answers as skipped there. + it.runIf(process.platform !== "win32")( + "keeps a 0o664 target group-writable through an update under a 0o022 umask", + async () => { + await fs.chmod(currentTestFilePath, 0o664) + const umaskBefore = process.umask(0o022) + try { + await safeWriteJson(currentTestFilePath, { updated: "content" }) + const published = await fs.stat(currentTestFilePath) + expect(published.mode & 0o777).toBe(0o664) + expect(await readFileContent(currentTestFilePath)).toEqual({ updated: "content" }) + } finally { + process.umask(umaskBefore) + } + }, + ) + }) // Failure Scenarios test("should handle failure when writing to tempNewFilePath", async () => { @@ -328,9 +545,8 @@ describe("safeWriteJson", () => { expect(content).toEqual(newData) }) - // Test for console error suppression during backup deletion - test("should suppress console.error when backup deletion fails", async () => { - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) // Suppress console.error + // Test for best-effort backup deletion (the backup lifecycle now lives in safeWriteText) + test("does not fail the write when backup deletion fails (orphaned backup is acceptable)", async () => { const initialData = { message: "Initial" } const newData = { message: "New" } @@ -349,12 +565,17 @@ describe("safeWriteJson", () => { return fsPromisesActuals.unlink!(filePath) }) + // The write must still succeed: backup cleanup is best-effort after the + // commit rename and never masks the committed content. await safeWriteJson(currentTestFilePath, newData) - // Verify console.error was called with the expected message - expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("Successfully wrote"), expect.any(Error)) + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(newData) + + // The orphaned backup is still on disk because its deletion failed. + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes(".bak_"))).toBe(true) - consoleErrorSpy.mockRestore() vi.mocked(fs.unlink).mockRestore() }) @@ -818,4 +1039,370 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual(newData) }) + // proper-lockfile with realpath:false keys the lock by the given path, so a + // symlink alias and its referent must coordinate through ONE lock on the + // resolved referent — otherwise a concurrent merge through both aliases + // reads the same JSON and overwrites one update. (Real symlinks are + // unavailable in this CI lane, so the resolution is simulated by mocking + // fs.realpath.) + test("acquires the lock on the resolved referent, not the caller alias", async () => { + vi.resetModules() // fresh module instances so the doMock below is picked up + + const referentDir = path.join(tempDir, "lock-referent") + const linkDir = path.join(tempDir, "lock-link") + await fs.mkdir(referentDir, { recursive: true }) + await fs.mkdir(linkDir, { recursive: true }) + // caller-visible path (the link) vs the resolved referent path + const callerPath = path.join(linkDir, "locked.json") + const referentPath = path.join(referentDir, "locked.json") + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: 1 })) + + // Only the file resolves through the link; the directory is already canonical, + // so the lock key is the referent rather than the alias directory + basename. + const realpathSpy = vi + .spyOn(fs, "realpath") + .mockImplementation(async (target) => (target === callerPath ? referentPath : String(target))) + + // Wrap the real lock in a capturing mock, and drive the two rare error paths + // (the onCompromised callback and a failing release) so they stay covered + // without real lockfile staleness. The callback rethrows by design, so + // the mock swallows that throw and lets the real lock proceed. + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn( + async ( + file: Parameters[0], + options?: Parameters[1], + ) => { + try { + options?.onCompromised?.(new Error("lock compromised (test)")) + } catch { + // onCompromised rethrows by design; swallow so the real lock proceeds. + } + const release = await realLockfile.lock(file, options) + return async () => { + await release() + throw new Error("release failed (test)") + } + }, + ) + const lockMock = lockMockFn as unknown as typeof realLockfile.lock + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock, + })) + + // Re-import safeWriteJson so it picks up the mocked proper-lockfile. + const { safeWriteJson: mockedSafeWriteJson } = await import("../safeWriteJson") + + const mergeFn = vi.fn((existing: unknown, incoming: unknown) => ({ + ...(existing as Record), + ...(incoming as Record), + })) + + // Capture the compromise + release-failure logs. + const consoleErrorSpy = vi.spyOn(console, "error") + try { + await mockedSafeWriteJson(callerPath, { added: true }, { merge: mergeFn }) + + // The lock was keyed by the resolved referent — every alias shares it. + expect(lockMock).toHaveBeenCalledTimes(1) + expect(String(lockMockFn.mock.calls[0][0])).toBe(referentPath) + // The merge read the referent's content through that single lock. + expect(mergeFn).toHaveBeenCalledWith({ seed: 1 }, { added: true }) + // The commit rename lands on the requested path: under the replacement + // protocol the alias itself is replaced, so the merged content arrives at the + // alias path and the referent keeps the seeded content. + expect(await readFileContent(callerPath)).toEqual({ seed: 1, added: true }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1 }) + // The compromise callback and the failed release were logged, not thrown. + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("was compromised"), expect.any(Error)) + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Failed to release lock"), + expect.any(Error), + ) + } finally { + // Cleanup must run even when an assertion fails: a leaked mock registration or a + // leftover console spy changes later tests. The undo has to be the runtime form: + // Vitest hoists vi.unmock above this file's imports, so an unmock written here is + // registered before the vi.doMock above runs and never removes this test's wrapper. + realpathSpy.mockRestore() + vi.doUnmock("proper-lockfile") + vi.resetModules() + consoleErrorSpy.mockRestore() + } + }) + + // The undo in the case above has to be visible from the other side of the file: a later + // test that imports proper-lockfile fresh must resolve the real module, not the wrapper + // that case installed for its own import graph. This is the case that reddens when the + // undo is written as vi.unmock -- Vitest hoists that call above the imports, so it is + // registered before the vi.doMock above runs, the wrapper is never removed, and it is + // what a later import resolves. + test("resolves the real proper-lockfile after the capture above is undone", async () => { + vi.resetModules() + const realLockfile = await vi.importActual("proper-lockfile") + const imported = await import("proper-lockfile") + + expect(imported.lock).toBe(realLockfile.lock) + expect(vi.isMockFunction(imported.lock)).toBe(false) + }) + + test("rejects a confined write whose target is outside the confined directory", async () => { + const scope = path.join(tempDir, "project") + await fs.mkdir(scope) + const outside = path.join(tempDir, "elsewhere.json") + + // No symlink needed: the check runs on the resolved publish target, so an + // out-of-scope path is rejected on every platform, and it is rejected before the + // lock is taken and before anything is staged. + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: scope })).rejects.toThrow( + ConfinedPathEscapeError, + ) + + const left = await fs.readdir(tempDir) + expect(left).not.toContain("elsewhere.json") + expect(left.filter((entry) => entry.includes(".new_") || entry.endsWith(".lock"))).toEqual([]) + }) + + test("treats an empty confineTo as a declared scope rather than as no scope", async () => { + const cwd = process.cwd() + // An empty string is a scope declaration: _resolveScopeRoot runs it through + // path.resolve, which yields the process working directory. The temp target has + // to sit outside that directory for this test to be about the scope rather than + // about where the temp files happen to live, so the precondition is asserted. + const outside = path.join(tempDir, "empty-scope.json") + expect(path.resolve(outside).startsWith(cwd + path.sep)).toBe(false) + expect(path.resolve(outside)).not.toBe(cwd) + + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: "" })).rejects.toBeInstanceOf( + ConfinedPathEscapeError, + ) + + // Rejected the way every other confinement rejection is: nothing written. + expect(await fs.readdir(tempDir)).not.toContain("empty-scope.json") + }) + + test("rejects an in-scope dangling symlink to an outside referent before the advisory lock", async () => { + // The fixture needs a dangling link, and the two platforms build that shape + // differently: an unprivileged Windows cannot create a file or directory symlink + // at all (EPERM), while a junction needs no privilege, resolves through Node's + // reparse handling and reports as a symbolic link to a path that does not exist - + // the same lstat/realpath pair the POSIX file symlink produces. lstat of the + // junction here reports isSymbolicLink true and realpath reports ENOENT, which is + // the branch resolvePublishTarget and the lock-key walk take on either platform, + // so the row runs everywhere instead of only where a file link is permitted. + const projectDir = path.join(tempDir, "dangling-scope") + await fs.mkdir(projectDir) + const outsideDir = path.join(tempDir, "dangling-outside") + await fs.mkdir(outsideDir) + const link = path.join(projectDir, "link.json") + // junction on win32, file symlink elsewhere; the type is what makes the same + // dangling-link shape buildable on both lanes. + const linkType: "junction" | "file" = process.platform === "win32" ? "junction" : "file" + await fs.symlink(path.join(outsideDir, "gone.json"), link, linkType) + + const realLockfile = await vi.importActual("proper-lockfile") + const lockCalls: string[] = [] + const lockMock = vi.fn(async (file: unknown, options?: unknown) => { + lockCalls.push(String(file)) + return realLockfile.lock(file as never, options as never) + }) + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock as unknown as typeof realLockfile.lock, + })) + + vi.resetModules() + const { safeWriteJson: reimported, ConfinedPathEscapeError: ReimportedError } = await import("../safeWriteJson") + + try { + const outcome = await reimported(link, { mcpServers: {} }, { confineTo: projectDir }).then( + function (value: unknown) { + return { ok: true as const, value: value, error: undefined as unknown } + }, + function (error: unknown) { + return { ok: false as const, value: undefined as unknown, error: error } + }, + ) + // A dangling link resolves to its referent for the lock key, and the referent + // is outside the declared scope, so the second confinement check rejects + // before the lock file - which proper-lockfile writes NEXT TO the key - is + // ever attempted. Asserted first, because the point of the row is the + // ordering: a rejection that arrives after a lock is a rejection that already + // wrote outside the scope. + expect(lockCalls).toEqual([]) + expect(outcome.ok).toBe(false) + expect(outcome.error).toBeInstanceOf(ReimportedError) + expect( + (await fs.readdir(outsideDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) + expect( + (await fs.readdir(projectDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) + expect((await fs.readdir(projectDir)).sort()).toEqual(["link.json"]) + } finally { + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) + + test("rejects an out-of-scope target before the advisory lock is taken", async () => { + const projectDir = path.join(tempDir, "scope") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "elsewhere.json") + + // Capture every lock attempt without changing how locking works. The lock file + // lives NEXT TO the key, so taking the lock on an out-of-scope referent already + // writes outside the scope the caller declared - and an unwritable directory + // there would surface a lock error instead of the confinement verdict. + const realLockfile = await vi.importActual("proper-lockfile") + const lockCalls: string[] = [] + const lockMock = vi.fn(async (file: unknown, options?: unknown) => { + lockCalls.push(String(file)) + return realLockfile.lock(file as never, options as never) + }) + vi.doMock("proper-lockfile", () => ({ + ...realLockfile, + lock: lockMock as unknown as typeof realLockfile.lock, + })) + + // Re-import the whole graph so the SUT AND its fileLock dependency pick up the + // wrapped lock; without the reset, fileLock keeps the already-loaded real + // proper-lockfile and the capture never sees a call. The error class is taken from + // the same module instance the SUT came from. + vi.resetModules() + const { safeWriteJson: reimported, ConfinedPathEscapeError: ReimportedError } = await import("../safeWriteJson") + + try { + await expect(reimported(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + ReimportedError, + ) + + // The confinement verdict came first: no lock was ever attempted. + expect(lockCalls).toEqual([]) + expect( + (await fs.readdir(tempDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) + } finally { + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) + + test("rejects an out-of-scope target before creating any directory", async () => { + const projectDir = path.join(tempDir, "scope") + await fs.mkdir(projectDir) + // The target lives in a directory that does not exist. A confinement check that runs + // after the mkdir -p would create that directory OUTSIDE the declared scope first - + // the same side effect the pre-lock check exists to prevent, reached through a + // symlinked ancestor in the reported case - and an unwritable referent would surface + // the mkdir error instead of the confinement verdict. + const missingParent = path.join(tempDir, "no-such-dir") + const outside = path.join(missingParent, "elsewhere.json") + + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + ConfinedPathEscapeError, + ) + + // Nothing was created outside the scope, and no lock was attempted there. + expect(fsSyncActual.existsSync(missingParent)).toBe(false) + expect( + (await fs.readdir(tempDir)).filter(function (entry: string) { + return entry.endsWith(".lock") + }), + ).toEqual([]) + }) + + test.skipIf(process.platform === "win32")( + "rejects a confined write whose symlink resolves outside the confined directory", + async () => { + const projectDir = path.join(tempDir, "project") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "outside.json") + await fsSyncActual.promises.writeFile(outside, JSON.stringify({ secret: "original" }), "utf8") + // A repository that plants its project settings file as a link to somewhere else + // must not receive the settings write at the linked path. The caller picked + // projectDir/mcp.json from the workspace, so it declares that scope. + const projectConfig = path.join(projectDir, "mcp.json") + await fs.symlink(outside, projectConfig) + + await expect(safeWriteJson(projectConfig, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + ConfinedPathEscapeError, + ) + + // The linked file is untouched and nothing was staged beside it. + expect(JSON.parse(await fsSyncActual.promises.readFile(outside, "utf8"))).toEqual({ secret: "original" }) + const entries = await fs.readdir(tempDir) + expect(entries).toContain("outside.json") + expect( + entries.filter( + (entry) => entry.includes(".new_") || entry.includes("safeWriteText") || entry.endsWith(".lock"), + ), + ).toEqual([]) + }, + ) + + test.skipIf(process.platform === "win32")( + "publishes over a symlink referent that stays inside the confined directory", + async () => { + const projectDir = path.join(tempDir, "project-in") + await fs.mkdir(projectDir) + const referent = path.join(projectDir, "real-mcp.json") + await fsSyncActual.promises.writeFile(referent, JSON.stringify({ mcpServers: {} }), "utf8") + const alias = path.join(projectDir, "mcp.json") + await fs.symlink(referent, alias) + + // Confining is about the scope, not about forbidding links: the write lands at + // the path the caller named. Main's body renames to that path and replaces the + // link node in place -- the alias becomes a regular file holding the payload and + // the old referent keeps the seed, matching the lock-referent test's contract. + await safeWriteJson( + alias, + { mcpServers: { local: { url: "http://localhost" } } }, + { confineTo: projectDir }, + ) + + expect(JSON.parse(await fsSyncActual.promises.readFile(alias, "utf8"))).toEqual({ + mcpServers: { local: { url: "http://localhost" } }, + }) + expect(JSON.parse(await fsSyncActual.promises.readFile(referent, "utf8"))).toEqual({ mcpServers: {} }) + expect(fsSyncActual.lstatSync(alias).isFile()).toBe(true) + }, + ) + + test.skipIf(process.platform === "win32")( + "confines a scope path that itself runs through a symlink and does not exist yet", + async () => { + const real = path.join(tempDir, "real-project") + await fs.mkdir(real) + const alias = path.join(tempDir, "alias-project") + await fs.symlink(real, alias) + // The scope is declared through the alias, and the directory it names does not + // exist yet. Resolving it lexically would compare an unresolved scope against a + // fully resolved target and reject a write that is in fact inside the project - + // the macOS /var -> /private/var shape. The nearest existing ancestor is resolved + // and the remainder re-joined instead. + const nested = path.join(alias, "nested") + const target = path.join(nested, "mcp.json") + + await safeWriteJson(target, { mcpServers: {} }, { confineTo: nested }) + + expect( + JSON.parse(await fsSyncActual.promises.readFile(path.join(real, "nested", "mcp.json"), "utf8")), + ).toEqual({ mcpServers: {} }) + }, + ) + + // (removed: "preserves a restrictive 0o600 target mode through the atomic publish" + + // its CWE-732 comment. The mode-preservation contract belonged to the delegation + // flow this merge dropped; main's body stages the temp itself, never chmods it to + // the old target's mode, and main has no such test -- a 0o600 target republishes + // at the temp's mode in main today, and keeping the old assertion here would + // claim a guarantee main does not make.) }) diff --git a/src/utils/fileLock.ts b/src/utils/fileLock.ts index 49807ea154..d4251f0078 100644 --- a/src/utils/fileLock.ts +++ b/src/utils/fileLock.ts @@ -1,3 +1,4 @@ +import * as fs from "fs/promises" import * as path from "path" import { tracePersistenceEvent } from "../core/task-persistence/persistenceTrace" import * as lockfile from "proper-lockfile" @@ -10,8 +11,88 @@ import * as lockfile from "proper-lockfile" export const LOCK_STALE_MS = 31_000 const RECOVERY_RETRY_MS = 1000 + +/** + * The advisory lock for a path is the filesystem artefact at this location. + * proper-lockfile's default is `.lock`; `acquireFileLock` below passes the + * value explicitly so the two cannot drift apart, and `safeWriteJson`'s lock-key drift + * guard answers "is this the same lock?" by comparing two artefacts. A caller that + * derived the location on its own would be comparing something other than what this + * module actually locks. + */ +export function lockPathFor(filePath: string): string { + return path.resolve(filePath) + ".lock" +} const RECOVERY_GRACE_MS = 4000 +/** + * Create the directory that holds a path's lock artefact, and report only the levels this + * call actually created. proper-lockfile's own mkdir of the artefact is not recursive, so a + * write into a directory that did not exist yet failed here with ENOENT - before the publish + * primitive, and so before any rollback it would have owned - and left the empty tree behind. + * + * This asks the filesystem to create, and lets the answer say what was made: recursive mkdir + * resolves with the first directory it actually created, and with nothing when every level + * already existed. There is deliberately no fs.stat here. The task-persistence suites mock + * fs.stat to reject for every path, and safeWriteJson.stagingDirectory.spec.ts pins that the + * write must not start asking fs.stat questions about the directory it is allowed to write + * into; a stat-driven walk would reintroduce exactly that question, and did. + */ +async function createLockDirectoryChain(lockfilePath: string): Promise { + const parent = path.dirname(lockfilePath) + // Measured on this host, not inferred from the docs: Node reports the first directory it + // created with an extended-length prefix (\\?\C:\...), so the answer is normalised before + // it is compared with anything. Comparing it raw is what made a rollback reach above the + // tree this call had built. + const firstMade = await fs.mkdir(parent, { recursive: true }) + if (firstMade === undefined || firstMade === null) { + return [] + } + const made = path.resolve(withoutExtendedLengthPrefix(String(firstMade))) + const tail = path.relative(made, parent) + if (tail.startsWith("..")) { + // The answer is not a location this call can account for; claim the parent and no + // more, which is the conservative reading of "only what we created". + return [parent] + } + const chain: string[] = [made] + let cursor = made + if (tail !== "") { + for (const segment of tail.split(path.sep)) { + cursor = path.join(cursor, segment) + chain.push(cursor) + } + } + return chain +} + +/** Strip the extended-length prefix Node reports on Windows, leaving other paths alone. */ +function withoutExtendedLengthPrefix(candidate: string): string { + if (candidate.startsWith("\\\\?\\UNC\\")) { + return "\\\\" + candidate.slice(8) + } + if (candidate.startsWith("\\\\?\\")) { + return candidate.slice(4) + } + return candidate +} +/** + * Undo the directories a failed acquisition created, deepest first and best-effort. A + * directory somebody else populated is not this call's to take away, and a rollback + * failure must not arrive dressed as the error that caused the rollback. + */ +async function removeCreatedDirectories(created: string[]): Promise { + for (let index = created.length - 1; index >= 0; index--) { + try { + await fs.rmdir(created[index]) + } catch (rollbackError: unknown) { + // Includes ENOTEMPTY: the directory is in use, which is a reason to leave it, + // not a reason to report a second failure over the first. + void rollbackError + } + } +} + export interface FileLockOptions { /** Recovery may wait out a crashed writer's staleness window without stealing a live lock. */ waitForStaleLock?: boolean @@ -29,10 +110,23 @@ export interface FileLockOptions { export async function acquireFileLock(filePath: string, options?: FileLockOptions): Promise<() => Promise> { const absoluteFilePath = path.resolve(filePath) if (options?.waitForStaleLock) tracePersistenceEvent("json-lock-wait", { filePath: absoluteFilePath }) + const lockfilePath = lockPathFor(absoluteFilePath) + // Created before the lock is taken, and removed again only if the acquisition + // fails: see createLockDirectoryChain. A failure here is rolled back before it is + // reported, so a refused write does not litter the tree it refused to enter. + let created: string[] = [] + try { + created = await createLockDirectoryChain(lockfilePath) + } catch (mkdirError: unknown) { + await removeCreatedDirectories(created) + console.error(`Failed to prepare the lock directory for ${absoluteFilePath}:`, mkdirError) + throw mkdirError + } try { return await lockfile.lock(absoluteFilePath, { stale: LOCK_STALE_MS, update: 10000, // Update mtime every 10 seconds to prevent staleness if operation is long + lockfilePath, realpath: false, // the file may not exist yet, which is acceptable retries: options?.immediate ? 0 @@ -55,7 +149,10 @@ export async function acquireFileLock(filePath: string, options?: FileLockOption throw err }, }) - } catch (lockError) { + } catch (lockError: unknown) { + // The parent directories this call made are the only state it + // owns at this point; give them back, then report the acquisition failure unchanged. + await removeCreatedDirectories(created) console.error(`Failed to acquire lock for ${absoluteFilePath}:`, lockError) throw lockError } diff --git a/src/utils/jsonFileRecovery.ts b/src/utils/jsonFileRecovery.ts index 2350f30685..cacb6ca31f 100644 --- a/src/utils/jsonFileRecovery.ts +++ b/src/utils/jsonFileRecovery.ts @@ -5,6 +5,7 @@ import { getErrorCode } from "./errorCode" import { tracePersistenceEvent } from "../core/task-persistence/persistenceTrace" import { withFileLock } from "./fileLock" +import { resolveLockKey } from "../services/file-safety/safeWriteText" function getTempFilePrefix(absoluteFilePath: string, kind: "new" | "bak"): string { return `.${path.basename(absoluteFilePath)}.${kind}_` @@ -75,9 +76,23 @@ export async function recoverMissingJsonFile( * Delete recoverable leftovers before the canonical file under the writer's * lock. Failure preserves the canonical copy, rather than authorizing recovery * of a stale backup after a successful deletion. Missing files are harmless. + * + * The lock is taken on the key `safeWriteJson` publishes under - `resolveLockKey`, + * which follows an existing symlink to the file it refers to - so a delete asked for + * through an alias contends with the writer that holds the referent's lock, and what + * the unlink removes is the referent: the link the caller named survives as a dangling + * symlink, and a caller that wants its own spelling removed has to remove it itself. + * Computing that key is fail-closed. If the filesystem will not answer what the path + * refers to, the delete does not happen at all and the caller receives that error + * rather than an unlocked deletion under a key that names something else. */ export async function deleteJsonFile(filePath: string): Promise { - await withFileLock(filePath, async (absoluteFilePath) => { + // Resolved before the lock is taken, the way guardedWrite.ts:192 and DiffViewProvider.ts:722 + // resolve for a write. The key below is the one the writer holds, so the contention is real, + // and everything the loop unlinks is named from it: for an alias that is the referent, its + // leftovers, and the backup prefix built from the referent's basename rather than the + // spelling the caller passed in. + await withFileLock(await resolveLockKey(path.resolve(filePath)), async (absoluteFilePath) => { let entries: string[] try { entries = await fs.readdir(path.dirname(absoluteFilePath)) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index e54b8c51d3..7b6ba8451c 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,7 +4,8 @@ import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" import { getErrorCode } from "./errorCode" -import { acquireFileLock } from "./fileLock" +import { acquireFileLock, lockPathFor } from "./fileLock" +import { resolveLockKey, resolvePublishTarget } from "../services/file-safety/safeWriteText" import { recoverInterruptedReplacement } from "./jsonFileRecovery" /** @@ -40,6 +41,15 @@ export interface SafeWriteJsonOptions { */ merge?: (existing: unknown, incoming: unknown) => unknown + /** + * Restrict the write to a directory. The publish target is resolved through + * symlinks before this check runs, so a caller that picked the path from a + * known scope (a workspace, a project settings directory) can refuse a write + * that a planted symlink would land somewhere else. The check runs before the + * advisory lock is taken and before anything is staged. + */ + confineTo?: string + /** * When provided, receives one event per replacement-protocol step (see * SafeWriteJsonTraceStage). Callers such as the task-message save use it @@ -48,6 +58,197 @@ export interface SafeWriteJsonOptions { trace?: SafeWriteJsonTrace } +/** + * Thrown when a write declared with `confineTo` resolves outside that directory. + */ +export class ConfinedPathEscapeError extends Error { + constructor( + readonly requestedPath: string, + readonly resolvedPath: string, + readonly confineTo: string, + ) { + super( + `Refusing to write ${resolvedPath}: it resolves outside the confined directory ${confineTo} (requested ${requestedPath}).`, + ) + this.name = "ConfinedPathEscapeError" + } +} + +/** + * Thrown when the advisory lock this write holds no longer names the path it is about to + * publish. The lock key is resolved before the lock is taken and the publish target is + * resolved under it, so an alias repointed in between would otherwise let this writer commit + * onto a file whose lock belongs to another writer. Nothing is staged when this rejects. + */ +class LockKeyDriftError extends Error { + constructor(requestedPath: string, lockKey: string, publishTarget: string) { + super( + `Refusing to publish ${publishTarget}: the advisory lock was taken for ${lockKey}, which is no longer the file this write resolves to (requested ${requestedPath}). Retry the write.`, + ) + this.name = "LockKeyDriftError" + } +} +/** + * Whether two lock keys denote one lock. + * + * The drift guard further down exists because two writers holding two different locks can + * commit onto one file, each believing it owned it. That is a claim about the locks, and the + * lock for a key is the artefact at lockPathFor(key). On a case-folding filesystem (NTFS, + * APFS) `SETTINGS.JSON.lock` and `settings.json.lock` ARE one artefact, so a writer that + * computed its key from a name that did not exist yet and then meets that file's true name + * under the lock is holding the very lock the other spelling would take: nothing about its + * lock changed, and comparing the two spellings refuses a write that never needed refusing. + * + * The question is put to the filesystem through fs.stat with bigint: true, for the same + * reason safeWriteText.ts puts its staging-containment question that way: on NTFS and ReFS a + * file identity can exceed Number.MAX_SAFE_INTEGER, and a rounded number makes two artefacts + * look different from each other or two different artefacts look alike. The spelling + * comparison stays in front of this as the fast path, so a stat is only asked when the + * spellings already differ. A missing artefact is the one failure that carries no information + * about identity, so the keys are then treated as different locks and the original rejection + * stands. Any other stat failure propagates: a guard that answered "different locks" from an + * EACCES would be refusing for a reason it never measured. + */ +async function _locksAreOneLock(firstKey: string, secondKey: string): Promise { + // A divergence in another directory is not this question. A key whose parent was + // re-keyed under the writer can resolve its own artefact spelling onto some other + // writer's artefact, and an identity match there says nothing about the lock this + // writer took. Only a name that differs from its counterpart by case, within the + // same directory, can be one lock under two spellings - and even then the + // filesystem, not the spelling, is what answers. + if (path.dirname(firstKey) !== path.dirname(secondKey)) { + return false + } + if (path.basename(firstKey).toLowerCase() !== path.basename(secondKey).toLowerCase()) { + return false + } + const first = await _statLockArtefact(lockPathFor(firstKey)) + if (first === undefined) { + return false + } + const second = await _statLockArtefact(lockPathFor(secondKey)) + if (second === undefined) { + return false + } + return first.dev === second.dev && first.ino === second.ino +} + +/** + * Stat one lock artefact. undefined means the artefact is not there - the only failure that + * says something about this comparison. Anything else is the guard losing its ability to + * decide, and it propagates rather than being read as "not the same lock". + */ +async function _statLockArtefact(lockPath: string): Promise { + try { + return await fs.stat(lockPath, { bigint: true }) + } catch (error: unknown) { + if (getErrorCode(error) === "ENOENT") { + return undefined + } + throw error + } +} + +/** + * Thrown when the directory this write was approved to stage into no longer names that + * directory. The publish target and the staging location are resolved under the lock, but + * a resolved path is a pathname, not a bound identity: if the target's parent is replaced + * with a link pointing elsewhere before the staged file is written, the staged JSON is + * created outside the directory this write was approved for, and the commit rename that + * follows would then move that stray file onto the target. Rejecting here leaves nothing + * on disk. + */ +class StagingDirectoryDriftError extends Error { + constructor(requestedPath: string, approvedDirectory: string, actualDirectory: string) { + super( + `Refusing to stage ${requestedPath}: the directory this write was approved for (${approvedDirectory}) now resolves to ${actualDirectory}. Retry the write.`, + ) + this.name = "StagingDirectoryDriftError" + } +} + +/** + * Whether two paths name the same directory. `realpath` reports the on-disk spelling, + * which can differ in case on Windows, so the identity being compared here is the + * directory, not the spelling of it. + */ +function _sameDirectory(approved: string, actual: string): boolean { + return process.platform === "win32" ? approved.toLowerCase() === actual.toLowerCase() : approved === actual +} + +/** + * Canonicalize a directory a write is confined to or approved to stage into. The publish + * resolved through symlinks, so the scope has to be resolved the same way or a + * scope path that itself runs through a symlink (macOS /var -> /private/var is the + * common case) would compare lexically against a resolved target and reject every + * legitimate in-scope write. When the scope does not exist yet, the nearest + * existing ancestor is resolved and the remainder re-appended. + */ +async function _resolveScopeRoot(confineTo: string): Promise { + const lexical = path.resolve(confineTo) + try { + return await fs.realpath(lexical) + } catch (error: unknown) { + // Only a missing path means "walk up and re-join". EACCES or ELOOP means the + // scope cannot be canonicalized at all, and continuing would build a partly + // lexical root that can disagree with the canonical target - the failure has to + // surface rather than decide the scope from a guess. + if (_scopeErrorCode(error) !== "ENOENT") { + throw error + } + const missing: string[] = [] + let ancestor = lexical + while (true) { + const parent = path.dirname(ancestor) + if (parent === ancestor) { + return lexical + } + missing.push(path.basename(ancestor)) + ancestor = parent + try { + const real = await fs.realpath(ancestor) + return path.join(real, ...missing.reverse()) + } catch (innerError: unknown) { + if (_scopeErrorCode(innerError) !== "ENOENT") { + throw innerError + } + } + } + } +} + +/** + * Whether `candidate` sits outside `scopeRoot`. The scope root itself counts as + * outside: a file write cannot land on the directory that declares the scope. + */ +function _escapesScope(scopeRoot: string, candidate: string): boolean { + const relative = path.relative(scopeRoot, candidate) + return relative === "" || relative === ".." || relative.startsWith(".." + path.sep) || path.isAbsolute(relative) +} + +function _scopeErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined +} + +/** + * Canonicalize the staging directory this write was approved to stage into, or report that the + * probe could not answer. Returning undefined instead of throwing is the point: the drift check + * can only read meaning out of a resolution that succeeded, and every failure this probe can + * produce - ENOENT while the directory exists because a component is mid-replacement or because + * the caller's fs double reports the path as missing, EACCES, EPERM, ELOOP - is equally consistent + * with the approved directory being exactly where it was approved. A probe that cannot answer is + * not an answer. + */ +async function _probeApprovedStagingDirectory(directory: string): Promise { + try { + return await _resolveScopeRoot(directory) + } catch { + return undefined + } +} + function createTempFilePath(absoluteFilePath: string, kind: "new" | "bak"): string { return path.join( path.dirname(absoluteFilePath), @@ -79,28 +280,74 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit const dirPath = path.dirname(absoluteFilePath) // Ensure directory structure exists with improved reliability + // Declared outside the protected block so the catch and finally can still name + // the target when the resolution itself rejects. + let resolvedTargetPath: string | undefined + + // An empty confineTo is a declared scope, not the absence of one: `""` resolves to the + // process working directory, so the checks below test for presence, and the presence + // decision happens once, here. A truthiness test would silently drop a scope a caller + // declared as an empty string. No production call site passes confineTo at all - the + // option is read nowhere outside this module - so nothing in the tree changes behaviour + // under either reading. If a maintainer decides an empty string should mean "unset" + // instead, that is a one-line change to this presence test with a test row of its own, + // not a redesign. + const confineTo = options?.confineTo + + // Confinement is checked before ANY filesystem side effect. mkdir -p through a + // planted symlinked ancestor (projectDir/.roo -> /elsewhere) would create + // directories outside the declared scope, and an unwritable referent would surface + // the mkdir error instead of the ConfinedPathEscapeError the caller is entitled to. + // _resolveScopeRoot resolves a not-yet-existing path through its nearest existing + // ancestor, so this works for the target's missing parents too. + if (confineTo !== undefined) { + const scopeRoot = await _resolveScopeRoot(confineTo) + const requested = await _resolveScopeRoot(absoluteFilePath) + if (_escapesScope(scopeRoot, requested)) { + throw new ConfinedPathEscapeError(absoluteFilePath, requested, scopeRoot) + } + } + try { - // Create directory with recursive option await fs.mkdir(dirPath, { recursive: true }) - - // Verify directory exists after creation attempt await fs.access(dirPath) } catch (dirError: unknown) { console.error(`Failed to create or access directory for ${absoluteFilePath}:`, dirError) throw dirError } - // Acquire the lock before any file operations. `acquireFileLock` owns the - // shared advisory lock protocol, so callers that lock the same path with - // it (for example task-history deletion) serialize with this write. - // If lock acquisition fails, it throws immediately. The releaseLock - // remains a no-op, so the finally block in the main file operations - // try-catch-finally won't try to release an unacquired lock if this - // path is taken. - releaseLock = await acquireFileLock(absoluteFilePath) + // Lock key: the symlink referent when the path is an existing symlink, so a + // symlink alias and its referent share one lock. The key must stay computable while + // a peer writer is mid-commit - the commit rename briefly leaves the path without + // the referent it had when the walk started - so the walk tolerates a dangling link + // instead of rejecting it here. (The backup itself is a copy: the target is never + // renamed away, so a dangling link here means a peer's commit or an external + // unlink, not a backup rename.) + const lockKey = await resolveLockKey(absoluteFilePath) - // Variables to hold the actual paths of temp files if they are created. + // Confinement is checked here as well as under the lock. The lock file lives NEXT + // TO the key, so a planted symlink that points outside the declared scope would + // first make this write create ".lock" outside that scope - a side effect + // the caller declared it would not have - and if that directory is not writable the + // caller would get a lock-acquisition error instead of the ConfinedPathEscapeError + // it is entitled to. The key is the symlink referent, so this check sees the same + // destination the lock would be taken on. The under-lock check stays: it covers the + // target re-resolved after a peer commits. + if (confineTo !== undefined) { + const scopeRoot = await _resolveScopeRoot(confineTo) + if (_escapesScope(scopeRoot, await _resolveScopeRoot(lockKey))) { + throw new ConfinedPathEscapeError(absoluteFilePath, lockKey, scopeRoot) + } + } + + // Acquire the lock before any file operations. If acquisition fails it throws + // immediately, and releaseLock stays a no-op so the finally block does not try + // to release an unacquired lock. + releaseLock = await acquireFileLock(lockKey) + + // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null + // Present only while the EPERM fallback holds the target renamed aside as a backup. let actualTempBackupFilePath: string | null = null try { @@ -116,13 +363,45 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit options?.trace?.({ stage: "recover", detail: recovery.restoredFrom ?? "cleaned-leftovers" }) } + // Resolve the publish target under the lock: the peer has committed by now, so + // the strict dangling-link rejection still applies to a real dangling link. It + // must stay inside the protected block, otherwise a rejection here leaves the + // advisory lock held until the stale timeout for every other writer. + resolvedTargetPath = await resolvePublishTarget(absoluteFilePath) + + // The lock above was chosen from the path as it looked before the lock was taken, and the + // publish target is resolved under the lock - after a peer committed, and after the alias + // itself may have been repointed. If the two no longer name the same file, this writer is + // holding the lock of a path it is not about to publish while some other writer can hold + // the lock of the one it is about to publish, which is two commits onto one file each + // believing it owned it. Nothing has been staged or merged yet, so rejecting here leaves + // every file untouched and the caller can retry against the key it resolves to now. + const underLockKey = await resolveLockKey(resolvedTargetPath) + // Different spellings are not yet evidence of a different lock: the pair is refused + // only when the two lock artefacts are not the same filesystem object. + if (underLockKey !== lockKey && !(await _locksAreOneLock(lockKey, underLockKey))) { + throw new LockKeyDriftError(absoluteFilePath, lockKey, resolvedTargetPath) + } + + // Confinement, if the caller declared a scope. Both sides are canonicalized the + // same way: the publish target is resolved through symlinks, and a target that + // does not exist yet still carries the alias components of the path it was + // given. This runs before the merge read and before anything is staged, so a + // rejected write leaves nothing behind. + if (confineTo !== undefined) { + const scopeRoot = await _resolveScopeRoot(confineTo) + if (_escapesScope(scopeRoot, await _resolveScopeRoot(resolvedTargetPath))) { + throw new ConfinedPathEscapeError(absoluteFilePath, resolvedTargetPath, scopeRoot) + } + } + // If a merge callback was provided, read the current file under the lock // and let the caller merge before we write. Must be inside try/finally // so a throwing merge still releases the lock. if (options?.merge) { let existing: unknown = null try { - existing = JSON.parse(await fs.readFile(absoluteFilePath, "utf8")) + existing = JSON.parse(await fs.readFile(resolvedTargetPath, "utf8")) } catch (error: unknown) { const code = getErrorCode(error) if (!(error instanceof SyntaxError) && code !== "ENOENT") { @@ -136,8 +415,63 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit // making a best-effort flush before committing the replacement. options?.trace?.({ stage: "write-start" }) actualTempNewFilePath = createTempFilePath(absoluteFilePath, "new") + // The commit rename publishes the staging inode, so the file that lands at the target carries + // the staging file's mode rather than the target's: on POSIX a rename does not carry the + // replaced file's permissions over. A staging file created with the process default (0o666 & + // ~umask, normally 0o644) therefore turns an update of a 0o600 file into a world-readable one + // sitting in a traversable directory. Read the publish target's mode before staging and create + // the staging file with it. A target that is not there yet has no mode to carry over, and the + // creation default stands; any other stat failure is propagated, because the mode a private + // file is published with is not something this write gets to guess. + const publishTargetMode = await _readPublishTargetMode(resolvedTargetPath ?? absoluteFilePath) + // The staging location was chosen from the parent directory resolved under the lock. Re-verify + // that directory against itself before writing anything into it: a resolved pathname is not an + // identity, and a parent that was replaced with a link pointing elsewhere in the meantime would + // put the staged file outside the directory this write was approved for; the commit rename + // that follows would move whatever landed there onto the target. Both sides of this + // comparison are canonicalized with the same directory walk the confinement check above + // uses, because for a target that does not exist yet resolvePublishTarget returns the path + // it was given: comparing that lexical spelling against fs.realpath of the same directory + // rejects an ordinary write + // whenever the two spellings differ. On a Windows runner the temp root arrives spelled with its + // 8.3 short name (C:\Users\RUNNER~1\AppData\Local\Temp) and realpath reports the long name for + // that very same directory, so every write into a directory that did not exist yet rejected + // here. The two walks run against the same frozen approval one after the other, and both sides + // are compared as canonical directories rather than as the text of a path, so a parent that + // really was replaced by a link still lands on a different canonical directory and still + // rejects. + // + // What this guard may act on is one thing only: a re-resolution that succeeds and lands on a + // different directory. A probe that cannot answer is not an answer. A probe that fails - ENOENT + // because a component is mid-replacement or is simply not there yet, EACCES, EPERM or ELOOP + // because the filesystem refused to say - carries no information about replacement whatsoever, + // and a failing probe is a normal condition of this write path rather than an attack: the task + // persistence suite runs this write against an fs/promises double whose stat rejects with + // ENOENT for every path, and its task directory was created moments earlier with the real fs. + // Reading that as drift rejected an ordinary write, which is the shape upstream's newer + // Task.spec hit on a Windows temp path. So a probe that cannot answer proceeds. A directory that + // really was deleted in the meantime then fails at the staging write itself: still fail-closed, + // still nothing created outside the directory the write was approved for, and the error names + // the failed write instead of claiming to be evidence of replacement. The confinement checks + // above decide the opposite way on purpose - there the guard has to prove the write is inside + // the scope, and a probe that cannot answer cannot prove anything. + const approvedStagingDirectory = path.dirname(actualTempNewFilePath) + const approvedCanonical = await _probeApprovedStagingDirectory(approvedStagingDirectory) + const currentStagingDirectory = + approvedCanonical === undefined ? undefined : await _probeApprovedStagingDirectory(approvedCanonical) + if ( + approvedCanonical !== undefined && + currentStagingDirectory !== undefined && + !_sameDirectory(approvedCanonical, currentStagingDirectory) + ) { + throw new StagingDirectoryDriftError( + resolvedTargetPath ?? absoluteFilePath, + approvedCanonical, + currentStagingDirectory, + ) + } - await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) + await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint, publishTargetMode) await _flushFileToDisk(actualTempNewFilePath) // Step 2: Commit the replacement with a single atomic rename. The @@ -235,6 +569,25 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit } } +/** + * The mode the staging file has to be created with, taken from the file it is about to replace. + * undefined means there is no such file yet and the creation default stands; a failure that is + * not "not there" is propagated, because guessing the mode of a private file is how a 0o600 + * settings file becomes 0o644 without anyone deciding it. + */ +async function _readPublishTargetMode(publishTargetPath: string): Promise { + let mode: number + try { + mode = (await fs.stat(publishTargetPath)).mode + } catch (error: unknown) { + if (getErrorCode(error) === "ENOENT") { + return undefined + } + throw error + } + return mode & 0o777 +} + /** * Helper function to stream JSON data to a file. * @param targetPath The path to write the stream to. @@ -242,10 +595,37 @@ async function safeWriteJson(filePath: string, data: unknown, options?: SafeWrit * @param prettyPrint Whether to format the JSON with indentation. * @returns Promise */ -async function _streamDataToFile(targetPath: string, data: unknown, prettyPrint = false): Promise { - // Stream data to avoid high memory usage for large JSON objects. - const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8" }) - +async function _streamDataToFile(targetPath: string, data: unknown, prettyPrint = false, mode?: number): Promise { + // Stream data to avoid high memory usage for large JSON objects. The mode is the publish + // target's own mode, so the inode that replaces it is not created more openly than the file it + // replaces; on Windows the creation mode carries only the read-only bit and per-user rights come + // from directory inheritance, which is why the end-to-end mode assertion is POSIX-shaped. + // The mode cannot ride on the create call alone. createWriteStream opens the file, and an + // open's creation mode is narrowed by the process umask, so an existing 0o664 target would be + // published as 0o644 under a 0o022 umask - the group write it had would go missing with the + // rename. One fchmod on the descriptor states the mode exactly. It is bound to the descriptor + // rather than to the path, as safeWriteText.ts:499-:503 and :529-:547 do for text writes: the + // staging name is this writer's own, and an fd-bound chmod cannot be retargeted by another + // writer moving a path underneath it. A missing mode means there was no publish target to + // copy from, and the creation default is left to stand, un-enforced as before. + let stagingFd: number | undefined + try { + stagingFd = fsSync.openSync(targetPath, "w", mode) + if (mode !== undefined) { + fsSync.fchmodSync(stagingFd, mode) + } + } catch (stageError: unknown) { + // An fchmod that fails leaves a descriptor behind, and a write that is going to report a + // failure should not also leak the handle it opened to make it. + if (stagingFd !== undefined) { + fsSync.closeSync(stagingFd) + } + throw stageError + } + // The descriptor is already open, so the stream takes it and closes it when the write ends, + // as it did when it opened the file itself. Passing the path alongside the fd is for the + // error message Node attaches to a stream failure; Node ignores the path once an fd is given. + const fileWriteStream = fsSync.createWriteStream(targetPath, { encoding: "utf8", fd: stagingFd }) // JsonStreamStringify traverses the object and streams tokens directly // The 'spaces' parameter adds indentation during streaming, not via a separate pass // Convert undefined to null for valid JSON serialization (undefined is not valid JSON)