fix(restic): treat all flag-like snap id args as positional args

This commit is contained in:
Nicolas Meienberger
2026-03-12 18:01:33 +01:00
parent 332e5bffda
commit a1b2d97dbc
14 changed files with 355 additions and 19 deletions

View File

@@ -98,6 +98,18 @@ describe("backup command", () => {
expect(hasFlag("--files-from")).toBe(false);
});
test("treats flag-like source paths as positional args", async () => {
const { getArgs } = setup();
const source = "--help";
await backup(config, source, { organizationId: "org-1" }, mockDeps);
const separatorIndex = getArgs().indexOf("--");
expect(separatorIndex).toBeGreaterThan(-1);
expect(getArgs()[separatorIndex + 1]).toBe(source);
expect(getArgs().at(-1)).toBe(source);
});
test("uses --files-from instead of source path when include list is provided", async () => {
const { hasFlag, getArgs } = setup();
await backup(

View File

@@ -0,0 +1,67 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node";
import { copy } from "../copy";
import type { ResticDeps } from "../../types";
const mockDeps: ResticDeps = {
resolveSecret: async (s) => s,
getOrganizationResticPassword: async () => "org-restic-password",
resticCacheDir: "/tmp/restic-cache",
resticPassFile: "/tmp/restic.pass",
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
};
const sourceConfig = {
backend: "local" as const,
path: "/tmp/source-repo",
isExistingRepository: true,
customPassword: "source-password",
};
const destConfig = {
backend: "local" as const,
path: "/tmp/dest-repo",
isExistingRepository: true,
customPassword: "dest-password",
};
const setup = () => {
let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
capturedArgs = args ?? [];
return { exitCode: 0, stdout: "copied", stderr: "", timedOut: false };
});
return {
getArgs: () => capturedArgs,
};
};
afterEach(() => {
mock.restore();
});
describe("copy command", () => {
test("treats flag-like snapshot IDs as positional args", async () => {
const { getArgs } = setup();
const snapshotId = "--help";
await copy(sourceConfig, destConfig, { organizationId: "org-1", snapshotId, tag: "daily" }, mockDeps);
expect(getArgs()).toEqual([
"--repo",
"/tmp/dest-repo",
"copy",
"--from-repo",
"/tmp/source-repo",
"--tag",
"daily",
"--json",
"--",
snapshotId,
]);
});
});

View File

@@ -0,0 +1,49 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node";
import { deleteSnapshots } from "../delete-snapshots";
import type { ResticDeps } from "../../types";
const mockDeps: ResticDeps = {
resolveSecret: async (s) => s,
getOrganizationResticPassword: async () => "org-restic-password",
resticCacheDir: "/tmp/restic-cache",
resticPassFile: "/tmp/restic.pass",
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
};
const config = {
backend: "local" as const,
path: "/tmp/restic-repo",
isExistingRepository: true,
customPassword: "custom-password",
};
const setup = () => {
let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
capturedArgs = args ?? [];
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
});
return {
getArgs: () => capturedArgs,
};
};
afterEach(() => {
mock.restore();
});
describe("deleteSnapshots command", () => {
test("treats flag-like snapshot IDs as positional args", async () => {
const { getArgs } = setup();
const snapshotIds = ["--help", "--password-command=sh -c 'id'"];
await deleteSnapshots(config, snapshotIds, "org-1", mockDeps);
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "forget", "--prune", "--json", "--", ...snapshotIds]);
});
});

View File

@@ -0,0 +1,62 @@
import { PassThrough } from "node:stream";
import { spawn } from "node:child_process";
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node";
import { dump } from "../dump";
import type { ResticDeps } from "../../types";
const mockDeps: ResticDeps = {
resolveSecret: async (s) => s,
getOrganizationResticPassword: async () => "org-restic-password",
resticCacheDir: "/tmp/restic-cache",
resticPassFile: "/tmp/restic.pass",
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
};
const config = {
backend: "local" as const,
path: "/tmp/restic-repo",
isExistingRepository: true,
customPassword: "custom-password",
};
const setup = () => {
let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeSpawn").mockImplementation((params) => {
capturedArgs = params.args;
const child = { stdout: new PassThrough() } as unknown as ReturnType<typeof spawn>;
params.onSpawn?.(child);
return Promise.resolve({ exitCode: 0, summary: "", error: "" });
});
return {
getArgs: () => capturedArgs,
};
};
afterEach(() => {
mock.restore();
});
describe("dump command", () => {
test("treats snapshot reference as a positional arg", async () => {
const { getArgs } = setup();
const result = await dump(config, "--help", { organizationId: "org-1", path: "folder/file.txt" }, mockDeps);
await result.completion;
expect(getArgs()).toEqual([
"--repo",
"/tmp/restic-repo",
"dump",
"--archive",
"tar",
"--",
"--help",
"/folder/file.txt",
]);
});
});

