Skip to content

Commit ce052db

Browse files
authored
refactor(storage): better fs check (#7164)
* refactor(storage): better fs check * fix(storage): fs check inconsistencies * test(storage): more thorough testing
1 parent 090737c commit ce052db

File tree

2 files changed

+159
-4
lines changed

2 files changed

+159
-4
lines changed

src/utils/__tests__/storage.spec.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import * as vscode from "vscode"
2+
3+
vi.mock("fs/promises", async () => {
4+
const mod = await import("../../__mocks__/fs/promises")
5+
return (mod as any).default ?? mod
6+
})
7+
8+
describe("getStorageBasePath - customStoragePath", () => {
9+
const defaultPath = "/test/global-storage"
10+
11+
beforeEach(() => {
12+
vi.clearAllMocks()
13+
})
14+
15+
afterEach(() => {
16+
vi.restoreAllMocks()
17+
})
18+
19+
it("returns the configured custom path when it is writable", async () => {
20+
const customPath = "/test/storage/path"
21+
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
22+
get: vi.fn().mockReturnValue(customPath),
23+
} as any)
24+
25+
const fsPromises = await import("fs/promises")
26+
const { getStorageBasePath } = await import("../storage")
27+
28+
const result = await getStorageBasePath(defaultPath)
29+
30+
expect(result).toBe(customPath)
31+
expect((fsPromises as any).mkdir).toHaveBeenCalledWith(customPath, { recursive: true })
32+
expect((fsPromises as any).access).toHaveBeenCalledWith(customPath, 7) // 7 = R_OK(4) | W_OK(2) | X_OK(1)
33+
})
34+
35+
it("falls back to default and shows an error when custom path is not writable", async () => {
36+
const customPath = "/test/storage/unwritable"
37+
38+
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
39+
get: vi.fn().mockReturnValue(customPath),
40+
} as any)
41+
42+
const showErrorSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined as any)
43+
44+
const fsPromises = await import("fs/promises")
45+
const { getStorageBasePath } = await import("../storage")
46+
47+
await (fsPromises as any).mkdir(customPath, { recursive: true })
48+
49+
const accessMock = (fsPromises as any).access as ReturnType<typeof vi.fn>
50+
accessMock.mockImplementationOnce(async (p: string) => {
51+
if (p === customPath) {
52+
const err: any = new Error("EACCES: permission denied")
53+
err.code = "EACCES"
54+
throw err
55+
}
56+
return Promise.resolve()
57+
})
58+
59+
const result = await getStorageBasePath(defaultPath)
60+
61+
expect(result).toBe(defaultPath)
62+
expect(showErrorSpy).toHaveBeenCalledTimes(1)
63+
const firstArg = showErrorSpy.mock.calls[0][0]
64+
expect(typeof firstArg).toBe("string")
65+
})
66+
it("returns the default path when customStoragePath is an empty string and does not touch fs", async () => {
67+
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
68+
get: vi.fn().mockReturnValue(""),
69+
} as any)
70+
71+
const fsPromises = await import("fs/promises")
72+
const { getStorageBasePath } = await import("../storage")
73+
74+
const result = await getStorageBasePath(defaultPath)
75+
76+
expect(result).toBe(defaultPath)
77+
expect((fsPromises as any).mkdir).not.toHaveBeenCalled()
78+
expect((fsPromises as any).access).not.toHaveBeenCalled()
79+
})
80+
81+
it("falls back to default when mkdir fails and does not attempt access", async () => {
82+
const customPath = "/test/storage/failmkdir"
83+
84+
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
85+
get: vi.fn().mockReturnValue(customPath),
86+
} as any)
87+
88+
const showErrorSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined as any)
89+
90+
const fsPromises = await import("fs/promises")
91+
const { getStorageBasePath } = await import("../storage")
92+
93+
const mkdirMock = (fsPromises as any).mkdir as ReturnType<typeof vi.fn>
94+
mkdirMock.mockImplementationOnce(async (p: string) => {
95+
if (p === customPath) {
96+
const err: any = new Error("EACCES: permission denied")
97+
err.code = "EACCES"
98+
throw err
99+
}
100+
return Promise.resolve()
101+
})
102+
103+
const result = await getStorageBasePath(defaultPath)
104+
105+
expect(result).toBe(defaultPath)
106+
expect((fsPromises as any).access).not.toHaveBeenCalled()
107+
expect(showErrorSpy).toHaveBeenCalledTimes(1)
108+
})
109+
110+
it("passes the correct permission flags (R_OK | W_OK | X_OK) to fs.access", async () => {
111+
const customPath = "/test/storage/path"
112+
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
113+
get: vi.fn().mockReturnValue(customPath),
114+
} as any)
115+
116+
const fsPromises = await import("fs/promises")
117+
const { getStorageBasePath } = await import("../storage")
118+
119+
await getStorageBasePath(defaultPath)
120+
121+
const constants = (fsPromises as any).constants
122+
const expectedFlags = constants.R_OK | constants.W_OK | constants.X_OK
123+
124+
expect((fsPromises as any).access).toHaveBeenCalledWith(customPath, expectedFlags)
125+
})
126+
127+
it("falls back when directory is readable but not writable (partial permissions)", async () => {
128+
const customPath = "/test/storage/readonly"
129+
vi.spyOn(vscode.workspace, "getConfiguration").mockReturnValue({
130+
get: vi.fn().mockReturnValue(customPath),
131+
} as any)
132+
133+
const showErrorSpy = vi.spyOn(vscode.window, "showErrorMessage").mockResolvedValue(undefined as any)
134+
135+
const fsPromises = await import("fs/promises")
136+
const { getStorageBasePath } = await import("../storage")
137+
138+
const accessMock = (fsPromises as any).access as ReturnType<typeof vi.fn>
139+
const constants = (fsPromises as any).constants
140+
accessMock.mockImplementationOnce(async (p: string, mode?: number) => {
141+
// Simulate readable (R_OK) but not writable/executable (W_OK | X_OK)
142+
if (p === customPath && mode && mode & (constants.W_OK | constants.X_OK)) {
143+
const err: any = new Error("EACCES: permission denied")
144+
err.code = "EACCES"
145+
throw err
146+
}
147+
return Promise.resolve()
148+
})
149+
150+
const result = await getStorageBasePath(defaultPath)
151+
152+
expect(result).toBe(defaultPath)
153+
expect(showErrorSpy).toHaveBeenCalledTimes(1)
154+
})
155+
})

