feat: add deep secret resolution for all config fields

- Add resolveSecretsDeep() to recursively resolve secret placeholders
  (env://, file://, encv1:) in any config field
- Refactor restic operations to resolve secrets once via resolveAndBuild()
- Refactor volume backend dispatcher to resolve secrets at mount time
- Remove redundant per-field resolveSecret() calls from SMB, WebDAV, SFTP backends
- Simplify notifications service by replacing switch-based decryption
- Update backend-compatibility to use deep resolution for credential comparison

This allows users to use secret references in any configuration field,
not just the designated sensitive fields.
This commit is contained in:
Jakub Trávník 2026-01-05 17:35:56 +01:00
parent 21040d7d4c
commit 92ba5f9706
8 changed files with 150 additions and 160 deletions

View file

@ -1,5 +1,6 @@
import type { BackendStatus } from "~/schemas/volumes"; import type { BackendConfig, BackendStatus } from "~/schemas/volumes";
import type { Volume } from "../../db/schema"; import type { Volume } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { getVolumePath } from "../volumes/helpers"; import { getVolumePath } from "../volumes/helpers";
import { makeDirectoryBackend } from "./directory/directory-backend"; import { makeDirectoryBackend } from "./directory/directory-backend";
import { makeNfsBackend } from "./nfs/nfs-backend"; import { makeNfsBackend } from "./nfs/nfs-backend";
@ -19,27 +20,33 @@ export type VolumeBackend = {
checkHealth: () => Promise<OperationResult>; checkHealth: () => Promise<OperationResult>;
}; };
export const createVolumeBackend = (volume: Volume): VolumeBackend => { const getBackendFactory = (backendType: BackendConfig["backend"]) => {
const path = getVolumePath(volume); switch (backendType) {
case "nfs":
switch (volume.config.backend) { return makeNfsBackend;
case "nfs": { case "smb":
return makeNfsBackend(volume.config, path); return makeSmbBackend;
} case "directory":
case "smb": { return makeDirectoryBackend;
return makeSmbBackend(volume.config, path); case "webdav":
} return makeWebdavBackend;
case "directory": { case "rclone":
return makeDirectoryBackend(volume.config, path); return makeRcloneBackend;
} case "sftp":
case "webdav": { return makeSftpBackend;
return makeWebdavBackend(volume.config, path);
}
case "rclone": {
return makeRcloneBackend(volume.config, path);
}
case "sftp": {
return makeSftpBackend(volume.config, path);
}
} }
}; };
export const createVolumeBackend = (volume: Volume): VolumeBackend => {
const path = getVolumePath(volume);
const makeBackend = getBackendFactory(volume.config.backend);
return {
mount: async () => {
const resolvedConfig = await cryptoUtils.resolveSecretsDeep(volume.config);
return makeBackend(resolvedConfig, path).mount();
},
unmount: () => makeBackend(volume.config, path).unmount(),
checkHealth: () => makeBackend(volume.config, path).checkHealth(),
};
};

View file