View File

@@ -0,0 +1,63 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as spawnModule from "../../../utils/spawn";
import { ls } from "../ls";
import type { ResticDeps } from "../../types";
import type { SafeSpawnParams } from "../../../utils/spawn";
const mockDeps: ResticDeps = {
resolveSecret: async (s) => s,
getOrganizationResticPassword: async () => "org-restic-password",
resticCacheDir: "/tmp/restic-cache",
resticPassFile: "/tmp/restic.pass",
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
};
const config = {
backend: "local" as const,
path: "/tmp/restic-repo",
isExistingRepository: true,
customPassword: "custom-password",
};
const snapshotLine = JSON.stringify({
time: "2025-01-01T00:00:00Z",
tree: "abc",
paths: ["/"],
hostname: "host",
id: "id",
short_id: "short",
struct_type: "snapshot",
message_type: "snapshot",
});
const setup = () => {
let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
capturedArgs = params.args;
params.onStdout?.(snapshotLine);
return Promise.resolve({ exitCode: 0, summary: snapshotLine, error: "" });
});
return {
getArgs: () => capturedArgs,
};
};
afterEach(() => {
mock.restore();
});
describe("ls command", () => {
test("treats flag-like snapshot and path values as positional args", async () => {
const { getArgs } = setup();
const snapshotId = "--password-command=sh -c 'id'";
const path = "--help";
await ls(config, snapshotId, "org-1", path, undefined, mockDeps);
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "ls", "--long", "--json", "--", snapshotId, path]);
});
});

View File

@@ -39,11 +39,11 @@ const setup = () => {
});
const getRestoreArg = () => {
const restoreIndex = capturedArgs.indexOf("restore");
if (restoreIndex < 0 || !capturedArgs[restoreIndex + 1]) {
throw new Error("Expected restore argument after restore command");
const separatorIndex = capturedArgs.indexOf("--");
if (separatorIndex < 0 || !capturedArgs[separatorIndex + 1]) {
throw new Error("Expected restore argument after separator");
}
return capturedArgs[restoreIndex + 1]!;
return capturedArgs[separatorIndex + 1]!;
};
const getOptionValues = (option: string): string[] => {
@@ -160,4 +160,31 @@ describe("restore command", () => {
expect(getOptionValues("--include")).toEqual([]);
expect(getArgs()).not.toContain("");
});
test("treats flag-like snapshot IDs as positional restore args", async () => {
const { getArgs, getRestoreArg } = setup();
await restore(
config,
"--help",
"/tmp/restore-target",
{
organizationId: "org-1",
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
},
mockDeps,
);
expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
expect(getArgs()).toEqual([
"--repo",
"/tmp/restic-repo",
"restore",
"--target",
"/tmp/restore-target",
"--json",
"--",
"--help:/var/lib/zerobyte/volumes/vol123/_data",
]);
});
});

View File

@@ -0,0 +1,49 @@
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
import * as nodeModule from "../../../node";
import { tagSnapshots } from "../tag-snapshots";
import type { ResticDeps } from "../../types";
const mockDeps: ResticDeps = {
resolveSecret: async (s) => s,
getOrganizationResticPassword: async () => "org-restic-password",
resticCacheDir: "/tmp/restic-cache",
resticPassFile: "/tmp/restic.pass",
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
};
const config = {
backend: "local" as const,
path: "/tmp/restic-repo",
isExistingRepository: true,
customPassword: "custom-password",
};
const setup = () => {
let capturedArgs: string[] = [];
spyOn(cleanupModule, "cleanupTemporaryKeys").mockImplementation(() => Promise.resolve());
spyOn(nodeModule, "safeExec").mockImplementation(async ({ args }) => {
capturedArgs = args ?? [];
return { exitCode: 0, stdout: "", stderr: "", timedOut: false };
});
return {
getArgs: () => capturedArgs,
};
};
afterEach(() => {
mock.restore();
});
describe("tagSnapshots command", () => {
test("treats flag-like snapshot IDs as positional args", async () => {
const { getArgs } = setup();
const snapshotIds = ["--help", "--password-command=sh -c 'id'"];
await tagSnapshots(config, snapshotIds, { add: ["keep"] }, "org-1", mockDeps);
expect(getArgs()).toEqual(["--repo", "/tmp/restic-repo", "tag", "--add", "keep", "--json", "--", ...snapshotIds]);
});
});

