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";