From b132ad6df5d33890d089bd0db9ee1b9dfcd11d49 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Thu, 12 Mar 2026 18:01:45 +0100 Subject: [PATCH] fix: add additional chmod after file write Closes #478 The mode option of fs.writeFile does not reliably apply permissions on all filesystems. On Synology NAS Docker volumes (btrfs), the file ends up with 0755 permissions instead of 0600, causing SSH to refuse the key. --- .../modules/backends/sftp/sftp-backend.ts | 5 +-- packages/core/src/restic/helpers/build-env.ts | 22 ++++--------- packages/core/src/utils/__tests__/fs.test.ts | 32 +++++++++++++++++++ packages/core/src/utils/fs.ts | 12 +++++++ packages/core/src/utils/index.ts | 1 + 5 files changed, 55 insertions(+), 17 deletions(-) create mode 100644 packages/core/src/utils/__tests__/fs.test.ts create mode 100644 packages/core/src/utils/fs.ts diff --git a/app/server/modules/backends/sftp/sftp-backend.ts b/app/server/modules/backends/sftp/sftp-backend.ts index 25ebf7b5..c69cbcf3 100644 --- a/app/server/modules/backends/sftp/sftp-backend.ts +++ b/app/server/modules/backends/sftp/sftp-backend.ts @@ -6,6 +6,7 @@ import { OPERATION_TIMEOUT } from "../../../core/constants"; import { cryptoUtils } from "../../../utils/crypto"; import { toMessage } from "../../../utils/errors"; import { logger } from "@zerobyte/core/node"; +import { FILE_MODES, writeFileWithMode } from "@zerobyte/core/utils"; import { getMountForPath } from "../../../utils/mountinfo"; import { withTimeout } from "../../../utils/timeout"; import type { VolumeBackend } from "../backend"; @@ -65,7 +66,7 @@ const mount = async (config: BackendConfig, mountPath: string) => { options.push("StrictHostKeyChecking=no", "UserKnownHostsFile=/dev/null"); } else if (config.knownHosts) { const knownHostsPath = getKnownHostsPath(mountPath); - await fs.writeFile(knownHostsPath, config.knownHosts, { mode: 0o600 }); + await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); options.push(`UserKnownHostsFile=${knownHostsPath}`, "StrictHostKeyChecking=yes"); } @@ -84,7 +85,7 @@ const mount = async (config: BackendConfig, mountPath: string) => { if (!normalizedKey.endsWith("\n")) { normalizedKey += "\n"; } - await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 }); + await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite); options.push(`IdentityFile=${keyPath}`); } diff --git a/packages/core/src/restic/helpers/build-env.ts b/packages/core/src/restic/helpers/build-env.ts index 4b9a2ffd..aef0bc7d 100644 --- a/packages/core/src/restic/helpers/build-env.ts +++ b/packages/core/src/restic/helpers/build-env.ts @@ -1,9 +1,9 @@ import crypto from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import type { ResticDeps, ResticEnv } from "../types"; import type { RepositoryConfig } from "../schemas"; import { logger } from "../../node"; +import { FILE_MODES, writeFileWithMode } from "../../utils/fs.js"; export const buildEnv = async ( config: RepositoryConfig, @@ -19,17 +19,13 @@ export const buildEnv = async ( const decryptedPassword = await deps.resolveSecret(config.customPassword); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); - await fs.writeFile(passwordFilePath, decryptedPassword, { - mode: 0o600, - }); + await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite); env.RESTIC_PASSWORD_FILE = passwordFilePath; } else { const encryptedPassword = await deps.getOrganizationResticPassword(organizationId); const decryptedPassword = await deps.resolveSecret(encryptedPassword); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); - await fs.writeFile(passwordFilePath, decryptedPassword, { - mode: 0o600, - }); + await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite); env.RESTIC_PASSWORD_FILE = passwordFilePath; } @@ -51,9 +47,7 @@ export const buildEnv = async ( case "gcs": { const decryptedCredentials = await deps.resolveSecret(config.credentialsJson); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); - await fs.writeFile(credentialsPath, decryptedCredentials, { - mode: 0o600, - }); + await writeFileWithMode(credentialsPath, decryptedCredentials, FILE_MODES.ownerReadWrite); env.GOOGLE_PROJECT_ID = config.projectId; env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; break; @@ -89,7 +83,7 @@ export const buildEnv = async ( throw new Error("Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key."); } - await fs.writeFile(keyPath, normalizedKey, { mode: 0o600 }); + await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite); env._SFTP_KEY_PATH = keyPath; @@ -124,9 +118,7 @@ export const buildEnv = async ( sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"); } else if (config.knownHosts) { const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`); - await fs.writeFile(knownHostsPath, config.knownHosts, { - mode: 0o600, - }); + await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath; sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`); } @@ -144,7 +136,7 @@ export const buildEnv = async ( if (config.cacert) { const decryptedCert = await deps.resolveSecret(config.cacert); const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); - await fs.writeFile(certPath, decryptedCert, { mode: 0o600 }); + await writeFileWithMode(certPath, decryptedCert, FILE_MODES.ownerReadWrite); env.RESTIC_CACERT = certPath; } diff --git a/packages/core/src/utils/__tests__/fs.test.ts b/packages/core/src/utils/__tests__/fs.test.ts new file mode 100644 index 00000000..34ecf7b2 --- /dev/null +++ b/packages/core/src/utils/__tests__/fs.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "bun:test"; +import { FILE_MODES, writeFileWithMode } from "../fs"; + +const tempDirectories = new Set(); + +afterEach(async () => { + await Promise.all( + [...tempDirectories].map(async (directoryPath) => { + await fs.rm(directoryPath, { recursive: true, force: true }); + }), + ); + tempDirectories.clear(); +}); + +describe("writeFileWithMode", () => { + test("applies the requested mode even when rewriting an existing file", async () => { + const tempDirectory = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-write-file-with-mode-")); + tempDirectories.add(tempDirectory); + + const filePath = path.join(tempDirectory, "identity"); + await fs.writeFile(filePath, "old-content"); + await fs.chmod(filePath, 0o755); + + await writeFileWithMode(filePath, "new-content", FILE_MODES.ownerReadWrite); + + expect(await fs.readFile(filePath, "utf8")).toBe("new-content"); + expect((await fs.stat(filePath)).mode & 0o777).toBe(0o600); + }); +}); diff --git a/packages/core/src/utils/fs.ts b/packages/core/src/utils/fs.ts new file mode 100644 index 00000000..4a2fec9b --- /dev/null +++ b/packages/core/src/utils/fs.ts @@ -0,0 +1,12 @@ +import fs from "node:fs/promises"; + +export const FILE_MODES = { + ownerReadWrite: 0o600, +} as const; + +export type FileMode = (typeof FILE_MODES)[keyof typeof FILE_MODES]; + +export const writeFileWithMode = async (filePath: string, data: string, mode: FileMode) => { + await fs.writeFile(filePath, data, { mode }); + await fs.chmod(filePath, mode); +}; diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 46aac9c1..cc54cd6d 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,3 +1,4 @@ export { safeJsonParse } from "./json.js"; export { normalizeAbsolutePath } from "./path.js"; export { findCommonAncestor } from "./common-ancestor.js"; +export { FILE_MODES, writeFileWithMode } from "./fs.js";