View File

@@ -50,6 +50,8 @@ export const backup = async (
}
let includeFile: string | null = null;
const usesSourceArg = !options.include || options.include.length === 0;
if (options.include && options.include.length > 0) {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "zerobyte-restic-include-"));
includeFile = path.join(tmp, "include.txt");
@@ -57,8 +59,6 @@ export const backup = async (
await fs.writeFile(includeFile, options.include.join("\n"), "utf-8");
args.push("--files-from", includeFile);
} else {
args.push(source);
}
for (const exclude of deps.defaultExcludes) {
@@ -94,6 +94,10 @@ export const backup = async (
addCommonArgs(args, env, config);
if (usesSourceArg) {
args.push("--", source);
}
const logData = throttle((data: string) => {
logger.info(data.trim());
}, 5000);

View File

@@ -32,12 +32,6 @@ export const copy = async (
args.push("--tag", options.tag);
}
if (options.snapshotId) {
args.push(options.snapshotId);
} else {
args.push("latest");
}
addCommonArgs(args, env, destConfig, { skipBandwidth: true });
const sourceDownloadLimit = formatBandwidthLimit(sourceConfig.downloadLimit);
@@ -51,6 +45,8 @@ export const copy = async (
args.push("--limit-upload", destUploadLimit);
}
args.push("--", options.snapshotId ?? "latest");
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
logger.debug(`Executing: restic ${args.join(" ")}`);

View File

@@ -20,8 +20,9 @@ export const deleteSnapshots = async (
throw new Error("No snapshot IDs provided for deletion.");
}
const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
const args: string[] = ["--repo", repoUrl, "forget", "--prune"];
addCommonArgs(args, env, config);
args.push("--", ...snapshotIds);
const res = await safeExec({ command: "restic", args, env });
await cleanupTemporaryKeys(env, deps);

View File

@@ -32,13 +32,14 @@ export const dump = async (
const env = await buildEnv(config, options.organizationId, deps);
const pathToDump = normalizeDumpPath(options.path);
const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump];
const args: string[] = ["--repo", repoUrl, "dump"];
if (options.archive !== false) {
args.push("--archive", "tar");
}
addCommonArgs(args, env, config, { includeJson: false });
args.push("--", snapshotRef, pathToDump);
logger.debug(`Executing: restic ${args.join(" ")}`);

View File

@@ -60,14 +60,15 @@ export const ls = async (
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, organizationId, deps);
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
const args: string[] = ["--repo", repoUrl, "ls", "--long"];
addCommonArgs(args, env, config);
args.push("--", snapshotId);
if (path) {
args.push(path);
}
addCommonArgs(args, env, config);
let snapshot: LsSnapshotInfo | null = null;
const nodes: LsNode[] = [];
let totalNodes = 0;
@@ -77,6 +78,8 @@ export const ls = async (
const offset = Math.max(options?.offset ?? 0, 0);
const limit = Math.min(Math.max(options?.limit ?? 500, 1), 500);
logger.debug(`Running restic ls with args: ${args.join(" ")}`);
const res = await safeSpawn({
command: "restic",
args,

View File

@@ -57,7 +57,7 @@ export const restore = async (
restoreArg = `${snapshotId}:${commonAncestor}`;
}
const args = ["--repo", repoUrl, "restore", restoreArg, "--target", target];
const args = ["--repo", repoUrl, "restore", "--target", target];
if (options.overwrite) {
args.push("--overwrite", options.overwrite);
@@ -95,6 +95,7 @@ export const restore = async (
}
addCommonArgs(args, env, config);
args.push("--", restoreArg);
const streamProgress = throttle((data: string) => {
if (options.onProgress) {

View File

@@ -21,7 +21,7 @@ export const tagSnapshots = async (
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, organizationId, deps);
const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds];
const args: string[] = ["--repo", repoUrl, "tag"];
if (tags.add) {
for (const tag of tags.add) {
@@ -42,6 +42,7 @@ export const tagSnapshots = async (
}
addCommonArgs(args, env, config);
args.push("--", ...snapshotIds);
const res = await safeExec({ command: "restic", args, env });
await cleanupTemporaryKeys(env, deps);