Repository navigation
feat(file-safety): guarded text publish module + specs (A4, #1375) #1395
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a37dd24
7fd49bc
744f3a3
8f9cf8b
14d26dc
df2694b
db3cc26
34b37ae
d3aec7c
8b838f3
e7843fb
8e37bc1
99fee2c
febe20d
e367bdb
5ce4613
964ec83
ed72010
1a0430c
7470a19
157946f
41c1de3
f65e913
843176e
4b2193c
4d45c87
aa85a3d
4f0a85c
fa740b0
823a0e7
568f981
6df83d8
80de8fb
de893fa
110f944
56dad4d
4468ee7
65e6dc9
1a2c41b
9159a87
30cefbb
64923c6
73a842a
1476e58
e25d642
796e412
d10554d
b98c07b
8c78c20
53df674
d127f2d
0db5cce
9133789
9601933
8367f1f
97829c8
e81a0f0
3374a2c
a3cecca
a46816a
2150061
69d2707
7b415a9
1368529
1ee8226
a25278a
573de63
1f8ae7f
89384b3
39d4b95
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| import * as vscode from "vscode" | ||
| import * as path from "path" | ||
| import * as fs from "fs/promises" | ||
| import { constants as fsConstants } from "fs" | ||
| import * as diff from "diff" | ||
| import stripBom from "strip-bom" | ||
| import delay from "delay" | ||
|
|
@@ -18,6 +19,11 @@ import { arePathsEqual, getReadablePath } from "../../utils/path" | |
| import { formatResponse } from "../../core/prompts/responses" | ||
| import { diagnosticsToProblemsString, getNewDiagnostics } from "../diagnostics" | ||
| import { Task } from "../../core/task/Task" | ||
| import { | ||
| assertPublishWithinAuthorizedRoot, | ||
| isPublishCommittedError, | ||
| safeWriteText, | ||
| } from "../../services/file-safety/safeWriteText" | ||
|
|
||
| import { DecorationController } from "./DecorationController" | ||
|
|
||
|
|
@@ -1155,9 +1161,44 @@ export class DiffViewProvider { | |
| // Get diagnostics before editing the file | ||
| this.preDiagnostics = vscode.languages.getDiagnostics() | ||
|
|
||
| // Reject an escape through an intermediate symlink before anything is | ||
| // created on disk: createDirectoriesForFile follows symlinks, so an | ||
| // approved inside path whose intermediate link points outside the | ||
| // workspace would otherwise create the outside directory before | ||
| // safeWriteText's authorizedRoot check rejected the publish. The same | ||
| // canonical check runs inside safeWriteText; running it here first keeps | ||
| // the allowlist decision free of filesystem side effects. | ||
| await assertPublishWithinAuthorizedRoot(this.cwd, absolutePath, absolutePath) | ||
|
|
||
| // Write the content directly to the file | ||
| await createDirectoriesForFile(absolutePath) | ||
| await fs.writeFile(absolutePath, content, "utf-8") | ||
| // safeWriteText publishes with rename, which only needs write permission on the | ||
| // DIRECTORY. The previous fs.writeFile required it on the file itself, so a target | ||
| // the user marked read-only used to fail with EACCES; keep that contract instead of | ||
| // silently renaming over it. A not-yet-existing target stays creatable. | ||
| try { | ||
| await fs.access(absolutePath, fsConstants.W_OK) | ||
| } catch (error: unknown) { | ||
| const code = | ||
| typeof error === "object" && error !== null && "code" in error | ||
| ? (error as { code?: string }).code | ||
| : undefined | ||
| if (code !== "ENOENT") throw error | ||
| } | ||
| try { | ||
| await safeWriteText(absolutePath, content, { authorizedRoot: this.cwd }) | ||
| } catch (error) { | ||
| // Both errors are raised after the atomic rename committed the content: they | ||
| // describe a durability or access-rights condition the tool layer cannot fix by | ||
| // writing again. Treat the save as completed so the tool's bookkeeping matches | ||
| // the disk (and content already on disk is not retried), and surface the | ||
| // condition as a warning instead. | ||
| if (isPublishCommittedError(error)) { | ||
| console.warn(`saveDirectly: content committed with a warning: ${error.message}`) | ||
|
Comment on lines
+1196
to
+1197
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| } else { | ||
| throw error | ||
| } | ||
| } | ||
|
|
||
| // Open the document to ensure diagnostics are loaded | ||
| // When openFile is false (PREVENT_FOCUS_DISRUPTION enabled), we only open in memory | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import fsSync from "node:fs" | ||
| import os from "node:os" | ||
| import path from "node:path" | ||
|
|
||
| import { safeWriteText } from "../safeWriteText" | ||
|
|
||
| /** | ||
| * Every other suite in this directory mocks fs/promises and fs, so nothing there proves the real | ||
| * open/write/fsync/rename sequence produces a file on disk. This suite uses a real temporary | ||
| * directory and no mocks at all: it asserts the outcome (bytes on disk) and the absence of residue | ||
| * (staging directory, temp file, backup). | ||
| */ | ||
| describe("safeWriteText against a real filesystem", () => { | ||
| let dir: string | ||
|
|
||
| beforeEach(() => { | ||
| dir = fsSync.mkdtempSync(path.join(os.tmpdir(), "safe-write-it-")) | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| fsSync.rmSync(dir, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| function _leftovers(): string[] { | ||
| return ( | ||
| fsSync | ||
| .readdirSync(dir) | ||
| // The Windows DACL dump is written next to the target and only ever shows up on the | ||
| // windows-latest run, so a filter that misses it lets a leaked dump pass a suite | ||
| // whose claim is the absence of residue. | ||
| .filter(function (name) { | ||
| return ( | ||
| name.includes(".file-safety-staging") || | ||
| name.includes(".new_") || | ||
| name.includes(".bak") || | ||
| name.includes(".acl.tmp") | ||
| ) | ||
| }) | ||
| ) | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Resolved
|
||
|
|
||
| it("creates a new file with the requested bytes and leaves no residue", async () => { | ||
| const target = path.join(dir, "brand-new.txt") | ||
|
|
||
| await safeWriteText(target, "first content") | ||
|
|
||
| expect(fsSync.readFileSync(target, "utf8")).toBe("first content") | ||
| expect(_leftovers()).toEqual([]) | ||
| }) | ||
|
|
||
| it("replaces an existing file and leaves no residue", async () => { | ||
| const target = path.join(dir, "existing.txt") | ||
| fsSync.writeFileSync(target, "old") | ||
|
|
||
| await safeWriteText(target, "replacement") | ||
|
|
||
| expect(fsSync.readFileSync(target, "utf8")).toBe("replacement") | ||
| expect(_leftovers()).toEqual([]) | ||
| }) | ||
| }) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 21041
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 48922
Web query:
Node.js v22 fs.access Windows W_OK ACL does not check access control list documentationResult:
Web query:
Microsoft Windows rename replace file permissions DELETE FILE_DELETE_CHILD directory deny FILE_WRITE_DATA fileResult:
Web query:
Node.js v22.23.4 bundled libuv Windows fs rename MoveFileExW MOVEFILE_REPLACE_EXISTING sourceResult:
Web query:
Microsoft icacls save DACL READ_CONTROL permission security descriptor required fileResult:
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 20220
Web query:
site:raw.githubusercontent.com/nodejs/node/v22.23.4/deps/uv/src/win/fs.c "fs__rename" "MoveFileExW"Result:
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 6512
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 9667
Check the target’s Windows write authorization before publishing.
When the target’s DACL denies file writes but permits replacement,
fs.access(..., W_OK)can succeed and the rename can overwrite the file. The previousfs.writeFilerejected that write. Check write authorization without truncating the target before publishing, and add a Windows DACL regression test.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not changed in this push - this row needs a decision I should not make alone, so I am asking rather than editing production code on a guess.
What I verified: the probe at
DiffViewProvider.ts:1179-1187isfs.access(absolutePath, fsConstants.W_OK), carried over from the oldfs.writeFilepath, andDiffViewProvider.spec.ts:842-868already pins that it is handed the absolute target with the write bit and that it runs beforesafeWriteText. Your point is a different one and it stands: for a rename-based publish, a DACL that denies file writes but permits directory replacement can letaccess(..., W_OK)pass and the rename then overwrite, which the truncate-and-write path rejected.Why it is not in this commit: the fix is production code in
src/integrations/editor/DiffViewProvider.ts(blobf5ef407514f9at 39d4b95, this PR), and the candidate shapes do not promise the same thing - queryicaclsfor the write ACE, or stage-and-verify, or drop the pre-publish shortcut and let the publish report the failure. Budget is not the reason: Measured here: the gate's ownselectFromGitagainst base 73931fe returns 359 counted executable lines of the 500 cap and the same 128 selectors (selectorsHash bd67c22397aded1e) as the previous head, so the mutation-diff inputs are identical. There are 141 counted lines of headroom.Named deferral: @edelauna (whose CHANGES_REQUESTED review 5646298552 has stood on this file family since 2026-10-26) to say which check
saveDirectlyshould own. With that answer the change is that blob plus arunIf(process.platform === "win32")regression case; I have not quoted a counted-line cost for it because none of that code exists yet.