Skip to content

Commit 5277656

Browse files
committed
fix: SSHConfig: atomically write ssh config
1 parent eda19a2 commit 5277656

File tree

2 files changed

+36
-10
lines changed

2 files changed

+36
-10
lines changed

src/sshConfig.test.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const mockFileSystem = {
88
readFile: vi.fn(),
99
mkdir: vi.fn(),
1010
writeFile: vi.fn(),
11+
rename: vi.fn(),
1112
}
1213

1314
afterEach(() => {
@@ -38,7 +39,12 @@ Host coder-vscode--*
3839
# --- END CODER VSCODE ---`
3940

4041
expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())
41-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything())
42+
expect(mockFileSystem.writeFile).toBeCalledWith(
43+
expect.stringContaining(sshFilePath),
44+
expectedOutput,
45+
expect.anything(),
46+
)
47+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
4248
})
4349

4450
it("creates a new file and adds the config", async () => {
@@ -65,7 +71,12 @@ Host coder-vscode.dev.coder.com--*
6571
# --- END CODER VSCODE dev.coder.com ---`
6672

6773
expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())
68-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything())
74+
expect(mockFileSystem.writeFile).toBeCalledWith(
75+
expect.stringContaining(sshFilePath),
76+
expectedOutput,
77+
expect.anything(),
78+
)
79+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
6980
})
7081

7182
it("adds a new coder config in an existent SSH configuration", async () => {
@@ -100,10 +111,11 @@ Host coder-vscode.dev.coder.com--*
100111
UserKnownHostsFile /dev/null
101112
# --- END CODER VSCODE dev.coder.com ---`
102113

103-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
114+
expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, {
104115
encoding: "utf-8",
105116
mode: 384,
106117
})
118+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
107119
})
108120

109121
it("updates an existent coder config", async () => {
@@ -164,10 +176,11 @@ Host coder-vscode.dev-updated.coder.com--*
164176
Host *
165177
SetEnv TEST=1`
166178

167-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
179+
expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, {
168180
encoding: "utf-8",
169181
mode: 384,
170182
})
183+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
171184
})
172185

173186
it("does not remove deployment-unaware SSH config and adds the new one", async () => {
@@ -209,10 +222,11 @@ Host coder-vscode.dev.coder.com--*
209222
UserKnownHostsFile /dev/null
210223
# --- END CODER VSCODE dev.coder.com ---`
211224

212-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
225+
expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, {
213226
encoding: "utf-8",
214227
mode: 384,
215228
})
229+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
216230
})
217231

218232
it("it does not remove a user-added block that only matches the host of an old coder SSH config", async () => {
@@ -243,10 +257,11 @@ Host coder-vscode.dev.coder.com--*
243257
UserKnownHostsFile /dev/null
244258
# --- END CODER VSCODE dev.coder.com ---`
245259

246-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
260+
expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, {
247261
encoding: "utf-8",
248262
mode: 384,
249263
})
264+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
250265
})
251266

252267
it("throws an error if there is a mismatched start and end block count", async () => {
@@ -426,10 +441,11 @@ Host afterconfig
426441
LogLevel: "ERROR",
427442
})
428443

429-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, {
444+
expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, {
430445
encoding: "utf-8",
431446
mode: 384,
432447
})
448+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
433449
})
434450

435451
it("override values", async () => {
@@ -470,5 +486,10 @@ Host coder-vscode.dev.coder.com--*
470486
# --- END CODER VSCODE dev.coder.com ---`
471487

472488
expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())
473-
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything())
489+
expect(mockFileSystem.writeFile).toBeCalledWith(
490+
expect.stringContaining(sshFilePath),
491+
expectedOutput,
492+
expect.anything(),
493+
)
494+
expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath)
474495
})

src/sshConfig.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { mkdir, readFile, writeFile } from "fs/promises"
1+
import { mkdir, readFile, writeFile, rename } from "fs/promises"
22
import path from "path"
33
import { countSubstring } from "./util"
44

@@ -23,12 +23,14 @@ export interface FileSystem {
2323
readFile: typeof readFile
2424
mkdir: typeof mkdir
2525
writeFile: typeof writeFile
26+
rename: typeof rename
2627
}
2728

2829
const defaultFileSystem: FileSystem = {
2930
readFile,
3031
mkdir,
3132
writeFile,
33+
rename,
3234
}
3335

3436
// mergeSSHConfigValues will take a given ssh config and merge it with the overrides
@@ -223,10 +225,13 @@ export class SSHConfig {
223225
mode: 0o700, // only owner has rwx permission, not group or everyone.
224226
recursive: true,
225227
})
226-
return this.fileSystem.writeFile(this.filePath, this.getRaw(), {
228+
const randSuffix = Math.random().toString(36).substring(8)
229+
const tempFilePath = `${this.filePath}.${randSuffix}`
230+
await this.fileSystem.writeFile(tempFilePath, this.getRaw(), {
227231
mode: 0o600, // owner rw
228232
encoding: "utf-8",
229233
})
234+
await this.fileSystem.rename(tempFilePath, this.filePath)
230235
}
231236

232237
public getRaw() {

0 commit comments

Comments
 (0)