src/utils/storage.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import * as vscode from "vscode"
22
import * as path from "path"
33
import * as fs from "fs/promises"
4+
import { constants as fsConstants } from "fs"
45

56
import { Package } from "../shared/package"
67
import { t } from "../i18n"
@@ -32,10 +33,8 @@ export async function getStorageBasePath(defaultPath: string): Promise<string> {
3233
// Ensure custom path exists
3334
await fs.mkdir(customStoragePath, { recursive: true })
3435

35-
// Test if path is writable
36-
const testFile = path.join(customStoragePath, ".write_test")
37-
await fs.writeFile(testFile, "test")
38-
await fs.rm(testFile)
36+
// Check directory write permission without creating temp files
37+
await fs.access(customStoragePath, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK)
3938

4039
return customStoragePath
4140
} catch (error) {
@@ -132,6 +131,7 @@ export async function promptForCustomStoragePath(): Promise<void> {
132131
try {
133132
// Test if path is accessible
134133
await fs.mkdir(result, { recursive: true })
134+
await fs.access(result, fsConstants.R_OK | fsConstants.W_OK | fsConstants.X_OK)
135135
vscode.window.showInformationMessage(t("common:info.custom_storage_path_set", { path: result }))
136136
} catch (error) {
137137
vscode.window.showErrorMessage(

0 commit comments

Comments
 (0)