diff --git a/packages/core/src/restic/commands/__tests__/backup.test.ts b/packages/core/src/restic/commands/__tests__/backup.test.ts index a4ba073f..fdd0e14b 100644 --- a/packages/core/src/restic/commands/__tests__/backup.test.ts +++ b/packages/core/src/restic/commands/__tests__/backup.test.ts @@ -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( diff --git a/packages/core/src/restic/commands/__tests__/copy.test.ts b/packages/core/src/restic/commands/__tests__/copy.test.ts new file mode 100644 index 00000000..dbc15fdb --- /dev/null +++ b/packages/core/src/restic/commands/__tests__/copy.test.ts @@ -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, + ]); + }); +}); diff --git a/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts new file mode 100644 index 00000000..ad136890 --- /dev/null +++ b/packages/core/src/restic/commands/__tests__/delete-snapshots.test.ts @@ -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]); + }); +}); diff --git a/packages/core/src/restic/commands/__tests__/dump.test.ts b/packages/core/src/restic/commands/__tests__/dump.test.ts new file mode 100644 index 00000000..0b21ba5f --- /dev/null +++ b/packages/core/src/restic/commands/__tests__/dump.test.ts @@ -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; + 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", + ]); + }); +}); diff --git a/packages/core/src/restic/commands/__tests__/ls.test.ts b/packages/core/src/restic/commands/__tests__/ls.test.ts new file mode 100644 index 00000000..861ff3cb --- /dev/null +++ b/packages/core/src/restic/commands/__tests__/ls.test.ts @@ -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]); + }); +}); diff --git a/packages/core/src/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts index 3908c468..1a22c1d4 100644 --- a/packages/core/src/restic/commands/__tests__/restore.test.ts +++ b/packages/core/src/restic/commands/__tests__/restore.test.ts @@ -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", + ]); + }); }); diff --git a/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts new file mode 100644 index 00000000..77246c1a --- /dev/null +++ b/packages/core/src/restic/commands/__tests__/tag-snapshots.test.ts @@ -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]); + }); +}); diff --git a/packages/core/src/restic/commands/backup.ts b/packages/core/src/restic/commands/backup.ts index 4465938b..aa27aa98 100644 --- a/packages/core/src/restic/commands/backup.ts +++ b/packages/core/src/restic/commands/backup.ts @@ -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); diff --git a/packages/core/src/restic/commands/copy.ts b/packages/core/src/restic/commands/copy.ts index 1f793eec..3ff8ef60 100644 --- a/packages/core/src/restic/commands/copy.ts +++ b/packages/core/src/restic/commands/copy.ts @@ -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(" ")}`); diff --git a/packages/core/src/restic/commands/delete-snapshots.ts b/packages/core/src/restic/commands/delete-snapshots.ts index 5368391b..bf52b04c 100644 --- a/packages/core/src/restic/commands/delete-snapshots.ts +++ b/packages/core/src/restic/commands/delete-snapshots.ts @@ -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); diff --git a/packages/core/src/restic/commands/dump.ts b/packages/core/src/restic/commands/dump.ts index f4ac9b6a..34104581 100644 --- a/packages/core/src/restic/commands/dump.ts +++ b/packages/core/src/restic/commands/dump.ts @@ -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(" ")}`); diff --git a/packages/core/src/restic/commands/ls.ts b/packages/core/src/restic/commands/ls.ts index 5d8860a6..1d703025 100644 --- a/packages/core/src/restic/commands/ls.ts +++ b/packages/core/src/restic/commands/ls.ts @@ -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, diff --git a/packages/core/src/restic/commands/restore.ts b/packages/core/src/restic/commands/restore.ts index 9539122a..df41d7be 100644 --- a/packages/core/src/restic/commands/restore.ts +++ b/packages/core/src/restic/commands/restore.ts @@ -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) { diff --git a/packages/core/src/restic/commands/tag-snapshots.ts b/packages/core/src/restic/commands/tag-snapshots.ts index 997c8add..a2c661f1 100644 --- a/packages/core/src/restic/commands/tag-snapshots.ts +++ b/packages/core/src/restic/commands/tag-snapshots.ts @@ -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);