From 1a4fb41c994f2e660d5a6153e16ec5b5cff83fa0 Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 30 Jun 2026 16:05:48 +0200 Subject: [PATCH] refactor: add snapshot path context facade Snapshot browsing, restore planning, dump preparation, and Windows path handling were sharing the same path rules through many helpers and callers. This creates one core facade that owns source-path detection, restic path conversion, display path, restore targets, and dump --- .../restic/commands/__tests__/restore.test.ts | 113 ++++++ packages/core/src/restic/commands/restore.ts | 51 +-- .../helpers/__tests__/restore-paths.test.ts | 89 +++++ .../__tests__/snapshot-path-context.test.ts | 261 ++++++++++++ .../core/src/restic/helpers/restore-paths.ts | 78 ++++ .../restic/helpers/snapshot-path-context.ts | 374 ++++++++++++++++++ .../core/src/restic/helpers/snapshot-paths.ts | 203 ++++++++++ packages/core/src/restic/index.ts | 17 + packages/core/src/restic/server.ts | 1 + .../core/src/utils/__tests__/path.test.ts | 26 +- packages/core/src/utils/index.ts | 9 +- packages/core/src/utils/path.ts | 34 ++ 12 files changed, 1220 insertions(+), 36 deletions(-) create mode 100644 packages/core/src/restic/helpers/__tests__/restore-paths.test.ts create mode 100644 packages/core/src/restic/helpers/__tests__/snapshot-path-context.test.ts create mode 100644 packages/core/src/restic/helpers/restore-paths.ts create mode 100644 packages/core/src/restic/helpers/snapshot-path-context.ts create mode 100644 packages/core/src/restic/helpers/snapshot-paths.ts diff --git a/packages/core/src/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts index 3fc8dea86..350a46cbb 100644 --- a/packages/core/src/restic/commands/__tests__/restore.test.ts +++ b/packages/core/src/restic/commands/__tests__/restore.test.ts @@ -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", () => { diff --git a/packages/core/src/restic/commands/restore.ts b/packages/core/src/restic/commands/restore.ts index 35b488602..2628083de 100644 --- a/packages/core/src/restic/commands/restore.ts +++ b/packages/core/src/restic/commands/restore.ts @@ -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"; @@ -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"; @@ -50,12 +49,15 @@ 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]; @@ -63,31 +65,12 @@ export const restore = ( 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) { @@ -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"; diff --git a/packages/core/src/restic/helpers/__tests__/restore-paths.test.ts b/packages/core/src/restic/helpers/__tests__/restore-paths.test.ts new file mode 100644 index 000000000..3963c828d --- /dev/null +++ b/packages/core/src/restic/helpers/__tests__/restore-paths.test.ts @@ -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]"], + }); + }); +}); diff --git a/packages/core/src/restic/helpers/__tests__/snapshot-path-context.test.ts b/packages/core/src/restic/helpers/__tests__/snapshot-path-context.test.ts new file mode 100644 index 000000000..bc95daada --- /dev/null +++ b/packages/core/src/restic/helpers/__tests__/snapshot-path-context.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, test } from "vitest"; +import { createSnapshotPathContext, SnapshotRestorePlanningError } from "../snapshot-path-context"; + +describe("createSnapshotPathContext", () => { + test("plans POSIX browsing and display conversion paths", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/mnt/project/docs", "/mnt/project/photos"], + targetPlatform: "linux", + displayBasePath: "/mnt", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "posix", + queryBasePath: "/mnt/project", + requiresCustomTarget: false, + canRestoreOriginal: true, + }); + expect(context.browser.initialQueryPath()).toBe("/mnt/project"); + expect(context.browser.isDisplayBaseCompatible()).toBe(true); + expect(context.browser.toDisplayPath("/mnt/project/docs")).toBe("/project/docs"); + expect(context.browser.toSnapshotPath("/project/photos")).toBe("/mnt/project/photos"); + }); + + test("plans POSIX restore paths", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/mnt/project/docs", "/mnt/project/photos"], + targetPlatform: "linux", + displayBasePath: "/mnt", + }); + + expect( + context.restore.plan({ + location: { kind: "custom", targetPath: "/restore-target" }, + include: ["/mnt/project/docs/report.txt"], + selectedItemKind: "file", + }), + ).toMatchObject({ + target: "/restore-target", + options: { + basePath: "/mnt/project", + sourcePathKind: "posix", + include: ["/mnt/project/docs/report.txt"], + selectedItemKind: "file", + }, + }); + }); + + test("keeps POSIX restore selections and exclude patterns literal", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/tmp/foo%2Fbar.txt"], + targetPlatform: "linux", + }); + + expect( + context.restore.plan({ + location: { kind: "custom", targetPath: "/restore-target" }, + include: ["/tmp/foo%2Fbar.txt"], + selectedItemKind: "file", + exclude: ["*.tmp"], + }), + ).toMatchObject({ + target: "/restore-target", + options: { + basePath: "/tmp/foo%2Fbar.txt", + sourcePathKind: "posix", + include: ["/tmp/foo%2Fbar.txt"], + selectedItemKind: "file", + exclude: ["*.tmp"], + }, + }); + }); + + test("plans POSIX dump paths", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/mnt/project/docs", "/mnt/project/photos"], + targetPlatform: "linux", + displayBasePath: "/mnt", + }); + + expect(context.dump.plan({ snapshotId: "snap-1", requestedPath: "/mnt/project/docs" })).toEqual({ + snapshotRef: "snap-1:/mnt/project", + path: "/docs", + }); + }); + + test("keeps restic drive-looking paths as POSIX source paths on POSIX targets", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/C/projects/App", "/C/projects/app/data"], + targetPlatform: "linux", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "posix", + queryBasePath: "/C/projects", + requiresCustomTarget: false, + canRestoreOriginal: true, + }); + expect(context.restore.plan({ location: { kind: "original" } })).toMatchObject({ + target: "/", + options: { + basePath: "/C/projects", + sourcePathKind: "posix", + }, + }); + }); + + test("keeps ambiguous lowercase drive-looking paths as POSIX source paths on Windows targets", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/a/foo", "/a/Foo/bar"], + targetPlatform: "win32", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "posix", + queryBasePath: "/a", + requiresCustomTarget: false, + canRestoreOriginal: true, + }); + }); + + test("handles Windows host paths with case-insensitive display roots on Windows targets", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["C:\\Users\\foo\\Photos", "c:/Users/foo/Documents"], + targetPlatform: "win32", + displayBasePath: "C:\\Users\\Foo", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "windows", + queryBasePath: "/C/Users/foo", + requiresCustomTarget: false, + canRestoreOriginal: true, + }); + expect(context.browser.isDisplayBaseCompatible()).toBe(true); + expect(context.browser.toDisplayPath("/C/Users/foo/Photos")).toBe("/Photos"); + expect(context.browser.toSnapshotPath("/Downloads")).toBe("/C/Users/foo/Downloads"); + expect(context.restore.plan({ location: { kind: "original" } })).toMatchObject({ + target: "C:\\Users\\foo", + options: { basePath: "/C/Users/foo", sourcePathKind: "windows" }, + }); + }); + + test("keeps Windows query base when original restore is unavailable on POSIX targets", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["C:\\Users\\Nicolas\\Photos", "C:\\Users\\Nicolas\\Documents"], + targetPlatform: "linux", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "windows", + queryBasePath: "/C/Users/Nicolas", + requiresCustomTarget: true, + canRestoreOriginal: false, + }); + expect(context.browser.initialQueryPath()).toBe("/C/Users/Nicolas"); + expect(context.restore.targetPlan()).toEqual({ + queryBasePath: "/C/Users/Nicolas", + requiresCustomTarget: true, + }); + expect(context.dump.plan({ snapshotId: "snap-1", requestedPath: "/C/Users/Nicolas/Photos" })).toEqual({ + snapshotRef: "snap-1:/C/Users/Nicolas", + path: "/Photos", + }); + expect(() => context.restore.plan({ location: { kind: "original" } })).toThrow(SnapshotRestorePlanningError); + }); + + test("treats restic Windows drive paths as Windows source paths on Windows targets", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/C/Users/foo/Photos", "/C/Users/foo/Documents"], + targetPlatform: "win32", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "windows", + queryBasePath: "/C/Users/foo", + requiresCustomTarget: false, + }); + expect(context.restore.plan({ location: { kind: "original" } })).toMatchObject({ + target: "C:\\Users\\foo", + options: { basePath: "/C/Users/foo", sourcePathKind: "windows" }, + }); + }); + + test("treats restic Windows drive paths as Windows source paths with native Windows display roots", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/C/Users/foo/Photos", "/C/Users/foo/Documents"], + displayBasePath: "C:\\Users\\Foo", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "windows", + queryBasePath: "/C/Users/foo", + requiresCustomTarget: false, + }); + expect(context.browser.toDisplayPath("/C/Users/foo/Photos")).toBe("/Photos"); + }); + + test("keeps selected Windows files in their original folder on Windows targets", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["C:\\Users\\Foo\\Downloads"], + targetPlatform: "win32", + }); + + expect( + context.restore.plan({ + location: { kind: "original" }, + include: ["/C/Users/Foo/Downloads/DumpStack.log"], + selectedItemKind: "file", + }), + ).toMatchObject({ + target: "C:\\Users\\Foo\\Downloads", + options: { + basePath: "/C/Users/Foo/Downloads", + sourcePathKind: "windows", + include: ["/C/Users/Foo/Downloads/DumpStack.log"], + selectedItemKind: "file", + }, + }); + }); + + test("requires custom restore targets for Windows paths spanning drives", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["C:\\some\\path", "D:\\other\\path"], + targetPlatform: "win32", + }); + + expect(context.source).toMatchObject({ + sourcePathKind: "windows", + queryBasePath: "/", + requiresCustomTarget: true, + canRestoreOriginal: false, + }); + expect(() => context.restore.plan({ location: { kind: "original" } })).toThrow(SnapshotRestorePlanningError); + }); + + test("requires custom restore targets when display base does not contain the snapshot base", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/mnt/project"], + targetPlatform: "linux", + displayBasePath: "/other/root", + }); + + expect(context.browser.isDisplayBaseCompatible()).toBe(false); + expect(context.restore.targetPlan()).toEqual({ + queryBasePath: "/mnt/project", + requiresCustomTarget: true, + }); + expect(context.browser.toDisplayPath("/mnt/project")).toBe("/mnt/project"); + }); + + test("rejects dump paths outside the snapshot base", () => { + const context = createSnapshotPathContext({ + snapshotPaths: ["/mnt/project/docs"], + targetPlatform: "linux", + }); + + expect(() => context.dump.plan({ snapshotId: "snap-1", requestedPath: "/other/path" })).toThrow( + "Requested path is outside the snapshot base path", + ); + }); +}); diff --git a/packages/core/src/restic/helpers/restore-paths.ts b/packages/core/src/restic/helpers/restore-paths.ts new file mode 100644 index 000000000..d3b305322 --- /dev/null +++ b/packages/core/src/restic/helpers/restore-paths.ts @@ -0,0 +1,78 @@ +import { + getRelativeResticPath, + getResticRestoreRoot, + toResticSnapshotPath, + type SnapshotSourcePathKind, +} from "./snapshot-paths"; + +export { findResticCommonAncestor, getResticRestoreRoot } from "./snapshot-paths"; + +type RestorePathInput = { + snapshotId: string; + target: string; + basePath?: string; + sourcePathKind?: SnapshotSourcePathKind; + include?: string[]; + exclude?: string[]; + selectedItemKind?: "file" | "dir"; +}; + +type RestorePathArgs = { + restoreArg: string; + includePatterns: string[]; + excludePatterns: string[]; +}; + +const escapeResticIncludePattern = (pattern: string): string => pattern.replace(/[\\*?[\]]/g, "\\$&"); + +const getPathPatterns = ( + patterns: string[], + target: string, + restoreRoot: string, + sourcePathKind?: SnapshotSourcePathKind, +): string[] => { + if (target === "/") { + return patterns.map(toResticSnapshotPath); + } + + return patterns.map((pattern) => getRelativeResticPath(restoreRoot, pattern, sourcePathKind)); +}; + +const getIncludePatterns = ( + includes: string[], + target: string, + restoreRoot: string, + sourcePathKind?: SnapshotSourcePathKind, +): string[] => { + if (!includes.length) return []; + + const includePatterns = getPathPatterns(includes, target, restoreRoot, sourcePathKind); + const includesCoverRestoreRoot = + target !== "/" && includePatterns.some((pattern) => pattern === "" || pattern === "."); + + if (includesCoverRestoreRoot) { + return []; + } + + return includePatterns.map(escapeResticIncludePattern); +}; + +export const createRestorePathArgs = ({ + snapshotId, + target, + basePath, + sourcePathKind, + include, + exclude, + selectedItemKind, +}: RestorePathInput): RestorePathArgs => { + const includes = include?.length ? include : [basePath ?? "/"]; + const restoreRoot = getResticRestoreRoot(includes, selectedItemKind, sourcePathKind); + const restoreArg = target === "/" ? snapshotId : `${snapshotId}:${restoreRoot}`; + + return { + restoreArg, + includePatterns: getIncludePatterns(includes, target, restoreRoot, sourcePathKind), + excludePatterns: exclude ?? [], + }; +}; diff --git a/packages/core/src/restic/helpers/snapshot-path-context.ts b/packages/core/src/restic/helpers/snapshot-path-context.ts new file mode 100644 index 000000000..1bb36611c --- /dev/null +++ b/packages/core/src/restic/helpers/snapshot-path-context.ts @@ -0,0 +1,374 @@ +import { isWindowsHostPath, windowsResticSnapshotPathToHostPath } from "../../utils/path"; +import type { OverwriteMode } from "../schemas"; +import { + findResticCommonAncestor, + findWindowsResticCommonAncestor, + getPosixRelativePath, + getResticRestoreRoot, + isUnsupportedNativeSnapshotPath, + isWindowsResticSnapshotPath, + toResticSnapshotPath, + type HostPathKind, + type SnapshotSourcePathKind, +} from "./snapshot-paths"; + +export type { HostPathKind, SnapshotSourcePathKind } from "./snapshot-paths"; + +export type SnapshotPathContextInput = { + snapshotPaths: string[]; + targetPlatform?: string; + displayBasePath?: string; +}; + +export type SnapshotSourcePathPlan = { + sourcePathKind: SnapshotSourcePathKind; + queryBasePath: string; + originalRestoreBasePath: string; + customRestoreBasePath: string; + requiresCustomTarget: boolean; +}; + +export type SnapshotPathContextSource = SnapshotSourcePathPlan & { + canRestoreOriginal: boolean; +}; + +export type SnapshotRestoreLocation = { kind: "original" } | { kind: "custom"; targetPath: string }; + +export type SnapshotRestoreRequest = { + location: SnapshotRestoreLocation; + include?: string[]; + selectedItemKind?: "file" | "dir"; + exclude?: string[]; + excludeXattr?: string[]; + delete?: boolean; + overwrite?: OverwriteMode; +}; + +export type SnapshotRestoreExecutionPlan = { + target: string; + options: { + basePath: string; + sourcePathKind: SnapshotSourcePathKind; + include?: string[]; + selectedItemKind?: "file" | "dir"; + exclude?: string[]; + excludeXattr?: string[]; + delete?: boolean; + overwrite?: OverwriteMode; + }; + sourcePathPlan: SnapshotSourcePathPlan; +}; + +export type SnapshotRestoreTargetPlan = Pick; + +export type SnapshotDumpPlan = { + snapshotRef: string; + path: string; +}; + +export type SnapshotDumpPlanRequest = { + snapshotId: string; + requestedPath?: string; + kind?: "file" | "dir"; +}; + +export type SnapshotPathContext = { + source: SnapshotPathContextSource; + browser: { + initialQueryPath(): string; + toDisplayPath(snapshotPath: string): string; + toSnapshotPath(displayPath: string): string; + isDisplayBaseCompatible(): boolean; + }; + restore: { + plan(request: SnapshotRestoreRequest): SnapshotRestoreExecutionPlan; + targetPlan(): SnapshotRestoreTargetPlan; + }; + dump: { + plan(request: SnapshotDumpPlanRequest): SnapshotDumpPlan; + }; +}; + +export class SnapshotRestorePlanningError extends Error { + constructor(message: string) { + super(message); + this.name = "SnapshotRestorePlanningError"; + } +} + +export class SnapshotDumpPlanningError extends Error { + constructor(message: string) { + super(message); + this.name = "SnapshotDumpPlanningError"; + } +} + +const rootSourcePlan = ( + requiresCustomTarget = false, + sourcePathKind: SnapshotSourcePathKind = requiresCustomTarget ? "unsupported" : "posix", +): SnapshotPathContextSource => ({ + sourcePathKind, + queryBasePath: "/", + originalRestoreBasePath: "/", + customRestoreBasePath: "/", + requiresCustomTarget, + canRestoreOriginal: !requiresCustomTarget, +}); + +const hostPathKindFromPlatform = (platform?: string): HostPathKind | undefined => { + if (!platform) return undefined; + return platform === "win32" ? "windows" : "posix"; +}; + +const pathKindFromDisplayBasePath = (displayBasePath?: string): HostPathKind | undefined => + displayBasePath && isWindowsHostPath(displayBasePath) ? "windows" : undefined; + +const getTargetPathKind = (input: SnapshotPathContextInput): HostPathKind => + hostPathKindFromPlatform(input.targetPlatform) ?? pathKindFromDisplayBasePath(input.displayBasePath) ?? "posix"; + +const isWindowsSourcePath = (snapshotPath: string, targetPathKind: HostPathKind): boolean => + isWindowsHostPath(snapshotPath) || (targetPathKind === "windows" && isWindowsResticSnapshotPath(snapshotPath)); + +const isPosixSourcePath = (snapshotPath: string, targetPathKind: HostPathKind): boolean => + snapshotPath.startsWith("/") && !(targetPathKind === "windows" && isWindowsResticSnapshotPath(snapshotPath)); + +const createSourcePlan = (snapshotPaths: string[], targetPathKind: HostPathKind): SnapshotPathContextSource => { + if (snapshotPaths.length === 0) return rootSourcePlan(); + + const hasWindowsPaths = snapshotPaths.some((snapshotPath) => isWindowsSourcePath(snapshotPath, targetPathKind)); + const hasPosixPaths = snapshotPaths.some((snapshotPath) => isPosixSourcePath(snapshotPath, targetPathKind)); + const hasUnsupportedNativePaths = snapshotPaths.some(isUnsupportedNativeSnapshotPath); + + if (hasUnsupportedNativePaths || (hasWindowsPaths && hasPosixPaths)) { + return rootSourcePlan(true); + } + + if (hasWindowsPaths) { + const basePath = findWindowsResticCommonAncestor(snapshotPaths); + if (!basePath) return rootSourcePlan(true, "windows"); + + return { + sourcePathKind: "windows", + queryBasePath: basePath, + originalRestoreBasePath: basePath, + customRestoreBasePath: "/", + requiresCustomTarget: targetPathKind !== "windows", + canRestoreOriginal: targetPathKind === "windows", + }; + } + + const basePath = findResticCommonAncestor(snapshotPaths, "posix"); + + return { + sourcePathKind: "posix", + queryBasePath: basePath, + originalRestoreBasePath: basePath, + customRestoreBasePath: basePath, + requiresCustomTarget: false, + canRestoreOriginal: true, + }; +}; + +const isPathWithinLiteral = (base: string, target: string): boolean => + base === "/" || target === base || target.startsWith(`${base}/`); + +const isPathWithinCaseInsensitive = (base: string, target: string): boolean => + isPathWithinLiteral(base.toLowerCase(), target.toLowerCase()); + +const isWindowsComparison = (sourcePathKind: SnapshotSourcePathKind): boolean => sourcePathKind === "windows"; + +const isSnapshotPathWithin = (base: string, target: string, sourcePathKind: SnapshotSourcePathKind): boolean => { + if (isPathWithinLiteral(base, target)) return true; + return isWindowsComparison(sourcePathKind) && isPathWithinCaseInsensitive(base, target); +}; + +const isSameSnapshotPath = (left: string, right: string, sourcePathKind: SnapshotSourcePathKind): boolean => { + if (left === right) return true; + return isWindowsComparison(sourcePathKind) && left.toLowerCase() === right.toLowerCase(); +}; + +const getCasedPathPrefix = (basePath: string, targetPath: string): string => { + if (basePath === "/") return "/"; + return targetPath.slice(0, basePath.length); +}; + +const getRelativeSnapshotPath = ( + basePath: string, + targetPath: string, + sourcePathKind: SnapshotSourcePathKind, +): string | undefined => { + if (isSameSnapshotPath(basePath, targetPath, sourcePathKind)) return ""; + if (!isSnapshotPathWithin(basePath, targetPath, sourcePathKind)) return undefined; + + if (isWindowsComparison(sourcePathKind)) { + return targetPath.slice(basePath.length).replace(/^\/+/, ""); + } + + return getPosixRelativePath(basePath, targetPath); +}; + +const getOriginalRestoreTargetForRoot = ({ + restoreRoot, + sourcePathKind, +}: { + restoreRoot: string; + sourcePathKind: SnapshotSourcePathKind; +}): string => { + if (sourcePathKind !== "windows") return "/"; + + const hostRestoreRoot = windowsResticSnapshotPathToHostPath(restoreRoot); + if (!hostRestoreRoot) { + throw new Error("Windows original restore root must use restic drive syntax."); + } + + return hostRestoreRoot; +}; + +export const createSnapshotPathContext = (input: SnapshotPathContextInput): SnapshotPathContext => { + const targetPathKind = getTargetPathKind(input); + const source = createSourcePlan(input.snapshotPaths, targetPathKind); + const normalizedDisplayBasePath = toResticSnapshotPath(input.displayBasePath ?? "/"); + + const isDisplayBaseCompatible = () => { + if (!input.displayBasePath) return true; + return isSnapshotPathWithin(normalizedDisplayBasePath, source.queryBasePath, source.sourcePathKind); + }; + + const getEffectiveDisplayBasePath = () => { + if (isPathWithinLiteral(normalizedDisplayBasePath, source.queryBasePath)) { + return normalizedDisplayBasePath; + } + + if ( + isWindowsComparison(source.sourcePathKind) && + isPathWithinCaseInsensitive(normalizedDisplayBasePath, source.queryBasePath) + ) { + return getCasedPathPrefix(normalizedDisplayBasePath, source.queryBasePath); + } + + return "/"; + }; + + const restoreTargetPlan = (): SnapshotRestoreTargetPlan => ({ + queryBasePath: source.queryBasePath, + requiresCustomTarget: source.requiresCustomTarget || !isDisplayBaseCompatible(), + }); + + const restorePlan = (request: SnapshotRestoreRequest): SnapshotRestoreExecutionPlan => { + const location = request.location; + const isCustomLocation = location.kind === "custom"; + + if (location.kind === "custom" && location.targetPath.length === 0) { + throw new SnapshotRestorePlanningError("Restore target path is required for custom-location restores."); + } + + if (!isCustomLocation && restoreTargetPlan().requiresCustomTarget) { + throw new SnapshotRestorePlanningError( + "Original location restore is unavailable for this snapshot. Restore it to a custom location instead.", + ); + } + + const basePath = isCustomLocation ? source.customRestoreBasePath : source.originalRestoreBasePath; + const restoreIncludes = request.include?.length ? request.include.map(toResticSnapshotPath) : [basePath]; + const restoreRoot = getResticRestoreRoot(restoreIncludes, request.selectedItemKind, source.sourcePathKind); + const target = + location.kind === "custom" + ? location.targetPath + : getOriginalRestoreTargetForRoot({ restoreRoot, sourcePathKind: source.sourcePathKind }); + + return { + target, + options: { + basePath, + sourcePathKind: source.sourcePathKind, + ...(request.include ? { include: restoreIncludes } : {}), + ...(request.selectedItemKind ? { selectedItemKind: request.selectedItemKind } : {}), + ...(request.exclude ? { exclude: request.exclude } : {}), + ...(request.excludeXattr ? { excludeXattr: request.excludeXattr } : {}), + ...(request.delete !== undefined ? { delete: request.delete } : {}), + ...(request.overwrite ? { overwrite: request.overwrite } : {}), + }, + sourcePathPlan: { + sourcePathKind: source.sourcePathKind, + queryBasePath: source.queryBasePath, + originalRestoreBasePath: source.originalRestoreBasePath, + customRestoreBasePath: source.customRestoreBasePath, + requiresCustomTarget: source.requiresCustomTarget, + }, + }; + }; + + const dumpPlan = ({ snapshotId, requestedPath }: SnapshotDumpPlanRequest): SnapshotDumpPlan => { + const normalizedRequestedPath = toResticSnapshotPath(requestedPath ?? "/"); + const basePath = source.queryBasePath; + + if (basePath === "/") { + return { + snapshotRef: snapshotId, + path: normalizedRequestedPath, + }; + } + + if ( + normalizedRequestedPath === "/" || + isSameSnapshotPath(normalizedRequestedPath, basePath, source.sourcePathKind) + ) { + return { + snapshotRef: `${snapshotId}:${basePath}`, + path: "/", + }; + } + + if (isSnapshotPathWithin(normalizedRequestedPath, basePath, source.sourcePathKind)) { + return { + snapshotRef: `${snapshotId}:${normalizedRequestedPath}`, + path: "/", + }; + } + + const relativePath = getRelativeSnapshotPath(basePath, normalizedRequestedPath, source.sourcePathKind); + if (relativePath === undefined) { + throw new SnapshotDumpPlanningError("Requested path is outside the snapshot base path"); + } + + return { + snapshotRef: `${snapshotId}:${basePath}`, + path: relativePath ? `/${relativePath}` : "/", + }; + }; + + return { + source, + browser: { + initialQueryPath: () => source.queryBasePath, + toDisplayPath: (snapshotPath: string) => { + const normalizedSnapshotPath = toResticSnapshotPath(snapshotPath); + const displayBasePath = getEffectiveDisplayBasePath(); + + if (displayBasePath === "/") return normalizedSnapshotPath; + if (isSameSnapshotPath(displayBasePath, normalizedSnapshotPath, source.sourcePathKind)) return "/"; + if (isSnapshotPathWithin(displayBasePath, normalizedSnapshotPath, source.sourcePathKind)) { + return normalizedSnapshotPath.slice(displayBasePath.length) || "/"; + } + + return normalizedSnapshotPath; + }, + toSnapshotPath: (displayPath: string) => { + const normalizedDisplayPath = toResticSnapshotPath(displayPath); + const displayBasePath = getEffectiveDisplayBasePath(); + + if (displayBasePath === "/") return normalizedDisplayPath; + if (normalizedDisplayPath === "/") return displayBasePath; + return `${displayBasePath}${normalizedDisplayPath}`; + }, + isDisplayBaseCompatible, + }, + restore: { + plan: restorePlan, + targetPlan: restoreTargetPlan, + }, + dump: { + plan: dumpPlan, + }, + }; +}; diff --git a/packages/core/src/restic/helpers/snapshot-paths.ts b/packages/core/src/restic/helpers/snapshot-paths.ts new file mode 100644 index 000000000..aec80ddc9 --- /dev/null +++ b/packages/core/src/restic/helpers/snapshot-paths.ts @@ -0,0 +1,203 @@ +import { findCommonAncestor } from "../../utils/common-ancestor"; +import { isWindowsHostPath, windowsHostPathToResticSnapshotPath } from "../../utils/path"; + +export type HostPathKind = "posix" | "windows"; +export type SnapshotSourcePathKind = HostPathKind | "unsupported"; + +const windowsResticSnapshotPathPattern = /^\/[A-Z](?:\/|$)/; +const windowsResticSnapshotPathSyntaxPattern = /^\/[A-Za-z](?:\/|$)/; + +const stripTrailingSlashes = (value: string): string => { + let end = value.length; + while (end > 1 && value[end - 1] === "/") { + end--; + } + return value.slice(0, end); +}; + +export const isWindowsResticSnapshotPath = (value: string): boolean => + windowsResticSnapshotPathPattern.test(stripTrailingSlashes(value)); + +export const isWindowsSnapshotPath = (value?: string): boolean => { + if (!value) return false; + return windowsHostPathToResticSnapshotPath(value) !== undefined || isWindowsResticSnapshotPath(value); +}; + +export const isPosixSnapshotPath = (value: string): boolean => { + if (!value.startsWith("/")) return false; + return !isWindowsResticSnapshotPath(value); +}; + +export const isUnsupportedNativeSnapshotPath = (value: string): boolean => + !value.startsWith("/") && !isWindowsHostPath(value); + +export const toResticSnapshotPath = (value: string): string => + windowsHostPathToResticSnapshotPath(value) ?? stripTrailingSlashes(value.trim() ? value : "/"); + +export const toWindowsResticSnapshotPath = (value: string): string | undefined => { + const hostPath = windowsHostPathToResticSnapshotPath(value); + if (hostPath) return hostPath; + + const resticPath = toResticSnapshotPath(value); + return windowsResticSnapshotPathSyntaxPattern.test(resticPath) ? resticPath : undefined; +}; + +export const findWindowsResticCommonAncestor = (paths: string[]): string | undefined => { + const resticPaths: string[] = []; + for (const snapshotPath of paths) { + const resticPath = toWindowsResticSnapshotPath(snapshotPath); + if (!resticPath) return undefined; + resticPaths.push(resticPath); + } + + const drive = resticPaths[0]?.split("/")[1]; + if (!drive || resticPaths.some((resticPath) => resticPath.split("/")[1]?.toLowerCase() !== drive.toLowerCase())) { + return undefined; + } + + const splitPaths = resticPaths.map((resticPath) => resticPath.split("/").filter(Boolean)); + const minLength = Math.min(...splitPaths.map((parts) => parts.length)); + + const commonParts: string[] = []; + for (let i = 0; i < minLength; i++) { + const firstPart = splitPaths[0]?.[i]; + if (!firstPart) break; + + if (splitPaths.every((parts) => parts[i]?.toLowerCase() === firstPart.toLowerCase())) { + commonParts.push(firstPart); + } else { + break; + } + } + + return commonParts.length ? `/${commonParts.join("/")}` : undefined; +}; + +const splitRelativePath = (value: string): string[] => { + return stripTrailingSlashes(value).split("/").filter(Boolean); +}; + +export const getPosixRelativePath = (basePath: string, targetPath: string): string => { + const baseParts = splitRelativePath(basePath); + const targetParts = splitRelativePath(targetPath); + + let commonLength = 0; + while ( + commonLength < baseParts.length && + commonLength < targetParts.length && + baseParts[commonLength] === targetParts[commonLength] + ) { + commonLength++; + } + + const upParts = Array.from({ length: baseParts.length - commonLength }, () => ".."); + return [...upParts, ...targetParts.slice(commonLength)].join("/"); +}; + +const isWindowsComparison = ( + basePath: string, + targetPath: string, + sourcePathKind?: SnapshotSourcePathKind, +): boolean => { + if (sourcePathKind) return sourcePathKind === "windows"; + return isWindowsHostPath(basePath) || isWindowsHostPath(targetPath); +}; + +export const getRelativeResticPath = ( + basePath: string, + targetPath: string, + sourcePathKind?: SnapshotSourcePathKind, +): string => { + const useWindowsComparison = isWindowsComparison(basePath, targetPath, sourcePathKind); + const normalizedBasePath = toResticSnapshotPath(basePath); + const normalizedTargetPath = toResticSnapshotPath(targetPath); + + if (useWindowsComparison && normalizedBasePath.startsWith("/") && normalizedTargetPath.startsWith("/")) { + if (normalizedBasePath === "/") return normalizedTargetPath.replace(/^\/+/, ""); + + const lowerBasePath = normalizedBasePath.toLowerCase(); + const lowerTargetPath = normalizedTargetPath.toLowerCase(); + if (lowerTargetPath === lowerBasePath) return ""; + if (lowerTargetPath.startsWith(`${lowerBasePath}/`)) { + return normalizedTargetPath.slice(normalizedBasePath.length).replace(/^\/+/, ""); + } + } + + return getPosixRelativePath(normalizedBasePath, normalizedTargetPath); +}; + +const findRelativeCommonAncestor = (paths: string[]): string => { + if (paths.length === 0) return "/"; + if (paths.length === 1) return paths[0] || "/"; + + const splitPaths = paths.map((snapshotPath) => snapshotPath.split("/").filter(Boolean)); + const minLength = Math.min(...splitPaths.map((parts) => parts.length)); + + const commonParts: string[] = []; + for (let i = 0; i < minLength; i++) { + const firstPart = splitPaths[0]?.[i]; + if (!firstPart) break; + + if (splitPaths.every((parts) => parts[i]?.toLowerCase() === firstPart.toLowerCase())) { + commonParts.push(firstPart); + } else { + break; + } + } + + if (!commonParts.length) { + throw new Error("Snapshot paths must be on the same drive."); + } + + return commonParts.join("/"); +}; + +export const findResticCommonAncestor = (snapshotPaths: string[], sourcePathKind?: SnapshotSourcePathKind): string => { + const resticPaths = snapshotPaths.map(toResticSnapshotPath); + const absolutePaths = resticPaths.filter((snapshotPath) => snapshotPath.startsWith("/")); + + if (absolutePaths.length === resticPaths.length) { + if (sourcePathKind === "windows") { + return findWindowsResticCommonAncestor(resticPaths) ?? "/"; + } + + return findCommonAncestor(resticPaths); + } + + if (absolutePaths.length > 0) { + throw new Error("Snapshot paths must use a single path format."); + } + + return findRelativeCommonAncestor(resticPaths); +}; + +const getResticParentPath = (snapshotPath: string): string => { + const resticPath = toResticSnapshotPath(snapshotPath); + + if (!resticPath.startsWith("/")) { + const withoutTrailingSlash = stripTrailingSlashes(resticPath); + const lastSlashIndex = withoutTrailingSlash.lastIndexOf("/"); + + if (lastSlashIndex < 0) return "."; + if (lastSlashIndex === 0) return "/"; + return withoutTrailingSlash.slice(0, lastSlashIndex); + } + + const normalizedPath = stripTrailingSlashes(resticPath); + const lastSlashIndex = normalizedPath.lastIndexOf("/"); + + if (lastSlashIndex <= 0) return "/"; + return normalizedPath.slice(0, lastSlashIndex); +}; + +export const getResticRestoreRoot = ( + includes: string[], + selectedItemKind?: "file" | "dir", + sourcePathKind?: SnapshotSourcePathKind, +): string => { + if (selectedItemKind === "file" && includes.length === 1) { + return getResticParentPath(includes[0] ?? "/"); + } + + return findResticCommonAncestor(includes, sourcePathKind); +}; diff --git a/packages/core/src/restic/index.ts b/packages/core/src/restic/index.ts index 2f975a258..a864d59c6 100644 --- a/packages/core/src/restic/index.ts +++ b/packages/core/src/restic/index.ts @@ -1,6 +1,23 @@ export * from "./schemas"; export * from "./restic-dto"; export { isResticError, ResticError, ResticLockError } from "./error"; +export { + createSnapshotPathContext, + SnapshotDumpPlanningError, + SnapshotRestorePlanningError, +} from "./helpers/snapshot-path-context"; +export type { + SnapshotDumpPlan, + SnapshotDumpPlanRequest, + SnapshotPathContext, + SnapshotPathContextInput, + SnapshotPathContextSource, + SnapshotSourcePathPlan, + SnapshotRestoreExecutionPlan, + SnapshotRestoreLocation, + SnapshotRestoreRequest, + SnapshotRestoreTargetPlan, +} from "./helpers/snapshot-path-context"; export type { ResticDeps, ResticEnv, diff --git a/packages/core/src/restic/server.ts b/packages/core/src/restic/server.ts index 08e50f478..eb596d699 100644 --- a/packages/core/src/restic/server.ts +++ b/packages/core/src/restic/server.ts @@ -23,6 +23,7 @@ export { addCommonArgs } from "./helpers/add-common-args"; export { buildEnv } from "./helpers/build-env"; export { buildRepoUrl } from "./helpers/build-repo-url"; export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys"; +export { getResticRestoreRoot } from "./helpers/restore-paths"; export { validateCustomResticParams } from "./helpers/validate-custom-params"; export { isResticError, ResticError, ResticLockError } from "./error"; diff --git a/packages/core/src/utils/__tests__/path.test.ts b/packages/core/src/utils/__tests__/path.test.ts index ae1c1adbb..3b2bc0c44 100644 --- a/packages/core/src/utils/__tests__/path.test.ts +++ b/packages/core/src/utils/__tests__/path.test.ts @@ -1,7 +1,14 @@ import path from "node:path"; import fc from "fast-check"; import { describe, expect, test } from "vitest"; -import { hasPathListSeparator, isPathWithin, normalizeAbsolutePath } from "../path"; +import { + hasPathListSeparator, + isWindowsHostPath, + isPathWithin, + normalizeAbsolutePath, + windowsHostPathToResticSnapshotPath, + windowsResticSnapshotPathToHostPath, +} from "../path"; const safePathSegmentArb = fc .array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), { @@ -94,6 +101,23 @@ describe("isPathWithin", () => { }); }); +describe("Windows restic snapshot path conversion", () => { + test("maps native Windows host paths to restic snapshot paths", () => { + expect(windowsHostPathToResticSnapshotPath("C:\\Users\\foo")).toBe("/C/Users/foo"); + expect(windowsHostPathToResticSnapshotPath("c:/Users/foo/")).toBe("/C/Users/foo"); + }); + + test("does not treat bare drive letters as rooted Windows host paths", () => { + expect(isWindowsHostPath("C:")).toBe(false); + expect(windowsHostPathToResticSnapshotPath("C:")).toBeUndefined(); + }); + + test("maps restic snapshot paths to native Windows host paths only when called explicitly", () => { + expect(windowsResticSnapshotPathToHostPath("/C/Users/foo")).toBe("C:\\Users\\foo"); + expect(windowsResticSnapshotPathToHostPath("/d/source")).toBe("D:\\source"); + }); +}); + describe("path list character support", () => { test("allows line breaks in raw path lists", () => { expect(hasPathListSeparator("Photos", "raw")).toBe(false); diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 889fd77f8..8ab36117a 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,6 +1,13 @@ export { safeJsonParse } from "./json.js"; export { toErrorDetails, toMessage } from "./errors.js"; -export { hasPathListSeparator, isPathWithin, normalizeAbsolutePath } from "./path.js"; +export { + hasPathListSeparator, + isPathWithin, + normalizeAbsolutePath, + normalizeWindowsHostPath, + windowsHostPathToResticSnapshotPath, + windowsResticSnapshotPathToHostPath, +} from "./path.js"; export { findCommonAncestor } from "./common-ancestor.js"; export { DATE_FORMATS, DEFAULT_TIME_FORMAT, inferDateTimePreferences, TIME_FORMATS } from "./datetime.js"; export type { DateFormatPreference, TimeFormatPreference } from "./datetime.js"; diff --git a/packages/core/src/utils/path.ts b/packages/core/src/utils/path.ts index 9428d4e51..f2baf7fbf 100644 --- a/packages/core/src/utils/path.ts +++ b/packages/core/src/utils/path.ts @@ -1,3 +1,37 @@ +export const isWindowsHostPath = (value: string): boolean => /^[A-Za-z]:[\\/]/.test(value); + +export const normalizeWindowsHostPath = (value: string): string | undefined => { + if (!isWindowsHostPath(value)) return undefined; + + const parts: string[] = []; + for (const part of value.slice(2).replace(/\\/g, "/").split("/")) { + if (!part || part === ".") continue; + if (part === "..") { + parts.pop(); + continue; + } + parts.push(part); + } + + return `${value[0]?.toUpperCase()}:\\${parts.join("\\")}`; +}; + +export const windowsHostPathToResticSnapshotPath = (value: string): string | undefined => { + const normalized = normalizeWindowsHostPath(value); + if (!normalized) return undefined; + + const withoutDrive = normalized.slice(3).replace(/\\/g, "/"); + return withoutDrive ? `/${normalized[0]}/${withoutDrive}` : `/${normalized[0]}`; +}; + +export const windowsResticSnapshotPathToHostPath = (value: string): string | undefined => { + const match = /^\/([A-Za-z])(?:\/(.*))?$/.exec(value); + if (!match?.[1]) return undefined; + + const segments = match[2]?.split("/").filter(Boolean) ?? []; + return `${match[1].toUpperCase()}:\\${segments.join("\\")}`; +}; + export const normalizeAbsolutePath = (value?: string): string => { if (!value?.trim()) return "/";