@ -3,7 +3,6 @@ import * as os from "node:os";
import * as path from "node:path"; import * as path from "node:path";
import { $ } from "bun"; import { $ } from "bun";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@ -79,8 +78,7 @@ const mount = async (config: BackendConfig, mountPath: string) => {
const keyPath = getPrivateKeyPath(mountPath); const keyPath = getPrivateKeyPath(mountPath);
if (config.privateKey) { if (config.privateKey) {
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey); let normalizedKey = config.privateKey.replace(/\r\n/g, "\n");
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n");
if (!normalizedKey.endsWith("\n")) { if (!normalizedKey.endsWith("\n")) {
normalizedKey += "\n"; normalizedKey += "\n";
} }
@ -95,10 +93,9 @@ const mount = async (config: BackendConfig, mountPath: string) => {
let result: $.ShellOutput; let result: $.ShellOutput;
if (config.password) { if (config.password) {
const password = await cryptoUtils.resolveSecret(config.password);
args.push("-o", "password_stdin"); args.push("-o", "password_stdin");
logger.info(`Executing sshfs: echo "******" | sshfs ${args.join(" ")}`); logger.info(`Executing sshfs: echo "******" | sshfs ${args.join(" ")}`);
result = await $`echo ${password} | sshfs ${args}`.nothrow(); result = await $`echo ${config.password} | sshfs ${args}`.nothrow();
} else { } else {
logger.info(`Executing sshfs: sshfs ${args.join(" ")}`); logger.info(`Executing sshfs: sshfs ${args.join(" ")}`);
result = await $`sshfs ${args}`.nothrow(); result = await $`sshfs ${args}`.nothrow();

View file

@ -1,7 +1,6 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@ -36,13 +35,11 @@ const mount = async (config: BackendConfig, path: string) => {
const run = async () => { const run = async () => {
await fs.mkdir(path, { recursive: true }); await fs.mkdir(path, { recursive: true });
const password = await cryptoUtils.resolveSecret(config.password);
const source = `//${config.server}/${config.share}`; const source = `//${config.server}/${config.share}`;
const { uid, gid } = os.userInfo(); const { uid, gid } = os.userInfo();
const options = [ const options = [
`user=${config.username}`, `user=${config.username}`,
`pass=${password}`, `pass=${config.password}`,
`vers=${config.vers}`, `vers=${config.vers}`,
`port=${config.port}`, `port=${config.port}`,
`uid=${uid}`, `uid=${uid}`,

View file

@ -1,7 +1,6 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { cryptoUtils } from "../../../utils/crypto";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { getMountForPath } from "../../../utils/mountinfo"; import { getMountForPath } from "../../../utils/mountinfo";
@ -49,9 +48,8 @@ const mount = async (config: BackendConfig, path: string) => {
: [`uid=${uid}`, `gid=${gid}`, "file_mode=0664", "dir_mode=0775"]; : [`uid=${uid}`, `gid=${gid}`, "file_mode=0664", "dir_mode=0775"];
if (config.username && config.password) { if (config.username && config.password) {
const password = await cryptoUtils.resolveSecret(config.password);
const secretsFile = "/etc/davfs2/secrets"; const secretsFile = "/etc/davfs2/secrets";
const secretsContent = `${source} ${config.username} ${password}\n`; const secretsContent = `${source} ${config.username} ${config.password}\n`;
await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 }); await fs.appendFile(secretsFile, secretsContent, { mode: 0o600 });
} }

View file

@ -83,55 +83,6 @@ async function encryptSensitiveFields(config: NotificationConfig): Promise<Notif
} }
} }
async function decryptSensitiveFields(config: NotificationConfig): Promise<NotificationConfig> {
switch (config.type) {
case "email":
return {
...config,
password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
};
case "slack":
return {
...config,
webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
};
case "discord":
return {
...config,
webhookUrl: await cryptoUtils.resolveSecret(config.webhookUrl),
};
case "gotify":
return {
...config,
token: await cryptoUtils.resolveSecret(config.token),
};
case "ntfy":
return {
...config,
password: config.password ? await cryptoUtils.resolveSecret(config.password) : undefined,
};
case "pushover":
return {
...config,
apiToken: await cryptoUtils.resolveSecret(config.apiToken),
};
case "telegram":
return {
...config,
botToken: await cryptoUtils.resolveSecret(config.botToken),
};
case "generic":
return config;
case "custom":
return {
...config,
shoutrrrUrl: await cryptoUtils.resolveSecret(config.shoutrrrUrl),
};
default:
return config;
}
}
const createDestination = async (name: string, config: NotificationConfig) => { const createDestination = async (name: string, config: NotificationConfig) => {
const slug = slugify(name, { lower: true, strict: true }); const slug = slugify(name, { lower: true, strict: true });
@ -225,9 +176,9 @@ const testDestination = async (id: number) => {
throw new ConflictError("Cannot test disabled notification destination"); throw new ConflictError("Cannot test disabled notification destination");
} }
const decryptedConfig = await decryptSensitiveFields(destination.config); const resolvedConfig = await cryptoUtils.resolveSecretsDeep(destination.config);
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); const shoutrrrUrl = buildShoutrrrUrl(resolvedConfig);
console.log("Testing notification with Shoutrrr URL:", shoutrrrUrl); console.log("Testing notification with Shoutrrr URL:", shoutrrrUrl);
@ -327,8 +278,8 @@ const sendBackupNotification = async (
for (const assignment of relevantAssignments) { for (const assignment of relevantAssignments) {
try { try {
const decryptedConfig = await decryptSensitiveFields(assignment.destination.config); const resolvedConfig = await cryptoUtils.resolveSecretsDeep(assignment.destination.config);
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); const shoutrrrUrl = buildShoutrrrUrl(resolvedConfig);
const result = await sendNotification({ const result = await sendNotification({
shoutrrrUrl, shoutrrrUrl,

View file

@ -35,52 +35,54 @@ export const hasCompatibleCredentials = async (
return true; return true;
} }
// Resolve secrets in both configs for comparison
const resolvedConfig1 = await cryptoUtils.resolveSecretsDeep(config1);
const resolvedConfig2 = await cryptoUtils.resolveSecretsDeep(config2);
switch (group1) { switch (group1) {
case "s3": { case "s3": {
if ( if (
(config1.backend === "s3" || config1.backend === "r2") && (resolvedConfig1.backend === "s3" || resolvedConfig1.backend === "r2") &&
(config2.backend === "s3" || config2.backend === "r2") (resolvedConfig2.backend === "s3" || resolvedConfig2.backend === "r2")
) { ) {
const accessKey1 = await cryptoUtils.resolveSecret(config1.accessKeyId); return (
const secretKey1 = await cryptoUtils.resolveSecret(config1.secretAccessKey); resolvedConfig1.accessKeyId === resolvedConfig2.accessKeyId &&
resolvedConfig1.secretAccessKey === resolvedConfig2.secretAccessKey
const accessKey2 = await cryptoUtils.resolveSecret(config2.accessKeyId); );
const secretKey2 = await cryptoUtils.resolveSecret(config2.secretAccessKey);
return accessKey1 === accessKey2 && secretKey1 === secretKey2;
} }
return false; return false;
} }
case "gcs": { case "gcs": {
if (config1.backend === "gcs" && config2.backend === "gcs") { if (resolvedConfig1.backend === "gcs" && resolvedConfig2.backend === "gcs") {
const credentials1 = await cryptoUtils.resolveSecret(config1.credentialsJson); return (
const credentials2 = await cryptoUtils.resolveSecret(config2.credentialsJson); resolvedConfig1.credentialsJson === resolvedConfig2.credentialsJson &&
resolvedConfig1.projectId === resolvedConfig2.projectId
return credentials1 === credentials2 && config1.projectId === config2.projectId; );
} }
return false; return false;
} }
case "azure": { case "azure": {
if (config1.backend === "azure" && config2.backend === "azure") { if (resolvedConfig1.backend === "azure" && resolvedConfig2.backend === "azure") {
const config1Accountkey = await cryptoUtils.resolveSecret(config1.accountKey); return (
const config2Accountkey = await cryptoUtils.resolveSecret(config2.accountKey); resolvedConfig1.accountName === resolvedConfig2.accountName &&
resolvedConfig1.accountKey === resolvedConfig2.accountKey
return config1.accountName === config2.accountName && config1Accountkey === config2Accountkey; );
} }
return false; return false;
} }
case "rest": { case "rest": {
if (config1.backend === "rest" && config2.backend === "rest") { if (resolvedConfig1.backend === "rest" && resolvedConfig2.backend === "rest") {
if (!config1.username && !config2.username && !config1.password && !config2.password) { if (
!resolvedConfig1.username &&
!resolvedConfig2.username &&
!resolvedConfig1.password &&
!resolvedConfig2.password
) {
return true; return true;
} }
return (
const config1Username = await cryptoUtils.resolveSecret(config1.username || ""); resolvedConfig1.username === resolvedConfig2.username && resolvedConfig1.password === resolvedConfig2.password
const config1Password = await cryptoUtils.resolveSecret(config1.password || ""); );
const config2Username = await cryptoUtils.resolveSecret(config2.username || "");
const config2Password = await cryptoUtils.resolveSecret(config2.password || "");
return config1Username === config2Username && config1Password === config2Password;
} }
return false; return false;
} }

View file

@ -183,7 +183,53 @@ const sealSecret = async (value: string): Promise<string> => {
return encrypt(value); return encrypt(value);
}; };
/**
* Helper to check if a value is a plain object (not null, array, Date, etc.)
*/
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value) && value.constructor === Object;
};
/**
* Recursively resolves all secret references in an object or array.
* Handles nested objects, arrays, and circular references.
*/
const resolveSecretsDeep = async <T>(input: T): Promise<T> => {
const seen = new WeakMap<object, unknown>();
const resolve = async (value: unknown): Promise<unknown> => {
if (typeof value === "string") {
return resolveSecret(value);
}
if (Array.isArray(value)) {
if (seen.has(value)) return seen.get(value);
const result: unknown[] = [];
seen.set(value, result);
for (const item of value) {
result.push(await resolve(item));
}
return result;
}
if (isPlainObject(value)) {
if (seen.has(value)) return seen.get(value);
const result: Record<string, unknown> = {};
seen.set(value, result);
for (const [key, val] of Object.entries(value)) {
result[key] = await resolve(val);
}
return result;
}
return value;
};
return resolve(input) as Promise<T>;
};
export const cryptoUtils = { export const cryptoUtils = {
resolveSecret, resolveSecret,
sealSecret, sealSecret,
resolveSecretsDeep,
}; };

View file

@ -112,10 +112,9 @@ export const buildEnv = async (config: RepositoryConfig) => {
}; };
if (config.isExistingRepository && config.customPassword) { if (config.isExistingRepository && config.customPassword) {
const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword);
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`); const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 }); await fs.writeFile(passwordFilePath, config.customPassword, { mode: 0o600 });
env.RESTIC_PASSWORD_FILE = passwordFilePath; env.RESTIC_PASSWORD_FILE = passwordFilePath;
} else { } else {
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE; env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
@ -123,26 +122,25 @@ export const buildEnv = async (config: RepositoryConfig) => {
switch (config.backend) { switch (config.backend) {
case "s3": case "s3":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); env.AWS_ACCESS_KEY_ID = config.accessKeyId;
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey;
break; break;
case "r2": case "r2":
env.AWS_ACCESS_KEY_ID = await cryptoUtils.resolveSecret(config.accessKeyId); env.AWS_ACCESS_KEY_ID = config.accessKeyId;
env.AWS_SECRET_ACCESS_KEY = await cryptoUtils.resolveSecret(config.secretAccessKey); env.AWS_SECRET_ACCESS_KEY = config.secretAccessKey;
env.AWS_REGION = "auto"; env.AWS_REGION = "auto";
env.AWS_S3_FORCE_PATH_STYLE = "true"; env.AWS_S3_FORCE_PATH_STYLE = "true";
break; break;
case "gcs": { case "gcs": {
const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson);
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`); const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 }); await fs.writeFile(credentialsPath, config.credentialsJson, { mode: 0o600 });
env.GOOGLE_PROJECT_ID = config.projectId; env.GOOGLE_PROJECT_ID = config.projectId;
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath; env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
break; break;
} }
case "azure": { case "azure": {
env.AZURE_ACCOUNT_NAME = config.accountName; env.AZURE_ACCOUNT_NAME = config.accountName;
env.AZURE_ACCOUNT_KEY = await cryptoUtils.resolveSecret(config.accountKey); env.AZURE_ACCOUNT_KEY = config.accountKey;
if (config.endpointSuffix) { if (config.endpointSuffix) {
env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix; env.AZURE_ENDPOINT_SUFFIX = config.endpointSuffix;
} }
@ -150,18 +148,17 @@ export const buildEnv = async (config: RepositoryConfig) => {
} }
case "rest": { case "rest": {
if (config.username) { if (config.username) {
env.RESTIC_REST_USERNAME = await cryptoUtils.resolveSecret(config.username); env.RESTIC_REST_USERNAME = config.username;
} }
if (config.password) { if (config.password) {
env.RESTIC_REST_PASSWORD = await cryptoUtils.resolveSecret(config.password); env.RESTIC_REST_PASSWORD = config.password;
} }
break; break;
} }
case "sftp": { case "sftp": {
const decryptedKey = await cryptoUtils.resolveSecret(config.privateKey);
const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`); const keyPath = path.join("/tmp", `zerobyte-ssh-${crypto.randomBytes(8).toString("hex")}`);
let normalizedKey = decryptedKey.replace(/\r\n/g, "\n"); let normalizedKey = config.privateKey.replace(/\r\n/g, "\n");
if (!normalizedKey.endsWith("\n")) { if (!normalizedKey.endsWith("\n")) {
normalizedKey += "\n"; normalizedKey += "\n";
} }
@ -206,9 +203,8 @@ export const buildEnv = async (config: RepositoryConfig) => {
} }
if (config.cacert) { if (config.cacert) {
const decryptedCert = await cryptoUtils.resolveSecret(config.cacert);
const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`); const certPath = path.join("/tmp", `zerobyte-cacert-${crypto.randomBytes(8).toString("hex")}.pem`);
await fs.writeFile(certPath, decryptedCert, { mode: 0o600 }); await fs.writeFile(certPath, config.cacert, { mode: 0o600 });
env.RESTIC_CACERT = certPath; env.RESTIC_CACERT = certPath;
} }
@ -219,15 +215,24 @@ export const buildEnv = async (config: RepositoryConfig) => {
return env; return env;
}; };
/**
* Resolves all secret placeholders in config and builds repo URL + env.
* Call this at the start of each restic operation.
*/
const resolveAndBuild = async (config: RepositoryConfig) => {
const resolved = await cryptoUtils.resolveSecretsDeep(config);
const repoUrl = buildRepoUrl(resolved);
const env = await buildEnv(resolved);
return { resolved, repoUrl, env };
};
const init = async (config: RepositoryConfig) => { const init = async (config: RepositoryConfig) => {
await ensurePassfile(); await ensurePassfile();
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
logger.info(`Initializing restic repository at ${repoUrl}...`); logger.info(`Initializing restic repository at ${repoUrl}...`);
const env = await buildEnv(config);
const args = ["init", "--repo", repoUrl]; const args = ["init", "--repo", repoUrl];
addCommonArgs(args, env); addCommonArgs(args, env);
@ -270,8 +275,7 @@ const backup = async (
onProgress?: (progress: BackupProgress) => void; onProgress?: (progress: BackupProgress) => void;
}, },
) => { ) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"]; const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"];
@ -422,8 +426,7 @@ const restore = async (
overwrite?: OverwriteMode; overwrite?: OverwriteMode;
}, },
) => { ) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target]; const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
@ -505,8 +508,7 @@ const restore = async (
const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => { const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => {
const { tags } = options; const { tags } = options;
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args = ["--repo", repoUrl, "snapshots"]; const args = ["--repo", repoUrl, "snapshots"];
@ -537,8 +539,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
}; };
const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => { const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag]; const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
@ -579,8 +580,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
}; };
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => { const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
if (snapshotIds.length === 0) { if (snapshotIds.length === 0) {
throw new Error("No snapshot IDs provided for deletion."); throw new Error("No snapshot IDs provided for deletion.");
@ -609,8 +609,7 @@ const tagSnapshots = async (
snapshotIds: string[], snapshotIds: string[],
tags: { add?: string[]; remove?: string[]; set?: string[] }, tags: { add?: string[]; remove?: string[]; set?: string[] },
) => { ) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
if (snapshotIds.length === 0) { if (snapshotIds.length === 0) {
throw new Error("No snapshot IDs provided for tagging."); throw new Error("No snapshot IDs provided for tagging.");
@ -677,8 +676,7 @@ const lsSnapshotInfoSchema = type({
}); });
const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => { const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"]; const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
@ -733,8 +731,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
}; };
const unlock = async (config: RepositoryConfig) => { const unlock = async (config: RepositoryConfig) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args = ["unlock", "--repo", repoUrl, "--remove-all"]; const args = ["unlock", "--repo", repoUrl, "--remove-all"];
addCommonArgs(args, env); addCommonArgs(args, env);
@ -752,8 +749,7 @@ const unlock = async (config: RepositoryConfig) => {
}; };
const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => { const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args: string[] = ["--repo", repoUrl, "check"]; const args: string[] = ["--repo", repoUrl, "check"];
@ -790,8 +786,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
}; };
const repairIndex = async (config: RepositoryConfig) => { const repairIndex = async (config: RepositoryConfig) => {
const repoUrl = buildRepoUrl(config); const { repoUrl, env } = await resolveAndBuild(config);
const env = await buildEnv(config);
const args = ["repair", "index", "--repo", repoUrl]; const args = ["repair", "index", "--repo", repoUrl];
addCommonArgs(args, env); addCommonArgs(args, env);
@ -822,11 +817,8 @@ const copy = async (
snapshotId?: string; snapshotId?: string;
}, },
) => { ) => {
const sourceRepoUrl = buildRepoUrl(sourceConfig); const { resolved: resolvedSource, repoUrl: sourceRepoUrl, env: sourceEnv } = await resolveAndBuild(sourceConfig);
const destRepoUrl = buildRepoUrl(destConfig); const { repoUrl: destRepoUrl, env: destEnv } = await resolveAndBuild(destConfig);
const sourceEnv = await buildEnv(sourceConfig);
const destEnv = await buildEnv(destConfig);
const env: Record<string, string> = { const env: Record<string, string> = {
...sourceEnv, ...sourceEnv,
@ -848,7 +840,7 @@ const copy = async (
addCommonArgs(args, env); addCommonArgs(args, env);
if (sourceConfig.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) { if (resolvedSource.backend === "sftp" && sourceEnv._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`); args.push("-o", `sftp.args=${sourceEnv._SFTP_SSH_ARGS}`);
} }