feat: restore snapshot as tar (#550)

* feat: export snapshot as tar file

chore(mutext): prevent double lock release

* chore: pr feedbacks

* fix: dump single file no tar

* chore: pr feedbacks
This commit is contained in:
Nico 2026-02-21 10:19:20 +01:00 committed by GitHub
parent 8681ebc0c0
commit 182d39a887
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 1273 additions and 184 deletions

View file

@ -25,6 +25,7 @@ import {
deleteVolume,
devPanelExec,
downloadResticPassword,
dumpSnapshot,
getBackupSchedule,
getBackupScheduleForVolume,
getDevPanel,
@ -100,6 +101,8 @@ import type {
DevPanelExecResponse,
DownloadResticPasswordData,
DownloadResticPasswordResponse,
DumpSnapshotData,
DumpSnapshotResponse,
GetBackupScheduleData,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponse,
@ -851,6 +854,25 @@ export const listSnapshotFilesInfiniteOptions = (options: Options<ListSnapshotFi
},
);
export const dumpSnapshotQueryKey = (options: Options<DumpSnapshotData>) => createQueryKey("dumpSnapshot", options);
/**
* Download a snapshot path as a tar archive (folders) or raw file stream (single files)
*/
export const dumpSnapshotOptions = (options: Options<DumpSnapshotData>) =>
queryOptions<DumpSnapshotResponse, DefaultError, DumpSnapshotResponse, ReturnType<typeof dumpSnapshotQueryKey>>({
queryFn: async ({ queryKey, signal }) => {
const { data } = await dumpSnapshot({
...options,
...queryKey[0],
signal,
throwOnError: true,
});
return data;
},
queryKey: dumpSnapshotQueryKey(options),
});
/**
* Restore a snapshot to a target path on the filesystem
*/

View file

@ -16,6 +16,7 @@ export {
deleteVolume,
devPanelExec,
downloadResticPassword,
dumpSnapshot,
getBackupSchedule,
getBackupScheduleForVolume,
getDevPanel,
@ -109,6 +110,9 @@ export type {
DownloadResticPasswordData,
DownloadResticPasswordResponse,
DownloadResticPasswordResponses,
DumpSnapshotData,
DumpSnapshotResponse,
DumpSnapshotResponses,
GetBackupScheduleData,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponse,

View file

@ -35,6 +35,8 @@ import type {
DevPanelExecResponses,
DownloadResticPasswordData,
DownloadResticPasswordResponses,
DumpSnapshotData,
DumpSnapshotResponses,
GetBackupScheduleData,
GetBackupScheduleForVolumeData,
GetBackupScheduleForVolumeResponses,
@ -431,6 +433,15 @@ export const listSnapshotFiles = <ThrowOnError extends boolean = false>(
...options,
});
/**
* Download a snapshot path as a tar archive (folders) or raw file stream (single files)
*/
export const dumpSnapshot = <ThrowOnError extends boolean = false>(options: Options<DumpSnapshotData, ThrowOnError>) =>
(options.client ?? client).get<DumpSnapshotResponses, unknown, ThrowOnError>({
url: "/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump",
...options,
});
/**
* Restore a snapshot to a target path on the filesystem
*/

View file

@ -1970,6 +1970,28 @@ export type ListSnapshotFilesResponses = {
export type ListSnapshotFilesResponse = ListSnapshotFilesResponses[keyof ListSnapshotFilesResponses];
export type DumpSnapshotData = {
body?: never;
path: {
shortId: string;
snapshotId: string;
};
query?: {
kind?: "dir" | "file";
path?: string;
};
url: "/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump";
};
export type DumpSnapshotResponses = {
/**
* Snapshot content stream
*/
200: Blob | File;
};
export type DumpSnapshotResponse = DumpSnapshotResponses[keyof DumpSnapshotResponses];
export type RestoreSnapshotData = {
body?: {
snapshotId: string;

View file

@ -3,13 +3,7 @@ import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-que
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
const normalizeAbsolutePath = (path?: string): string => {
if (!path) return "/";
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
const trimmed = withLeadingSlash.replace(/\/+$/, "");
return trimmed || "/";
};
import { normalizeAbsolutePath } from "~/utils/path";
type LocalFileBrowserProps = FileBrowserUiProps & {
initialPath?: string;

View file

@ -4,13 +4,7 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
import { useFileBrowser } from "~/client/hooks/use-file-browser";
import { parseError } from "~/client/lib/errors";
const normalizeAbsolutePath = (path?: string): string => {
if (!path) return "/";
const withLeadingSlash = path.startsWith("/") ? path : `/${path}`;
const trimmed = withLeadingSlash.replace(/\/+$/, "");
return trimmed || "/";
};
import { normalizeAbsolutePath } from "~/utils/path";
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
repositoryId: string;
@ -18,6 +12,7 @@ type SnapshotTreeBrowserProps = FileBrowserUiProps & {
basePath?: string;
pageSize?: number;
enabled?: boolean;
onSingleSelectionKindChange?: (kind: "file" | "dir" | null) => void;
};
export const SnapshotTreeBrowser = ({
@ -28,7 +23,7 @@ export const SnapshotTreeBrowser = ({
enabled = true,
...uiProps
}: SnapshotTreeBrowserProps) => {
const { selectedPaths, onSelectionChange, ...fileBrowserUiProps } = uiProps;
const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps;
const queryClient = useQueryClient();
const normalizedBasePath = normalizeAbsolutePath(basePath);
@ -72,20 +67,6 @@ export const SnapshotTreeBrowser = ({
return displayPaths;
}, [selectedPaths, stripBasePath]);
const handleSelectionChange = useCallback(
(nextDisplayPaths: Set<string>) => {
if (!onSelectionChange) return;
const nextFullPaths = new Set<string>();
for (const displayPath of nextDisplayPaths) {
nextFullPaths.add(addBasePath(displayPath));
}
onSelectionChange(nextFullPaths);
},
[onSelectionChange, addBasePath],
);
const fileBrowser = useFileBrowser({
initialData: data,
isLoading,
@ -119,6 +100,41 @@ export const SnapshotTreeBrowser = ({
},
});
const displayPathKinds = useMemo(() => {
const kinds = new Map<string, "file" | "dir">();
for (const entry of fileBrowser.fileArray) {
kinds.set(entry.path, entry.type === "file" ? "file" : "dir");
}
return kinds;
}, [fileBrowser.fileArray]);
const handleSelectionChange = useCallback(
(nextDisplayPaths: Set<string>) => {
if (!onSelectionChange) return;
const nextFullPaths = new Set<string>();
for (const displayPath of nextDisplayPaths) {
nextFullPaths.add(addBasePath(displayPath));
}
if (onSingleSelectionKindChange) {
if (nextDisplayPaths.size === 1) {
const [selectedDisplayPath] = nextDisplayPaths;
if (selectedDisplayPath) {
onSingleSelectionKindChange(displayPathKinds.get(selectedDisplayPath) ?? null);
} else {
onSingleSelectionKindChange(null);
}
} else {
onSingleSelectionKindChange(null);
}
}
onSelectionChange(nextFullPaths);
},
[onSelectionChange, addBasePath, onSingleSelectionKindChange, displayPathKinds],
);
const errorDetails = parseError(error)?.message;
const errorMessage = errorDetails
? `Failed to load files: ${errorDetails}`

View file

@ -1,7 +1,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { ChevronDown, FolderOpen, RotateCcw } from "lucide-react";
import { ChevronDown, Download, FolderOpen, RotateCcw } from "lucide-react";
import { Button } from "~/client/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "~/client/components/ui/tooltip";
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";
@ -24,6 +25,7 @@ import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
import type { Repository } from "~/client/lib/types";
import { handleRepositoryError } from "~/client/lib/errors";
import { useNavigate } from "@tanstack/react-router";
import { cn } from "~/client/lib/utils";
type RestoreLocation = "original" | "custom";
@ -51,41 +53,55 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
const restoreCompletedRef = useRef(false);
const [selectedPaths, setSelectedPaths] = useState<Set<string>>(new Set());
const [selectedPathKind, setSelectedPathKind] = useState<"file" | "dir" | null>(null);
useEffect(() => {
const unsubscribeStarted = addEventListener("restore:started", (startedData) => {
if (startedData.repositoryId === repository.id && startedData.snapshotId === snapshotId) {
restoreCompletedRef.current = false;
setIsRestoreActive(true);
setRestoreResult(null);
setShowRestoreResultAlert(false);
}
});
const abortController = new AbortController();
const signal = abortController.signal;
const unsubscribeProgress = addEventListener("restore:progress", (progressData) => {
if (progressData.repositoryId === repository.id && progressData.snapshotId === snapshotId) {
if (restoreCompletedRef.current) {
return;
addEventListener(
"restore:started",
(startedData) => {
if (startedData.repositoryId === repository.shortId && startedData.snapshotId === snapshotId) {
restoreCompletedRef.current = false;
setIsRestoreActive(true);
setRestoreResult(null);
setShowRestoreResultAlert(false);
}
setIsRestoreActive(true);
}
});
},
{ signal },
);
const unsubscribeCompleted = addEventListener("restore:completed", (completedData) => {
if (completedData.repositoryId === repository.id && completedData.snapshotId === snapshotId) {
restoreCompletedRef.current = true;
setIsRestoreActive(false);
setRestoreResult(completedData);
setShowRestoreResultAlert(true);
}
});
addEventListener(
"restore:progress",
(progressData) => {
if (progressData.repositoryId === repository.shortId && progressData.snapshotId === snapshotId) {
if (restoreCompletedRef.current) {
return;
}
setIsRestoreActive(true);
}
},
{ signal },
);
addEventListener(
"restore:completed",
(completedData) => {
if (completedData.repositoryId === repository.shortId && completedData.snapshotId === snapshotId) {
restoreCompletedRef.current = true;
setIsRestoreActive(false);
setRestoreResult(completedData);
setShowRestoreResultAlert(true);
}
},
{ signal },
);
return () => {
unsubscribeStarted();
unsubscribeProgress();
unsubscribeCompleted();
abortController.abort();
};
}, [addEventListener, repository.id, snapshotId]);
}, [addEventListener, repository.shortId, snapshotId]);
const { mutate: restoreSnapshot, isPending: isRestoring } = useMutation({
...restoreSnapshotMutation(),
@ -133,6 +149,33 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
restoreSnapshot,
]);
const handleDownload = useCallback(() => {
if (selectedPaths.size > 1) {
return;
}
const dumpUrl = new URL(
`/api/v1/repositories/${repository.shortId}/snapshots/${snapshotId}/dump`,
window.location.origin,
);
if (selectedPaths.size === 1) {
const [selectedPath] = selectedPaths;
if (selectedPath) {
dumpUrl.searchParams.set("path", selectedPath);
if (selectedPathKind) {
dumpUrl.searchParams.set("kind", selectedPathKind);
}
}
}
const link = document.createElement("a");
link.href = dumpUrl.toString();
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, [repository.shortId, snapshotId, selectedPathKind, selectedPaths]);
const acknowledgeRestoreResult = useCallback(() => {
setShowRestoreResultAlert(false);
setRestoreResult(null);
@ -145,35 +188,62 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
}, []);
const canRestore = restoreLocation === "original" || customTargetPath.trim();
const canDownload = selectedPaths.size <= 1;
const isRestoreRunning = isRestoring || isRestoreActive;
function getDownloadButtonText(): string {
if (selectedPaths.size > 0) {
return `Download ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
}
return "Download All";
}
function getRestoreButtonText(): string {
if (isRestoreRunning) {
return "Restoring...";
}
if (selectedPaths.size > 0) {
return `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`;
}
return "Restore All";
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-2xl font-bold">Restore Snapshot</h1>
<p className="text-sm text-muted-foreground">
{repository.name} / {snapshotId}
</p>
</div>
<div className="flex gap-2">
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => navigate({ to: returnPath })}>
Cancel
</Button>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button variant="outline" onClick={handleDownload} disabled={!canDownload}>
<Download className="h-4 w-4 mr-2" />
{getDownloadButtonText()}
</Button>
</span>
</TooltipTrigger>
<TooltipContent className={cn({ hidden: canDownload })}>
<p>Download is available only for one selected item, or with no selection to download everything.</p>
</TooltipContent>
</Tooltip>
<Button variant="primary" onClick={handleRestore} disabled={isRestoreRunning || !canRestore}>
<RotateCcw className="h-4 w-4 mr-2" />
{isRestoreRunning
? "Restoring..."
: selectedPaths.size > 0
? `Restore ${selectedPaths.size} ${selectedPaths.size === 1 ? "item" : "items"}`
: "Restore All"}
{getRestoreButtonText()}
</Button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="space-y-6">
{isRestoreRunning && <RestoreProgress repositoryId={repository.id} snapshotId={snapshotId} />}
{isRestoreRunning && <RestoreProgress repositoryId={repository.shortId} snapshotId={snapshotId} />}
<Card>
<CardHeader>
@ -292,6 +362,7 @@ export function RestoreForm({ repository, snapshotId, returnPath, basePath }: Re
withCheckboxes
selectedPaths={selectedPaths}
onSelectionChange={setSelectedPaths}
onSingleSelectionKindChange={setSelectedPathKind}
stateClassName="flex-1 min-h-0"
/>
</CardContent>

View file

@ -16,22 +16,29 @@ export const RestoreProgress = ({ repositoryId, snapshotId }: Props) => {
const [progress, setProgress] = useState<RestoreProgressEvent | null>(null);
useEffect(() => {
const unsubscribe = addEventListener("restore:progress", (progressData) => {
if (progressData.repositoryId === repositoryId && progressData.snapshotId === snapshotId) {
setProgress(progressData);
}
});
const abortController = new AbortController();
const unsubscribeComplete = addEventListener("restore:completed", (completedData) => {
if (completedData.repositoryId === repositoryId && completedData.snapshotId === snapshotId) {
setProgress(null);
}
});
addEventListener(
"restore:progress",
(progressData) => {
if (progressData.repositoryId === repositoryId && progressData.snapshotId === snapshotId) {
setProgress(progressData);
}
},
{ signal: abortController.signal },
);
return () => {
unsubscribe();
unsubscribeComplete();
};
addEventListener(
"restore:completed",
(completedData) => {
if (completedData.repositoryId === repositoryId && completedData.snapshotId === snapshotId) {
setProgress(null);
}
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, repositoryId, snapshotId]);
if (!progress) {

View file

@ -1,9 +1,10 @@
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { Loader2 } from "lucide-react";
import type * as React from "react";
import * as React from "react";
import { cn } from "~/client/lib/utils";
import { useMinimumDuration } from "~/client/hooks/useMinimumDuration";
const buttonVariants = cva(
"inline-flex cursor-pointer uppercase rounded-sm items-center justify-center gap-2 whitespace-nowrap text-xs font-semibold tracking-wide transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-ring border-0",
@ -33,28 +34,32 @@ const buttonVariants = cva(
},
);
const MINIMUM_LOADING_DURATION = 300;
function Button({
className,
variant,
size,
asChild = false,
loading,
disabled,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean;
} & { loading?: boolean }) {
const Comp = asChild ? Slot : "button";
const isLoading = useMinimumDuration(loading ?? false, MINIMUM_LOADING_DURATION);
return (
<Comp
disabled={loading}
{...props}
data-slot="button"
className={cn(buttonVariants({ variant, size, className }), "transition-all")}
{...props}
disabled={disabled || loading || isLoading}
>
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !loading })} />
<div className={cn("flex items-center justify-center", { invisible: loading })}>{props.children}</div>
<Loader2 className={cn("h-4 w-4 animate-spin absolute", { invisible: !isLoading })} />
<div className={cn("flex items-center justify-center", { invisible: isLoading })}>{props.children}</div>
</Comp>
);
}

View file

@ -98,20 +98,36 @@ export function useServerEvents() {
};
}, [emit, queryClient]);
const addEventListener = useCallback(<T extends ServerEventType>(eventName: T, handler: EventHandler<T>) => {
const existingHandlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
eventHandlers.add(handler);
handlersRef.current[eventName] = eventHandlers as EventHandlerMap[T];
return () => {
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
delete handlersRef.current[eventName];
const addEventListener = useCallback(
<T extends ServerEventType>(eventName: T, handler: EventHandler<T>, options?: { signal?: AbortSignal }) => {
if (options?.signal?.aborted) {
return () => {};
}
};
}, []);
const existingHandlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
const eventHandlers = existingHandlers ?? new Set<EventHandler<T>>();
eventHandlers.add(handler);
handlersRef.current[eventName] = eventHandlers as EventHandlerMap[T];
const unsubscribe = () => {
const handlers = handlersRef.current[eventName] as EventHandlerSet<T> | undefined;
handlers?.delete(handler);
if (handlers && handlers.size === 0) {
delete handlersRef.current[eventName];
}
if (options?.signal) {
options.signal.removeEventListener("abort", unsubscribe);
}
};
if (options?.signal) {
options.signal.addEventListener("abort", unsubscribe, { once: true });
}
return unsubscribe;
},
[],
);
return { addEventListener };
}

View file

@ -0,0 +1,39 @@
import { useEffect, useRef, useState } from "react";
export function useMinimumDuration(isActive: boolean, minimumDuration: number): boolean {
const [displayActive, setDisplayActive] = useState(isActive);
const startTimeRef = useRef<number | null>(null);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (isActive) {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
startTimeRef.current = Date.now();
setDisplayActive(true);
} else if (startTimeRef.current !== null) {
const elapsed = Date.now() - startTimeRef.current;
const remaining = Math.max(0, minimumDuration - elapsed);
if (remaining > 0) {
timeoutRef.current = setTimeout(() => {
setDisplayActive(false);
startTimeRef.current = null;
}, remaining);
} else {
setDisplayActive(false);
startTimeRef.current = null;
}
}
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [isActive, minimumDuration]);
return displayActive;
}

View file

@ -15,22 +15,29 @@ export const BackupProgressCard = ({ scheduleShortId }: Props) => {
const [progress, setProgress] = useState<BackupProgressEvent | null>(null);
useEffect(() => {
const unsubscribe = addEventListener("backup:progress", (progressData) => {
if (progressData.scheduleId === scheduleShortId) {
setProgress(progressData);
}
});
const abortController = new AbortController();
const unsubscribeComplete = addEventListener("backup:completed", (completedData) => {
if (completedData.scheduleId === scheduleShortId) {
setProgress(null);
}
});
addEventListener(
"backup:progress",
(progressData) => {
if (progressData.scheduleId === scheduleShortId) {
setProgress(progressData);
}
},
{ signal: abortController.signal },
);
return () => {
unsubscribe();
unsubscribeComplete();
};
addEventListener(
"backup:completed",
(completedData) => {
if (completedData.scheduleId === scheduleShortId) {
setProgress(null);
}
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, scheduleShortId]);
const percentDone = progress ? Math.round(progress.percent_done * 100) : 0;

View file

@ -99,37 +99,44 @@ export const ScheduleMirrorsConfig = ({ scheduleShortId, primaryRepositoryId, re
}, [compatibility]);
useEffect(() => {
const unsubscribeStarted = addEventListener("mirror:started", (event) => {
if (event.scheduleId !== scheduleShortId) return;
setAssignments((prev) => {
const next = new Map(prev);
const existing = next.get(event.repositoryId);
if (!existing) return prev;
next.set(event.repositoryId, { ...existing, lastCopyStatus: "in_progress", lastCopyError: null });
return next;
});
});
const abortController = new AbortController();
const unsubscribeCompleted = addEventListener("mirror:completed", (event) => {
if (event.scheduleId !== scheduleShortId) return;
setAssignments((prev) => {
const next = new Map(prev);
const existing = next.get(event.repositoryId);
if (!existing) return prev;
next.set(event.repositoryId, {
...existing,
lastCopyStatus: event.status ?? existing.lastCopyStatus,
lastCopyError: event.error ?? null,
lastCopyAt: Date.now(),
addEventListener(
"mirror:started",
(event) => {
if (event.scheduleId !== scheduleShortId) return;
setAssignments((prev) => {
const next = new Map(prev);
const existing = next.get(event.repositoryId);
if (!existing) return prev;
next.set(event.repositoryId, { ...existing, lastCopyStatus: "in_progress", lastCopyError: null });
return next;
});
return next;
});
});
},
{ signal: abortController.signal },
);
return () => {
unsubscribeStarted();
unsubscribeCompleted();
};
addEventListener(
"mirror:completed",
(event) => {
if (event.scheduleId !== scheduleShortId) return;
setAssignments((prev) => {
const next = new Map(prev);
const existing = next.get(event.repositoryId);
if (!existing) return prev;
next.set(event.repositoryId, {
...existing,
lastCopyStatus: event.status ?? existing.lastCopyStatus,
lastCopyError: event.error ?? null,
lastCopyAt: Date.now(),
});
return next;
});
},
{ signal: abortController.signal },
);
return () => abortController.abort();
}, [addEventListener, scheduleShortId]);
const addRepository = (repositoryId: string) => {

View file

@ -19,6 +19,13 @@ const restoreEventBaseSchema = type({
snapshotId: "string",
});
const dumpStartedEventSchema = type({
repositoryId: "string",
snapshotId: "string",
path: "string",
filename: "string",
});
const restoreProgressMetricsSchema = type({
seconds_elapsed: "number",
percent_done: "number",
@ -59,6 +66,8 @@ export const serverRestoreProgressEventSchema = organizationScopedSchema.and(res
export const serverRestoreCompletedEventSchema = organizationScopedSchema.and(restoreCompletedEventSchema);
export const serverDumpStartedEventSchema = organizationScopedSchema.and(dumpStartedEventSchema);
export type BackupEventStatusDto = typeof backupEventStatusSchema.infer;
export type BackupStartedEventDto = typeof backupStartedEventSchema.infer;
export type BackupProgressEventDto = typeof backupProgressEventSchema.infer;
@ -66,9 +75,11 @@ export type BackupCompletedEventDto = typeof backupCompletedEventSchema.infer;
export type RestoreStartedEventDto = typeof restoreStartedEventSchema.infer;
export type RestoreProgressEventDto = typeof restoreProgressEventSchema.infer;
export type RestoreCompletedEventDto = typeof restoreCompletedEventSchema.infer;
export type DumpStartedEventDto = typeof dumpStartedEventSchema.infer;
export type ServerBackupStartedEventDto = typeof serverBackupStartedEventSchema.infer;
export type ServerBackupProgressEventDto = typeof serverBackupProgressEventSchema.infer;
export type ServerBackupCompletedEventDto = typeof serverBackupCompletedEventSchema.infer;
export type ServerRestoreStartedEventDto = typeof serverRestoreStartedEventSchema.infer;
export type ServerRestoreProgressEventDto = typeof serverRestoreProgressEventSchema.infer;
export type ServerRestoreCompletedEventDto = typeof serverRestoreCompletedEventSchema.infer;
export type ServerDumpStartedEventDto = typeof serverDumpStartedEventSchema.infer;

View file

@ -2,6 +2,7 @@ import type {
ServerBackupCompletedEventDto,
ServerBackupProgressEventDto,
ServerBackupStartedEventDto,
ServerDumpStartedEventDto,
ServerRestoreCompletedEventDto,
ServerRestoreProgressEventDto,
ServerRestoreStartedEventDto,
@ -21,6 +22,7 @@ export const serverEventPayloads = {
"restore:started": payload<ServerRestoreStartedEventDto>(),
"restore:progress": payload<ServerRestoreProgressEventDto>(),
"restore:completed": payload<ServerRestoreCompletedEventDto>(),
"dump:started": payload<ServerDumpStartedEventDto>(),
"mirror:started": payload<{
organizationId: string;
scheduleId: string;

View file

@ -122,4 +122,163 @@ describe("RepositoryMutex", () => {
release();
expect(repoMutex.isLocked(repoId)).toBe(false);
});
test("should allow concurrent shared locks", async () => {
const repoId = "concurrent-shared";
const release1 = await repoMutex.acquireShared(repoId, "op1");
const release2 = await repoMutex.acquireShared(repoId, "op2");
const release3 = await repoMutex.acquireShared(repoId, "op3");
expect(repoMutex.isLocked(repoId)).toBe(true);
release1();
release2();
release3();
expect(repoMutex.isLocked(repoId)).toBe(false);
});
test("should block exclusive lock until all shared locks are released", async () => {
const repoId = "shared-blocks-exclusive";
let exclusiveAcquired = false;
const releaseShared1 = await repoMutex.acquireShared(repoId, "s1");
const releaseShared2 = await repoMutex.acquireShared(repoId, "s2");
const exclusivePromise = repoMutex.acquireExclusive(repoId, "e1").then((release) => {
exclusiveAcquired = true;
return release;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(exclusiveAcquired).toBe(false);
releaseShared1();
await new Promise((resolve) => setTimeout(resolve, 10));
expect(exclusiveAcquired).toBe(false); // still waiting for s2
releaseShared2();
const releaseExclusive = await exclusivePromise;
expect(exclusiveAcquired).toBe(true);
releaseExclusive();
});
test("should block all locks while exclusive lock is held", async () => {
const repoId = "exclusive-blocks-all";
const results: string[] = [];
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "e1");
results.push("e1-acquired");
const s1Promise = repoMutex.acquireShared(repoId, "s1").then((release) => {
results.push("s1-acquired");
return release;
});
const e2Promise = repoMutex.acquireExclusive(repoId, "e2").then((release) => {
results.push("e2-acquired");
return release;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(results).toEqual(["e1-acquired"]);
releaseExclusive();
const releaseS1 = await s1Promise;
expect(results).toEqual(["e1-acquired", "s1-acquired"]);
releaseS1();
const releaseE2 = await e2Promise;
expect(results).toEqual(["e1-acquired", "s1-acquired", "e2-acquired"]);
releaseE2();
});
test("should grant all waiting shared locks at once when exclusive lock is released", async () => {
const repoId = "batch-shared";
const results: string[] = [];
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "e1");
const s1Promise = repoMutex.acquireShared(repoId, "s1").then((release) => {
results.push("s1");
return release;
});
const s2Promise = repoMutex.acquireShared(repoId, "s2").then((release) => {
results.push("s2");
return release;
});
const s3Promise = repoMutex.acquireShared(repoId, "s3").then((release) => {
results.push("s3");
return release;
});
await new Promise((resolve) => setTimeout(resolve, 10));
expect(results).toEqual([]);
releaseExclusive();
const [releaseS1, releaseS2, releaseS3] = await Promise.all([s1Promise, s2Promise, s3Promise]);
expect(results.length).toBe(3);
expect(results).toContain("s1");
expect(results).toContain("s2");
expect(results).toContain("s3");
releaseS1();
releaseS2();
releaseS3();
});
test("should safely handle multiple calls to the release function", async () => {
const repoId = "idempotent-release";
const releaseShared = await repoMutex.acquireShared(repoId, "s1");
releaseShared();
releaseShared(); // Should not throw or cause issues
releaseShared();
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "e1");
releaseExclusive();
releaseExclusive(); // Should not throw
expect(repoMutex.isLocked(repoId)).toBe(false);
});
test("should immediately throw if AbortSignal is already aborted", async () => {
const repoId = "already-aborted";
const controller = new AbortController();
controller.abort(new Error("pre-aborted"));
expect(repoMutex.acquireShared(repoId, "s1", controller.signal)).rejects.toThrow("pre-aborted");
expect(repoMutex.acquireExclusive(repoId, "e1", controller.signal)).rejects.toThrow("pre-aborted");
expect(repoMutex.isLocked(repoId)).toBe(false);
});
test("should accurately report isLocked status", async () => {
const repoId = "is-locked-status";
expect(repoMutex.isLocked(repoId)).toBe(false);
const releaseShared1 = await repoMutex.acquireShared(repoId, "s1");
expect(repoMutex.isLocked(repoId)).toBe(true);
const releaseShared2 = await repoMutex.acquireShared(repoId, "s2");
expect(repoMutex.isLocked(repoId)).toBe(true);
releaseShared1();
expect(repoMutex.isLocked(repoId)).toBe(true); // still locked by s2
releaseShared2();
expect(repoMutex.isLocked(repoId)).toBe(false); // all shared released
const releaseExclusive = await repoMutex.acquireExclusive(repoId, "e1");
expect(repoMutex.isLocked(repoId)).toBe(true);
releaseExclusive();
expect(repoMutex.isLocked(repoId)).toBe(false);
});
});

View file

@ -99,7 +99,7 @@ class RepositoryMutex {
throw signal.reason || new Error("Operation aborted");
}
return () => this.releaseShared(repositoryId, lockId!);
return this.createRelease("shared", repositoryId, lockId!);
}
async acquireExclusive(repositoryId: string, operation: string, signal?: AbortSignal): Promise<() => void> {
@ -154,7 +154,8 @@ class RepositoryMutex {
}
logger.debug(`[Mutex] Acquired exclusive lock for repo ${repositoryId}: ${operation} (${lockId})`);
return () => this.releaseExclusive(repositoryId, lockId!);
return this.createRelease("exclusive", repositoryId, lockId!);
}
private releaseShared(repositoryId: string, lockId: string): void {
@ -239,6 +240,19 @@ class RepositoryMutex {
if (!state) return false;
return state.exclusiveHolder !== null || state.sharedHolders.size > 0;
}
private createRelease(type: LockType, repositoryId: string, lockId: string) {
let released = false;
return () => {
if (released) return;
released = true;
if (type === "shared") {
this.releaseShared(repositoryId, lockId);
} else {
this.releaseExclusive(repositoryId, lockId);
}
};
}
}
export const repoMutex = new RepositoryMutex();

View file

@ -25,6 +25,7 @@ const broadcastEvents = [
"restore:started",
"restore:progress",
"restore:completed",
"dump:started",
"doctor:started",
"doctor:completed",
"doctor:cancelled",

View file

@ -76,6 +76,7 @@ describe("repositories security", () => {
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots" },
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/files" },
{ method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/dump" },
{ method: "POST", path: "/api/v1/repositories/test-repo/restore" },
{ method: "POST", path: "/api/v1/repositories/test-repo/doctor" },
{ method: "DELETE", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" },

View file

@ -1,9 +1,13 @@
import { randomUUID } from "node:crypto";
import { Readable } from "node:stream";
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
import type { RepositoryConfig } from "~/schemas/restic";
import { REPOSITORY_BASE } from "~/server/core/constants";
import { serverEvents } from "~/server/core/events";
import { withContext } from "~/server/core/request-context";
import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema";
import { generateShortId } from "~/server/utils/id";
import { restic } from "~/server/utils/restic";
import { createTestSession } from "~/test/helpers/auth";
import { repositoriesService } from "../repositories.service";
@ -77,3 +81,225 @@ describe("repositoriesService.createRepository", () => {
expect(created.status).toBe("healthy");
});
});
describe("repositoriesService.dumpSnapshot", () => {
afterEach(() => {
mock.restore();
});
test("calls restic.dump with common-ancestor selector and stripped path", async () => {
const { organizationId, user } = await createTestSession();
const shortId = generateShortId();
const basePath = "/var/lib/zerobyte/volumes/vol123/_data";
await db.insert(repositoriesTable).values({
id: randomUUID(),
shortId,
name: `Repository-${randomUUID()}`,
type: "local",
config: {
backend: "local",
path: `/tmp/repository-${randomUUID()}`,
isExistingRepository: true,
},
compressionMode: "off",
organizationId,
});
const snapshotsMock = mock(() =>
Promise.resolve([
{
id: "snapshot-123",
short_id: "snapshot-123",
time: new Date().toISOString(),
tree: "tree-1",
paths: [basePath],
hostname: "host",
},
]),
);
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
const dumpMock = mock(() =>
Promise.resolve({
stream: Readable.from([]),
completion: Promise.resolve(),
abort: () => {},
}),
);
spyOn(restic, "dump").mockImplementation(dumpMock);
const emitSpy = spyOn(serverEvents, "emit");
await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-123", `${basePath}/documents`, "dir"),
);
expect(dumpMock).toHaveBeenCalledTimes(1);
expect(dumpMock).toHaveBeenCalledWith(
expect.objectContaining({
backend: "local",
}),
`snapshot-123:${basePath}`,
{
organizationId,
path: "/documents",
},
);
expect(emitSpy).toHaveBeenCalledWith(
"dump:started",
expect.objectContaining({
organizationId,
repositoryId: shortId,
snapshotId: "snapshot-123",
path: "/documents",
}),
);
});
test("streams a single file directly when selected path is a file", async () => {
const { organizationId, user } = await createTestSession();
const shortId = generateShortId();
const basePath = "/var/lib/zerobyte/volumes/vol123/_data";
await db.insert(repositoriesTable).values({
id: randomUUID(),
shortId,
name: `Repository-${randomUUID()}`,
type: "local",
config: {
backend: "local",
path: `/tmp/repository-${randomUUID()}`,
isExistingRepository: true,
},
compressionMode: "off",
organizationId,
});
const snapshotsMock = mock(() =>
Promise.resolve([
{
id: "snapshot-file",
short_id: "snapshot-file",
time: new Date().toISOString(),
tree: "tree-file",
paths: [basePath],
hostname: "host",
},
]),
);
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
const dumpMock = mock(() =>
Promise.resolve({
stream: Readable.from([]),
completion: Promise.resolve(),
abort: () => {},
}),
);
spyOn(restic, "dump").mockImplementation(dumpMock);
const result = await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-file", `${basePath}/documents/report.txt`, "file"),
);
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-file:${basePath}`, {
organizationId,
path: "/documents/report.txt",
archive: false,
});
expect(result.filename).toBe("report.txt");
expect(result.contentType).toBe("application/octet-stream");
});
test("rejects path downloads without a kind", async () => {
const { organizationId, user } = await createTestSession();
const shortId = generateShortId();
const basePath = "/var/lib/zerobyte/volumes/vol123/_data";
await db.insert(repositoriesTable).values({
id: randomUUID(),
shortId,
name: `Repository-${randomUUID()}`,
type: "local",
config: {
backend: "local",
path: `/tmp/repository-${randomUUID()}`,
isExistingRepository: true,
},
compressionMode: "off",
organizationId,
});
const snapshotsMock = mock(() =>
Promise.resolve([
{
id: "snapshot-no-kind",
short_id: "snapshot-no-kind",
time: new Date().toISOString(),
tree: "tree-no-kind",
paths: [basePath],
hostname: "host",
},
]),
);
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
await expect(
withContext({ organizationId, userId: user.id }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-no-kind", `${basePath}/documents/report.txt`),
),
).rejects.toThrow("Path kind is required when downloading a specific snapshot path");
});
test("downloads full snapshot relative to common ancestor when path is omitted", async () => {
const { organizationId, user } = await createTestSession();
const shortId = generateShortId();
const basePath = "/var/lib/zerobyte/volumes/vol555/_data";
await db.insert(repositoriesTable).values({
id: randomUUID(),
shortId,
name: `Repository-${randomUUID()}`,
type: "local",
config: {
backend: "local",
path: `/tmp/repository-${randomUUID()}`,
isExistingRepository: true,
},
compressionMode: "off",
organizationId,
});
const snapshotsMock = mock(() =>
Promise.resolve([
{
id: "snapshot-999",
short_id: "snapshot-999",
time: new Date().toISOString(),
tree: "tree-9",
paths: [basePath],
hostname: "host",
},
]),
);
spyOn(restic, "snapshots").mockImplementation(snapshotsMock as typeof restic.snapshots);
const dumpMock = mock(() =>
Promise.resolve({
stream: Readable.from([]),
completion: Promise.resolve(),
abort: () => {},
}),
);
spyOn(restic, "dump").mockImplementation(dumpMock);
await withContext({ organizationId, userId: user.id }, () =>
repositoriesService.dumpSnapshot(shortId, "snapshot-999"),
);
expect(dumpMock).toHaveBeenCalledWith(expect.anything(), `snapshot-999:${basePath}`, {
organizationId,
path: "/",
});
});
});

View file

@ -1,15 +1,15 @@
import { eq } from "drizzle-orm";
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { restic } from "../../utils/restic";
import { repoMutex } from "../../core/repository-mutex";
import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/schemas/restic";
import { type } from "arktype";
import { serverEvents } from "../../core/events";
import { logger } from "../../utils/logger";
import { safeJsonParse } from "../../utils/json";
import { getOrganizationId } from "~/server/core/request-context";
import { restic } from "~/server/utils/restic";
import { toMessage } from "~/server/utils/errors";
import { safeJsonParse } from "~/server/utils/json";
import { logger } from "~/server/utils/logger";
import { db } from "~/server/db/db";
import { repositoriesTable } from "~/server/db/schema";
import { repoMutex } from "~/server/core/repository-mutex";
import { serverEvents } from "~/server/core/events";
class AbortError extends Error {
name = "AbortError";

View file

@ -0,0 +1,50 @@
import { BadRequestError } from "http-errors-enhanced";
import path from "node:path";
import { findCommonAncestor } from "~/utils/common-ancestor";
import { normalizeAbsolutePath } from "~/utils/path";
const sanitizeFilenamePart = (value: string): string => {
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, "");
return sanitized || "snapshot";
};
export const prepareSnapshotDump = (params: {
snapshotId: string;
snapshotPaths: string[];
requestedPath?: string;
}) => {
const { snapshotId, snapshotPaths, requestedPath } = params;
const archiveFilename = `snapshot-${sanitizeFilenamePart(snapshotId)}.tar`;
const normalizedRequestedPath = normalizeAbsolutePath(requestedPath);
const basePath = normalizeAbsolutePath(findCommonAncestor(snapshotPaths));
if (basePath === "/") {
return {
snapshotRef: snapshotId,
path: normalizedRequestedPath,
filename: archiveFilename,
};
}
if (normalizedRequestedPath === "/" || normalizedRequestedPath === basePath) {
return {
snapshotRef: `${snapshotId}:${basePath}`,
path: "/",
filename: archiveFilename,
};
}
const relativeFromBase = path.posix.relative(basePath, normalizedRequestedPath);
if (relativeFromBase === ".." || relativeFromBase.startsWith("../")) {
throw new BadRequestError("Requested path is outside the snapshot base path");
}
const relativePath = relativeFromBase ? `/${relativeFromBase}` : "/";
return {
snapshotRef: `${snapshotId}:${basePath}`,
path: relativePath,
filename: archiveFilename,
};
};

View file

@ -1,6 +1,8 @@
import { Readable } from "node:stream";
import { Hono } from "hono";
import { validator } from "hono-openapi";
import { streamSSE } from "hono/streaming";
import contentDisposition from "content-disposition";
import {
createRepositoryBody,
createRepositoryDto,
@ -19,6 +21,8 @@ import {
listSnapshotFilesQuery,
listSnapshotsDto,
listSnapshotsFilters,
dumpSnapshotDto,
dumpSnapshotQuery,
restoreSnapshotBody,
restoreSnapshotDto,
tagSnapshotsBody,
@ -165,6 +169,30 @@ export const repositoriesController = new Hono()
return c.json<ListSnapshotFilesDto>(result, 200);
},
)
.get("/:shortId/snapshots/:snapshotId/dump", dumpSnapshotDto, validator("query", dumpSnapshotQuery), async (c) => {
const { shortId, snapshotId } = c.req.param();
const { path, kind } = c.req.valid("query");
const dumpStream = await repositoriesService.dumpSnapshot(shortId, snapshotId, path, kind);
const signal = c.req.raw.signal;
if (signal.aborted) {
dumpStream.abort();
} else {
signal.addEventListener("abort", () => dumpStream.abort(), { once: true });
}
const webStream = Readable.toWeb(dumpStream.stream) as unknown as ReadableStream<Uint8Array>;
return new Response(webStream, {
status: 200,
headers: {
"Content-Type": dumpStream.contentType,
"Content-Disposition": contentDisposition(dumpStream.filename || "snapshot.tar"),
"X-Content-Type-Options": "nosniff",
},
});
})
.post("/:shortId/restore", restoreSnapshotDto, validator("json", restoreSnapshotBody), async (c) => {
const { shortId } = c.req.param();
const { snapshotId, ...options } = c.req.valid("json");

View file

@ -290,6 +290,41 @@ export const listSnapshotFilesDto = describeRoute({
},
});
const DUMP_PATH_KINDS = {
file: "file",
dir: "dir",
} as const;
export const dumpPathKindSchema = type.valueOf(DUMP_PATH_KINDS);
export type DumpPathKind = typeof dumpPathKindSchema.infer;
/**
* Download snapshot paths as tar archives (folders) or raw file streams (single files)
*/
export const dumpSnapshotQuery = type({
path: "string?",
kind: dumpPathKindSchema.optional(),
});
export const dumpSnapshotDto = describeRoute({
description: "Download a snapshot path as a tar archive (folders) or raw file stream (single files)",
tags: ["Repositories"],
operationId: "dumpSnapshot",
responses: {
200: {
description: "Snapshot content stream",
content: {
"application/x-tar": {
schema: { type: "string", format: "binary" },
},
"application/octet-stream": {
schema: { type: "string", format: "binary" },
},
},
},
},
});
/**
* Restore a snapshot
*/

View file

@ -1,4 +1,5 @@
import crypto from "node:crypto";
import nodePath from "node:path";
import { type } from "arktype";
import { and, eq } from "drizzle-orm";
import { BadRequestError, ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced";
@ -22,10 +23,11 @@ import { generateShortId } from "../../utils/id";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } from "../../utils/restic";
import { safeSpawn } from "../../utils/spawn";
import { backupsService } from "../backups/backups.service";
import type { UpdateRepositoryBody } from "./repositories.dto";
import { executeDoctor } from "./doctor";
import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto";
import { REPOSITORY_BASE } from "~/server/core/constants";
import { findCommonAncestor } from "~/utils/common-ancestor";
import { prepareSnapshotDump } from "./helpers/dump";
import { executeDoctor } from "./helpers/doctor";
const runningDoctors = new Map<string, AbortController>();
@ -389,6 +391,66 @@ const restoreSnapshot = async (
}
};
const dumpSnapshot = async (shortId: string, snapshotId: string, path?: string, kind?: DumpPathKind) => {
const organizationId = getOrganizationId();
const repository = await findRepository(shortId);
if (!repository) {
throw new NotFoundError("Repository not found");
}
const releaseLock = await repoMutex.acquireShared(repository.id, `dump:${snapshotId}`);
let dumpStream: Awaited<ReturnType<typeof restic.dump>> | undefined = undefined;
try {
const snapshot = await getSnapshotDetails(repository.shortId, snapshotId);
const preparedDump = prepareSnapshotDump({ snapshotId, snapshotPaths: snapshot.paths, requestedPath: path });
const dumpOptions: Parameters<typeof restic.dump>[2] = {
organizationId,
path: preparedDump.path,
};
let filename = preparedDump.filename;
let contentType = "application/x-tar";
if (path && preparedDump.path !== "/") {
if (!kind) {
throw new BadRequestError("Path kind is required when downloading a specific snapshot path");
}
if (kind === "file") {
dumpOptions.archive = false;
contentType = "application/octet-stream";
const fileName = nodePath.posix.basename(preparedDump.path);
if (fileName) {
filename = fileName;
}
}
}
dumpStream = await restic.dump(repository.config, preparedDump.snapshotRef, dumpOptions);
serverEvents.emit("dump:started", {
organizationId,
repositoryId: repository.shortId,
snapshotId,
path: preparedDump.path,
filename,
});
const completion = dumpStream.completion.finally(releaseLock);
void completion.catch(() => {});
return { ...dumpStream, completion, filename, contentType };
} catch (error) {
if (dumpStream) {
dumpStream.abort();
}
releaseLock();
throw error;
}
};
const getSnapshotDetails = async (shortId: string, snapshotId: string) => {
const organizationId = getOrganizationId();
const repository = await findRepository(shortId);
@ -777,6 +839,7 @@ export const repositoriesService = {
listSnapshots,
listSnapshotFiles,
restoreSnapshot,
dumpSnapshot,
getSnapshotDetails,
checkHealth,
startDoctor,

View file

@ -11,7 +11,7 @@ const successfulRestoreSummary = JSON.stringify({
let lastSafeSpawnArgs: string[] = [];
const safeSpawnMock = mock((params: Parameters<typeof spawnModule.safeSpawn>[0]) => {
const safeSpawnMock = mock((params: spawnModule.SafeSpawnParams) => {
lastSafeSpawnArgs = params.args;
return Promise.resolve({

View file

@ -1,7 +1,9 @@
import crypto from "node:crypto";
import { normalizeAbsolutePath } from "~/utils/path";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { Readable } from "node:stream";
import { type } from "arktype";
import { throttle } from "es-toolkit";
import type { BandwidthLimit, CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic";
@ -83,10 +85,14 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string)
const decryptedPassword = await cryptoUtils.resolveSecret(config.customPassword);
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
await fs.writeFile(passwordFilePath, decryptedPassword, {
mode: 0o600,
});
env.RESTIC_PASSWORD_FILE = passwordFilePath;
} else {
const org = await db.query.organization.findFirst({ where: { id: organizationId } });
const org = await db.query.organization.findFirst({
where: { id: organizationId },
});
if (!org) {
throw new Error(`Organization ${organizationId} not found`);
@ -98,7 +104,9 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string)
} else {
const decryptedPassword = await cryptoUtils.resolveSecret(metadata.resticPassword);
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
await fs.writeFile(passwordFilePath, decryptedPassword, {
mode: 0o600,
});
env.RESTIC_PASSWORD_FILE = passwordFilePath;
}
}
@ -122,7 +130,9 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string)
case "gcs": {
const decryptedCredentials = await cryptoUtils.resolveSecret(config.credentialsJson);
const credentialsPath = path.join("/tmp", `zerobyte-gcs-${crypto.randomBytes(8).toString("hex")}.json`);
await fs.writeFile(credentialsPath, decryptedCredentials, { mode: 0o600 });
await fs.writeFile(credentialsPath, decryptedCredentials, {
mode: 0o600,
});
env.GOOGLE_PROJECT_ID = config.projectId;
env.GOOGLE_APPLICATION_CREDENTIALS = credentialsPath;
break;
@ -193,7 +203,9 @@ export const buildEnv = async (config: RepositoryConfig, organizationId: string)
sshArgs.push("-o", "StrictHostKeyChecking=no", "-o", "UserKnownHostsFile=/dev/null");
} else if (config.knownHosts) {
const knownHostsPath = path.join("/tmp", `zerobyte-known-hosts-${crypto.randomBytes(8).toString("hex")}`);
await fs.writeFile(knownHostsPath, config.knownHosts, { mode: 0o600 });
await fs.writeFile(knownHostsPath, config.knownHosts, {
mode: 0o600,
});
env._SFTP_KNOWN_HOSTS_PATH = knownHostsPath;
sshArgs.push("-o", "StrictHostKeyChecking=yes", "-o", `UserKnownHostsFile=${knownHostsPath}`);
}
@ -232,7 +244,12 @@ const init = async (config: RepositoryConfig, organizationId: string, options?:
const args = ["init", "--repo", repoUrl];
addCommonArgs(args, env, config);
const res = await exec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 20000 });
const res = await exec({
command: "restic",
args,
env,
timeout: options?.timeoutMs ?? 20000,
});
await cleanupTemporaryKeys(env);
if (res.exitCode !== 0) {
@ -398,6 +415,13 @@ const restoreProgressSchema = type({
});
export type RestoreProgress = typeof restoreProgressSchema.infer;
export interface ResticDumpStream {
stream: Readable;
completion: Promise<void>;
abort: () => void;
}
const restore = async (
config: RepositoryConfig,
snapshotId: string,
@ -533,6 +557,106 @@ const restore = async (
return result;
};
const normalizeDumpPath = (pathToDump?: string): string => {
const trimmedPath = pathToDump?.trim();
if (!trimmedPath) {
return "/";
}
return normalizeAbsolutePath(trimmedPath);
};
const dump = async (
config: RepositoryConfig,
snapshotRef: string,
options: {
organizationId: string;
path?: string;
archive?: false;
},
): Promise<ResticDumpStream> => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId);
const pathToDump = normalizeDumpPath(options.path);
const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump];
if (options.archive !== false) {
args.push("--archive", "tar");
}
addCommonArgs(args, env, config, { includeJson: false });
logger.debug(`Executing: restic ${args.join(" ")}`);
let didCleanup = false;
const cleanup = async () => {
if (didCleanup) {
return;
}
didCleanup = true;
await cleanupTemporaryKeys(env);
};
let stream: Readable | null = null;
let abortController: AbortController | null = new AbortController();
const MAX_STDERR_CHARS = 64 * 1024;
let stderrTail = "";
const completion = safeSpawn({
command: "restic",
args,
env,
signal: abortController.signal,
stdoutMode: "raw",
onSpawn: (child) => {
stream = child.stdout;
},
onStderr: (line) => {
const chunk = line.trim();
if (chunk) {
stderrTail += `${line}\n`;
if (stderrTail.length > MAX_STDERR_CHARS) {
stderrTail = stderrTail.slice(-MAX_STDERR_CHARS);
}
}
},
})
.then((result) => {
if (result.exitCode === 0) {
return;
}
const stderr = stderrTail.trim() || result.error;
logger.error(`Restic dump failed: ${stderr}`);
throw new ResticError(result.exitCode, stderr);
})
.finally(async () => {
abortController = null;
await cleanup();
});
completion.catch(() => {});
const completionPromise = new Promise<void>((res, rej) => completion.then(res, rej));
if (!stream) {
await cleanup();
throw new Error("Failed to initialize restic dump stream");
}
return {
stream,
completion: completionPromise,
abort: () => {
if (abortController) {
abortController.abort();
}
},
};
};
const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => {
const { tags, organizationId } = options;
@ -852,7 +976,12 @@ const unlock = async (config: RepositoryConfig, options: { signal?: AbortSignal;
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
addCommonArgs(args, env, config);
const res = await exec({ command: "restic", args, env, signal: options?.signal });
const res = await exec({
command: "restic",
args,
env,
signal: options?.signal,
});
await cleanupTemporaryKeys(env);
if (options?.signal?.aborted) {
@ -871,7 +1000,11 @@ const unlock = async (config: RepositoryConfig, options: { signal?: AbortSignal;
const check = async (
config: RepositoryConfig,
options: { readData?: boolean; signal?: AbortSignal; organizationId: string },
options: {
readData?: boolean;
signal?: AbortSignal;
organizationId: string;
},
) => {
const repoUrl = buildRepoUrl(config);
const env = await buildEnv(config, options.organizationId);
@ -884,12 +1017,22 @@ const check = async (
addCommonArgs(args, env, config);
const res = await exec({ command: "restic", args, env, signal: options?.signal });
const res = await exec({
command: "restic",
args,
env,
signal: options?.signal,
});
await cleanupTemporaryKeys(env);
if (options?.signal?.aborted) {
logger.warn("Restic check was aborted by signal.");
return { success: false, hasErrors: true, output: "", error: "Operation aborted" };
return {
success: false,
hasErrors: true,
output: "",
error: "Operation aborted",
};
}
const { stdout, stderr } = res;
@ -922,7 +1065,12 @@ const repairIndex = async (config: RepositoryConfig, options: { signal?: AbortSi
const args = ["repair", "index", "--repo", repoUrl];
addCommonArgs(args, env, config);
const res = await exec({ command: "restic", args, env, signal: options?.signal });
const res = await exec({
command: "restic",
args,
env,
signal: options?.signal,
});
await cleanupTemporaryKeys(env);
if (options?.signal?.aborted) {
@ -1041,9 +1189,11 @@ export const addCommonArgs = (
args: string[],
env: Record<string, string>,
config?: RepositoryConfig,
options?: { skipBandwidth?: boolean },
options?: { skipBandwidth?: boolean; includeJson?: boolean },
) => {
args.push("--json");
if (options?.includeJson !== false) {
args.push("--json");
}
if (env._SFTP_SSH_ARGS) {
args.push("-o", `sftp.args=${env._SFTP_SSH_ARGS}`);
@ -1078,6 +1228,7 @@ export const restic = {
init,
backup,
restore,
dump,
snapshots,
forget,
deleteSnapshot,

View file

@ -14,7 +14,11 @@ export const exec = async ({ command, args = [], env = {}, ...rest }: ExecProps)
};
try {
const { stdout, stderr } = await promisify(execFile)(command, args, { ...options, ...rest, encoding: "utf8" });
const { stdout, stderr } = await promisify(execFile)(command, args, {
...options,
...rest,
encoding: "utf8",
});
return { exitCode: 0, stdout, stderr };
} catch (error) {
@ -28,23 +32,39 @@ export const exec = async ({ command, args = [], env = {}, ...rest }: ExecProps)
}
};
export interface SafeSpawnParams {
export interface SafeSpawnParamsBase {
command: string;
args: string[];
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
onStdout?: (line: string) => void;
onStderr?: (error: string) => void;
onSpawn?: (child: ReturnType<typeof spawn>) => void;
}
type SpawnResult = {
export interface SafeSpawnParamsLines extends SafeSpawnParamsBase {
stdoutMode?: "lines";
onStdout?: (line: string) => void;
}
export interface SafeSpawnParamsRaw extends SafeSpawnParamsBase {
stdoutMode: "raw";
onStdout?: never;
}
export type SafeSpawnParams = SafeSpawnParamsLines | SafeSpawnParamsRaw;
export type SpawnResult = {
exitCode: number;
summary: string;
error: string;
};
export const safeSpawn = (params: SafeSpawnParams) => {
const { command, args, env = {}, signal, onStdout, onStderr } = params;
export function safeSpawn(params: SafeSpawnParamsLines): Promise<SpawnResult>;
export function safeSpawn(params: SafeSpawnParamsRaw): Promise<SpawnResult>;
export function safeSpawn(params: SafeSpawnParams): Promise<SpawnResult> {
const { command, args, env = {}, signal, onStderr, onSpawn } = params;
const stdoutMode = params.stdoutMode ?? "lines";
const onStdout = stdoutMode === "lines" ? params.onStdout : undefined;
let lastStdout = "";
let lastStderr = "";
@ -56,19 +76,25 @@ export const safeSpawn = (params: SafeSpawnParams) => {
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.setEncoding("utf8");
onSpawn?.(child);
child.stderr.setEncoding("utf8");
const rl = createInterface({ input: child.stdout });
const rlErr = createInterface({ input: child.stderr });
rl.on("line", (line) => {
if (onStdout) onStdout(line);
const trimmed = line.trim();
if (trimmed.length > 0) {
lastStdout = line;
}
});
if (stdoutMode === "lines") {
child.stdout.setEncoding("utf8");
const rl = createInterface({ input: child.stdout });
rl.on("line", (line) => {
if (onStdout) onStdout(line);
const trimmed = line.trim();
if (trimmed.length > 0) {
lastStdout = line;
}
});
}
rlErr.on("line", (line) => {
if (onStderr) onStderr(line);
@ -79,11 +105,19 @@ export const safeSpawn = (params: SafeSpawnParams) => {
});
child.on("error", (err) => {
resolve({ exitCode: -1, summary: lastStdout, error: err.message || lastStderr });
resolve({
exitCode: -1,
summary: lastStdout,
error: err.message || lastStderr,
});
});
child.on("close", (code) => {
resolve({ exitCode: code ?? -1, summary: lastStdout, error: lastStderr });
resolve({
exitCode: code ?? -1,
summary: lastStdout,
error: lastStderr,
});
});
});
};
}

View file

@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test";
import { normalizeAbsolutePath } from "../path";
describe("normalizeAbsolutePath", () => {
test("handles undefined and empty inputs", () => {
expect(normalizeAbsolutePath()).toBe("/");
expect(normalizeAbsolutePath("")).toBe("/");
expect(normalizeAbsolutePath(" ")).toBe("/");
});
test("normalizes posix paths", () => {
expect(normalizeAbsolutePath("/foo/bar")).toBe("/foo/bar");
expect(normalizeAbsolutePath("foo/bar")).toBe("/foo/bar");
expect(normalizeAbsolutePath("/foo//bar")).toBe("/foo/bar");
expect(normalizeAbsolutePath("/foo/./bar")).toBe("/foo/bar");
expect(normalizeAbsolutePath("/foo/../bar")).toBe("/bar");
});
test("trims trailing slashes", () => {
expect(normalizeAbsolutePath("/foo/bar/")).toBe("/foo/bar");
expect(normalizeAbsolutePath("/foo/bar//")).toBe("/foo/bar");
});
test("handles windows style paths from URI", () => {
expect(normalizeAbsolutePath("foo\\\\bar")).toBe("/foo/bar");
expect(normalizeAbsolutePath("foo\\\\bar\\\\")).toBe("/foo/bar");
});
test("handles URI encoded paths", () => {
expect(normalizeAbsolutePath("/foo%20bar")).toBe("/foo bar");
expect(normalizeAbsolutePath("foo%2Fbar")).toBe("/foo/bar");
});
test("prevents parent traversal beyond root", () => {
expect(normalizeAbsolutePath("..")).toBe("/");
expect(normalizeAbsolutePath("/..")).toBe("/");
expect(normalizeAbsolutePath("/foo/../../bar")).toBe("/bar");
});
});

45
app/utils/path.ts Normal file
View file

@ -0,0 +1,45 @@
export const normalizeAbsolutePath = (value?: string): string => {
const trimmed = value?.trim();
if (!trimmed) return "/";
let normalizedInput: string;
try {
normalizedInput = decodeURIComponent(trimmed).replace(/\\+/g, "/");
} catch {
normalizedInput = trimmed.replace(/\\+/g, "/");
}
const withLeadingSlash = normalizedInput.startsWith("/") ? normalizedInput : `/${normalizedInput}`;
const parts = withLeadingSlash.split("/");
const stack: string[] = [];
for (const part of parts) {
if (part === "" || part === ".") {
continue;
}
if (part === "..") {
if (stack.length > 0) {
stack.pop();
}
} else {
stack.push(part);
}
}
let normalized = "/" + stack.join("/");
if (!normalized || normalized === "." || normalized.startsWith("..")) {
return "/";
}
const withoutTrailingSlash = normalized.replace(/\/+$/, "");
if (!withoutTrailingSlash) {
return "/";
}
const withSingleLeadingSlash = withoutTrailingSlash.startsWith("/")
? `/${withoutTrailingSlash.replace(/^\/+/, "")}`
: `/${withoutTrailingSlash}`;
return withSingleLeadingSlash || "/";
};

View file

@ -39,6 +39,7 @@
"clsx": "^2.1.1",
"commander": "^14.0.2",
"consola": "^3.4.2",
"content-disposition": "^1.0.1",
"cron-parser": "^5.5.0",
"date-fns": "^4.1.0",
"dither-plugin": "^1.1.1",
@ -81,6 +82,7 @@
"@testing-library/react": "^16.3.2",
"@total-typescript/shoehorn": "^0.1.2",
"@types/bun": "^1.3.6",
"@types/content-disposition": "^0.5.9",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@ -831,6 +833,8 @@
"@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="],
"@types/content-disposition": ["@types/content-disposition@0.5.9", "", {}, "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ=="],
"@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="],
"@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="],
@ -989,6 +993,8 @@
"consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
"cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="],

View file

@ -46,8 +46,9 @@ services:
- LOG_LEVEL=debug
volumes:
- /etc/localtime:/etc/localtime:ro
- /var/lib/zerobyte:/var/lib/zerobyte
- ~/.config/rclone:/root/.config/rclone:ro
- ./tmp:/test-data
- ./data:/var/lib/zerobyte
zerobyte-e2e:
build:

View file

@ -60,6 +60,7 @@
"clsx": "^2.1.1",
"commander": "^14.0.2",
"consola": "^3.4.2",
"content-disposition": "^1.0.1",
"cron-parser": "^5.5.0",
"date-fns": "^4.1.0",
"dither-plugin": "^1.1.1",
@ -102,6 +103,7 @@
"@testing-library/react": "^16.3.2",
"@total-typescript/shoehorn": "^0.1.2",
"@types/bun": "^1.3.6",
"@types/content-disposition": "^0.5.9",
"@types/node": "^25.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",