diff --git a/app/client/components/__test__/restore-form.test.tsx b/app/client/components/__test__/restore-form.test.tsx index aa651ab3..824232d7 100644 --- a/app/client/components/__test__/restore-form.test.tsx +++ b/app/client/components/__test__/restore-form.test.tsx @@ -102,6 +102,12 @@ describe("RestoreForm", () => { filesSkipped: 0, }); }), + http.get("/api/v1/volumes/filesystem/browse", () => { + return HttpResponse.json({ + path: "/", + directories: [{ name: "restore-target", path: "/restore-target", type: "dir" }], + }); + }), ); render( @@ -114,8 +120,26 @@ describe("RestoreForm", () => { />, ); + expect( + screen.getByText( + "This snapshot was created from source paths that do not match this Zerobyte server or the current linked volume. Restoring to the original location is unavailable. Restore it to a custom location, or download it instead.", + ), + ).toBeTruthy(); + expect(screen.getByRole("button", { name: "Original location" }).hasAttribute("disabled")).toBe(true); + expect(screen.getByRole("button", { name: "Restore All" }).hasAttribute("disabled")).toBe(true); + + await userEvent.click(screen.getByRole("button", { name: "Change" })); + await userEvent.click(await screen.findByRole("button", { name: "restore-target" })); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Restore All" }).hasAttribute("disabled")).toBe(false); + }); + const row = await screen.findByRole("button", { name: "mnt" }); await userEvent.click(within(row).getByRole("checkbox")); + await waitFor(() => { + expect(screen.getByRole("button", { name: "Restore 1 item" }).hasAttribute("disabled")).toBe(false); + }); + await userEvent.click(screen.getByRole("button", { name: "Restore 1 item" })); await waitFor(() => { @@ -123,6 +147,7 @@ describe("RestoreForm", () => { snapshotId: "snap-1", include: ["/mnt"], selectedItemKind: "dir", + targetPath: "/restore-target", overwrite: "always", }); }); diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index 467e3601..1f03995b 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useMutation } from "@tanstack/react-query"; -import { ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react"; +import { AlertTriangle, ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react"; import { Button } from "~/client/components/ui/button"; import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip"; +import { Alert, AlertDescription, AlertTitle } from "~/client/components/ui/alert"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "~/client/components/ui/card"; import { Input } from "~/client/components/ui/input"; import { Label } from "~/client/components/ui/label"; @@ -22,6 +23,7 @@ import { RestoreProgress } from "~/client/components/restore-progress"; import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen"; import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events"; import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic"; +import { isPathWithin } from "@zerobyte/core/utils"; import type { Repository } from "~/client/lib/types"; import { handleRepositoryError } from "~/client/lib/errors"; import { useNavigate } from "@tanstack/react-router"; @@ -35,15 +37,27 @@ interface RestoreFormProps { returnPath: string; queryBasePath?: string; displayBasePath?: string; + hasNonPosixSnapshotPaths?: boolean; } -export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, displayBasePath }: RestoreFormProps) { +export function RestoreForm({ + repository, + snapshotId, + returnPath, + queryBasePath, + displayBasePath, + hasNonPosixSnapshotPaths = false, +}: RestoreFormProps) { const navigate = useNavigate(); const { addEventListener } = useServerEvents(); const snapshotBasePath = queryBasePath ?? "/"; + const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath); + const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath; - const [restoreLocation, setRestoreLocation] = useState("original"); + const [restoreLocation, setRestoreLocation] = useState( + restoreRequiresCustomTarget ? "custom" : "original", + ); const [customTargetPath, setCustomTargetPath] = useState(""); const [overwriteMode, setOverwriteMode] = useState("always"); const [showAdvanced, setShowAdvanced] = useState(false); @@ -55,6 +69,15 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, const [selectedPaths, setSelectedPaths] = useState>(new Set()); const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null); + const trimmedCustomTargetPath = customTargetPath.trim(); + const hasCustomTargetPath = trimmedCustomTargetPath !== ""; + const selectedPathCount = selectedPaths.size; + + useEffect(() => { + if (restoreRequiresCustomTarget) { + setRestoreLocation("custom"); + } + }, [restoreRequiresCustomTarget]); useEffect(() => { const abortController = new AbortController(); @@ -114,13 +137,13 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, }); const handleRestore = useCallback(() => { - const excludeXattrArray = excludeXattr - ?.split(",") - .map((s) => s.trim()) + const excludeXattrValues = excludeXattr + .split(",") + .map((value) => value.trim()) .filter(Boolean); const isCustomLocation = restoreLocation === "custom"; - const targetPath = isCustomLocation && customTargetPath.trim() ? customTargetPath.trim() : undefined; + const targetPath = isCustomLocation && hasCustomTargetPath ? trimmedCustomTargetPath : undefined; const includePaths = Array.from(selectedPaths); @@ -135,7 +158,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, snapshotId, include: includePaths.length > 0 ? includePaths : undefined, selectedItemKind: includePaths.length === 1 ? (selectedPathKind ?? undefined) : undefined, - excludeXattr: excludeXattrArray && excludeXattrArray.length > 0 ? excludeXattrArray : undefined, + excludeXattr: excludeXattrValues.length > 0 ? excludeXattrValues : undefined, targetPath, overwrite: overwriteMode, }, @@ -144,8 +167,9 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, repository.shortId, snapshotId, excludeXattr, + hasCustomTargetPath, restoreLocation, - customTargetPath, + trimmedCustomTargetPath, selectedPaths, selectedPathKind, overwriteMode, @@ -182,13 +206,15 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, } }, []); - const canRestore = restoreLocation === "original" || customTargetPath.trim(); - const canDownload = selectedPaths.size <= 1; + const canRestore = restoreRequiresCustomTarget + ? hasCustomTargetPath + : restoreLocation === "original" || hasCustomTargetPath; + const canDownload = selectedPathCount <= 1; const isRestoreRunning = isRestoring || isRestoreActive; function getDownloadButtonText(): string { - if (selectedPaths.size > 0) { - return `Download ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`; + if (selectedPathCount > 0) { + return `Download ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`; } return "Download All"; } @@ -197,8 +223,8 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, if (isRestoreRunning) { return "Restoring..."; } - if (selectedPaths.size > 0) { - return `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`; + if (selectedPathCount > 0) { + return `Restore ${selectedPathCount} ${selectedPathCount === 1 ? "item" : "items"}`; } return "Restore All"; } @@ -240,6 +266,18 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath,
{isRestoreRunning && } + {restoreRequiresCustomTarget && ( + + + Source paths do not match + + This snapshot was created from source paths that do not match this Zerobyte server or the current linked + volume. Restoring to the original location is unavailable. Restore it to a custom location, or download + it instead. + + + )} + Restore Location @@ -253,6 +291,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, queryBasePath, size="sm" className="flex justify-start gap-2" onClick={() => setRestoreLocation("original")} + disabled={!!restoreRequiresCustomTarget} > Original location diff --git a/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx b/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx index 81b855bc..719cd9d1 100644 --- a/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx +++ b/app/client/modules/backups/components/__tests__/snapshot-file-browser.test.tsx @@ -68,4 +68,39 @@ describe("SnapshotFileBrowser", () => { expect(await screen.findByRole("button", { name: "project" })).toBeTruthy(); }); + + test("renders snapshots backed up from Windows absolute paths", async () => { + const requests: string[] = []; + + server.use( + http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", ({ request }) => { + const url = new URL(request.url); + requests.push(url.searchParams.get("path") ?? ""); + + return HttpResponse.json({ + files: [ + { name: "tmp", path: "/tmp", type: "dir" }, + { name: "source", path: "/tmp/source", type: "dir" }, + ], + }); + }), + ); + + render( + , + ); + + await waitFor(() => { + expect(requests[0]).toBe("/"); + }); + + expect(await screen.findByRole("button", { name: "tmp" })).toBeTruthy(); + }); }); diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index 60e10b19..b1136522 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -30,7 +30,8 @@ export const SnapshotFileBrowser = (props: Props) => { const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props; const { formatDateTime } = useTimeFormat(); - const queryBasePath = findCommonAncestor(snapshot.paths); + const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); + const queryBasePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths); return (
diff --git a/app/client/modules/repositories/routes/restore-snapshot.tsx b/app/client/modules/repositories/routes/restore-snapshot.tsx index 24761b42..4f2ec36f 100644 --- a/app/client/modules/repositories/routes/restore-snapshot.tsx +++ b/app/client/modules/repositories/routes/restore-snapshot.tsx @@ -7,10 +7,11 @@ type Props = { returnPath: string; queryBasePath?: string; displayBasePath?: string; + hasNonPosixSnapshotPaths?: boolean; }; export function RestoreSnapshotPage(props: Props) { - const { returnPath, snapshotId, repository, queryBasePath, displayBasePath } = props; + const { returnPath, snapshotId, repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = props; return ( ); } diff --git a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx index 2082f56f..26d1f0c5 100644 --- a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx +++ b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx @@ -26,12 +26,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId }), ]); + const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); + return { snapshot, repository, schedule: schedule.data, - queryBasePath: findCommonAncestor(snapshot.paths), + queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths), displayBasePath: getVolumeMountPath(schedule.data.volume), + hasNonPosixSnapshotPaths, }; }, head: ({ params }) => ({ @@ -55,7 +58,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId function RouteComponent() { const { backupId, snapshotId } = Route.useParams(); - const { repository, queryBasePath, displayBasePath } = Route.useLoaderData(); + const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData(); return ( ); } diff --git a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx index f5c298c1..aed977aa 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx @@ -25,7 +25,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s } } - return { snapshot, repository, queryBasePath: findCommonAncestor(snapshot.paths), displayBasePath }; + const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); + + return { + snapshot, + repository, + queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths), + displayBasePath, + hasNonPosixSnapshotPaths, + }; }, staticData: { breadcrumb: (match) => [ @@ -48,7 +56,7 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s function RouteComponent() { const { repositoryId, snapshotId } = Route.useParams(); - const { repository, queryBasePath, displayBasePath } = Route.useLoaderData(); + const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData(); return ( ); } diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index 6a81028f..d3c67996 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -376,6 +376,28 @@ describe("repositoriesService.dumpSnapshot", () => { }), ); }); + + test("downloads the full snapshot from root when source paths are non-posix", async () => { + const { organizationId, userId, shortId } = await setupDumpSnapshotScenario({ + snapshotId: "snapshot-windows", + basePath: "/tmp/repro/source", + snapshotPaths: ["d:\\some\\path"], + }); + + const result = await withContext({ organizationId, userId }, () => + repositoriesService.dumpSnapshot(shortId, "snapshot-windows"), + ); + + expect(result.filename).toBe("snapshot-snapshot-windows.tar"); + expect(result.contentType).toBe("application/x-tar"); + expect(await readStreamText(result.stream)).toBe( + JSON.stringify({ + snapshotRef: "snapshot-windows", + path: "/", + archive: true, + }), + ); + }); }); describe("repositoriesService.restoreSnapshot", () => { @@ -383,7 +405,7 @@ describe("repositoriesService.restoreSnapshot", () => { vi.restoreAllMocks(); }); - const setupRestoreSnapshotScenario = async () => { + const setupRestoreSnapshotScenario = async (paths = ["/var/lib/zerobyte/volumes/vol123/_data"]) => { const organizationId = session.organizationId; const repository = await createTestRepository(organizationId); @@ -392,7 +414,7 @@ describe("repositoriesService.restoreSnapshot", () => { id: "snapshot-restore", short_id: "snapshot-restore", time: new Date().toISOString(), - paths: ["/var/lib/zerobyte/volumes/vol123/_data"], + paths, hostname: "host", }, ]); @@ -456,6 +478,50 @@ describe("repositoriesService.restoreSnapshot", () => { }), ); }); + + test("rejects original-location restore for snapshots with non-posix source paths", async () => { + const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([ + "d:\\some\\path", + ]); + + await expect( + withContext({ organizationId, userId }, () => + repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", { + include: ["/tmp/source"], + selectedItemKind: "dir", + }), + ), + ).rejects.toThrow("Original location restore is unavailable for this snapshot"); + + expect(restoreMock).not.toHaveBeenCalled(); + }); + + test("allows restore-all to a custom target for snapshots with non-posix source paths", async () => { + const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([ + "d:\\some\\path", + ]); + const targetPath = await fs.mkdtemp(nodePath.join(process.cwd(), "restore-target-")); + + try { + await withContext({ organizationId, userId }, () => + repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", { targetPath }), + ); + } finally { + await fs.rm(targetPath, { recursive: true, force: true }); + } + + expect(restoreMock).toHaveBeenCalledWith( + expect.objectContaining({ + backend: "local", + }), + "snapshot-restore", + targetPath, + expect.objectContaining({ + organizationId, + basePath: "/", + }), + ); + }); }); describe("repositoriesService.getRetentionCategories", () => { diff --git a/app/server/modules/repositories/helpers/dump.ts b/app/server/modules/repositories/helpers/dump.ts index 66752ce6..d59fdb63 100644 --- a/app/server/modules/repositories/helpers/dump.ts +++ b/app/server/modules/repositories/helpers/dump.ts @@ -16,7 +16,8 @@ export const prepareSnapshotDump = (params: { const archiveFilename = `snapshot-${sanitizeFilenamePart(snapshotId)}.tar`; const normalizedRequestedPath = normalizeAbsolutePath(requestedPath); - const basePath = normalizeAbsolutePath(findCommonAncestor(snapshotPaths)); + const hasNonPosixSnapshotPaths = snapshotPaths.some((snapshotPath) => !snapshotPath.startsWith("/")); + const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshotPaths); if (basePath === "/") { return { diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 1acd63ca..7571655d 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -307,6 +307,7 @@ const restoreSnapshot = async ( snapshotId: string, options?: { include?: string[]; + selectedItemKind?: "file" | "dir"; exclude?: string[]; excludeXattr?: string[]; delete?: boolean; @@ -334,7 +335,15 @@ const restoreSnapshot = async ( } const { paths } = await getSnapshotDetails(repository.shortId, snapshotId); - const basePath = findCommonAncestor(paths); + const hasNonPosixSnapshotPaths = paths.some((path) => !path.startsWith("/")); + + if (hasNonPosixSnapshotPaths && !options?.targetPath) { + throw new BadRequestError( + "Original location restore is unavailable for this snapshot. Restore it to a custom location instead.", + ); + } + + const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(paths); const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`); try {