Repository navigation
feat(tools): guarded write CAS core with per-path FIFO chain (S4a, #1375) #1405
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
3dd8700
ba1332e
6813013
588b95f
7a25fc0
56ce4bf
be894d9
58bb5ce
bb93d62
4d4d511
1ce0421
968078d
a2c364d
9f8a857
cb72ac7
8cb5920
2810c8f
006389d
2dd792c
51f0cea
8e5061e
bacb9d3
fc94f62
171a26e
17c736e
f1b2ae4
17c53e0
509208e
df0ce3a
55d69c1
a37f284
5e66c2f
ca77e38
a54e21a
1d205cd
777df3c
3ee59e5
e56d189
ea730f0
97f2f4d
2439c80
95cb6df
6b6f97c
e3799c3
f9a7746
3aa08be
9d7ae92
34ac5ff
50e5ac6
7878fed
348112b
4133527
a56c410
37cd82d
462874b
6c9236c
7e46bd2
4517148
1f9c8f7
c54fb6d
2760614
3d5f6f9
1786825
aa44471
65f8658
90bbdad
7ea7080
94dc7c4
8186900
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 |
|---|---|---|
|
|
@@ -24,6 +24,7 @@ import { safeWriteJson } from "../../../utils/safeWriteJson" | |
|
|
||
| import type { Mock } from "vitest" | ||
| import { providerIdentifiers, retiredProviderIdentifiers } from "@roo-code/types/provider-identifiers" | ||
| import { t } from "../../../i18n" | ||
|
|
||
| function createDeferred<T>() { | ||
| let resolve!: (value: T) => void | ||
|
|
@@ -1616,10 +1617,14 @@ describe("importExport", () => { | |
| expect(mockContextProxy.export).toHaveBeenCalled() | ||
| expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true }) | ||
|
|
||
| expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }) | ||
| expect(safeWriteJson).toHaveBeenCalledWith( | ||
| "/mock/path/zoo-code-settings.json", | ||
| { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }, | ||
| { refuseSymlinkTarget: true }, | ||
| ) | ||
| }) | ||
|
|
||
| it("should include globalSettings when allowedMaxRequests is null", async () => { | ||
|
|
@@ -1648,10 +1653,14 @@ describe("importExport", () => { | |
| contextProxy: mockContextProxy, | ||
| }) | ||
|
|
||
| expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }) | ||
| expect(safeWriteJson).toHaveBeenCalledWith( | ||
| "/mock/path/zoo-code-settings.json", | ||
| { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }, | ||
| { refuseSymlinkTarget: true }, | ||
| ) | ||
| }) | ||
|
|
||
| it("should handle errors during the export process", async () => { | ||
|
|
@@ -1678,10 +1687,52 @@ describe("importExport", () => { | |
| expect(mockProviderSettingsManager.export).toHaveBeenCalled() | ||
| expect(mockContextProxy.export).toHaveBeenCalled() | ||
| expect(fs.mkdir).toHaveBeenCalledWith("/mock/path", { recursive: true }) | ||
| expect(safeWriteJson).toHaveBeenCalled() // safeWriteJson is called, but it will throw | ||
| // The error is caught and the function exits silently. | ||
| // Optionally, ensure no error message was shown if that's part of "silent" | ||
| // expect(vscode.window.showErrorMessage).not.toHaveBeenCalled(); | ||
| }) | ||
|
|
||
| it("notifies the user when the export write is refused, and still swallows the failure", async () => { | ||
| ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ | ||
| fsPath: "/mock/path/zoo-code-settings.json", | ||
| }) | ||
|
|
||
| mockProviderSettingsManager.export.mockResolvedValue({ | ||
| currentApiConfigName: "test", | ||
| apiConfigs: { test: { apiProvider: providerIdentifiers.openai, id: "test-id" } }, | ||
| migrations: { rateLimitSecondsMigrated: false }, | ||
| }) | ||
| mockContextProxy.export.mockResolvedValue({ mode: "code" }) | ||
|
|
||
| // The refusal this unit introduced: refuseSymlinkTarget is set at the call above, so a | ||
| // link at the path the user picked makes the writer reject instead of publishing. | ||
| const refusal = new Error( | ||
| "safeWriteJson: refusing to write through the symlink now at /mock/path/zoo-code-settings.json (before publication); the payload would land at /elsewhere/settings.json", | ||
| ) | ||
| ;(safeWriteJson as Mock).mockRejectedValueOnce(refusal) | ||
|
|
||
| const showErrorMessageSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined) | ||
| const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) | ||
|
|
||
| // The contract the caller relies on: webviewMessageHandler awaits exportSettings without a | ||
| // try, so a refusal must not become an unhandled rejection. | ||
| await expect( | ||
| exportSettings({ | ||
| providerSettingsManager: mockProviderSettingsManager, | ||
| contextProxy: mockContextProxy, | ||
| }), | ||
| ).resolves.toBeUndefined() | ||
|
|
||
| // The user learns the file did not land, through the same channel and the same key shape the | ||
| // import route uses. Expected is computed with the same t() call the module makes, so the | ||
| // assertion holds whether this lane resolves t to the string or to the key. | ||
| expect(showErrorMessageSpy).toHaveBeenCalledTimes(1) | ||
| expect(showErrorMessageSpy).toHaveBeenCalledWith( | ||
| t("common:errors.settings_export_failed", { error: refusal.message }), | ||
| ) | ||
| expect(showErrorMessageSpy).toHaveBeenCalledWith(expect.stringContaining("errors.settings_export_failed")) | ||
|
Comment on lines
+1727
to
+1730
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.
|
||
| expect(showErrorMessageSpy).not.toHaveBeenCalledWith(expect.stringContaining("settings_import_failed")) | ||
| expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to export settings:", refusal) | ||
|
|
||
| showErrorMessageSpy.mockRestore() | ||
| consoleErrorSpy.mockRestore() | ||
| }) | ||
|
|
||
| it("should handle errors during directory creation", async () => { | ||
|
|
@@ -1771,10 +1822,14 @@ describe("importExport", () => { | |
| contextProxy: mockContextProxy, | ||
| }) | ||
|
|
||
| expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }) | ||
| expect(safeWriteJson).toHaveBeenCalledWith( | ||
| "/mock/path/zoo-code-settings.json", | ||
| { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }, | ||
| { refuseSymlinkTarget: true }, | ||
| ) | ||
| }) | ||
|
|
||
| it("should export model dimension for OpenAI Compatible provider", async () => { | ||
|
|
@@ -1924,10 +1979,14 @@ describe("importExport", () => { | |
| }) | ||
|
|
||
| // Should not throw an error and should preserve original settings | ||
| expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, // Should remain unchanged | ||
| }) | ||
| expect(safeWriteJson).toHaveBeenCalledWith( | ||
| "/mock/path/zoo-code-settings.json", | ||
| { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, // Should remain unchanged | ||
| }, | ||
| { refuseSymlinkTarget: true }, | ||
| ) | ||
| }) | ||
|
|
||
| it("should maintain backward compatibility with existing exports", async () => { | ||
|
|
@@ -1967,10 +2026,14 @@ describe("importExport", () => { | |
| }) | ||
|
|
||
| // Should not modify settings for non-openai-compatible providers | ||
| expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, // Should remain unchanged | ||
| }) | ||
| expect(safeWriteJson).toHaveBeenCalledWith( | ||
| "/mock/path/zoo-code-settings.json", | ||
| { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, // Should remain unchanged | ||
| }, | ||
| { refuseSymlinkTarget: true }, | ||
| ) | ||
| }) | ||
|
|
||
| it("should handle missing current provider gracefully", async () => { | ||
|
|
@@ -2012,10 +2075,14 @@ describe("importExport", () => { | |
| }) | ||
|
|
||
| // Should not throw an error and should preserve original settings | ||
| expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/zoo-code-settings.json", { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, // Should remain unchanged | ||
| }) | ||
| expect(safeWriteJson).toHaveBeenCalledWith( | ||
| "/mock/path/zoo-code-settings.json", | ||
| { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, // Should remain unchanged | ||
| }, | ||
| { refuseSymlinkTarget: true }, | ||
| ) | ||
| }) | ||
| }) | ||
|
|
||
|
|
@@ -2249,10 +2316,14 @@ describe("importExport", () => { | |
| }) | ||
|
|
||
| // Step 4: Verify the exported data includes the model dimension | ||
| expect(safeWriteJson).toHaveBeenCalledWith("/mock/path/test-settings.json", { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }) | ||
| expect(safeWriteJson).toHaveBeenCalledWith( | ||
| "/mock/path/test-settings.json", | ||
| { | ||
| providerProfiles: mockProviderProfiles, | ||
| globalSettings: mockGlobalSettings, | ||
| }, | ||
| { refuseSymlinkTarget: true }, | ||
| ) | ||
|
|
||
| // Step 5: Get the exported data for import test | ||
| const exportedData = (safeWriteJson as Mock).mock.calls[0][1] | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -346,10 +346,18 @@ export const exportSettings = async ({ providerSettingsManager, contextProxy }: | |
|
|
||
| const dirname = path.dirname(uri.fsPath) | ||
| await fs.mkdir(dirname, { recursive: true }) | ||
| await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings }) | ||
| await safeWriteJson(uri.fsPath, { providerProfiles, globalSettings }, { refuseSymlinkTarget: true }) | ||
| } catch (e) { | ||
| console.error("Failed to export settings:", e) | ||
| // Don't re-throw - the UI will handle showing error messages | ||
| // Don't re-throw - this route has no result object for the caller to report, so the | ||
| // notification happens here, the way the import route notifies through | ||
| // settings_import_failed below. A refusal to publish (a link at the chosen path the user | ||
| // never picked) is otherwise invisible: the dialog closes, the log line is not user-facing, | ||
| // and the file the user asked for does not exist. The writer's own message names the path it | ||
| // refused and the destination it would have written through, so the reason travels with the | ||
| // message. | ||
| const reason = e instanceof Error ? e.message : String(e) | ||
| await vscode.window.showErrorMessage(t("common:errors.settings_export_failed", { error: reason })) | ||
|
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.
|
||
| } | ||
| } | ||
|
|
||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
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: 15943
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 34829
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 31557
Script executed:
Repository: Zoo-Code-Org/Zoo-Code
Length of output: 7710
Test symlink refusal through
exportSettings.The export tests mock
safeWriteJson, so they cannot verify refusal through the export entry point. Add a test with the real writer for a symlinked destination and an ordinary destination. The writer tests separately cover refusal with a simulated symlink.As per path instructions: “For guards and validators, require tests for both the accept path and the reject path through every public entry point.”
Source: Path instructions
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.
Cited at 8186900, and your premise is correct on the facts I measured.
src/core/config/tests/importExport.spec.ts:83 mocks the writer wholesale (vi.mock("../../../utils/safeWriteJson")), so this row proves only that a rejection from that seam is surfaced: it asserts the swallowed result, the notification, the interpolation input, and the console.error argument. The refusal itself is proved in src/utils/tests/safeWriteJson.test.ts - the refuseSymlinkTarget row at :1175-1188 asserts the rejection with /refusing to write through the symlink/, and the real-link coverage that lane can build is the junction/directory-symlink row at :1108-1119 (fs.symlink with "junction" on win32, "dir" elsewhere), while :927, :957 and :1173 record that real symlinks are unavailable in the CI lane, which is why the staging and lock rows simulate the alias by mocking fs.realpath.
So the uncovered composition is exactly what you name: the export entry point driven through the real writer against a link, with src/core/config/importExport.ts:349 as the call that sets refuseSymlinkTarget. That composition can live in this same lane - the writer's own seam is fs.realpath, so a row can leave safeWriteJson real and stub realpath over a temp directory instead of stubbing the writer - and it is test-only, which matters here because scripts/stryker-diff.mjs:203-219 returns undefined for /tests/ and *.spec.ts, so test lines never enter the segment's changed-executable-line count; the arm printed 497 lines for this unit against that 500 cap at this head.
I am not landing it in this push and I am not resolving the thread: it belongs with the writer-side rows on this unit, which are blocked on the maintainer's call about that 500-line headroom, and a guard-composition row written while that call is pending tends to get rewritten. Reporting the gap as measured: the accept/reject-through-every-entry-point instruction is satisfied today for the writer's own entry point and for the export route's notification, and not satisfied for their composition.
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.
@easonLiangWorldedtech Thank you for the breakdown. The export tests cover the option and notification, while the writer tests cover refusal separately. The combined accept and reject paths through
exportSettingsremain unverified.I understand you are deferring the composition test pending the maintainer’s decision. This thread will remain open.
Learnings used
You are interacting with an AI system.