Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions packages/core/src/restic/commands/__tests__/restore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,119 @@ describe("restore command", () => {
expect(separatorIndex).toBeGreaterThan(-1);
expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data");
});

test("uses basePath as an include when restoring a scoped path to the filesystem root", async () => {
const { getRestoreArg, getOptionValues } = setup();

await runRestore(
config,
"snapshot-scoped-root",
"/",
{
organizationId: "org-1",
basePath: "/var/lib/app",
},
mockDeps,
);

expect(getRestoreArg()).toBe("snapshot-scoped-root");
expect(getOptionValues("--include")).toEqual(["/var/lib/app"]);
});

test("uses restic snapshot tree syntax for Windows base paths restored to drive roots", async () => {
const { getRestoreArg, getOptionValues } = setup();

await runRestore(
config,
"snapshot-windows",
"C:\\",
{
organizationId: "org-1",
basePath: "C:\\Users\\foo",
},
mockDeps,
);

expect(getOptionValues("--target")).toEqual(["C:\\"]);
expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/foo");
});

test("keeps the precomputed Windows original-location target for restore-all", async () => {
const { getRestoreArg, getOptionValues } = setup();

await runRestore(
config,
"snapshot-windows",
"C:\\Users",
{
organizationId: "org-1",
basePath: "/C/Users/foo",
},
mockDeps,
);

expect(getOptionValues("--target")).toEqual(["C:\\Users"]);
expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/foo");
expect(getOptionValues("--include")).toEqual([]);
});

test("keeps the precomputed Windows original-location target for selections", async () => {
const { getRestoreArg, getOptionValues } = setup();

await runRestore(
config,
"snapshot-windows",
"C:\\Users\\foo",
{
organizationId: "org-1",
include: ["/C/Users/foo/Downloads"],
selectedItemKind: "dir",
},
mockDeps,
);

expect(getOptionValues("--target")).toEqual(["C:\\Users\\foo"]);
expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/foo/Downloads");
expect(getOptionValues("--include")).toEqual([]);
});

test("strips selected Windows directories for custom drive-root targets", async () => {
const { getRestoreArg, getOptionValues } = setup();

await runRestore(
config,
"snapshot-windows",
"C:\\",
{
organizationId: "org-1",
include: ["C:\\Users\\foo\\Documents"],
selectedItemKind: "dir",
},
mockDeps,
);

expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/foo/Documents");
expect(getOptionValues("--include")).toEqual([]);
});

test("passes exclude patterns verbatim", async () => {
const { getRestoreArg, getOptionValues } = setup();

await runRestore(
config,
"snapshot-windows",
"C:\\",
{
organizationId: "org-1",
basePath: "C:\\Users\\foo",
exclude: ["*.tmp", "C:\\Users\\foo\\Photos\\private"],
},
mockDeps,
);

expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/foo");
expect(getOptionValues("--exclude")).toEqual(["*.tmp", "C:\\Users\\foo\\Photos\\private"]);
});
});

