fix(restic): clean temp secrets dir on env setup failure

This commit is contained in:
Nicolas Meienberger 2026-05-17 15:23:05 +02:00
parent 3b1b3ff5fe
commit 0fa4e05ba0
No known key found for this signature in database
2 changed files with 193 additions and 153 deletions

View file

@ -1,4 +1,5 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { buildEnv } from "../build-env"; import { buildEnv } from "../build-env";
import type { ResticDeps } from "../../types"; import type { ResticDeps } from "../../types";
@ -36,6 +37,10 @@ const buildEnvForTest = async (
return env; return env;
}; };
const listResticTempDirs = async () => {
return new Set((await fs.readdir(os.tmpdir())).filter((name) => name.startsWith("zerobyte-restic-")));
};
afterEach(async () => { afterEach(async () => {
await Promise.all([...tempDirs].map((dir) => fs.rm(dir, { recursive: true, force: true }))); await Promise.all([...tempDirs].map((dir) => fs.rm(dir, { recursive: true, force: true })));
tempDirs.clear(); tempDirs.clear();
@ -107,10 +112,39 @@ describe("buildEnv", () => {
throw new Error("Organization non-existent-org not found"); throw new Error("Organization non-existent-org not found");
}, },
}); });
const before = await listResticTempDirs();
await expect( await expect(
buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org", deps), buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org", deps),
).rejects.toThrow("Organization non-existent-org not found"); ).rejects.toThrow("Organization non-existent-org not found");
expect(await listResticTempDirs()).toEqual(before);
});
test("cleans the temp directory when setup fails after creating one", async () => {
const before = await listResticTempDirs();
const deps = makeDeps({
resolveSecret: async (secret) => {
if (secret === "my-access-key") throw new Error("failed to resolve access key");
return secret;
},
});
await expect(
buildEnv(
withCustomPassword({
backend: "s3" as const,
endpoint: "https://s3.amazonaws.com",
bucket: "my-bucket",
accessKeyId: "my-access-key",
secretAccessKey: "my-secret-key",
}),
"org-1",
deps,
),
).rejects.toThrow("failed to resolve access key");
expect(await listResticTempDirs()).toEqual(before);
}); });
test("throws when getOrganizationResticPassword throws (no password configured)", async () => { test("throws when getOrganizationResticPassword throws (no password configured)", async () => {

View file

@ -17,6 +17,10 @@ const runtimeSecretPath = (runtimeSecretsDir: string, filename: string) => {
return path.join(runtimeSecretsDir, filename); return path.join(runtimeSecretsDir, filename);
}; };
const removeRuntimeSecretsDir = async (runtimeSecretsDir: string | undefined) => {
if (runtimeSecretsDir) await fs.rm(runtimeSecretsDir, { recursive: true, force: true }).catch(() => {});
};
export const buildEnv = async ( export const buildEnv = async (
config: RepositoryConfig, config: RepositoryConfig,
organizationId: string, organizationId: string,
@ -26,13 +30,17 @@ export const buildEnv = async (
RESTIC_CACHE_DIR: deps.resticCacheDir, RESTIC_CACHE_DIR: deps.resticCacheDir,
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin", PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
}; };
const runtimeSecretsDir = await createRuntimeSecretsDir(); let runtimeSecretsDir: string | undefined;
const getRuntimeSecretPath = async (filename: string) => {
runtimeSecretsDir = await createRuntimeSecretsDir();
env._RUNTIME_SECRETS_DIR = runtimeSecretsDir; env._RUNTIME_SECRETS_DIR = runtimeSecretsDir;
return runtimeSecretPath(runtimeSecretsDir, filename);
};
try {
if (config.isExistingRepository && config.customPassword) { if (config.isExistingRepository && config.customPassword) {
const decryptedPassword = await deps.resolveSecret(config.customPassword); const decryptedPassword = await deps.resolveSecret(config.customPassword);
const passwordFilePath = runtimeSecretPath( const passwordFilePath = await getRuntimeSecretPath(
runtimeSecretsDir,
`zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`, `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`,
); );
@ -41,8 +49,7 @@ export const buildEnv = async (
} else { } else {
const encryptedPassword = await deps.getOrganizationResticPassword(organizationId); const encryptedPassword = await deps.getOrganizationResticPassword(organizationId);
const decryptedPassword = await deps.resolveSecret(encryptedPassword); const decryptedPassword = await deps.resolveSecret(encryptedPassword);
const passwordFilePath = runtimeSecretPath( const passwordFilePath = await getRuntimeSecretPath(
runtimeSecretsDir,
`zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`, `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`,
); );
await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite); await writeFileWithMode(passwordFilePath, decryptedPassword, FILE_MODES.ownerReadWrite);
@ -66,8 +73,7 @@ export const buildEnv = async (
break; break;
case "gcs": { case "gcs": {
const decryptedCredentials = await deps.resolveSecret(config.credentialsJson); const decryptedCredentials = await deps.resolveSecret(config.credentialsJson);
const credentialsPath = runtimeSecretPath( const credentialsPath = await getRuntimeSecretPath(
runtimeSecretsDir,
`zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`, `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`,
); );
await writeFileWithMode(credentialsPath, decryptedCredentials, FILE_MODES.ownerReadWrite); await writeFileWithMode(credentialsPath, decryptedCredentials, FILE_MODES.ownerReadWrite);
@ -97,10 +103,6 @@ export const buildEnv = async (
break; break;
case "sftp": { case "sftp": {
const decryptedKey = await deps.resolveSecret(config.privateKey); const decryptedKey = await deps.resolveSecret(config.privateKey);
const keyPath = runtimeSecretPath(
runtimeSecretsDir,
`zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`,
);
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
if (!normalizedKey.endsWith("\n")) { if (!normalizedKey.endsWith("\n")) {
@ -108,12 +110,16 @@ export const buildEnv = async (
} }
if (normalizedKey.includes("ENCRYPTED")) { if (normalizedKey.includes("ENCRYPTED")) {
logger.error("SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key."); logger.error(
"SFTP: Private key appears to be passphrase-protected. Please use an unencrypted key.",
);
throw new Error( throw new Error(
"Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.", "Passphrase-protected SSH keys are not supported. Please provide an unencrypted private key.",
); );
} }
const keyPath = await getRuntimeSecretPath(`zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`);
await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite); await writeFileWithMode(keyPath, normalizedKey, FILE_MODES.ownerReadWrite);
env._SFTP_KEY_PATH = keyPath; env._SFTP_KEY_PATH = keyPath;
@ -148,8 +154,7 @@ export const buildEnv = async (
if (config.skipHostKeyCheck) { if (config.skipHostKeyCheck) {
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null"); sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) { } else if (config.knownHosts) {
const knownHostsPath = runtimeSecretPath( const knownHostsPath = await getRuntimeSecretPath(
runtimeSecretsDir,
`zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`, `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`,
); );
await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite); await writeFileWithMode(knownHostsPath, config.knownHosts, FILE_MODES.ownerReadWrite);
@ -171,10 +176,7 @@ export const buildEnv = async (
if (config.cacert) { if (config.cacert) {
const decryptedCert = await deps.resolveSecret(config.cacert); const decryptedCert = await deps.resolveSecret(config.cacert);
const certPath = runtimeSecretPath( const certPath = await getRuntimeSecretPath(`zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
runtimeSecretsDir,
`zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`,
);
await writeFileWithMode(certPath, decryptedCert, FILE_MODES.ownerReadWrite); await writeFileWithMode(certPath, decryptedCert, FILE_MODES.ownerReadWrite);
env.RESTIC_CACERT = certPath; env.RESTIC_CACERT = certPath;
} }
@ -188,4 +190,8 @@ export const buildEnv = async (
} }
return env; return env;
} catch (error) {
await removeRuntimeSecretsDir(runtimeSecretsDir);
throw error;
}
}; };