diff --git a/packages/core/src/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts index 3fc8dea8..350a46cb 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 35b48860..2628083d 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 00000000..3963c828 --- /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 00000000..bc95daad --- /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 00000000..d3b30532 --- /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 00000000..1bb36611 --- /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 00000000..aec80ddc --- /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 2f975a25..a864d59c 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 08e50f47..eb596d69 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 ae1c1adb..3b2bc0c4 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 889fd77f..8ab36117 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 9428d4e5..f2baf7fb 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 "/";