describe("output handling", () => {
Expand Down
51 changes: 17 additions & 34 deletions packages/core/src/restic/commands/restore.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import path from "node:path";
import { findCommonAncestor } from "../../utils/common-ancestor";
import { addCommonArgs } from "../helpers/add-common-args";
import { buildEnv } from "../helpers/build-env";
import { buildRepoUrl } from "../helpers/build-repo-url";
Expand All @@ -16,20 +14,21 @@ import {
import type { ResticDeps } from "../types";
import { Data, Effect } from "effect";
import { toMessage } from "../../utils";
import { createRestorePathArgs } from "../helpers/restore-paths";
import type { SnapshotSourcePathKind } from "../helpers/snapshot-paths";

class ResticRestoreCommandError extends Data.TaggedError("ResticRestoreCommandError")<{
cause: unknown;
message: string;
}> {}

const escapeResticIncludePattern = (pattern: string) => pattern.replace(/[\\*?[\]]/g, "\\$&");

export const restore = (
config: RepositoryConfig,
snapshotId: string,
target: string,
options: {
basePath?: string;
sourcePathKind?: SnapshotSourcePathKind;
organizationId: string;
include?: string[];
selectedItemKind?: "file" | "dir";
Expand All @@ -50,44 +49,28 @@ export const restore = (
(env) => Effect.promise(() => cleanupTemporaryKeys(env, deps)),
);

const includes = options.include?.length ? options.include : [options.basePath ?? "/"];
const commonAncestor =
options.selectedItemKind === "file" && includes.length === 1
? path.posix.dirname(includes[0] ?? "/")
: findCommonAncestor(includes);
const restoreArg = target === "/" ? snapshotId : `${snapshotId}:${commonAncestor}`;
const pathArgs = createRestorePathArgs({
snapshotId,
target,
basePath: options.basePath,
sourcePathKind: options.sourcePathKind,
include: options.include,
exclude: options.exclude,
selectedItemKind: options.selectedItemKind,
});

const args = ["--repo", repoUrl, "restore", "--target", target];

if (options.overwrite) {
args.push("--overwrite", options.overwrite);
}

if (options.include?.length) {
if (target === "/") {
for (const pattern of options.include) {
args.push("--include", escapeResticIncludePattern(pattern));
}
} else {
const strippedIncludes = options.include.map((pattern) =>
path.posix.relative(commonAncestor, pattern),
);
const includesCoverRestoreRoot = strippedIncludes.some(
(pattern) => pattern === "" || pattern === ".",
);

if (!includesCoverRestoreRoot) {
for (const pattern of strippedIncludes) {
args.push("--include", escapeResticIncludePattern(pattern));
}
}
}
for (const pattern of pathArgs.includePatterns) {
args.push("--include", pattern);
}

if (options.exclude?.length) {
for (const pattern of options.exclude) {
args.push("--exclude", pattern);
}
for (const pattern of pathArgs.excludePatterns) {
args.push("--exclude", pattern);
}

if (options.excludeXattr?.length) {
Expand All @@ -97,7 +80,7 @@ export const restore = (
}

addCommonArgs(args, env, config);
args.push("--", restoreArg);
args.push("--", pathArgs.restoreArg);

const onProgress = options.onProgress;
const resticProgressFps = process.env.RESTIC_PROGRESS_FPS ?? "1";
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/restic/helpers/__tests__/restore-paths.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import { createRestorePathArgs, getResticRestoreRoot } from "../restore-paths";
import { findResticCommonAncestor, getRelativeResticPath } from "../snapshot-paths";

describe("getResticRestoreRoot", () => {
test("keeps single-letter POSIX roots case-sensitive unless source paths are explicitly Windows", () => {
expect(findResticCommonAncestor(["/a/foo", "/a/Foo/bar"])).toBe("/a");
expect(getRelativeResticPath("/a/foo", "/a/Foo/bar")).toBe("../Foo/bar");
});

test("uses the case-insensitive Windows common ancestor for same-drive paths", () => {
expect(getResticRestoreRoot(["/C/Users/Foo/Photos", "/c/users/foo/Documents"], undefined, "windows")).toBe(
"/C/Users/Foo",
);
});

test("uses the selected file parent for Windows restic paths", () => {
expect(getResticRestoreRoot(["/C/Users/Foo/Downloads/DumpStack.log"], "file", "windows")).toBe(
"/C/Users/Foo/Downloads",
);
});

test("uses restic root for Windows paths spanning drives", () => {
expect(getResticRestoreRoot(["/C/Users/Foo", "/D/Archive"], undefined, "windows")).toBe("/");
});
});

describe("createRestorePathArgs", () => {
test("uses the canonical Windows root for restore args and include patterns", () => {
expect(
createRestorePathArgs({
snapshotId: "snap-1",
target: "C:\\Users\\Foo",
sourcePathKind: "windows",
include: ["/C/Users/Foo/Photos", "/c/users/foo/Documents"],
}),
).toEqual({
restoreArg: "snap-1:/C/Users/Foo",
includePatterns: ["Photos", "Documents"],
excludePatterns: [],
});
});

test("restores multiple Windows drives from restic root for custom targets", () => {
expect(
createRestorePathArgs({
snapshotId: "snap-1",
target: "C:\\Restore",
sourcePathKind: "windows",
include: ["/C/Users/Foo", "/D/Archive"],
}),
).toEqual({
restoreArg: "snap-1:/",
includePatterns: ["C/Users/Foo", "D/Archive"],
excludePatterns: [],
});
});

test("keeps POSIX snapshot path names byte-for-byte", () => {
expect(
createRestorePathArgs({
snapshotId: "snap-1",
target: "/restore",
sourcePathKind: "posix",
include: ["/tmp/foo%2Fbar.txt"],
selectedItemKind: "file",
}),
).toEqual({
restoreArg: "snap-1:/tmp",
includePatterns: ["foo%2Fbar.txt"],
excludePatterns: [],
});
});

test("preserves exclude patterns verbatim", () => {
expect(
createRestorePathArgs({
snapshotId: "snap-1",
target: "/restore",
basePath: "/var/lib/app",
exclude: ["*.tmp", "cache/[old]"],
}),
).toEqual({
restoreArg: "snap-1:/var/lib/app",
includePatterns: [],
excludePatterns: ["*.tmp", "cache/[old]"],
});
});
});
Loading