diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 4de2b84590..5fbac2dd3d 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -111,6 +111,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" @@ -286,6 +287,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 3b664b3bd2..eeca26c198 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" @@ -68,7 +69,32 @@ 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) + } 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( @@ -173,7 +199,8 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { 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( @@ -182,6 +209,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { false, diagnosticsEnabled, writeDelayMs, + "edit", ) } else { // Original behavior with diff view @@ -221,7 +249,7 @@ export class ApplyDiffTool extends BaseTool<"apply_diff"> { } // Call saveChanges to update the DiffViewProvider properties - await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs) + await task.diffViewProvider.saveChanges(diagnosticsEnabled, writeDelayMs, "edit") } // Track file edit operation diff --git a/src/core/tools/ApplyPatchTool.ts b/src/core/tools/ApplyPatchTool.ts index 56b2bf8909..c3003729b1 100644 --- a/src/core/tools/ApplyPatchTool.ts +++ b/src/core/tools/ApplyPatchTool.ts @@ -11,6 +11,8 @@ 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 { GuardRejectedError, errorCode } from "./guardedWrite" import { BaseTool, ToolCallbacks } from "./BaseTool" import type { ToolUse } from "../../shared/tools" import { parsePatch, ParseError, processAllHunks } from "./apply-patch" @@ -85,10 +87,34 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { return } - // Process each hunk + // 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): Promise => { const absolutePath = path.resolve(task.cwd, filePath) - return await fs.readFile(absolutePath, "utf8") + const preReadStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const content: string = await fs.readFile(absolutePath, "utf8") + 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 this read returned the whole + // content, so the observation is complete. + const prior = task.observationRegistry.get(absolutePath) + // Nothing to carry when the model never observed the file: this read returned the + // whole content, so it is a complete observation. Carry 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) + } + } + return content } let changes: ApplyPatchFileChange[] @@ -214,9 +240,20 @@ 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", + ) } 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") } // Track file edit operation @@ -408,12 +445,59 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { // Save new content to the new path if (isPreventFocusDisruptionEnabled) { + // 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) + } + } await task.diffViewProvider.saveDirectly( change.movePath, newContent, false, diagnosticsEnabled, writeDelayMs, + "create", + sourceComplete, ) } else { // Write to new path and delete old file @@ -433,9 +517,22 @@ export class ApplyPatchTool extends BaseTool<"apply_patch"> { } 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", + ) } 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") } await task.fileContextTracker.trackFileContext(relPath, "roo_edited" as RecordSource) diff --git a/src/core/tools/ReadFileTool.ts b/src/core/tools/ReadFileTool.ts index 2107cfe21b..ba1be7deaf 100644 --- a/src/core/tools/ReadFileTool.ts +++ b/src/core/tools/ReadFileTool.ts @@ -16,13 +16,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 { @@ -214,14 +215,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) @@ -265,8 +288,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") { @@ -299,7 +328,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 @@ -322,11 +352,28 @@ 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) { + // Every line was returned, 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. + output = `IMPORTANT: Some lines exceed ${MAX_LINE_LENGTH} characters and were clipped in this view. The file was read in full, but the clipped lines were not shown in full. + ${result.content}` } else if (result.returnedLines === 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 } } /** @@ -768,9 +815,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") @@ -790,8 +848,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]` } } @@ -799,6 +865,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/__tests__/applyDiffTool.guardedWrite.spec.ts b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts new file mode 100644 index 0000000000..bf7bc4b520 --- /dev/null +++ b/src/core/tools/__tests__/applyDiffTool.guardedWrite.spec.ts @@ -0,0 +1,293 @@ +// 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, + ) + }) + + 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), + } + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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, + }) + + 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("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, + }) + + 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, + }) + + const observation = mockTask.observationRegistry.get(key) + expect(observation?.version).toBe("1:2:9:9:9") + expect(observation?.complete).toBe(true) + }) +}) diff --git a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts index 72ffb112bc..0aa6ed1dc6 100644 --- a/src/core/tools/__tests__/applyPatchTool.execute.spec.ts +++ b/src/core/tools/__tests__/applyPatchTool.execute.spec.ts @@ -4,14 +4,59 @@ 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" -vi.mock("fs/promises", () => ({ - default: { +// 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 + stat: ReturnType + unlink: MockedFunction + } + }, +) + +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), readFile: vi.fn().mockResolvedValue("original file content\n"), + // Stable on-disk version for the S2 self-read observation (the hunk + // read now stats before and after; equal tokens record the observe). + 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, + }), 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), + } + 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", () => ({ @@ -38,6 +83,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 +98,7 @@ describe("ApplyPatchTool.execute - delete file success path", () => { mockTask = { cwd: "/workspace/project", consecutiveMistakeCount: 0, + observationRegistry: new ObservationRegistry(), recordToolUsage: vi.fn(), recordToolError: vi.fn(), rooIgnoreController: { @@ -94,3 +141,561 @@ 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` + + 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", + }) + 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, + }) + + 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("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, + }) + + 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", + ) + + 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, + }) + + 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, + }) + + 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, + }) + + 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: 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, + }) + + // 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, + }) + + 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, + }) + + expect(mockTask.observationRegistry.has(path.resolve("/workspace/project", "src/thing.ts"))).toBe(false) + 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, + }) + + expect(mockSaveDirectly).toHaveBeenCalledWith( + "src/new.ts", + "new line one\nnew line two\n", + true, + true, + 1000, + "create", + ) + 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, + }) + + // 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, + ) + }) + + 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:4242:1234:1700000000123456789:1700000000789999999", true) + + await tool.execute({ patch: movePatch }, mockTask as Task, { + askApproval: mockAskApproval, + handleError: mockHandleError, + pushToolResult: mockPushToolResult, + }) + + // 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:4242: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, + }) + + 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:4242: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, + }) + + 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: 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:4242: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, + }) + + expect(reg.get(destKey)?.complete).toBe(true) + expect(mockHandleError).not.toHaveBeenCalled() + }) + + 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, + }) + + 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, + }) + + expect(reg.get(destKey)).toBeUndefined() + expect(mockHandleError).not.toHaveBeenCalled() + }) + + 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, + }) + + 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, + }) + + 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, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "edit") + 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, + }) + + expect(mockSaveChanges).toHaveBeenCalledWith(true, 1000, "create") + expect(mockPushToolResult).toHaveBeenCalledWith("Saved file") + expect(mockHandleError).not.toHaveBeenCalled() + }) +}) diff --git a/src/core/tools/__tests__/guardedWrite.spec.ts b/src/core/tools/__tests__/guardedWrite.spec.ts new file mode 100644 index 0000000000..e111bd74db --- /dev/null +++ b/src/core/tools/__tests__/guardedWrite.spec.ts @@ -0,0 +1,859 @@ +/** + * 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 { 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(), +})) + +vi.mock("../../../utils/versionToken", () => ({ + computeVersionToken: vi.fn(), +})) + +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn(), + resolveLockKey: vi.fn(async (p: string) => p), +})) + +vi.mock("../../../utils/fileLock", () => ({ + withFileLock: vi.fn(), +})) + +const mockedWithFileLock = vi.mocked(withFileLock) +const mockedResolveLockKey = vi.mocked(resolveLockKey) +const mockedFsAccess = vi.mocked(fs.access) +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))) + 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("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 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) + }) + }) + + 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("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.abort = true + 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.abort = true + 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() + }) +}) diff --git a/src/core/tools/__tests__/readFileTool.spec.ts b/src/core/tools/__tests__/readFileTool.spec.ts index 6c9e177d38..34d69ed4cc 100644 --- a/src/core/tools/__tests__/readFileTool.spec.ts +++ b/src/core/tools/__tests__/readFileTool.spec.ts @@ -13,10 +13,16 @@ */ import path from "path" +import type { Stats } from "fs" + +import type { LegacyReadFileParams } from "@roo-code/types" import { isBinaryFile } from "isbinaryfile" import { readFileTool, ReadFileTool } from "../ReadFileTool" +import type { Task } from "../../task/Task" +import { ObservationRegistry } from "../../task/observationRegistry" +import { computeVersionToken } from "../../../utils/versionToken" import { formatResponse } from "../../prompts/responses" import { validateImageForProcessing, @@ -136,6 +142,7 @@ interface MockTaskOptions { rooIgnoreAllowed?: boolean maxImageFileSize?: number maxTotalImageSize?: number + observationRegistry?: ObservationRegistry } function createMockTask(options: MockTaskOptions = {}) { @@ -143,6 +150,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 }, @@ -187,7 +197,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({ @@ -839,7 +860,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, @@ -1489,5 +1510,764 @@ 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") + }) + }) + + 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: 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/guardedWrite.ts b/src/core/tools/guardedWrite.ts new file mode 100644 index 0000000000..13d870308f --- /dev/null +++ b/src/core/tools/guardedWrite.ts @@ -0,0 +1,418 @@ +/** + * 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 { 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" + +/** 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, +): 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) + try { + await fs.access(absolutePath) + } 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(absolutePath, 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(absolutePath) + } + + 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, +): 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) + let currentVersion: string + try { + currentVersion = await computeVersionToken(absolutePath) + } 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(absolutePath, 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(absolutePath) + } + + 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) +} + +/** + * 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, +): 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. + + 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. + const displayPath = relPathOrAbsolute + + 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 + + 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, + ) + 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) + } else if (absent) { + // A "create" on a file that vanished after the read recreates it. + publishedToken = await createIfAbsent(absolutePath, content, displayPath, () => task.abort) + } else { + // The version recorded at read time must still match the on-disk + // token. + publishedToken = await replaceIfVersion( + absolutePath, + obs.version, + content, + displayPath, + () => task.abort, + ) + } + } + + // 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) + } + }) +} + +/** + * Reset the per-path tail-promise chains (test hook). + */ +export function resetChain(): void { + pendingChains.clear() +} diff --git a/src/eslint-suppressions.json b/src/eslint-suppressions.json index 583485c628..d607509cdc 100644 --- a/src/eslint-suppressions.json +++ b/src/eslint-suppressions.json @@ -976,7 +976,7 @@ }, "core/tools/__tests__/readFileTool.spec.ts": { "@typescript-eslint/no-explicit-any": { - "count": 98 + "count": 96 } }, "core/tools/__tests__/runSlashCommandTool.spec.ts": { @@ -1716,7 +1716,7 @@ }, "utils/safeWriteJson.ts": { "@typescript-eslint/no-explicit-any": { - "count": 4 + "count": 3 } }, "utils/tts.ts": { diff --git a/src/integrations/editor/DiffViewProvider.ts b/src/integrations/editor/DiffViewProvider.ts index bb3368f063..b29c7ecedf 100644 --- a/src/integrations/editor/DiffViewProvider.ts +++ b/src/integrations/editor/DiffViewProvider.ts @@ -18,6 +18,10 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" import { formatResponse } from "../../core/prompts/responses" import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" import { Task } from "../../core/task/Task" +import { versionTokenOfStat } from "../../utils/versionToken" +import { withFileLock } from "../../utils/fileLock" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" +import { guardedWrite, GuardRejectedError, type GuardedWriteKind } from "../../core/tools/guardedWrite" import { DecorationController } from "./DecorationController" @@ -39,6 +43,7 @@ export class DiffViewProvider { // it when re-showing the edited file afterward. private documentWasPinned = false private relPath?: string + private teardownInFlight: Promise | undefined private newContent?: string private activeDiffEditor?: vscode.TextEditor private fadedOverlayController?: DecorationController @@ -82,6 +87,23 @@ 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 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 constructor( private cwd: string, @@ -96,6 +118,13 @@ export class DiffViewProvider { const absolutePath = path.resolve(this.cwd, relPath) this.isEditing = true + // 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. const existingEditor = vscode.window.visibleTextEditors.find( @@ -120,7 +149,31 @@ 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) + } + } } else { this.originalContent = "" } @@ -132,6 +185,46 @@ export class DiffViewProvider { // Make sure the file exists before we open it. if (!fileExists) { await fs.writeFile(absolutePath, "") + // S4b follow-up (#44 / epic #1375): the empty placeholder is fully + // known (empty), but verify it with the same S2 stat-matched contract + // as the modify branch above: a stat-mismatched or non-empty read + // means another writer touched the placeholder in the window after + // open() 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) - 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 vs. the vanished file's token) fail every time, so recreating + // the file would always fail. The placeholder token is the correct + // baseline for the new file: an external change to the placeholder + // before the accept moves the on-disk token and fails the CAS. 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. + const placeholderPreStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + if (placeholderPreStats) { + const placeholderContent = await fs.readFile(absolutePath, "utf-8").catch(() => undefined) + const placeholderPostStats = await fs.stat(absolutePath, { bigint: true }).catch(() => undefined) + const placeholderToken = versionTokenOfStat(placeholderPreStats) + // Stat-matched (S2): the read is trusted only while the bracketing + // stats 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 open() + // wrote. + this.placeholderVersion = placeholderToken + const displayTask = this.taskRef.deref() + if (displayTask) { + displayTask.observationRegistry.observe(absolutePath, placeholderToken, true) + } + } + } } // If the file was already open, close it (must happen after showing the @@ -323,9 +416,81 @@ export class DiffViewProvider { } } + /** + * 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 + } async saveChanges( diagnosticsEnabled: boolean = true, writeDelayMs: number = DEFAULT_WRITE_DELAY_MS, + // Stryker disable next-line StringLiteral: an empty kind dispatches exactly + // like "update" in guardedWrite (only "edit" and "create" branch distinctly), + // so the StringLiteral mutant here is equivalent. + writeKind: GuardedWriteKind = "update", ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -339,20 +504,177 @@ 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) + } 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 ( + // Stryker disable next-line LogicalOperator: GuardRejectedError is only thrown + // by guardedWrite, and guardedWrite is reached only after the !saveTask check + // above has thrown a plain Error. So whenever this operand is evaluated saveTask + // is provably non-null: dropping it cannot change which branch is taken. + 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) + }) + } 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. + // 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. 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) { + await this.revertDocument(updatedDocument) + } + await this.closeAllDiffViews() // Read auto-close preferences from state; fall back to defaults that - // preserve the existing behavior when unset. - const saveTask = this.taskRef.deref() + // preserve the existing behavior when unset (saveTask was resolved above + // for the guarded publish). const saveState = await saveTask?.providerRef.deref()?.getState() await this.keepOrCloseEditedFile( @@ -527,55 +849,56 @@ export class DiffViewProvider { this.disposeActiveEditorListener() this.cancelDeferredScroll() - if (!fileExists) { - if (updatedDocument.isDirty) { - await updatedDocument.save() - } + await this.runTeardown(async () => { + 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) + 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() + // 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), - ) + const fullRange = new vscode.Range( + updatedDocument.positionAt(0), + updatedDocument.positionAt(updatedDocument.getText().length), + ) - edit.replace(updatedDocument.uri, fullRange, this.stripAllBOMs(this.originalContent ?? "")) + 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() + // 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() - await this.closeAllDiffViews() + await this.closeAllDiffViews() - // 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, - ) - } + // 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, + ) + } + }) // Restore any preview tabs the diff evicted, reconstructing the user's // prior not-yet-edited tab state. await this.restorePreviewTabs() @@ -618,6 +941,69 @@ 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 ( + tab.input.original.scheme === DIFF_VIEW_URI_SCHEME && + path.resolve(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 ( + typeof uri?.fsPath === "string" && + path.resolve(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): Promise { + if (this.teardownInFlight !== undefined) { + await this.teardownInFlight + return + } + const inFlight = cleanup() + this.teardownInFlight = inFlight + try { + await inFlight + } finally { + this.teardownInFlight = 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 { @@ -1108,7 +1494,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 @@ -1127,6 +1521,8 @@ export class DiffViewProvider { this.userTouchedDocument = false this.userTouchedDiffEditor = false this.snapshotPreviewTabs = [] + this.placeholderVersion = undefined + this.preOpenObservation = undefined } /** @@ -1136,6 +1532,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( @@ -1144,6 +1544,10 @@ 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, ): Promise<{ newProblemsMessage: string | undefined userEdits: string | undefined @@ -1154,9 +1558,19 @@ export class DiffViewProvider { // Get diagnostics before editing the file this.preDiagnostics = vscode.languages.getDiagnostics() - // Write the content directly to the file + // 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") + } await createDirectoriesForFile(absolutePath) - await fs.writeFile(absolutePath, content, "utf-8") + await guardedWrite(task, relPath, content, writeKind, completeOverride) // Open the document to ensure diagnostics are loaded // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory @@ -1167,13 +1581,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 aee88f4061..480dd2f9d7 100644 --- a/src/integrations/editor/__tests__/DiffViewProvider.spec.ts +++ b/src/integrations/editor/__tests__/DiffViewProvider.spec.ts @@ -5,6 +5,15 @@ import delay from "delay" import { 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 { ObservationRegistry } from "../../../core/task/observationRegistry" +import type { Task } from "../../../core/task/Task" + // Mock delay vi.mock("delay", () => ({ default: vi.fn().mockResolvedValue(undefined), @@ -15,23 +24,67 @@ 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), +})) + +// Mock safeWriteText (used by saveDirectly) +vi.mock("../../../services/file-safety/safeWriteText", () => ({ + safeWriteText: vi.fn().mockResolvedValue(undefined), + 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("/"), })) // 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, @@ -44,6 +97,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() })), @@ -149,8 +203,11 @@ describe("DiffViewProvider", () => { return mockWorkspaceEdit as any }) - // Create a mock Task instance + // Create a mock Task instance. The guarded write (S4b) consults the task's + // S2 observation registry, so the mock carries a real (in-memory) instance. mockTask = { + cwd: mockCwd, + observationRegistry: new ObservationRegistry(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -188,6 +245,15 @@ describe("DiffViewProvider", () => { addLines: vi.fn(), clear: vi.fn(), } + + // 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", () => { @@ -778,11 +844,188 @@ 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]) + }) + }) + + 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]) + }) + describe("saveDirectly method", () => { beforeEach(() => { // Mock vscode functions vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) 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 () => { @@ -791,9 +1034,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( @@ -814,14 +1057,32 @@ 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("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("should skip diagnostics when diagnosticsEnabled is false", async () => { const mockDelay = vi.mocked(delay) mockDelay.mockClear() @@ -829,9 +1090,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() @@ -858,144 +1119,1749 @@ describe("DiffViewProvider", () => { expect((diffViewProvider as any).relPath).toBe("test.ts") expect((diffViewProvider as any).newContent).toBe("new content") }) + + describe("guarded write (S4b, epic #1375)", () => { + const enoent = () => Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + + it("rejects an unobserved write to an existing file with the read-first remediation", async () => { + mockTask.observationRegistry.clear() + + 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() + }) + + it("creates an unobserved file when the target is absent", async () => { + mockTask.observationRegistry.clear() + 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("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") + + const result = diffViewProvider.saveDirectly("test.ts", "new content", true, false, 0) + + await expect(result).rejects.toThrow("Stale version") + await expect(result).rejects.toThrow("re-read the file, then retry.") + expect(safeWriteText).not.toHaveBeenCalled() + }) + + it("rejects an unobserved edit-kind write before any I/O", async () => { + mockTask.observationRegistry.clear() + + 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("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 method with diagnostic settings", () => { - beforeEach(() => { - // Setup common mocks for saveChanges tests - ;(diffViewProvider as any).relPath = "test.ts" - ;(diffViewProvider as any).newContent = "new content" - ;(diffViewProvider as any).activeDiffEditor = { + 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: { - getText: vi.fn().mockReturnValue("new content"), - isDirty: false, - save: vi.fn().mockResolvedValue(undefined), + uri: { fsPath, scheme: "file" }, + getText: vi.fn().mockReturnValue(text), + lineCount: 0, + encoding: "utf8", }, - } - ;(diffViewProvider as any).preDiagnostics = [] + 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) + }) + } - // Mock vscode functions - vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) + 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("should apply diagnostic delay when diagnosticsEnabled is true", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() - - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + 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() - const result = await diffViewProvider.saveChanges(true, 3000) + await diffViewProvider.open("mutated.ts") - // Verify delay was called with correct duration - expect(mockDelay).toHaveBeenCalledWith(3000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") + 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("should skip diagnostics when diagnosticsEnabled is false", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() - - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + 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() - const result = await diffViewProvider.saveChanges(false, 2000) + await diffViewProvider.open("gap.ts") - // Verify delay was NOT called and diagnostics were NOT checked - expect(mockDelay).not.toHaveBeenCalled() - expect(vscode.languages.getDiagnostics).not.toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") + expect(mockTask.observationRegistry.get(`${mockCwd}/gap.ts`)).toBeUndefined() }) - it("should use default values when no parameters provided", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() - - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + 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() - const result = await diffViewProvider.saveChanges() + await diffViewProvider.open("gap-create.ts") - // Verify default behavior (enabled=true, delay=2000ms) - expect(mockDelay).toHaveBeenCalledWith(1000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() - expect(result.newProblemsMessage).toBe("") + expect(mockTask.observationRegistry.get(`${mockCwd}/gap-create.ts`)).toBeUndefined() }) - it("should handle custom delay values", async () => { - const mockDelay = vi.mocked(delay) - mockDelay.mockClear() - - // Mock closeAllDiffViews - ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + it("publishes the accepted content through the guarded write (safeWriteText)", async () => { + const { safeWriteText } = await import("../../../services/file-safety/safeWriteText") - const result = await diffViewProvider.saveChanges(true, 5000) + const result = await diffViewProvider.saveChanges(false) - // Verify custom delay was used - expect(mockDelay).toHaveBeenCalledWith(5000) - expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(safeWriteText).toHaveBeenCalledWith(`${mockCwd}/test.ts`, Buffer.from("new content")) + expect(result.newProblemsMessage).toBe("") }) - }) - describe("preEditScrollLine capture and restore", () => { - it("should capture scroll line from visible editor at open() time", async () => { - const mockEditor = { + const openPreview = async () => { + // Enough of the editor double for open() to find the diff editor again. + const editor = { document: { - uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue(""), - lineCount: 0, + 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(), - visibleRanges: [{ start: { line: 42 } }], } - - vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + 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}/scroll.ts`, scheme: "file" } } as any), 0) + setTimeout(() => { + callback({ uri: { fsPath: `${mockCwd}/test.txt`, scheme: "file" } } as unknown as vscode.TextDocument) + }, 0) return { dispose: vi.fn() } }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - ;(diffViewProvider as any).editType = "modify" + // 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" + } - await diffViewProvider.open("scroll.ts") + 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) + }) - expect((diffViewProvider as any).preEditScrollLine).toBe(42) + 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("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { - const mockEditorNoRanges = { + 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}/new.ts`, scheme: "file" }, - getText: vi.fn().mockReturnValue(""), + 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(), - // No visibleRanges, so the capture in open() yields undefined. - } + } as unknown as vscode.TextEditor + diffViewProvider["activeDiffEditor"] = bomEditor - vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges as any] - vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges as any) - vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { - setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" } } as any), 0) - return { dispose: vi.fn() } + await diffViewProvider.saveChanges(false) + + expect(vi.mocked(vscode.workspace.encode)).toHaveBeenCalledWith("new content", { + encoding: "utf8bom", }) - vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) - ;(diffViewProvider as any).editType = "modify" + expect(safeWriteText).toHaveBeenCalledWith( + `${mockCwd}/test.ts`, + Buffer.concat([Buffer.from("\uFEFF"), Buffer.from("new content")]), + ) + }) - await diffViewProvider.open("new.ts") + 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") - expect((diffViewProvider as any).preEditScrollLine).toBeUndefined() + 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("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { - const mockRevealRange = vi.fn() + 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 + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-create.ts`, "") + 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") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/t3-dead-task.ts`, "") + // 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") + + expect(vi.mocked(fs.writeFile)).toHaveBeenCalledWith(`${mockCwd}/contested.ts`, "") + // 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() + }) + + 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("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") + await expect(diffViewProvider.saveChanges(false)).rejects.toThrow( + /Stale version|not read before this write/, + ) + + // 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 + 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() + expect(diffViewProvider["closeAllDiffViews"]).toHaveBeenCalled() + }) + + 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 as any).relPath = "test.ts" + ;(diffViewProvider as any).newContent = "new content" + ;(diffViewProvider as any).activeDiffEditor = { + document: { + getText: vi.fn().mockReturnValue("new content"), + isDirty: false, + save: vi.fn().mockResolvedValue(undefined), + }, + } + ;(diffViewProvider as any).preDiagnostics = [] + + // Mock vscode functions + vi.mocked(vscode.window.showTextDocument).mockResolvedValue({} as any) + 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 as any).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 as any).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 as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = await diffViewProvider.saveChanges() + + // Verify default behavior (enabled=true, delay=2000ms) + expect(mockDelay).toHaveBeenCalledWith(1000) + expect(vscode.languages.getDiagnostics).toHaveBeenCalled() + expect(result.newProblemsMessage).toBe("") + }) + + it("should handle custom delay values", async () => { + const mockDelay = vi.mocked(delay) + mockDelay.mockClear() + + // Mock closeAllDiffViews + ;(diffViewProvider as any).closeAllDiffViews = vi.fn().mockResolvedValue(undefined) + + const result = 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 = { + document: { + uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + visibleRanges: [{ start: { line: 42 } }], + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditor as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditor as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/scroll.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("scroll.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBe(42) + }) + + it("should set preEditScrollLine to undefined when the visible editor has no visibleRanges", async () => { + const mockEditorNoRanges = { + document: { + uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" }, + getText: vi.fn().mockReturnValue(""), + lineCount: 0, + }, + selection: { active: { line: 0, character: 0 }, anchor: { line: 0, character: 0 } }, + edit: vi.fn().mockResolvedValue(true), + revealRange: vi.fn(), + // No visibleRanges, so the capture in open() yields undefined. + } + + vi.mocked(vscode.window).visibleTextEditors = [mockEditorNoRanges as any] + vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockEditorNoRanges as any) + vi.mocked(vscode.workspace.onDidOpenTextDocument).mockImplementation((callback) => { + setTimeout(() => callback({ uri: { fsPath: `${mockCwd}/new.ts`, scheme: "file" } } as any), 0) + return { dispose: vi.fn() } + }) + vi.mocked(vscode.window.onDidChangeVisibleTextEditors).mockReturnValue({ dispose: vi.fn() }) + ;(diffViewProvider as any).editType = "modify" + + await diffViewProvider.open("new.ts") + + expect((diffViewProvider as any).preEditScrollLine).toBeUndefined() + }) + + it("saveChanges() calls revealRange(AtTop) when documentWasOpen and preEditScrollLine is set", async () => { + const mockRevealRange = vi.fn() const mockSavedEditor = { revealRange: mockRevealRange } vi.mocked(vscode.window.showTextDocument).mockResolvedValue(mockSavedEditor as any) @@ -1147,6 +3013,34 @@ describe("DiffViewProvider", () => { expect(vscode.window.showTextDocument).not.toHaveBeenCalled() }) + 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) + diffViewProvider["closeAllDiffViews"] = vi.fn().mockResolvedValue(undefined) + 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]) + + expect(applyEdit).toHaveBeenCalledTimes(1) + }) + it("saveChanges() keeps the file open when the user touched it", async () => { const closeFileTab = vi.fn().mockResolvedValue(undefined) vi.mocked(vscode.window.showTextDocument).mockResolvedValue({ revealRange: vi.fn() } as any) @@ -1664,6 +3558,11 @@ describe("DiffViewProvider", () => { const setupProvider = (stateOverrides: Record = {}) => { const task = { + 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(), providerRef: { deref: vi.fn().mockReturnValue({ getState: vi.fn().mockResolvedValue({ @@ -1675,6 +3574,10 @@ describe("DiffViewProvider", () => { }, } const provider = new DiffViewProvider(mockCwd, task as any) + // 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 as any).relPath = "auto-close-test.ts" ;(provider as any).newContent = "content" ;(provider as any).activeDiffEditor = buildActiveDiffEditor() diff --git a/src/integrations/misc/__tests__/indentation-reader.spec.ts b/src/integrations/misc/__tests__/indentation-reader.spec.ts index d46cb54277..e9b27e7191 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, @@ -279,11 +280,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") + // No line was returned, so nothing could have been clipped. + expect(result.hasClippedLines).toBe(false) }) it("should handle negative offset", () => { @@ -297,6 +332,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 aecabd5982..5cbd23d168 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 ──────────────────────────────────────────────────────────────── @@ -306,6 +308,7 @@ export function readWithIndentation(content: string, options: IndentationReadOpt totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -448,6 +451,7 @@ export function readWithSlice( totalLines, returnedLines: 0, wasTruncated: false, + hasClippedLines: false, } } @@ -455,6 +459,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) @@ -465,5 +474,6 @@ export function readWithSlice( totalLines, returnedLines: selectedLines.length, wasTruncated, + hasClippedLines, } } diff --git a/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts new file mode 100644 index 0000000000..81d758349e --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.integration.spec.ts @@ -0,0 +1,49 @@ +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" + +import { safeWriteText } from "../safeWriteText" + +// No fs mocks in this file: the point is to assert what a real filesystem ends up +// holding after a publish attempt, which the mocked spec cannot show. The failure is +// provoked with real filesystem semantics rather than with a stubbed call. +describe("safeWriteText against a real filesystem", () => { + let dir: string + + beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "safe-write-text-int-")) + }) + + afterEach(async () => { + await fs.rm(dir, { recursive: true, force: true }) + }) + + it("publishes the new bytes and leaves no staging or backup residue", async () => { + const targetPath = path.join(dir, "target.txt") + await fs.writeFile(targetPath, "old bytes") + + // No platform override: the real platform's own durability and ACL steps run. + // A failed icacls restore in a throwaway temp directory is reported, not thrown, + // so the publish still lands. + await safeWriteText(targetPath, "new bytes", { backup: true }) + + expect(await fs.readFile(targetPath, "utf8")).toBe("new bytes") + expect(await fs.readdir(dir)).toEqual(["target.txt"]) + }) + + it("leaves the target bytes untouched when the commit cannot replace it", async () => { + // A regular file cannot be renamed over a directory, so the backup copy and + // the commit both fail on a real filesystem with no mocking at all. + const targetPath = path.join(dir, "target-dir") + await fs.mkdir(targetPath) + const inside = path.join(targetPath, "payload.txt") + await fs.writeFile(inside, "original bytes") + + await expect(safeWriteText(targetPath, "new data", { backup: true })).rejects.toThrow() + + // The directory and its content are exactly as they were, and no backup copy + // or staging directory was left behind next to them. + expect(await fs.readFile(inside, "utf8")).toBe("original bytes") + expect(await fs.readdir(dir)).toEqual(["target-dir"]) + }) +}) 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..d3c1885c02 --- /dev/null +++ b/src/services/file-safety/__tests__/safeWriteText.spec.ts @@ -0,0 +1,1348 @@ +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 { + PostCommitDurabilityError, + resolveLockKey, + safeWriteText, + StagingPathError, + type SafeWriteTextOptions, +} 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(), +})) + +// 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(), + 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)) +} +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", () => { + 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 - and the backup copy is not + // left beside the target where no caller could find it. + expect(fs.unlink).toHaveBeenCalledWith(expect.stringContaining("safeWriteText.bak_")) + }) + }) + + // ── 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("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", () => { + it.skipIf(process.platform !== "win32")( + "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) + }) + + 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: reports through onWarning when the saved DACL cannot be restored", async () => { + const targetPath = "/tmp/test-dir/target.txt" + vi.mocked(fs.realpath).mockResolvedValue(targetPath) + vi.mocked(fsSync.openSync).mockReturnValue(1) + // The dump succeeds and the restore fails: icacls /restore commonly fails without the + // required privileges, and the committed file may then carry a different ACL. + let icaclsCalls = 0 + vi.mocked(execFile).mockImplementation((_cmd, _args, _opts, cb) => { + icaclsCalls++ + if (typeof cb === "function") { + if (icaclsCalls === 1) { + cb(null, "", "") + } else { + cb(new Error("icacls restore error"), "", "") + } + } + return fakeChild + }) + const warnings: string[] = [] + + await safeWriteText(targetPath, "data", { platform: "win32", onWarning: (m) => warnings.push(m) }) + + // The content is committed and the caller is told about the access-rights change. + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + expect(icaclsCalls).toBe(2) + expect(warnings.filter((m) => m.includes("could not be restored"))).toHaveLength(1) + }) + + // Warning delivery is advisory: it must not be able to fail the save it is reporting on. + it("win32: a throwing onWarning does not abort the write", 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 + }) + + await expect( + safeWriteText(targetPath, "data", { + platform: "win32", + onWarning: () => { + throw new Error("callback down") + }, + }), + ).resolves.toBeUndefined() + + expect(fs.rename).toHaveBeenCalledWith(expect.stringContaining(".file-safety-staging"), targetPath) + }) + + 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) + }) + + 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 read, so a single answer is enough and keeps the mock's + // return type matching fs.promises.readlink. + vi.mocked(fs.readlink).mockResolvedValue("referent.json") + + // 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")), + ) + }) + + it("terminates on a two-link cycle instead of walking forever", 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 the same link, so an unbounded walk would + // never end; the bounded walk returns the key it actually reached. + vi.mocked(fs.readlink).mockImplementation(async () => "a.json") + + await expect(resolveLockKey("/tmp/linkdir/a.json")).resolves.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("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)) + }) +}) diff --git a/src/services/file-safety/safeWriteText.ts b/src/services/file-safety/safeWriteText.ts new file mode 100644 index 0000000000..f957ef0825 --- /dev/null +++ b/src/services/file-safety/safeWriteText.ts @@ -0,0 +1,575 @@ +import * as fs from "fs/promises" +import * as fsSync from "fs" +import * as path from "path" +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. + */ +export class PostCommitDurabilityError extends Error { + readonly targetPath: string + + constructor(targetPath: string, cause: unknown) { + 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.", + { cause }, + ) + this.name = "PostCommitDurabilityError" + this.targetPath = targetPath + } +} +// -- 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) +} + +/** 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"], { 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], { 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 + }) +} +/** + * 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) + const canonicalDir = await fs.realpath(dirPath).catch(() => dirPath) + return path.join(canonicalDir, path.basename(absoluteFilePath)) +} + +/** + * 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). + * The walk is bounded so a two-link cycle terminates, and every key it returns is + * canonicalized through canonicalDirKey. + */ +export async function resolveLockKey(absoluteFilePath: string): Promise { + try { + return await canonicalDirKey(await resolvePublishTarget(absoluteFilePath)) + } catch { + // A real readlink throws for anything that is not a link, so a normal chain + // ends the walk. Two links that point at each other never would, so the + // walk is bounded and callers use the key they actually reached. + let key = absoluteFilePath + for (let depth = 0; depth < 8; depth++) { + const target = await fs.readlink(key).catch(() => undefined) + if (target === undefined) return await canonicalDirKey(key) + key = await canonicalDirKey(path.resolve(path.dirname(key), target)) + } + return await canonicalDirKey(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) + + // Ensure parent directory exists (mirrors safeWriteJson behaviour). + await fs.mkdir(dirPath, { recursive: true }) + await fs.access(dirPath) + + // 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 + 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) + if (path.dirname(supplied) !== path.resolve(dirPath)) { + 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. + tempPath = options.tempPath + } else { + stagingDir = _stagingDir(dirPath) + tempPath = _tempName(stagingDir, "safeWriteText") + } + + let backupPath: string | null = null + let releaseBackupOnSuccess = 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 (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 --------------------- + await fs.rename(tempPath, targetPath) + + // -- 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. + throw new PostCommitDurabilityError(targetPath, error) + } + } + + // -- 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(`safeWriteText: 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. + if (backupPath && releaseBackupOnSuccess) { + // 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 + // after it the published content. Either way the copy has served its purpose + // and must not be left beside the target 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__/safeWriteJson.lockKey.spec.ts b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts new file mode 100644 index 0000000000..beddaf9ea9 --- /dev/null +++ b/src/utils/__tests__/safeWriteJson.lockKey.spec.ts @@ -0,0 +1,184 @@ +// npx vitest run utils/__tests__/safeWriteJson.lockKey.spec.ts + +import * as os from "os" +import path from "path" +import type { BigIntStats } from "fs" +import * as fs from "fs/promises" +import { acquireFileLock } from "../fileLock" +import { safeWriteJson } from "../safeWriteJson" +import { resolveLockKey } from "../../services/file-safety/safeWriteText" + +vi.mock("../fileLock", () => ({ + 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() } +}) + +const mockedRealpath = vi.mocked(fs.realpath) +const mockedLstat = vi.mocked(fs.lstat) +const mockedReadlink = vi.mocked(fs.readlink) +const mockedAcquireFileLock = vi.mocked(acquireFileLock) + +const enoent = Object.assign(new Error("ENOENT: no such file or directory"), { code: "ENOENT" }) + +// Each test creates a real temp directory so the real fs calls still work. +// 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 = "" + +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(new Error("not a link")), + ) + 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) + // The two trailing lstat calls are safeWriteText's staging-path checks: the + // regular-file check on the temp file this write created, and the identity check + // that the staging path is not the target. Both run after the key was resolved + // and the lock was taken, so neither changes which lock the caller queued behind. + expect(order).toEqual(["resolve-failed", "lstat", "resolve", "resolve", "lock", "resolve", "resolve", "lstat", "lstat"]) + expect(JSON.parse(await fs.readFile(referent, "utf8"))).toEqual({ id: "task-1" }) + }) + + 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(new Error("not a link")), + ) + 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("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("does not log a cleanup error when the safety net finds the temp file already gone", async () => { + // safeWriteText removes its own temp file on failure, so the safety net in + // safeWriteJson normally finds it gone. That is the expected outcome, not a + // second failure, and it must not be logged as one. + const dir = await makeDir("cleanup-") + const target = path.join(dir, "history_item.json") + currentLink = "" + mockedRealpath.mockImplementation(async (t) => String(t)) + mockedLstat.mockImplementation(async (t) => symlinkStat(t)) + + const renameSpy = vi.spyOn(fs, "rename").mockRejectedValue(new Error("commit rename failed")) + const unlinkSpy = vi.spyOn(fs, "unlink").mockRejectedValue(enoent) + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}) + + await expect(safeWriteJson(target, { id: "task-1" })).rejects.toThrow("commit rename failed") + + // Only the original failure is reported. + expect(consoleError).toHaveBeenCalledTimes(1) + + renameSpy.mockRestore() + unlinkSpy.mockRestore() + consoleError.mockRestore() +}) diff --git a/src/utils/__tests__/safeWriteJson.test.ts b/src/utils/__tests__/safeWriteJson.test.ts index 79d08678a0..b19c7fd911 100644 --- a/src/utils/__tests__/safeWriteJson.test.ts +++ b/src/utils/__tests__/safeWriteJson.test.ts @@ -3,7 +3,8 @@ import { Writable } from "stream" import * as path from "path" import * as os from "os" -import { safeWriteJson } from "../safeWriteJson" +import { ConfinedPathEscapeError, safeWriteJson } from "../safeWriteJson" +import * as lockfile from "proper-lockfile" // Capture actual implementations before the vi.mock factory runs, // so they are never wrapped by vi.fn() — avoids infinite recursion when @@ -158,7 +159,7 @@ describe("safeWriteJson", () => { expect(content).toEqual({ initial: "content" }) }) - test("should handle failure when renaming filePath to tempBackupFilePath (filePath exists)", async () => { + test("should handle failure when the commit rename fails (filePath exists)", async () => { const initialData = { message: "Initial content, should remain" } const newData = { message: "New content, should not be written" } @@ -177,7 +178,7 @@ describe("safeWriteJson", () => { expect(content).toEqual(initialData) }) - test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup succeeded)", async () => { + test("should handle failure when renaming tempNewFilePath to filePath (filePath exists, backup copy taken)", async () => { const initialData = { message: "Initial content, should be restored" } const newData = { message: "New content" } @@ -191,14 +192,8 @@ describe("safeWriteJson", () => { vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { renameCallCount++ if (renameCallCount === 1) { - // First call: filePath -> tempBackupFilePath (should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) - } else if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) + // The commit rename is the only rename in this flow: it fails. throw new Error("Rename from temp to final failed") - } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, should succeed) - return fsPromisesActuals.rename!(oldPath, newPath) } // Default: use original implementation return fsPromisesActuals.rename!(oldPath, newPath) @@ -312,9 +307,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" } @@ -322,18 +316,23 @@ describe("safeWriteJson", () => { // fs.unlink is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn vi.mocked(fs.unlink).mockImplementation(async (filePath: any) => { - if (filePath.toString().includes(".bak_")) { + if (filePath.toString().includes("safeWriteText.bak_")) { throw new Error("Backup deletion failed") } return fsPromisesActuals.unlink!(filePath) }) + // The write must still succeed: backup cleanup is best-effort inside + // safeWriteText 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("safeWriteText.bak_"))).toBe(true) - consoleErrorSpy.mockRestore() vi.mocked(fs.unlink).mockRestore() }) @@ -345,16 +344,11 @@ describe("safeWriteJson", () => { await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) - // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn - let renameCallCount = 0 - vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { - renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (should fail) - throw new Error("Rename failed") - } - // For all other calls, use the original implementation - return fsPromisesActuals.rename!(oldPath, newPath) + // fs.rename is already vi.fn() — use vi.mocked to avoid double-wrapping via vi.spyOn. + // Once-only so the override does not leak into later tests: the commit rename is + // the only rename in this flow. + vi.mocked(fs.rename).mockImplementationOnce(async () => { + throw new Error("Rename failed") }) await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Rename failed") @@ -434,9 +428,10 @@ describe("safeWriteJson", () => { expect(vi.mocked(fs.access)).toHaveBeenCalled() }) - // Test for rollback failure scenario - test("should log error and re-throw original if rollback fails", async () => { - const initialData = { message: "Initial, should be lost if rollback fails" } + // The backup is a copy taken before the commit, so a failed commit has nothing to + // roll back: the target keeps its previous content and the copy is removed. + test("a failed commit keeps the previous content at the target and removes the backup copy", async () => { + const initialData = { message: "Initial, must survive a failed commit" } const newData = { message: "New content" } await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify(initialData)) @@ -447,24 +442,24 @@ describe("safeWriteJson", () => { let renameCallCount = 0 vi.mocked(fs.rename).mockImplementation(async (oldPath, newPath) => { renameCallCount++ - if (renameCallCount === 2) { - // Second call: tempNewFilePath -> filePath (fail) + if (renameCallCount === 1) { + // The commit rename fails; there is no rollback rename to fail. throw new Error("Primary rename failed") - } else if (renameCallCount === 3) { - // Third call: tempBackupFilePath -> filePath (rollback, also fail) - throw new Error("Rollback rename failed") } return fsPromisesActuals.rename!(oldPath, newPath) }) - // Should throw the original error, not the rollback error await expect(safeWriteJson(currentTestFilePath, newData)).rejects.toThrow("Primary rename failed") - // Verify console.error was called for the rollback failure - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining("Failed to restore backup"), - expect.objectContaining({ message: "Rollback rename failed" }), - ) + // Exactly one rename was attempted, and it was the commit. + expect(renameCallCount).toBe(1) + + // The target never left its path, so the previous content is still what a + // reader sees, and no orphaned backup copy is left behind either. + const content = await readFileContent(currentTestFilePath) + expect(content).toEqual(initialData) + const entries = await fs.readdir(tempDir) + expect(entries.some((entry) => entry.includes("safeWriteText.bak_"))).toBe(false) consoleErrorSpy.mockRestore() }) @@ -542,4 +537,279 @@ describe("safeWriteJson", () => { const content = await readFileContent(currentTestFilePath) expect(content).toEqual({ c: 3 }) }) + + // The commit rename targets the symlink referent. The staged temp file must + // therefore be created beside the RESOLVED target — staging beside the link + // would make the commit rename fail with EXDEV when the referent is on + // another filesystem. (Real symlinks are unavailable in this CI lane, so the + // resolution is simulated by mocking fs.realpath the same way.) + test("stages the temp file beside the symlink referent and commits onto it", async () => { + const referentDir = path.join(tempDir, "referent") + const linkDir = path.join(tempDir, "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, "test-file.json") + const referentPath = path.join(referentDir, "test-file.json") + // Seed the RESOLVED referent with real content (via the actual fs) so the + // write exercises replacement of an EXISTING referent: the lock, the + // backup, and the commit all target the resolved referent. + await fsPromisesActuals.writeFile!(referentPath, JSON.stringify({ seed: true })) + + // 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. + vi.spyOn(fs, "realpath").mockImplementation(async (target) => + target === callerPath ? referentPath : String(target), + ) + + await safeWriteJson(callerPath, { after: true }) + + // the temp file was created next to the resolved referent, NOT beside the link + const tempPaths = vi.mocked(fsSyncActual.createWriteStream).mock.calls.map((call) => String(call[0])) + expect(tempPaths.some((p) => p.startsWith(referentDir + path.sep) && p.includes(".new_"))).toBe(true) + expect(tempPaths.some((p) => p.startsWith(linkDir + path.sep))).toBe(false) + + // the content was committed onto the referent + expect(await readFileContent(referentPath)).toEqual({ after: true }) + }) + + // 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, the same way as the staging test above.) + 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 }) + expect(await readFileContent(referentPath)).toEqual({ seed: 1, added: true }) + // 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 console spy changes later tests, and vi.unmock + // alone does not reset a module that already imported the mock. + realpathSpy.mockRestore() + vi.unmock("proper-lockfile") + vi.resetModules() + consoleErrorSpy.mockRestore() + } + }) + + // CWE-732 regression: safeWriteJson stages the temp itself and passes it + // via tempPath, so safeWriteText must apply the existing target's mode to + // the staged temp before the atomic rename — otherwise a 0o600 target is + // published as 0o644. POSIX-only assertion (Windows ignores POSIX modes). + 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.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")( + "confines a write whose symlink referent 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: a link that stays + // inside the project still publishes to its referent. + await safeWriteJson(alias, { mcpServers: { local: { url: "http://localhost" } } }, { confineTo: projectDir }) + + expect(JSON.parse(await fsSyncActual.promises.readFile(referent, "utf8"))).toEqual({ + mcpServers: { local: { url: "http://localhost" } }, + }) + }, + ) + + 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: {} }) + }, + ) + + test.skipIf(process.platform === "win32")( + "preserves a restrictive 0o600 target mode through the atomic publish", + async () => { + await fsPromisesActuals.writeFile!(currentTestFilePath, JSON.stringify({ before: true })) + fsSyncActual.chmodSync(currentTestFilePath, 0o600) + + await safeWriteJson(currentTestFilePath, { after: true }) + + expect(fsSyncActual.statSync(currentTestFilePath).mode & 0o777).toBe(0o600) + expect(await readFileContent(currentTestFilePath)).toEqual({ after: true }) + }, + ) + + // Ordering matters for the security guarantee: proper-lockfile creates + // ${lockKey}.lock beside the lock key, and the key is the symlink referent. If + // confinement were checked only after the lock, an out-of-scope target would first + // create a lock directory outside the scope. A lock mock that throws if reached + // proves the check runs first. Written without symlinks so it runs on every lane. + test("rejects an out-of-scope target before the advisory lock is taken", async () => { + vi.resetModules() + const projectDir = path.join(tempDir, "order-project-plain") + await fs.mkdir(projectDir) + const outside = path.join(tempDir, "order-outside-plain.json") + + const realLockfile = await vi.importActual("proper-lockfile") + const lockMockFn = vi.fn(async () => { + throw new Error("lock taken for an out-of-scope target (test)") + }) + vi.doMock("proper-lockfile", () => ({ ...realLockfile, lock: lockMockFn })) + const { safeWriteJson: lockedSafeWriteJson } = await import("../safeWriteJson") + + try { + await expect(lockedSafeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + /resolves outside the confined directory/, + ) + expect(lockMockFn).not.toHaveBeenCalled() + const entries = await fs.readdir(tempDir) + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + } finally { + vi.doUnmock("proper-lockfile") + vi.resetModules() + } + }) + + test("does not create the parent directory of an out-of-scope confined target", async () => { + const projectDir = path.join(tempDir, "scope-dir-project") + await fs.mkdir(projectDir) + // The parent does not exist yet: the mkdir in safeWriteJson would create it - + // a filesystem change outside confineTo - before the confinement check rejected + // the write. + const outside = path.join(tempDir, "scope-missing-parent", "nested.json") + + await expect(safeWriteJson(outside, { mcpServers: {} }, { confineTo: projectDir })).rejects.toThrow( + /resolves outside the confined directory/, + ) + + const entries = await fs.readdir(tempDir) + expect(entries).not.toContain("scope-missing-parent") + expect(entries.filter((entry) => entry.endsWith(".lock") || entry.includes(".new_"))).toEqual([]) + }) }) diff --git a/src/utils/safeWriteJson.ts b/src/utils/safeWriteJson.ts index 7da68b2a7a..d5e559641b 100644 --- a/src/utils/safeWriteJson.ts +++ b/src/utils/safeWriteJson.ts @@ -4,6 +4,12 @@ import * as path from "path" import { JsonStreamStringify } from "json-stream-stringify" import { acquireFileLock } from "./fileLock" +import { + resolveLockKey, + resolvePublishTarget, + safeWriteText, + type SafeWriteTextOptions, +} from "../services/file-safety/safeWriteText" /** * Options for safeWriteJson function @@ -26,13 +32,103 @@ export interface SafeWriteJsonOptions { * cannot be parsed. */ 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 +} + +/** + * 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" + } +} + +/** + * Canonicalize the directory a write is confined to. The publish target is fully + * 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 + } + } + } + } +} + +/** + * Reject a candidate publish path that escapes the caller's confined scope. + * Shared by the pre-lock check and the in-lock check so both canonicalize the + * same way: the candidate is resolved through symlinks and compared against the + * resolved scope root. + */ +function _assertWithinScope(requestedPath: string, candidatePath: string, scopeRoot: string): void { + const relative = path.relative(scopeRoot, candidatePath) + if ( + relative === "" || + relative === ".." || + relative.startsWith(".." + path.sep) || + path.isAbsolute(relative) + ) { + throw new ConfinedPathEscapeError(requestedPath, candidatePath, scopeRoot) + } +} + +function _scopeErrorCode(error: unknown): string | undefined { + return typeof error === "object" && error !== null && "code" in error + ? (error as { code?: string }).code + : undefined } /** * Safely writes JSON data to a file. * - Creates parent directories if they don't exist * - Uses 'proper-lockfile' for inter-process advisory locking to prevent concurrent writes to the same path. - * - Writes to a temporary file first. + * - Writes to a temporary file first via JsonStreamStringify streaming. * - If the target file exists, it's backed up before being replaced. * - Attempts to roll back and clean up in case of errors. * - Supports pretty-printing with indentation while maintaining streaming efficiency. @@ -42,7 +138,6 @@ export interface SafeWriteJsonOptions { * @param {SafeWriteJsonOptions} options - Optional configuration for JSON formatting. * @returns {Promise} */ - async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJsonOptions): Promise { const absoluteFilePath = path.resolve(filePath) let releaseLock = async () => {} // Initialized to a no-op @@ -51,38 +146,71 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso 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 + + // 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 be computable even + // when the link does not resolve yet - a create writes through a dangling link, + // and a peer writer can be caught between creating its staging file and the + // commit rename - so the walk tolerates a dangling link instead of rejecting it + // here. + const lockKey = await resolveLockKey(absoluteFilePath) + + // Confinement, if the caller declared a scope, is checked before ANY filesystem + // side effect of this call: the directory creation below would otherwise create a + // parent directory outside confineTo for an out-of-scope target, and + // proper-lockfile would create ${lockKey}.lock beside the lock key (a + // repository-planted link out of the scope would also make an unwritable referent + // directory surface a lock-acquisition error after retries instead of + // ConfinedPathEscapeError). Repeated on the resolved publish target inside the + // lock, since a peer writer may move the referent in between. + if (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(lockKey), 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: any) { 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) + // 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 paths of temp files if they are created. + // Variables to hold the actual path of the temp file if it is created. let actualTempNewFilePath: string | null = null - let actualTempBackupFilePath: string | null = null try { + // 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) + + // 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 (options?.confineTo) { + const scopeRoot = await _resolveScopeRoot(options.confineTo) + _assertWithinScope(absoluteFilePath, await _resolveScopeRoot(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 = error && typeof error === "object" && "code" in error ? (error as { code: string }).code : undefined @@ -93,111 +221,74 @@ async function safeWriteJson(filePath: string, data: any, options?: SafeWriteJso data = options.merge(existing, data) } - // Step 1: Write data to a new temporary file. + // Step 1: Write data to a new temporary file via JSON streaming. + // Stage it beside the *resolved* target (the symlink referent when the path is + // a symlink; resolvedTargetPath above): safeWriteText commits by renaming + // onto that referent, and a rename across filesystems would fail with EXDEV. actualTempNewFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.new_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, + path.dirname(resolvedTargetPath), + ".new_" + Date.now() + "_" + Math.random().toString(36).substring(2) + ".tmp", ) await _streamDataToFile(actualTempNewFilePath, data, options?.prettyPrint) - // Step 2: Check if the target file exists. If so, rename it to a backup path. - try { - // Check for target file existence - await fs.access(absoluteFilePath) - // Target exists, create a backup path and rename. - actualTempBackupFilePath = path.join( - path.dirname(absoluteFilePath), - `.${path.basename(absoluteFilePath)}.bak_${Date.now()}_${Math.random().toString(36).substring(2)}.tmp`, - ) - await fs.rename(absoluteFilePath, actualTempBackupFilePath) - } catch (accessError: any) { - // Explicitly type accessError - if (accessError.code !== "ENOENT") { - // An error other than "file not found" occurred during access check. - throw accessError - } - // Target file does not exist, so no backup is made. actualTempBackupFilePath remains null. + // Step 2: Delegate backup + commit to safeWriteText with the pre-written + // temp path. backup:true keeps the old safeWriteJson semantics: a COPY of the + // target is taken before the commit rename and the target itself is never + // moved, which also keeps it in place until safeWriteText captures its Windows + // DACL (safeWriteText dumps the DACL before taking the backup copy and + // restores it onto the directory after the commit rename). + const textOptions: SafeWriteTextOptions = { + tempPath: actualTempNewFilePath, + backup: true, } - // Step 3: Rename the new temporary file to the target file path. - // This is the main "commit" step. - await fs.rename(actualTempNewFilePath, absoluteFilePath) + await safeWriteText(resolvedTargetPath, "", textOptions) - // If we reach here, the new file is successfully in place. - // The original actualTempNewFilePath is now the main file, so we shouldn't try to clean it up as "temp". - // Mark as "used" or "committed" + // If we reach here, the new file is successfully in place and any + // backup has already been handled by safeWriteText. actualTempNewFilePath = null - - // Step 4: If a backup was created, attempt to delete it. - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - // Mark backup as handled - actualTempBackupFilePath = null - } catch (unlinkBackupError) { - // Log this error, but do not re-throw. The main operation was successful. - // actualTempBackupFilePath remains set, indicating an orphaned backup. - console.error( - `Successfully wrote ${absoluteFilePath}, but failed to clean up backup ${actualTempBackupFilePath}:`, - unlinkBackupError, - ) - } - } } catch (originalError) { - console.error(`Operation failed for ${absoluteFilePath}: [Original Error Caught]`, originalError) + console.error( + `Operation failed for ${resolvedTargetPath ?? absoluteFilePath}: [Original Error Caught]`, + originalError, + ) const newFileToCleanupWithinCatch = actualTempNewFilePath - const backupFileToRollbackOrCleanupWithinCatch = actualTempBackupFilePath - // Attempt rollback if a backup was made - if (backupFileToRollbackOrCleanupWithinCatch) { - try { - await fs.rename(backupFileToRollbackOrCleanupWithinCatch, absoluteFilePath) - // Mark as handled, prevent later unlink of this path - actualTempBackupFilePath = null - } catch (rollbackError) { - // actualTempBackupFilePath (outer scope) remains pointing to backupFileToRollbackOrCleanupWithinCatch - console.error( - `[Catch] Failed to restore backup ${backupFileToRollbackOrCleanupWithinCatch} to ${absoluteFilePath}:`, - rollbackError, - ) - } - } - - // Cleanup the .new file if it exists + // A failed safeWriteText left the target alone: the commit rename is its last + // step, so the target still holds the pre-write bytes, and the backup copy it + // took is removed by safeWriteText itself. Clean up the .new file if it still + // exists (safeWriteText also cleans up its tempPath on failure; this is a + // safety net in case its cleanup missed it). if (newFileToCleanupWithinCatch) { try { await fs.unlink(newFileToCleanupWithinCatch) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, - cleanupError, - ) + } catch (cleanupError: unknown) { + // The expected case: safeWriteText already removed its own temp file, so a + // missing file here is not a cleanup failure worth logging. Returning would + // also swallow the original error the caller needs. + const isAbsent = + typeof cleanupError === "object" && + cleanupError !== null && + "code" in cleanupError && + cleanupError.code === "ENOENT" + if (!isAbsent) { + console.error( + `[Catch] Failed to clean up temporary new file ${newFileToCleanupWithinCatch}:`, + cleanupError, + ) + } } } - // Cleanup the .bak file if it still needs to be (i.e., wasn't successfully restored) - if (actualTempBackupFilePath) { - try { - await fs.unlink(actualTempBackupFilePath) - } catch (cleanupError) { - console.error( - `[Catch] Failed to clean up temporary backup file ${actualTempBackupFilePath}:`, - cleanupError, - ) - } - } throw originalError // This MUST be the error that rejects the promise. } finally { // Release the lock in the main finally block. try { - // releaseLock will be the actual unlock function if lock was acquired, - // or the initial no-op if acquisition failed. await releaseLock() } catch (unlockError) { - // Do not re-throw here, as the originalError from the try/catch (if any) is more important. - console.error(`Failed to release lock for ${absoluteFilePath}:`, unlockError) + console.error(`Failed to release lock for ${resolvedTargetPath ?? absoluteFilePath}:`, unlockError) } } }