From f9943cb2df7c6fb16692bdd137d50302fbde423e Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Tue, 20 May 2025 17:40:38 +0100 Subject: [PATCH 1/5] fix: SSHConfig: atomically write ssh config --- src/sshConfig.test.ts | 37 +++++++++++++++++++++++++++++-------- src/sshConfig.ts | 9 +++++++-- 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/sshConfig.test.ts b/src/sshConfig.test.ts index 1a6a081d..537b8394 100644 --- a/src/sshConfig.test.ts +++ b/src/sshConfig.test.ts @@ -8,6 +8,7 @@ const mockFileSystem = { readFile: vi.fn(), mkdir: vi.fn(), writeFile: vi.fn(), + rename: vi.fn(), } afterEach(() => { @@ -38,7 +39,12 @@ Host coder-vscode--* # --- END CODER VSCODE ---` expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything()) - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything()) + expect(mockFileSystem.writeFile).toBeCalledWith( + expect.stringContaining(sshFilePath), + expectedOutput, + expect.anything(), + ) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) it("creates a new file and adds the config", async () => { @@ -65,7 +71,12 @@ Host coder-vscode.dev.coder.com--* # --- END CODER VSCODE dev.coder.com ---` expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything()) - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything()) + expect(mockFileSystem.writeFile).toBeCalledWith( + expect.stringContaining(sshFilePath), + expectedOutput, + expect.anything(), + ) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) it("adds a new coder config in an existent SSH configuration", async () => { @@ -100,10 +111,11 @@ Host coder-vscode.dev.coder.com--* UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---` - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", mode: 384, }) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) it("updates an existent coder config", async () => { @@ -164,10 +176,11 @@ Host coder-vscode.dev-updated.coder.com--* Host * SetEnv TEST=1` - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", mode: 384, }) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) it("does not remove deployment-unaware SSH config and adds the new one", async () => { @@ -209,10 +222,11 @@ Host coder-vscode.dev.coder.com--* UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---` - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", mode: 384, }) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) 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--* UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---` - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", mode: 384, }) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) it("throws an error if there is a missing end block", async () => { @@ -517,10 +532,11 @@ Host afterconfig LogLevel: "ERROR", }) - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", mode: 384, }) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) it("override values", async () => { @@ -561,5 +577,10 @@ Host coder-vscode.dev.coder.com--* # --- END CODER VSCODE dev.coder.com ---` expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything()) - expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything()) + expect(mockFileSystem.writeFile).toBeCalledWith( + expect.stringContaining(sshFilePath), + expectedOutput, + expect.anything(), + ) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) diff --git a/src/sshConfig.ts b/src/sshConfig.ts index a949cdcc..11ccd920 100644 --- a/src/sshConfig.ts +++ b/src/sshConfig.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "fs/promises" +import { mkdir, readFile, writeFile, rename } from "fs/promises" import path from "path" import { countSubstring } from "./util" @@ -23,12 +23,14 @@ export interface FileSystem { readFile: typeof readFile mkdir: typeof mkdir writeFile: typeof writeFile + rename: typeof rename } const defaultFileSystem: FileSystem = { readFile, mkdir, writeFile, + rename, } // mergeSSHConfigValues will take a given ssh config and merge it with the overrides @@ -224,10 +226,13 @@ export class SSHConfig { mode: 0o700, // only owner has rwx permission, not group or everyone. recursive: true, }) - return this.fileSystem.writeFile(this.filePath, this.getRaw(), { + const randSuffix = Math.random().toString(36).substring(8) + const tempFilePath = `${this.filePath}.${randSuffix}` + await this.fileSystem.writeFile(tempFilePath, this.getRaw(), { mode: 0o600, // owner rw encoding: "utf-8", }) + await this.fileSystem.rename(tempFilePath, this.filePath) } public getRaw() { From 192556784455bab23a979e73065545d12fba6fdb Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 21 May 2025 11:58:57 +0100 Subject: [PATCH 2/5] preserve existing mode on config file if present --- src/sshConfig.test.ts | 24 +++++++++++++++++------- src/sshConfig.ts | 24 ++++++++++++++++++------ 2 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/sshConfig.test.ts b/src/sshConfig.test.ts index 537b8394..de00e2e3 100644 --- a/src/sshConfig.test.ts +++ b/src/sshConfig.test.ts @@ -5,10 +5,11 @@ import { SSHConfig } from "./sshConfig" const sshFilePath = "~/.config/ssh" const mockFileSystem = { - readFile: vi.fn(), mkdir: vi.fn(), - writeFile: vi.fn(), + readFile: vi.fn(), rename: vi.fn(), + stat: vi.fn(), + writeFile: vi.fn(), } afterEach(() => { @@ -17,6 +18,7 @@ afterEach(() => { it("creates a new file and adds config with empty label", async () => { mockFileSystem.readFile.mockRejectedValueOnce("No file found") + mockFileSystem.stat.mockRejectedValueOnce({ code: "ENOENT" }) const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) await sshConfig.load() @@ -49,6 +51,7 @@ Host coder-vscode--* it("creates a new file and adds the config", async () => { mockFileSystem.readFile.mockRejectedValueOnce("No file found") + mockFileSystem.stat.mockRejectedValueOnce({ code: "ENOENT" }) const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) await sshConfig.load() @@ -88,6 +91,7 @@ it("adds a new coder config in an existent SSH configuration", async () => { StrictHostKeyChecking=no UserKnownHostsFile=/dev/null` mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig) + mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o644 }) const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) await sshConfig.load() @@ -113,7 +117,7 @@ Host coder-vscode.dev.coder.com--* expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", - mode: 384, + mode: 0o644, }) expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) @@ -150,6 +154,7 @@ Host coder-vscode.dev.coder.com--* Host * SetEnv TEST=1` mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig) + mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o644 }) const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) await sshConfig.load() @@ -178,7 +183,7 @@ Host * expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", - mode: 384, + mode: 0o644, }) expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) @@ -199,6 +204,7 @@ Host coder-vscode--* UserKnownHostsFile=/dev/null # --- END CODER VSCODE ---` mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig) + mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o644 }) const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) await sshConfig.load() @@ -224,7 +230,7 @@ Host coder-vscode.dev.coder.com--* expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", - mode: 384, + mode: 0o644, }) expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) @@ -233,6 +239,7 @@ it("it does not remove a user-added block that only matches the host of an old c const existentSSHConfig = `Host coder-vscode--* ForwardAgent=yes` mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig) + mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o644 }) const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) await sshConfig.load() @@ -259,7 +266,7 @@ Host coder-vscode.dev.coder.com--* expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", - mode: 384, + mode: 0o644, }) expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) @@ -491,6 +498,7 @@ Host afterconfig const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig) + mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o644 }) await sshConfig.load() const expectedOutput = `Host beforeconfig @@ -534,13 +542,15 @@ Host afterconfig expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { encoding: "utf-8", - mode: 384, + mode: 0o644, }) expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) }) it("override values", async () => { mockFileSystem.readFile.mockRejectedValueOnce("No file found") + mockFileSystem.stat.mockRejectedValueOnce({ code: "ENOENT" }) + const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) await sshConfig.load() await sshConfig.update( diff --git a/src/sshConfig.ts b/src/sshConfig.ts index 11ccd920..8b7efa5a 100644 --- a/src/sshConfig.ts +++ b/src/sshConfig.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile, rename } from "fs/promises" +import { mkdir, readFile, rename, stat, writeFile } from "fs/promises" import path from "path" import { countSubstring } from "./util" @@ -20,17 +20,19 @@ export interface SSHValues { // Interface for the file system to make it easier to test export interface FileSystem { - readFile: typeof readFile mkdir: typeof mkdir - writeFile: typeof writeFile + readFile: typeof readFile rename: typeof rename + stat: typeof stat + writeFile: typeof writeFile } const defaultFileSystem: FileSystem = { - readFile, mkdir, - writeFile, + readFile, rename, + stat, + writeFile, } // mergeSSHConfigValues will take a given ssh config and merge it with the overrides @@ -222,6 +224,16 @@ export class SSHConfig { } private async save() { + // We want to preserve the original file mode. + const existingMode = await this.fileSystem + .stat(this.filePath) + .then((stat) => stat.mode) + .catch((ex) => { + if (ex.code && ex.code === "ENOENT") { + return 0o600 // default to 0600 if file does not exist + } + throw ex // Any other error is unexpected + }) await this.fileSystem.mkdir(path.dirname(this.filePath), { mode: 0o700, // only owner has rwx permission, not group or everyone. recursive: true, @@ -229,7 +241,7 @@ export class SSHConfig { const randSuffix = Math.random().toString(36).substring(8) const tempFilePath = `${this.filePath}.${randSuffix}` await this.fileSystem.writeFile(tempFilePath, this.getRaw(), { - mode: 0o600, // owner rw + mode: existingMode, encoding: "utf-8", }) await this.fileSystem.rename(tempFilePath, this.filePath) From 4997f50b994b3b0778ffeb59eeb5ebaed711bbd2 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 21 May 2025 12:23:38 +0100 Subject: [PATCH 3/5] adjust tempfile naming --- src/sshConfig.test.ts | 53 +++++++++++++++++++++++++++---------------- src/sshConfig.ts | 4 +++- 2 files changed, 36 insertions(+), 21 deletions(-) diff --git a/src/sshConfig.test.ts b/src/sshConfig.test.ts index de00e2e3..304363d0 100644 --- a/src/sshConfig.test.ts +++ b/src/sshConfig.test.ts @@ -2,7 +2,11 @@ import { it, afterEach, vi, expect } from "vitest" import { SSHConfig } from "./sshConfig" -const sshFilePath = "~/.config/ssh" +// This is not the usual path to ~/.ssh/config, but +// setting it to a different path makes it easier to test +// and makes mistakes abundantly clear. +const sshFilePath = "/Path/To/UserHomeDir/.sshConfigDir/sshConfigFile" +const sshTempFilePathExpr = `^/Path/To/UserHomeDir/.sshConfigDir/.sshConfigFile.vscode-coder-tmp.[a-z0-9]+$` const mockFileSystem = { mkdir: vi.fn(), @@ -42,11 +46,14 @@ Host coder-vscode--* expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything()) expect(mockFileSystem.writeFile).toBeCalledWith( - expect.stringContaining(sshFilePath), + expect.stringMatching(sshTempFilePathExpr), expectedOutput, - expect.anything(), + expect.objectContaining({ + encoding: "utf-8", + mode: 0o600, // Default mode for new files. + }), ) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) it("creates a new file and adds the config", async () => { @@ -75,11 +82,14 @@ Host coder-vscode.dev.coder.com--* expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything()) expect(mockFileSystem.writeFile).toBeCalledWith( - expect.stringContaining(sshFilePath), + expect.stringMatching(sshTempFilePathExpr), expectedOutput, - expect.anything(), + expect.objectContaining({ + encoding: "utf-8", + mode: 0o600, // Default mode for new files. + }), ) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) it("adds a new coder config in an existent SSH configuration", async () => { @@ -115,11 +125,11 @@ Host coder-vscode.dev.coder.com--* UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---` - expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), expectedOutput, { encoding: "utf-8", mode: 0o644, }) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) it("updates an existent coder config", async () => { @@ -181,11 +191,11 @@ Host coder-vscode.dev-updated.coder.com--* Host * SetEnv TEST=1` - expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), expectedOutput, { encoding: "utf-8", mode: 0o644, }) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) it("does not remove deployment-unaware SSH config and adds the new one", async () => { @@ -228,11 +238,11 @@ Host coder-vscode.dev.coder.com--* UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---` - expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), expectedOutput, { encoding: "utf-8", mode: 0o644, }) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) it("it does not remove a user-added block that only matches the host of an old coder SSH config", async () => { @@ -264,11 +274,11 @@ Host coder-vscode.dev.coder.com--* UserKnownHostsFile /dev/null # --- END CODER VSCODE dev.coder.com ---` - expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), expectedOutput, { encoding: "utf-8", mode: 0o644, }) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) it("throws an error if there is a missing end block", async () => { @@ -540,11 +550,11 @@ Host afterconfig LogLevel: "ERROR", }) - expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringContaining(sshFilePath), expectedOutput, { + expect(mockFileSystem.writeFile).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), expectedOutput, { encoding: "utf-8", mode: 0o644, }) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) it("override values", async () => { @@ -588,9 +598,12 @@ Host coder-vscode.dev.coder.com--* expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything()) expect(mockFileSystem.writeFile).toBeCalledWith( - expect.stringContaining(sshFilePath), + expect.stringMatching(sshTempFilePathExpr), expectedOutput, - expect.anything(), + expect.objectContaining({ + encoding: "utf-8", + mode: 0o600, // Default mode for new files. + }), ) - expect(mockFileSystem.rename).toBeCalledWith(expect.stringContaining(sshFilePath + "."), sshFilePath) + expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) diff --git a/src/sshConfig.ts b/src/sshConfig.ts index 8b7efa5a..ecadfaa3 100644 --- a/src/sshConfig.ts +++ b/src/sshConfig.ts @@ -239,7 +239,9 @@ export class SSHConfig { recursive: true, }) const randSuffix = Math.random().toString(36).substring(8) - const tempFilePath = `${this.filePath}.${randSuffix}` + const fileName = path.basename(this.filePath) + const dirName = path.dirname(this.filePath) + const tempFilePath = `${dirName}/.${fileName}.vscode-coder-tmp.${randSuffix}` await this.fileSystem.writeFile(tempFilePath, this.getRaw(), { mode: existingMode, encoding: "utf-8", From b61468b8df29abfeffbd6827f3b4f3181d4bcdd3 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 21 May 2025 12:25:32 +0100 Subject: [PATCH 4/5] escape dots in sshTempFilePathExpr --- src/sshConfig.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sshConfig.test.ts b/src/sshConfig.test.ts index 304363d0..c856d7e6 100644 --- a/src/sshConfig.test.ts +++ b/src/sshConfig.test.ts @@ -6,7 +6,7 @@ import { SSHConfig } from "./sshConfig" // setting it to a different path makes it easier to test // and makes mistakes abundantly clear. const sshFilePath = "/Path/To/UserHomeDir/.sshConfigDir/sshConfigFile" -const sshTempFilePathExpr = `^/Path/To/UserHomeDir/.sshConfigDir/.sshConfigFile.vscode-coder-tmp.[a-z0-9]+$` +const sshTempFilePathExpr = `^/Path/To/UserHomeDir/\\.sshConfigDir/\\.sshConfigFile\\.vscode-coder-tmp\\.[a-z0-9]+$` const mockFileSystem = { mkdir: vi.fn(), From 8ac1e7828301ed9330f3210de565df2fc7394c72 Mon Sep 17 00:00:00 2001 From: Cian Johnston Date: Wed, 21 May 2025 20:03:39 +0100 Subject: [PATCH 5/5] improve error message on failure to write/rename --- src/sshConfig.test.ts | 49 +++++++++++++++++++++++++++++++++++++++++++ src/sshConfig.ts | 26 ++++++++++++++++++----- 2 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/sshConfig.test.ts b/src/sshConfig.test.ts index c856d7e6..d4a8e41d 100644 --- a/src/sshConfig.test.ts +++ b/src/sshConfig.test.ts @@ -607,3 +607,52 @@ Host coder-vscode.dev.coder.com--* ) expect(mockFileSystem.rename).toBeCalledWith(expect.stringMatching(sshTempFilePathExpr), sshFilePath) }) + +it("fails if we are unable to write the temporary file", async () => { + const existentSSHConfig = `Host beforeconfig + HostName before.config.tld + User before` + + const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) + mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig) + mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o600 }) + mockFileSystem.writeFile.mockRejectedValueOnce(new Error("EACCES")) + + await sshConfig.load() + + expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything()) + await expect( + sshConfig.update("dev.coder.com", { + Host: "coder-vscode.dev.coder.com--*", + ProxyCommand: "some-command-here", + ConnectTimeout: "0", + StrictHostKeyChecking: "no", + UserKnownHostsFile: "/dev/null", + LogLevel: "ERROR", + }), + ).rejects.toThrow(/Failed to write temporary SSH config file.*EACCES/) +}) + +it("fails if we are unable to rename the temporary file", async () => { + const existentSSHConfig = `Host beforeconfig + HostName before.config.tld + User before` + + const sshConfig = new SSHConfig(sshFilePath, mockFileSystem) + mockFileSystem.readFile.mockResolvedValueOnce(existentSSHConfig) + mockFileSystem.stat.mockResolvedValueOnce({ mode: 0o600 }) + mockFileSystem.writeFile.mockResolvedValueOnce("") + mockFileSystem.rename.mockRejectedValueOnce(new Error("EACCES")) + + await sshConfig.load() + await expect( + sshConfig.update("dev.coder.com", { + Host: "coder-vscode.dev.coder.com--*", + ProxyCommand: "some-command-here", + ConnectTimeout: "0", + StrictHostKeyChecking: "no", + UserKnownHostsFile: "/dev/null", + LogLevel: "ERROR", + }), + ).rejects.toThrow(/Failed to rename temporary SSH config file.*EACCES/) +}) diff --git a/src/sshConfig.ts b/src/sshConfig.ts index ecadfaa3..4a75b209 100644 --- a/src/sshConfig.ts +++ b/src/sshConfig.ts @@ -242,11 +242,27 @@ export class SSHConfig { const fileName = path.basename(this.filePath) const dirName = path.dirname(this.filePath) const tempFilePath = `${dirName}/.${fileName}.vscode-coder-tmp.${randSuffix}` - await this.fileSystem.writeFile(tempFilePath, this.getRaw(), { - mode: existingMode, - encoding: "utf-8", - }) - await this.fileSystem.rename(tempFilePath, this.filePath) + try { + await this.fileSystem.writeFile(tempFilePath, this.getRaw(), { + mode: existingMode, + encoding: "utf-8", + }) + } catch (err) { + throw new Error( + `Failed to write temporary SSH config file at ${tempFilePath}: ${err instanceof Error ? err.message : String(err)}. ` + + `Please check your disk space, permissions, and that the directory exists.`, + ) + } + + try { + await this.fileSystem.rename(tempFilePath, this.filePath) + } catch (err) { + throw new Error( + `Failed to rename temporary SSH config file at ${tempFilePath} to ${this.filePath}: ${ + err instanceof Error ? err.message : String(err) + }. Please check your disk space, permissions, and that the directory exists.`, + ) + } } public getRaw() {