diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index d5f66cff..8fa24b27 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -4,8 +4,8 @@ import { type DefaultError, type InfiniteData, infiniteQueryOptions, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, cancelDoctor, createApiKey, createBackupSchedule, createDesktopSession, createNotificationDestination, createRepository, createVolume, deleteApiKey, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getApiKeys, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPasswordLoginStatus, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setPasswordLoginStatus, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateApiKeyData, CreateApiKeyResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateDesktopSessionData, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteApiKeyData, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetApiKeysData, GetApiKeysResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetPasswordLoginStatusData, SetPasswordLoginStatusResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, cancelDoctor, createApiKey, createBackupSchedule, createDesktopSession, createNotificationDestination, createRepository, createVolume, deleteApiKey, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteSnapshots, deleteSsoInvitation, deleteSsoProvider, deleteUserAccount, deleteVolume, downloadResticPassword, dumpSnapshot, getAdminUsers, getApiKeys, getBackupProgress, getBackupSchedule, getBackupScheduleForVolume, getDevPanel, getMirrorCompatibility, getMirrorSyncStatus, getNotificationDestination, getOrgMembers, getPasswordLoginStatus, getPublicSsoProviders, getRegistrationStatus, getRepository, getRepositoryStats, getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, getSnapshotRestorePlan, getSsoSettings, getStatus, getSystemInfo, getUpdates, getUserDeletionImpact, getUserSsoInvitations, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, mountVolume, type Options, refreshRepositoryStats, refreshSnapshots, removeOrgMember, reorderBackupSchedules, restoreSnapshot, runBackupNow, runForget, setPasswordLoginStatus, setRegistrationStatus, startDoctor, startInvitationSsoVerification, stopBackup, syncMirror, tagSnapshots, testConnection, testNotificationDestination, unlockRepository, unmountVolume, updateBackupSchedule, updateMemberRole, updateNotificationDestination, updateRepository, updateScheduleMirrors, updateScheduleNotifications, updateSsoProviderAutoLinking, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, CancelDoctorData, CancelDoctorResponse, CreateApiKeyData, CreateApiKeyResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateDesktopSessionData, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteApiKeyData, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteSnapshotsData, DeleteSnapshotsResponse, DeleteSsoInvitationData, DeleteSsoProviderData, DeleteUserAccountData, DeleteVolumeData, DeleteVolumeResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, DumpSnapshotData, DumpSnapshotResponse, GetAdminUsersData, GetAdminUsersResponse, GetApiKeysData, GetApiKeysResponse, GetBackupProgressData, GetBackupProgressResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetDevPanelData, GetDevPanelResponse, GetMirrorCompatibilityData, GetMirrorCompatibilityResponse, GetMirrorSyncStatusData, GetMirrorSyncStatusResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetOrgMembersData, GetOrgMembersResponse, GetPasswordLoginStatusData, GetPasswordLoginStatusResponse, GetPublicSsoProvidersData, GetPublicSsoProvidersResponse, GetRegistrationStatusData, GetRegistrationStatusResponse, GetRepositoryData, GetRepositoryResponse, GetRepositoryStatsData, GetRepositoryStatsResponse, GetScheduleMirrorsData, GetScheduleMirrorsResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSnapshotRestorePlanData, GetSnapshotRestorePlanResponse, GetSsoSettingsData, GetSsoSettingsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetUpdatesData, GetUpdatesResponse, GetUserDeletionImpactData, GetUserDeletionImpactResponse, GetUserSsoInvitationsData, GetUserSsoInvitationsResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, MountVolumeData, MountVolumeResponse, RefreshRepositoryStatsData, RefreshRepositoryStatsResponse, RefreshSnapshotsData, RefreshSnapshotsResponse, RemoveOrgMemberData, ReorderBackupSchedulesData, ReorderBackupSchedulesResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, SetPasswordLoginStatusData, SetPasswordLoginStatusResponse, SetRegistrationStatusData, SetRegistrationStatusResponse, StartDoctorData, StartDoctorResponse, StartInvitationSsoVerificationData, StopBackupData, StopBackupResponse, SyncMirrorData, SyncMirrorResponse, TagSnapshotsData, TagSnapshotsResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnlockRepositoryData, UnlockRepositoryResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateMemberRoleData, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateSsoProviderAutoLinkingData, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; export type QueryKey = [ Pick & { @@ -811,6 +811,24 @@ export const getSnapshotDetailsOptions = (options: Options) => createQueryKey('getSnapshotRestorePlan', options); + +/** + * Get a target-scoped restore plan for a snapshot + */ +export const getSnapshotRestorePlanOptions = (options: Options) => queryOptions>({ + queryFn: async ({ queryKey, signal }) => { + const { data } = await getSnapshotRestorePlan({ + ...options, + ...queryKey[0], + signal, + throwOnError: true + }); + return data; + }, + queryKey: getSnapshotRestorePlanQueryKey(options) +}); + export const listSnapshotFilesQueryKey = (options: Options) => createQueryKey('listSnapshotFiles', options); /** diff --git a/app/client/api-client/client/index.ts b/app/client/api-client/client/index.ts index 2b5d0631..eb37ca93 100644 --- a/app/client/api-client/client/index.ts +++ b/app/client/api-client/client/index.ts @@ -6,6 +6,8 @@ export type { QuerySerializerOptions } from "../core/bodySerializer.gen"; export { formDataBodySerializer, jsonBodySerializer, urlSearchParamsBodySerializer } from "../core/bodySerializer.gen"; export { buildClientParams } from "../core/params.gen"; export { serializeQueryKeyValue } from "../core/queryKeySerializer.gen"; +export type { ServerSentEventsResult } from "../core/serverSentEvents.gen"; +export type { ClientMeta } from "../core/types.gen"; export { createClient } from "./client.gen"; export type { Client, diff --git a/app/client/api-client/client/utils.gen.ts b/app/client/api-client/client/utils.gen.ts index 26ea50e2..d8f4d1ae 100644 --- a/app/client/api-client/client/utils.gen.ts +++ b/app/client/api-client/client/utils.gen.ts @@ -15,8 +15,8 @@ import type { Client, ClientOptions, Config, RequestOptions } from './types.gen' export const createQuerySerializer = ({ parameters = {}, ...args -}: QuerySerializerOptions = {}) => { - const querySerializer = (queryParams: T) => { +}: QuerySerializerOptions = {}): ((queryParams: T) => string) => { + const querySerializer = (queryParams: T): string => { const search: string[] = []; if (queryParams && typeof queryParams === 'object') { for (const name in queryParams) { diff --git a/app/client/api-client/core/auth.gen.ts b/app/client/api-client/core/auth.gen.ts index bc564957..01df2672 100644 --- a/app/client/api-client/core/auth.gen.ts +++ b/app/client/api-client/core/auth.gen.ts @@ -10,6 +10,13 @@ export interface Auth { * @default 'header' */ in?: 'header' | 'query' | 'cookie'; + /** + * A unique identifier for the security scheme. + * + * Defined only when there are multiple security schemes whose `Auth` + * shape would otherwise be identical. + */ + key?: string; /** * Header or query parameter name. * diff --git a/app/client/api-client/core/params.gen.ts b/app/client/api-client/core/params.gen.ts index 16689776..6e0250b1 100644 --- a/app/client/api-client/core/params.gen.ts +++ b/app/client/api-client/core/params.gen.ts @@ -63,7 +63,7 @@ type KeyMap = Map< } >; -const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { +function buildKeyMap(fields: FieldsConfig, map?: KeyMap): KeyMap { if (!map) { map = new Map(); } @@ -86,7 +86,7 @@ const buildKeyMap = (fields: FieldsConfig, map?: KeyMap): KeyMap => { } return map; -}; +} interface Params { body: unknown; @@ -95,16 +95,18 @@ interface Params { query: Record; } -const stripEmptySlots = (params: Params) => { +type ParamsSlotMap = Record; + +function stripEmptySlots(params: ParamsSlotMap): void { for (const [slot, value] of Object.entries(params)) { if (value && typeof value === 'object' && !Array.isArray(value) && !Object.keys(value).length) { delete params[slot as Slot]; } } -}; +} -export const buildClientParams = (args: ReadonlyArray, fields: FieldsConfig) => { - const params: Params = { +export function buildClientParams(args: ReadonlyArray, fields: FieldsConfig): Params { + const params: ParamsSlotMap = { body: Object.create(null), headers: Object.create(null), path: Object.create(null), @@ -166,5 +168,5 @@ export const buildClientParams = (args: ReadonlyArray, fields: FieldsCo stripEmptySlots(params); - return params; -}; + return params as Params; +} diff --git a/app/client/api-client/core/pathSerializer.gen.ts b/app/client/api-client/core/pathSerializer.gen.ts index 075a8bc4..f44fc016 100644 --- a/app/client/api-client/core/pathSerializer.gen.ts +++ b/app/client/api-client/core/pathSerializer.gen.ts @@ -26,7 +26,7 @@ interface SerializePrimitiveParam extends SerializePrimitiveOptions { value: string; } -export const separatorArrayExplode = (style: ArraySeparatorStyle) => { +export const separatorArrayExplode = (style: ArraySeparatorStyle): '.' | ';' | ',' | '&' => { switch (style) { case 'label': return '.'; @@ -39,7 +39,7 @@ export const separatorArrayExplode = (style: ArraySeparatorStyle) => { } }; -export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { +export const separatorArrayNoExplode = (style: ArraySeparatorStyle): ',' | '|' | '%20' => { switch (style) { case 'form': return ','; @@ -52,7 +52,7 @@ export const separatorArrayNoExplode = (style: ArraySeparatorStyle) => { } }; -export const separatorObjectExplode = (style: ObjectSeparatorStyle) => { +export const separatorObjectExplode = (style: ObjectSeparatorStyle): '.' | ';' | ',' | '&' => { switch (style) { case 'label': return '.'; @@ -73,7 +73,7 @@ export const serializeArrayParam = ({ value, }: SerializeOptions & { value: unknown[]; -}) => { +}): string => { if (!explode) { const joinedValues = ( allowReserved ? value : value.map((v) => encodeURIComponent(v as string)) @@ -111,7 +111,7 @@ export const serializePrimitiveParam = ({ allowReserved, name, value, -}: SerializePrimitiveParam) => { +}: SerializePrimitiveParam): string => { if (value === undefined || value === null) { return ''; } @@ -135,7 +135,7 @@ export const serializeObjectParam = ({ }: SerializeOptions & { value: Record | Date; valueOnly?: boolean; -}) => { +}): string => { if (value instanceof Date) { return valueOnly ? value.toISOString() : `${name}=${value.toISOString()}`; } diff --git a/app/client/api-client/core/queryKeySerializer.gen.ts b/app/client/api-client/core/queryKeySerializer.gen.ts index d7ba72b5..46b83b11 100644 --- a/app/client/api-client/core/queryKeySerializer.gen.ts +++ b/app/client/api-client/core/queryKeySerializer.gen.ts @@ -15,7 +15,7 @@ export type JsonValue = /** * Replacer that converts non-JSON values (bigint, Date, etc.) to safe substitutes. */ -export const queryKeyJsonReplacer = (_key: string, value: unknown) => { +export const queryKeyJsonReplacer = (_key: string, value: unknown): unknown | undefined => { if (value === undefined || typeof value === 'function' || typeof value === 'symbol') { return undefined; } diff --git a/app/client/api-client/core/types.gen.ts b/app/client/api-client/core/types.gen.ts index 8dd55e14..8df37c8b 100644 --- a/app/client/api-client/core/types.gen.ts +++ b/app/client/api-client/core/types.gen.ts @@ -92,6 +92,12 @@ export interface Config { responseValidator?: (data: unknown) => Promise; } +/** + * Arbitrary metadata passed through the `meta` request option. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface ClientMeta {} + type IsExactlyNeverOrNeverUndefined = [T] extends [never] ? true : [T] extends [never | undefined] diff --git a/app/client/api-client/core/utils.gen.ts b/app/client/api-client/core/utils.gen.ts index 99f21554..093ec64c 100644 --- a/app/client/api-client/core/utils.gen.ts +++ b/app/client/api-client/core/utils.gen.ts @@ -14,9 +14,9 @@ export interface PathSerializer { url: string; } -export const PATH_PARAM_RE = /\{[^{}]+\}/g; +export const PATH_PARAM_RE: RegExp = /\{[^{}]+\}/g; -export const defaultPathSerializer = ({ path, url: _url }: PathSerializer) => { +export const defaultPathSerializer = ({ path, url: _url }: PathSerializer): string => { let url = _url; const matches = _url.match(PATH_PARAM_RE); if (matches) { @@ -95,7 +95,7 @@ export const getUrl = ({ query?: Record; querySerializer: QuerySerializer; url: string; -}) => { +}): string => { const pathUrl = _url.startsWith('/') ? _url : `/${_url}`; let url = (baseUrl ?? '') + pathUrl; if (path) { @@ -115,7 +115,7 @@ export function getValidRequestBody(options: { body?: unknown; bodySerializer?: BodySerializer | null; serializedBody?: unknown; -}) { +}): unknown { const hasBody = options.body !== undefined; const isSerializedBody = hasBody && options.bodySerializer; diff --git a/app/client/api-client/index.ts b/app/client/api-client/index.ts index 4e89a099..e3df53ff 100644 --- a/app/client/api-client/index.ts +++ b/app/client/api-client/index.ts @@ -41,6 +41,7 @@ export { getScheduleMirrors, getScheduleNotifications, getSnapshotDetails, + getSnapshotRestorePlan, getSsoSettings, getStatus, getSystemInfo, @@ -209,6 +210,9 @@ export type { GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetSnapshotDetailsResponses, + GetSnapshotRestorePlanData, + GetSnapshotRestorePlanResponse, + GetSnapshotRestorePlanResponses, GetSsoSettingsData, GetSsoSettingsResponse, GetSsoSettingsResponses, diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index c93c43e8..74b27406 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -1,9 +1,9 @@ // @ts-nocheck // This file is auto-generated by @hey-api/openapi-ts -import type { Client, Options as Options2, TDataShape } from './client'; +import type { Client, ClientMeta, Options as Options2, RequestResult, ServerSentEventsResult, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteApiKeyData, DeleteApiKeyErrors, DeleteApiKeyResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetApiKeysData, GetApiKeysResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetPasswordLoginStatusData, SetPasswordLoginStatusResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, CancelDoctorData, CancelDoctorErrors, CancelDoctorResponses, CreateApiKeyData, CreateApiKeyErrors, CreateApiKeyResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateDesktopSessionData, CreateDesktopSessionResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteApiKeyData, DeleteApiKeyErrors, DeleteApiKeyResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteSnapshotsData, DeleteSnapshotsResponses, DeleteSsoInvitationData, DeleteSsoInvitationErrors, DeleteSsoInvitationResponses, DeleteSsoProviderData, DeleteSsoProviderErrors, DeleteSsoProviderResponses, DeleteUserAccountData, DeleteUserAccountErrors, DeleteUserAccountResponses, DeleteVolumeData, DeleteVolumeResponses, DevPanelExecData, DevPanelExecErrors, DevPanelExecResponse, DevPanelExecResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, DumpSnapshotData, DumpSnapshotResponses, GetAdminUsersData, GetAdminUsersResponses, GetApiKeysData, GetApiKeysResponses, GetBackupProgressData, GetBackupProgressResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetDevPanelData, GetDevPanelResponses, GetMirrorCompatibilityData, GetMirrorCompatibilityResponses, GetMirrorSyncStatusData, GetMirrorSyncStatusResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetOrgMembersData, GetOrgMembersResponses, GetPasswordLoginStatusData, GetPasswordLoginStatusResponses, GetPublicSsoProvidersData, GetPublicSsoProvidersResponses, GetRegistrationStatusData, GetRegistrationStatusResponses, GetRepositoryData, GetRepositoryResponses, GetRepositoryStatsData, GetRepositoryStatsResponses, GetScheduleMirrorsData, GetScheduleMirrorsResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetSnapshotRestorePlanData, GetSnapshotRestorePlanResponses, GetSsoSettingsData, GetSsoSettingsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetUpdatesData, GetUpdatesResponses, GetUserDeletionImpactData, GetUserDeletionImpactResponses, GetUserSsoInvitationsData, GetUserSsoInvitationsResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, MountVolumeData, MountVolumeResponses, RefreshRepositoryStatsData, RefreshRepositoryStatsResponses, RefreshSnapshotsData, RefreshSnapshotsResponses, RemoveOrgMemberData, RemoveOrgMemberErrors, RemoveOrgMemberResponses, ReorderBackupSchedulesData, ReorderBackupSchedulesResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, SetPasswordLoginStatusData, SetPasswordLoginStatusResponses, SetRegistrationStatusData, SetRegistrationStatusResponses, StartDoctorData, StartDoctorErrors, StartDoctorResponses, StartInvitationSsoVerificationData, StartInvitationSsoVerificationErrors, StartInvitationSsoVerificationResponses, StopBackupData, StopBackupErrors, StopBackupResponses, SyncMirrorData, SyncMirrorErrors, SyncMirrorResponses, TagSnapshotsData, TagSnapshotsResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnlockRepositoryData, UnlockRepositoryResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateMemberRoleData, UpdateMemberRoleErrors, UpdateMemberRoleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleMirrorsData, UpdateScheduleMirrorsResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateSsoProviderAutoLinkingData, UpdateSsoProviderAutoLinkingErrors, UpdateSsoProviderAutoLinkingResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -16,38 +16,38 @@ export type Options; + meta?: keyof ClientMeta extends never ? Record : ClientMeta; }; /** * Get authentication system status */ -export const getStatus = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/status', ...options }); +export const getStatus = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/auth/status', ...options }); /** * List admin users for settings management */ -export const getAdminUsers = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/admin-users', ...options }); +export const getAdminUsers = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/auth/admin-users', ...options }); /** * Delete an account linked to a user */ -export const deleteUserAccount = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options }); +export const deleteUserAccount = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/auth/admin-users/{userId}/accounts/{accountId}', ...options }); /** * Get impact of deleting a user */ -export const getUserDeletionImpact = (options: Options) => (options.client ?? client).get({ url: '/api/v1/auth/deletion-impact/{userId}', ...options }); +export const getUserDeletionImpact = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/auth/deletion-impact/{userId}', ...options }); /** * Get members of the active organization */ -export const getOrgMembers = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/org-members', ...options }); +export const getOrgMembers = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/auth/org-members', ...options }); /** * Update a member's role in the active organization */ -export const updateMemberRole = (options: Options) => (options.client ?? client).patch({ +export const updateMemberRole = (options: Options): RequestResult => (options.client ?? client).patch({ url: '/api/v1/auth/org-members/{memberId}/role', ...options, headers: { @@ -59,17 +59,17 @@ export const updateMemberRole = (options: /** * Remove a member from the active organization */ -export const removeOrgMember = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/org-members/{memberId}', ...options }); +export const removeOrgMember = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/auth/org-members/{memberId}', ...options }); /** * List API keys for the current user in the active organization */ -export const getApiKeys = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/api-keys', ...options }); +export const getApiKeys = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/auth/api-keys', ...options }); /** * Create an API key for the current user in the active organization */ -export const createApiKey = (options: Options) => (options.client ?? client).post({ +export const createApiKey = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/auth/api-keys', ...options, headers: { @@ -81,27 +81,27 @@ export const createApiKey = (options: Opti /** * Revoke an API key for the current user in the active organization */ -export const deleteApiKey = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/api-keys/{keyId}', ...options }); +export const deleteApiKey = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/auth/api-keys/{keyId}', ...options }); /** * Get public SSO providers for the instance */ -export const getPublicSsoProviders = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/sso-providers', ...options }); +export const getPublicSsoProviders = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/auth/sso-providers', ...options }); /** * Get SSO providers and invitations for the active organization */ -export const getSsoSettings = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/sso-settings', ...options }); +export const getSsoSettings = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/auth/sso-settings', ...options }); /** * Get pending SSO invitations for the authenticated user */ -export const getUserSsoInvitations = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/auth/sso-invitations', ...options }); +export const getUserSsoInvitations = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/auth/sso-invitations', ...options }); /** * Start SSO verification for accepting an organization invitation */ -export const startInvitationSsoVerification = (options: Options) => (options.client ?? client).post({ +export const startInvitationSsoVerification = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/auth/sso-invitations/{invitationId}/verify', ...options, headers: { @@ -113,12 +113,12 @@ export const startInvitationSsoVerification = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/sso-providers/{providerId}', ...options }); +export const deleteSsoProvider = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/auth/sso-providers/{providerId}', ...options }); /** * Update whether SSO sign-in can auto-link existing accounts by email */ -export const updateSsoProviderAutoLinking = (options: Options) => (options.client ?? client).patch({ +export const updateSsoProviderAutoLinking = (options: Options): RequestResult => (options.client ?? client).patch({ url: '/api/v1/auth/sso-providers/{providerId}/auto-linking', ...options, headers: { @@ -130,17 +130,17 @@ export const updateSsoProviderAutoLinking = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options }); +export const deleteSsoInvitation = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/auth/sso-invitations/{invitationId}', ...options }); /** * List all volumes */ -export const listVolumes = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/volumes', ...options }); +export const listVolumes = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/volumes', ...options }); /** * Create a new volume */ -export const createVolume = (options: Options) => (options.client ?? client).post({ +export const createVolume = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/volumes', ...options, headers: { @@ -152,7 +152,7 @@ export const createVolume = (options: Opti /** * Test connection to backend */ -export const testConnection = (options: Options) => (options.client ?? client).post({ +export const testConnection = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/volumes/test-connection', ...options, headers: { @@ -164,17 +164,17 @@ export const testConnection = (options: Op /** * Delete a volume */ -export const deleteVolume = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/volumes/{shortId}', ...options }); +export const deleteVolume = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/volumes/{shortId}', ...options }); /** * Get a volume by name */ -export const getVolume = (options: Options) => (options.client ?? client).get({ url: '/api/v1/volumes/{shortId}', ...options }); +export const getVolume = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/volumes/{shortId}', ...options }); /** * Update a volume's configuration */ -export const updateVolume = (options: Options) => (options.client ?? client).put({ +export const updateVolume = (options: Options): RequestResult => (options.client ?? client).put({ url: '/api/v1/volumes/{shortId}', ...options, headers: { @@ -186,37 +186,37 @@ export const updateVolume = (options: Opti /** * Mount a volume */ -export const mountVolume = (options: Options) => (options.client ?? client).post({ url: '/api/v1/volumes/{shortId}/mount', ...options }); +export const mountVolume = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/volumes/{shortId}/mount', ...options }); /** * Unmount a volume */ -export const unmountVolume = (options: Options) => (options.client ?? client).post({ url: '/api/v1/volumes/{shortId}/unmount', ...options }); +export const unmountVolume = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/volumes/{shortId}/unmount', ...options }); /** * Perform a health check on a volume */ -export const healthCheckVolume = (options: Options) => (options.client ?? client).post({ url: '/api/v1/volumes/{shortId}/health-check', ...options }); +export const healthCheckVolume = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/volumes/{shortId}/health-check', ...options }); /** * List files in a volume directory */ -export const listFiles = (options: Options) => (options.client ?? client).get({ url: '/api/v1/volumes/{shortId}/files', ...options }); +export const listFiles = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/volumes/{shortId}/files', ...options }); /** * Browse directories on the host filesystem */ -export const browseFilesystem = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/volumes/filesystem/browse', ...options }); +export const browseFilesystem = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/volumes/filesystem/browse', ...options }); /** * List all repositories */ -export const listRepositories = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/repositories', ...options }); +export const listRepositories = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/repositories', ...options }); /** * Create a new restic repository */ -export const createRepository = (options: Options) => (options.client ?? client).post({ +export const createRepository = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/repositories', ...options, headers: { @@ -228,22 +228,22 @@ export const createRepository = (options: /** * List all configured rclone remotes on the host system */ -export const listRcloneRemotes = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/repositories/rclone-remotes', ...options }); +export const listRcloneRemotes = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/repositories/rclone-remotes', ...options }); /** * Delete a repository */ -export const deleteRepository = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}', ...options }); +export const deleteRepository = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}', ...options }); /** * Get a single repository by ID */ -export const getRepository = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}', ...options }); +export const getRepository = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}', ...options }); /** * Update a repository's name or settings */ -export const updateRepository = (options: Options) => (options.client ?? client).patch({ +export const updateRepository = (options: Options): RequestResult => (options.client ?? client).patch({ url: '/api/v1/repositories/{shortId}', ...options, headers: { @@ -255,17 +255,17 @@ export const updateRepository = (options: /** * Get repository storage and compression statistics */ -export const getRepositoryStats = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/stats', ...options }); +export const getRepositoryStats = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/stats', ...options }); /** * Refresh repository storage and compression statistics */ -export const refreshRepositoryStats = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options }); +export const refreshRepositoryStats = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/stats/refresh', ...options }); /** * Delete multiple snapshots from a repository */ -export const deleteSnapshots = (options: Options) => (options.client ?? client).delete({ +export const deleteSnapshots = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}/snapshots', ...options, headers: { @@ -277,37 +277,42 @@ export const deleteSnapshots = (options: O /** * List all snapshots in a repository */ -export const listSnapshots = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots', ...options }); +export const listSnapshots = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots', ...options }); /** * Clear snapshot cache and force refresh from repository */ -export const refreshSnapshots = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/snapshots/refresh', ...options }); +export const refreshSnapshots = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/snapshots/refresh', ...options }); /** * Delete a specific snapshot from a repository */ -export const deleteSnapshot = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options }); +export const deleteSnapshot = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options }); /** * Get details of a specific snapshot */ -export const getSnapshotDetails = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options }); +export const getSnapshotDetails = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}', ...options }); + +/** + * Get a target-scoped restore plan for a snapshot + */ +export const getSnapshotRestorePlan = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/restore-plan', ...options }); /** * List files and directories in a snapshot */ -export const listSnapshotFiles = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/files', ...options }); +export const listSnapshotFiles = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/files', ...options }); /** * Download a snapshot path as a tar archive (folders) or raw file stream (single files) */ -export const dumpSnapshot = (options: Options) => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump', ...options }); +export const dumpSnapshot = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/dump', ...options }); /** * Restore a snapshot to a target path on the filesystem */ -export const restoreSnapshot = (options: Options) => (options.client ?? client).post({ +export const restoreSnapshot = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/restore', ...options, headers: { @@ -319,22 +324,22 @@ export const restoreSnapshot = (options: O /** * Cancel a running doctor operation on a repository */ -export const cancelDoctor = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}/doctor', ...options }); +export const cancelDoctor = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/repositories/{shortId}/doctor', ...options }); /** * Start an asynchronous doctor operation on a repository to fix common issues (unlock, check, repair index). The operation runs in the background and sends results via SSE events. */ -export const startDoctor = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/doctor', ...options }); +export const startDoctor = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/doctor', ...options }); /** * Unlock a repository by removing all stale locks */ -export const unlockRepository = (options: Options) => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/unlock', ...options }); +export const unlockRepository = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/unlock', ...options }); /** * Tag multiple snapshots in a repository */ -export const tagSnapshots = (options: Options) => (options.client ?? client).post({ +export const tagSnapshots = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/repositories/{shortId}/snapshots/tag', ...options, headers: { @@ -346,7 +351,7 @@ export const tagSnapshots = (options: Opti /** * Execute a restic command against a repository (dev panel only) */ -export const devPanelExec = (options: Options) => (options.client ?? client).sse.post({ +export const devPanelExec = (options: Options): Promise> => (options.client ?? client).sse.post({ url: '/api/v1/repositories/{shortId}/exec', ...options, headers: { @@ -358,12 +363,12 @@ export const devPanelExec = (options: Opti /** * List all backup schedules */ -export const listBackupSchedules = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/backups', ...options }); +export const listBackupSchedules = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/backups', ...options }); /** * Create a new backup schedule for a volume */ -export const createBackupSchedule = (options: Options) => (options.client ?? client).post({ +export const createBackupSchedule = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/backups', ...options, headers: { @@ -375,17 +380,17 @@ export const createBackupSchedule = (optio /** * Delete a backup schedule */ -export const deleteBackupSchedule = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/backups/{shortId}', ...options }); +export const deleteBackupSchedule = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/backups/{shortId}', ...options }); /** * Get a backup schedule by ID */ -export const getBackupSchedule = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}', ...options }); +export const getBackupSchedule = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}', ...options }); /** * Update a backup schedule */ -export const updateBackupSchedule = (options: Options) => (options.client ?? client).patch({ +export const updateBackupSchedule = (options: Options): RequestResult => (options.client ?? client).patch({ url: '/api/v1/backups/{shortId}', ...options, headers: { @@ -397,32 +402,32 @@ export const updateBackupSchedule = (optio /** * Get a backup schedule for a specific volume */ -export const getBackupScheduleForVolume = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/volume/{volumeShortId}', ...options }); +export const getBackupScheduleForVolume = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/backups/volume/{volumeShortId}', ...options }); /** * Trigger a backup immediately for a schedule */ -export const runBackupNow = (options: Options) => (options.client ?? client).post({ url: '/api/v1/backups/{shortId}/run', ...options }); +export const runBackupNow = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/backups/{shortId}/run', ...options }); /** * Stop a backup that is currently in progress */ -export const stopBackup = (options: Options) => (options.client ?? client).post({ url: '/api/v1/backups/{shortId}/stop', ...options }); +export const stopBackup = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/backups/{shortId}/stop', ...options }); /** * Manually apply retention policy to clean up old snapshots */ -export const runForget = (options: Options) => (options.client ?? client).post({ url: '/api/v1/backups/{shortId}/forget', ...options }); +export const runForget = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/backups/{shortId}/forget', ...options }); /** * Get notification assignments for a backup schedule */ -export const getScheduleNotifications = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/notifications', ...options }); +export const getScheduleNotifications = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/notifications', ...options }); /** * Update notification assignments for a backup schedule */ -export const updateScheduleNotifications = (options: Options) => (options.client ?? client).put({ +export const updateScheduleNotifications = (options: Options): RequestResult => (options.client ?? client).put({ url: '/api/v1/backups/{shortId}/notifications', ...options, headers: { @@ -434,12 +439,12 @@ export const updateScheduleNotifications = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/mirrors', ...options }); +export const getScheduleMirrors = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/mirrors', ...options }); /** * Update mirror repository assignments for a backup schedule */ -export const updateScheduleMirrors = (options: Options) => (options.client ?? client).put({ +export const updateScheduleMirrors = (options: Options): RequestResult => (options.client ?? client).put({ url: '/api/v1/backups/{shortId}/mirrors', ...options, headers: { @@ -451,12 +456,12 @@ export const updateScheduleMirrors = (opti /** * Get sync status for a specific mirror, including missing snapshots */ -export const getMirrorSyncStatus = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options }); +export const getMirrorSyncStatus = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/status', ...options }); /** * Sync selected snapshots to a specific mirror repository */ -export const syncMirror = (options: Options) => (options.client ?? client).post({ +export const syncMirror = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/backups/{shortId}/mirrors/{mirrorShortId}/sync', ...options, headers: { @@ -468,12 +473,12 @@ export const syncMirror = (options: Option /** * Get mirror compatibility info for all repositories relative to a backup schedule's primary repository */ -export const getMirrorCompatibility = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/mirrors/compatibility', ...options }); +export const getMirrorCompatibility = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/mirrors/compatibility', ...options }); /** * Reorder backup schedules by providing an array of schedule short IDs in the desired order */ -export const reorderBackupSchedules = (options: Options) => (options.client ?? client).post({ +export const reorderBackupSchedules = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/backups/reorder', ...options, headers: { @@ -485,17 +490,17 @@ export const reorderBackupSchedules = (opt /** * Get the last known progress for a currently running backup. Returns null if no progress has been reported yet. */ -export const getBackupProgress = (options: Options) => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/progress', ...options }); +export const getBackupProgress = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/backups/{shortId}/progress', ...options }); /** * List all notification destinations */ -export const listNotificationDestinations = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/notifications/destinations', ...options }); +export const listNotificationDestinations = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/notifications/destinations', ...options }); /** * Create a new notification destination */ -export const createNotificationDestination = (options: Options) => (options.client ?? client).post({ +export const createNotificationDestination = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/notifications/destinations', ...options, headers: { @@ -507,17 +512,17 @@ export const createNotificationDestination = (options: Options) => (options.client ?? client).delete({ url: '/api/v1/notifications/destinations/{id}', ...options }); +export const deleteNotificationDestination = (options: Options): RequestResult => (options.client ?? client).delete({ url: '/api/v1/notifications/destinations/{id}', ...options }); /** * Get a notification destination by ID */ -export const getNotificationDestination = (options: Options) => (options.client ?? client).get({ url: '/api/v1/notifications/destinations/{id}', ...options }); +export const getNotificationDestination = (options: Options): RequestResult => (options.client ?? client).get({ url: '/api/v1/notifications/destinations/{id}', ...options }); /** * Update a notification destination */ -export const updateNotificationDestination = (options: Options) => (options.client ?? client).patch({ +export const updateNotificationDestination = (options: Options): RequestResult => (options.client ?? client).patch({ url: '/api/v1/notifications/destinations/{id}', ...options, headers: { @@ -529,27 +534,27 @@ export const updateNotificationDestination = (options: Options) => (options.client ?? client).post({ url: '/api/v1/notifications/destinations/{id}/test', ...options }); +export const testNotificationDestination = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/notifications/destinations/{id}/test', ...options }); /** * Get system information including available capabilities */ -export const getSystemInfo = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/info', ...options }); +export const getSystemInfo = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/system/info', ...options }); /** * Check for application updates from GitHub */ -export const getUpdates = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/updates', ...options }); +export const getUpdates = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/system/updates', ...options }); /** * Get the current registration status for new users */ -export const getRegistrationStatus = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/registration-status', ...options }); +export const getRegistrationStatus = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/system/registration-status', ...options }); /** * Update the registration status for new users. Requires global admin role. */ -export const setRegistrationStatus = (options: Options) => (options.client ?? client).put({ +export const setRegistrationStatus = (options: Options): RequestResult => (options.client ?? client).put({ url: '/api/v1/system/registration-status', ...options, headers: { @@ -561,7 +566,7 @@ export const setRegistrationStatus = (opti /** * Download the organization's Restic password for backup recovery. Requires organization owner or admin role and may require password re-authentication. */ -export const downloadResticPassword = (options: Options) => (options.client ?? client).post({ +export const downloadResticPassword = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/system/restic-password', ...options, headers: { @@ -573,12 +578,12 @@ export const downloadResticPassword = (opt /** * Get whether password-based login is disabled */ -export const getPasswordLoginStatus = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/password-login-status', ...options }); +export const getPasswordLoginStatus = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/system/password-login-status', ...options }); /** * Disable or re-enable password-based login. Requires global admin role. */ -export const setPasswordLoginStatus = (options: Options) => (options.client ?? client).put({ +export const setPasswordLoginStatus = (options: Options): RequestResult => (options.client ?? client).put({ url: '/api/v1/system/password-login-status', ...options, headers: { @@ -590,12 +595,12 @@ export const setPasswordLoginStatus = (opt /** * Get the dev panel status */ -export const getDevPanel = (options?: Options) => (options?.client ?? client).get({ url: '/api/v1/system/dev-panel', ...options }); +export const getDevPanel = (options?: Options): RequestResult => (options?.client ?? client).get({ url: '/api/v1/system/dev-panel', ...options }); /** * Create an authenticated desktop session */ -export const createDesktopSession = (options: Options) => (options.client ?? client).post({ +export const createDesktopSession = (options: Options): RequestResult => (options.client ?? client).post({ url: '/api/v1/desktop/session', ...options, headers: { diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index 1002d0e3..de5dc330 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -2336,6 +2336,30 @@ export type GetSnapshotDetailsResponses = { export type GetSnapshotDetailsResponse = GetSnapshotDetailsResponses[keyof GetSnapshotDetailsResponses]; +export type GetSnapshotRestorePlanData = { + body?: never; + path: { + shortId: string; + snapshotId: string; + }; + query?: { + targetAgentId?: string; + }; + url: '/api/v1/repositories/{shortId}/snapshots/{snapshotId}/restore-plan'; +}; + +export type GetSnapshotRestorePlanResponses = { + /** + * Target-scoped snapshot restore plan + */ + 200: { + queryBasePath: string; + requiresCustomTarget: boolean; + }; +}; + +export type GetSnapshotRestorePlanResponse = GetSnapshotRestorePlanResponses[keyof GetSnapshotRestorePlanResponses]; + export type ListSnapshotFilesData = { body?: never; path: { diff --git a/app/client/components/__test__/restore-form.test.tsx b/app/client/components/__test__/restore-form.test.tsx index 824232d7..c196b393 100644 --- a/app/client/components/__test__/restore-form.test.tsx +++ b/app/client/components/__test__/restore-form.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { HttpResponse, http, server } from "~/test/msw/server"; import { cleanup, render, screen, userEvent, waitFor, within } from "~/test/test-utils"; import { fromAny } from "@total-typescript/shoehorn"; +import { getSnapshotSourcePathPlan } from "@zerobyte/core/utils"; vi.mock("@tanstack/react-router", async (importOriginal) => { const actual = await importOriginal(); @@ -14,6 +15,9 @@ vi.mock("@tanstack/react-router", async (importOriginal) => { import { RestoreForm } from "../restore-form"; +const sourcePathPlan = (snapshotPaths: string[], hostPathKind: "posix" | "windows" = "posix") => + getSnapshotSourcePathPlan({ snapshotPaths, hostPathKind }); + class MockEventSource { addEventListener() {} close() {} @@ -26,6 +30,19 @@ const originalEventSource = globalThis.EventSource; beforeEach(() => { globalThis.EventSource = MockEventSource as unknown as typeof EventSource; + server.use( + http.get("/api/v1/system/info", () => + HttpResponse.json({ + runtime: "server", + capabilities: { + rclone: false, + sysAdmin: false, + volumeBackends: ["directory"], + repositoryBackends: ["local", "s3", "r2", "gcs", "azure", "sftp", "rest"], + }, + }), + ), + ); }); afterEach(() => { @@ -62,7 +79,7 @@ describe("RestoreForm", () => { repository={fromAny({ shortId: "repo-1", name: "Repo 1" })} snapshotId="snap-1" returnPath="/repositories/repo-1/snap-1" - queryBasePath="/mnt/project/subdir" + snapshotSourcePathPlan={sourcePathPlan(["/mnt/project/subdir"])} displayBasePath="/mnt" />, ); @@ -81,7 +98,7 @@ describe("RestoreForm", () => { }); }); - test("restores the selected full path when the display root is unrelated", async () => { + test("requires a custom target when the display root is unrelated", async () => { let restoreRequestBody: unknown; server.use( @@ -115,7 +132,7 @@ describe("RestoreForm", () => { repository={fromAny({ shortId: "repo-1", name: "Repo 1" })} snapshotId="snap-1" returnPath="/repositories/repo-1/snap-1" - queryBasePath="/mnt/project" + snapshotSourcePathPlan={sourcePathPlan(["/mnt/project"])} displayBasePath="/other/root" />, ); @@ -152,4 +169,25 @@ describe("RestoreForm", () => { }); }); }); + + test("allows original restore when Windows snapshot paths match a Windows host", () => { + server.use( + http.get("/api/v1/repositories/:shortId/snapshots/:snapshotId/files", () => + HttpResponse.json({ files: [] }), + ), + ); + + render( + , + ); + + expect(screen.queryByText("Source paths do not match")).toBeNull(); + expect(screen.getByRole("button", { name: "Original location" }).hasAttribute("disabled")).toBe(false); + }); }); diff --git a/app/client/components/file-browsers/__tests__/local-file-browser.test.tsx b/app/client/components/file-browsers/__tests__/local-file-browser.test.tsx new file mode 100644 index 00000000..3d1e1abe --- /dev/null +++ b/app/client/components/file-browsers/__tests__/local-file-browser.test.tsx @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { HttpResponse, http, server } from "~/test/msw/server"; +import { cleanup, render, waitFor } from "~/test/test-utils"; +import { LocalFileBrowser } from "../local-file-browser"; + +afterEach(() => { + cleanup(); +}); + +describe("LocalFileBrowser", () => { + test("keeps Windows drive roots as host paths", async () => { + const requests: string[] = []; + + server.use( + http.get("/api/v1/volumes/filesystem/browse", ({ request }) => { + const url = new URL(request.url); + requests.push(url.searchParams.get("path") ?? ""); + + return HttpResponse.json({ + directories: [], + path: "C:\\", + }); + }), + ); + + render(); + + await waitFor(() => { + expect(requests).toEqual(["C:\\"]); + }); + }); +}); diff --git a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx index 23df6252..ac637b83 100644 --- a/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx +++ b/app/client/components/file-browsers/__tests__/snapshot-tree-browser.test.tsx @@ -208,6 +208,33 @@ describe("SnapshotTreeBrowser", () => { expect(selectedKind === "dir").toBe(true); }); + test("maps Windows display roots to restic snapshot tree paths", async () => { + mockListSnapshotFiles({ + files: [ + { name: "Downloads", path: "/C/Users/nicolas/Downloads", type: "dir" }, + { name: "a.txt", path: "/C/Users/nicolas/Downloads/a.txt", type: "file" }, + ], + }); + + let selectedPaths: Set | undefined; + + renderSnapshotTreeBrowser({ + queryBasePath: "/C/Users/nicolas", + displayBasePath: "C:\\Users\\nicolas", + withCheckboxes: true, + onSelectionChange: (paths) => { + selectedPaths = paths; + }, + }); + + const row = await screen.findByRole("button", { name: "Downloads" }); + const checkbox = within(row).getByRole("checkbox"); + + await userEvent.click(checkbox); + + expect(selectedPaths ? Array.from(selectedPaths) : []).toEqual(["/C/Users/nicolas/Downloads"]); + }); + test("uses the query base path for the initial request when display base path is broader", async () => { const requests = mockListSnapshotFiles(); diff --git a/app/client/components/file-browsers/local-file-browser.tsx b/app/client/components/file-browsers/local-file-browser.tsx index 69e8d010..0621e089 100644 --- a/app/client/components/file-browsers/local-file-browser.tsx +++ b/app/client/components/file-browsers/local-file-browser.tsx @@ -3,7 +3,6 @@ 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"; -import { normalizeAbsolutePath } from "@zerobyte/core/utils"; import { logger } from "~/client/lib/logger"; type LocalFileBrowserProps = FileBrowserUiProps & { @@ -13,10 +12,10 @@ type LocalFileBrowserProps = FileBrowserUiProps & { export const LocalFileBrowser = ({ initialPath = "/", enabled = true, ...uiProps }: LocalFileBrowserProps) => { const queryClient = useQueryClient(); - const normalizedInitialPath = normalizeAbsolutePath(initialPath); + const browsePath = initialPath.trim() ? initialPath : "/"; const { data, isLoading, error } = useQuery({ - ...browseFilesystemOptions({ query: { path: normalizedInitialPath } }), + ...browseFilesystemOptions({ query: { path: browsePath } }), enabled, }); diff --git a/app/client/components/file-browsers/snapshot-tree-browser.tsx b/app/client/components/file-browsers/snapshot-tree-browser.tsx index cf16b3d8..2093dfbf 100644 --- a/app/client/components/file-browsers/snapshot-tree-browser.tsx +++ b/app/client/components/file-browsers/snapshot-tree-browser.tsx @@ -4,7 +4,11 @@ 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"; -import { isPathWithin, normalizeAbsolutePath } from "@zerobyte/core/utils"; +import { isPathWithin, normalizeAbsolutePath, windowsHostPathToResticSnapshotPath } from "@zerobyte/core/utils"; + +function normalizeSnapshotTreeBasePath(basePath?: string) { + return windowsHostPathToResticSnapshotPath(basePath ?? "") ?? normalizeAbsolutePath(basePath ?? "/"); +} function createPathPrefixFns(basePath: string) { return { @@ -46,7 +50,7 @@ export const SnapshotTreeBrowser = (props: SnapshotTreeBrowserProps) => { const { selectedPaths, onSelectionChange, onSingleSelectionKindChange, ...fileBrowserUiProps } = uiProps; const queryClient = useQueryClient(); const normalizedQueryBasePath = normalizeAbsolutePath(queryBasePath); - const normalizedDisplayBasePath = normalizeAbsolutePath(displayBasePath ?? "/"); + const normalizedDisplayBasePath = normalizeSnapshotTreeBasePath(displayBasePath); const effectiveDisplayBasePath = isPathWithin(normalizedDisplayBasePath, normalizedQueryBasePath) ? normalizedDisplayBasePath : "/"; diff --git a/app/client/components/restore-form.tsx b/app/client/components/restore-form.tsx index 91779581..896efd98 100644 --- a/app/client/components/restore-form.tsx +++ b/app/client/components/restore-form.tsx @@ -23,37 +23,40 @@ 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 { isPathWithin, normalizeAbsolutePath, windowsHostPathToResticSnapshotPath } from "@zerobyte/core/utils"; 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"; +type SnapshotRestorePlan = { queryBasePath: string; requiresCustomTarget: boolean }; + +const normalizeSnapshotBasePath = (value?: string) => + windowsHostPathToResticSnapshotPath(value ?? "") ?? normalizeAbsolutePath(value ?? "/"); interface RestoreFormProps { repository: Repository; snapshotId: string; returnPath: string; - queryBasePath?: string; + snapshotSourcePathPlan: SnapshotRestorePlan; displayBasePath?: string; - hasNonPosixSnapshotPaths?: boolean; } export function RestoreForm({ repository, snapshotId, returnPath, - queryBasePath, + snapshotSourcePathPlan, displayBasePath, - hasNonPosixSnapshotPaths = false, }: RestoreFormProps) { const navigate = useNavigate(); const { addEventListener } = useServerEvents(); - const snapshotBasePath = queryBasePath ?? "/"; - const hasMismatchedDisplayBasePath = displayBasePath && !isPathWithin(displayBasePath, snapshotBasePath); - const restoreRequiresCustomTarget = hasNonPosixSnapshotPaths || hasMismatchedDisplayBasePath; + const snapshotBasePath = snapshotSourcePathPlan.queryBasePath; + const hasMismatchedDisplayBasePath = + displayBasePath && !isPathWithin(normalizeSnapshotBasePath(displayBasePath), snapshotBasePath); + const restoreRequiresCustomTarget = snapshotSourcePathPlan.requiresCustomTarget || !!hasMismatchedDisplayBasePath; const [restoreLocation, setRestoreLocation] = useState( restoreRequiresCustomTarget ? "custom" : "original", diff --git a/app/client/modules/backups/components/snapshot-file-browser.tsx b/app/client/modules/backups/components/snapshot-file-browser.tsx index b1136522..cf892a0a 100644 --- a/app/client/modules/backups/components/snapshot-file-browser.tsx +++ b/app/client/modules/backups/components/snapshot-file-browser.tsx @@ -6,7 +6,7 @@ import { useTimeFormat } from "~/client/lib/datetime"; import { cn } from "~/client/lib/utils"; import { Link } from "@tanstack/react-router"; import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-tree-browser"; -import { findCommonAncestor } from "@zerobyte/core/utils"; +import { getSnapshotSourcePathPlan, hostPathKindFromPath } from "@zerobyte/core/utils"; interface Props { snapshot: Snapshot; @@ -30,8 +30,10 @@ export const SnapshotFileBrowser = (props: Props) => { const { snapshot, repositoryId, backupId, displayBasePath, onDeleteSnapshot, isDeletingSnapshot } = props; const { formatDateTime } = useTimeFormat(); - const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); - const queryBasePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths); + const snapshotSourcePathPlan = getSnapshotSourcePathPlan({ + snapshotPaths: snapshot.paths, + hostPathKind: hostPathKindFromPath(displayBasePath), + }); return (
@@ -80,7 +82,7 @@ export const SnapshotFileBrowser = (props: Props) => { diff --git a/app/client/modules/repositories/routes/restore-snapshot.tsx b/app/client/modules/repositories/routes/restore-snapshot.tsx index 4f2ec36f..84f99e3e 100644 --- a/app/client/modules/repositories/routes/restore-snapshot.tsx +++ b/app/client/modules/repositories/routes/restore-snapshot.tsx @@ -1,26 +1,26 @@ import { RestoreForm } from "~/client/components/restore-form"; import type { Repository } from "~/client/lib/types"; +type SnapshotRestorePlan = { queryBasePath: string; requiresCustomTarget: boolean }; + type Props = { repository: Repository; snapshotId: string; returnPath: string; - queryBasePath?: string; + snapshotSourcePathPlan: SnapshotRestorePlan; displayBasePath?: string; - hasNonPosixSnapshotPaths?: boolean; }; export function RestoreSnapshotPage(props: Props) { - const { returnPath, snapshotId, repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = props; + const { returnPath, snapshotId, repository, snapshotSourcePathPlan, displayBasePath } = props; return ( ); } diff --git a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx index 26d1f0c5..35b283f6 100644 --- a/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx +++ b/app/routes/(dashboard)/backups/$backupId/$snapshotId.restore.tsx @@ -1,9 +1,12 @@ import { createFileRoute } from "@tanstack/react-router"; import { getBackupSchedule } from "~/client/api-client"; -import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { + getRepositoryOptions, + getSnapshotDetailsOptions, + getSnapshotRestorePlanOptions, +} from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; import { getVolumeMountPath } from "~/client/lib/volume-path"; -import { findCommonAncestor } from "@zerobyte/core/utils"; export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId/restore")({ component: RouteComponent, @@ -15,7 +18,7 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId throw new Response("Not Found", { status: 404 }); } - const [snapshot, repository] = await Promise.all([ + const [snapshot, repository, restorePlan] = await Promise.all([ context.queryClient.ensureQueryData({ ...getSnapshotDetailsOptions({ path: { shortId: schedule.data.repository.shortId, snapshotId: params.snapshotId }, @@ -24,17 +27,21 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: schedule.data.repository.shortId } }), }), + context.queryClient.ensureQueryData({ + ...getSnapshotRestorePlanOptions({ + path: { shortId: schedule.data.repository.shortId, snapshotId: params.snapshotId }, + }), + }), ]); - const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); + const displayBasePath = getVolumeMountPath(schedule.data.volume); return { snapshot, repository, schedule: schedule.data, - queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths), - displayBasePath: getVolumeMountPath(schedule.data.volume), - hasNonPosixSnapshotPaths, + displayBasePath, + snapshotSourcePathPlan: restorePlan, }; }, head: ({ params }) => ({ @@ -58,16 +65,15 @@ export const Route = createFileRoute("/(dashboard)/backups/$backupId/$snapshotId function RouteComponent() { const { backupId, snapshotId } = Route.useParams(); - const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData(); + const { repository, displayBasePath, snapshotSourcePathPlan } = Route.useLoaderData(); return ( ); } diff --git a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx index aed977aa..48d16de5 100644 --- a/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx +++ b/app/routes/(dashboard)/repositories/$repositoryId/$snapshotId/restore.tsx @@ -1,19 +1,29 @@ import { createFileRoute } from "@tanstack/react-router"; import { getBackupSchedule } from "~/client/api-client"; -import { getRepositoryOptions, getSnapshotDetailsOptions } from "~/client/api-client/@tanstack/react-query.gen"; +import { + getRepositoryOptions, + getSnapshotDetailsOptions, + getSnapshotRestorePlanOptions, +} from "~/client/api-client/@tanstack/react-query.gen"; import { RestoreSnapshotPage } from "~/client/modules/repositories/routes/restore-snapshot"; import { getVolumeMountPath } from "~/client/lib/volume-path"; -import { findCommonAncestor } from "@zerobyte/core/utils"; export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$snapshotId/restore")({ component: RouteComponent, errorComponent: (e) =>
{e.error.message}
, loader: async ({ params, context }) => { - const [snapshot, repository] = await Promise.all([ + const [snapshot, repository, restorePlan] = await Promise.all([ context.queryClient.ensureQueryData({ ...getSnapshotDetailsOptions({ path: { shortId: params.repositoryId, snapshotId: params.snapshotId } }), }), - context.queryClient.ensureQueryData({ ...getRepositoryOptions({ path: { shortId: params.repositoryId } }) }), + context.queryClient.ensureQueryData({ + ...getRepositoryOptions({ path: { shortId: params.repositoryId } }), + }), + context.queryClient.ensureQueryData({ + ...getSnapshotRestorePlanOptions({ + path: { shortId: params.repositoryId, snapshotId: params.snapshotId }, + }), + }), ]); let displayBasePath: string | undefined; @@ -25,21 +35,24 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s } } - const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); - return { snapshot, repository, - queryBasePath: hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths), displayBasePath, - hasNonPosixSnapshotPaths, + snapshotSourcePathPlan: restorePlan, }; }, staticData: { breadcrumb: (match) => [ { label: "Repositories", href: "/repositories" }, - { label: match.loaderData?.repository?.name || "Repository", href: `/repositories/${match.params.repositoryId}` }, - { label: match.params.snapshotId, href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}` }, + { + label: match.loaderData?.repository?.name || "Repository", + href: `/repositories/${match.params.repositoryId}`, + }, + { + label: match.params.snapshotId, + href: `/repositories/${match.params.repositoryId}/${match.params.snapshotId}`, + }, { label: "Restore" }, ], }, @@ -56,16 +69,15 @@ export const Route = createFileRoute("/(dashboard)/repositories/$repositoryId/$s function RouteComponent() { const { repositoryId, snapshotId } = Route.useParams(); - const { repository, queryBasePath, displayBasePath, hasNonPosixSnapshotPaths } = Route.useLoaderData(); + const { repository, displayBasePath, snapshotSourcePathPlan } = Route.useLoaderData(); return ( ); } diff --git a/app/server/modules/agents/__tests__/controller-runtime.test.ts b/app/server/modules/agents/__tests__/controller-runtime.test.ts index 82a19a9f..b899de70 100644 --- a/app/server/modules/agents/__tests__/controller-runtime.test.ts +++ b/app/server/modules/agents/__tests__/controller-runtime.test.ts @@ -2,7 +2,11 @@ import { Effect } from "effect"; import { afterEach, expect, test, vi } from "vitest"; import waitForExpect from "wait-for-expect"; import { fromPartial } from "@total-typescript/shoehorn"; -import { createAgentMessage } from "@zerobyte/contracts/agent-protocol"; +import { + createAgentMessage, + AGENT_PROTOCOL_VERSION, + SUPPORTED_AGENT_PROTOCOL_MAX_VERSION, +} from "@zerobyte/contracts/agent-protocol"; import type { Volume } from "@zerobyte/contracts/volumes"; import { LOCAL_AGENT_ID, LOCAL_AGENT_KIND, LOCAL_AGENT_NAME } from "../constants"; @@ -55,7 +59,7 @@ const backupVolume = { const readyPayload = { agentId: LOCAL_AGENT_ID, - protocolVersion: 1, + protocolVersion: AGENT_PROTOCOL_VERSION, hostname: "host", platform: "linux", capabilities: { backup: true }, @@ -205,11 +209,11 @@ test("websocket lifecycle updates agent connection status", async () => { agentKind: LOCAL_AGENT_KIND, }); expect(agentsServiceMocks.markAgentOnline).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number), { + platform: "linux", backup: true, - protocolVersion: 1, + protocolVersion: AGENT_PROTOCOL_VERSION, protocolCompatible: true, hostname: "host", - platform: "linux", }); expect(agentsServiceMocks.markAgentSeen).toHaveBeenCalledWith(LOCAL_AGENT_ID, expect.any(Number)); expect(agentsServiceMocks.markAgentOffline).toHaveBeenCalledWith(LOCAL_AGENT_ID); @@ -230,7 +234,7 @@ test("websocket protocol rejection forwards the event and closes the connection" JSON.stringify({ type: "agent.ready", payload: { - protocolVersion: 2, + protocolVersion: SUPPORTED_AGENT_PROTOCOL_MAX_VERSION + 1, hostname: "host", platform: "linux", }, diff --git a/app/server/modules/agents/__tests__/session.test.ts b/app/server/modules/agents/__tests__/session.test.ts index fb076077..b6b8446c 100644 --- a/app/server/modules/agents/__tests__/session.test.ts +++ b/app/server/modules/agents/__tests__/session.test.ts @@ -4,6 +4,7 @@ import waitForExpect from "wait-for-expect"; import { fromPartial } from "@total-typescript/shoehorn"; import { createAgentMessage, + AGENT_PROTOCOL_VERSION, SUPPORTED_AGENT_PROTOCOL_MAX_VERSION, type AgentMessage, } from "@zerobyte/contracts/agent-protocol"; @@ -141,7 +142,7 @@ test("invalid inbound messages are ignored", () => { session.handleMessage( createAgentMessage("agent.ready", { agentId: LOCAL_AGENT_ID, - protocolVersion: 1, + protocolVersion: AGENT_PROTOCOL_VERSION, hostname: "host", platform: "linux", capabilities: { backup: true }, @@ -166,7 +167,7 @@ test("agent.ready marks the session ready and forwards the event", () => { session.handleMessage( createAgentMessage("agent.ready", { agentId: LOCAL_AGENT_ID, - protocolVersion: 1, + protocolVersion: AGENT_PROTOCOL_VERSION, hostname: "host", platform: "linux", capabilities: { backup: true }, @@ -179,7 +180,7 @@ test("agent.ready marks the session ready and forwards the event", () => { type: "agent.ready", payload: { agentId: LOCAL_AGENT_ID, - protocolVersion: 1, + protocolVersion: AGENT_PROTOCOL_VERSION, hostname: "host", platform: "linux", capabilities: { backup: true }, @@ -214,7 +215,7 @@ test("backup agent messages are forwarded unchanged", () => { session.handleMessage( createAgentMessage("agent.ready", { agentId: LOCAL_AGENT_ID, - protocolVersion: 1, + protocolVersion: AGENT_PROTOCOL_VERSION, hostname: "host", platform: "linux", capabilities: { backup: true }, diff --git a/app/server/modules/agents/agents.service.ts b/app/server/modules/agents/agents.service.ts index 30e72d05..352570a0 100644 --- a/app/server/modules/agents/agents.service.ts +++ b/app/server/modules/agents/agents.service.ts @@ -56,6 +56,7 @@ const ensureLocalAgent = async () => { const markAgentConnecting = async (params: AgentConnectionRegistration) => { const { agentId, organizationId, agentName, agentKind, capabilities, connectedAt = Date.now() } = params; + const nextCapabilities = capabilities ?? LOCAL_AGENT_CAPABILITIES; await db .insert(agentsTable) @@ -65,7 +66,7 @@ const markAgentConnecting = async (params: AgentConnectionRegistration) => { name: agentName, kind: agentKind, status: "connecting", - capabilities: capabilities ?? {}, + capabilities: nextCapabilities, lastSeenAt: connectedAt, updatedAt: connectedAt, }) @@ -78,7 +79,7 @@ const markAgentConnecting = async (params: AgentConnectionRegistration) => { status: "connecting", lastSeenAt: connectedAt, updatedAt: connectedAt, - capabilities: capabilities ?? {}, + capabilities: nextCapabilities, }, }); @@ -95,7 +96,7 @@ const updateAgentRuntime = async (agentId: string, values: Partial) => { return updatedAgent; }; -const markAgentOnline = async (agentId: string, readyAt = Date.now(), metadata?: AgentCapabilities) => { +const markAgentOnline = async (agentId: string, readyAt = Date.now(), metadata = LOCAL_AGENT_CAPABILITIES) => { return updateAgentRuntime(agentId, { status: "online", capabilities: metadata, diff --git a/app/server/modules/repositories/__tests__/repositories.controller.test.ts b/app/server/modules/repositories/__tests__/repositories.controller.test.ts index 9a123cd4..da02342f 100644 --- a/app/server/modules/repositories/__tests__/repositories.controller.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.controller.test.ts @@ -121,6 +121,7 @@ describe("repositories security", () => { { method: "GET", path: "/api/v1/repositories/test-repo/snapshots" }, { method: "POST", path: "/api/v1/repositories/test-repo/snapshots/refresh" }, { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot" }, + { method: "GET", path: "/api/v1/repositories/test-repo/snapshots/test-snapshot/restore-plan" }, { 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" }, diff --git a/app/server/modules/repositories/__tests__/repositories.service.test.ts b/app/server/modules/repositories/__tests__/repositories.service.test.ts index 9d50bf5b..ff6c6707 100644 --- a/app/server/modules/repositories/__tests__/repositories.service.test.ts +++ b/app/server/modules/repositories/__tests__/repositories.service.test.ts @@ -526,7 +526,7 @@ describe("repositoriesService.dumpSnapshot", () => { ); }); - test("downloads the full snapshot from root when source paths are non-posix", async () => { + test("downloads the full snapshot from root when source paths are native Windows paths", async () => { const { organizationId, userId, shortId } = await setupDumpSnapshotScenario({ snapshotId: "snapshot-windows", basePath: "/tmp/repro/source", @@ -574,6 +574,17 @@ describe("repositoriesService.restoreSnapshot", () => { }); }; + const withPlatform = async (platform: NodeJS.Platform, run: () => Promise) => { + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { value: platform }); + + try { + return await run(); + } finally { + Object.defineProperty(process, "platform", { value: originalPlatform }); + } + }; + beforeEach(() => { originalEnableLocalAgent = config.flags.enableLocalAgent; config.flags.enableLocalAgent = true; @@ -647,11 +658,10 @@ describe("repositoriesService.restoreSnapshot", () => { expect.objectContaining({ payload: expect.objectContaining({ snapshotId: "snapshot-restore", - target: targetPath, + snapshotPaths: ["/var/lib/zerobyte/volumes/vol123/_data"], repositoryConfig: expect.objectContaining({ backend: "local" }), - options: expect.objectContaining({ - organizationId, - basePath: "/var/lib/zerobyte/volumes/vol123/_data", + request: expect.objectContaining({ + location: { kind: "custom", targetPath }, }), }), }), @@ -768,7 +778,7 @@ describe("repositoriesService.restoreSnapshot", () => { name: "Remote Agent", kind: "remote", status: "online", - capabilities: {}, + capabilities: { platform: "linux" }, updatedAt: Date.now(), }); vi.spyOn(restic, "snapshots").mockReturnValue( @@ -802,7 +812,9 @@ describe("repositoriesService.restoreSnapshot", () => { agentId, expect.objectContaining({ payload: expect.objectContaining({ - target: targetPath, + request: expect.objectContaining({ + location: { kind: "custom", targetPath }, + }), }), }), ); @@ -830,7 +842,7 @@ describe("repositoriesService.restoreSnapshot", () => { name: "Other Org Agent", kind: "remote", status: "online", - capabilities: {}, + capabilities: { platform: "linux" }, updatedAt: Date.now(), }); vi.spyOn(restic, "snapshots").mockReturnValue( @@ -895,7 +907,7 @@ describe("repositoriesService.restoreSnapshot", () => { }); }); - test("rejects original-location restore for snapshots with non-posix source paths", async () => { + test("rejects original-location restore for Windows snapshot paths on POSIX hosts", async () => { const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([ "d:\\some\\path", ]); @@ -912,7 +924,112 @@ describe("repositoriesService.restoreSnapshot", () => { expect(restoreMock).not.toHaveBeenCalled(); }); - test("allows restore-all to a custom target for snapshots with non-posix source paths", async () => { + test("allows original-location restore for Windows snapshot paths on Windows hosts", async () => { + const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([ + "C:\\Users\\nicolas\\Photos", + "C:\\Users\\nicolas\\Documents", + ]); + + await withPlatform("win32", () => + withContext({ organizationId, userId }, () => + repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore"), + ), + ); + + await waitForExpect(() => { + expect(restoreMock).toHaveBeenCalledWith( + "local", + expect.objectContaining({ + payload: expect.objectContaining({ + snapshotId: "snapshot-restore", + snapshotPaths: ["C:\\Users\\nicolas\\Photos", "C:\\Users\\nicolas\\Documents"], + request: expect.objectContaining({ + location: { kind: "original" }, + }), + }), + }), + ); + }); + }); + + test("keeps the selected Windows file in its original folder", async () => { + const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([ + "C:\\Users\\Nicolas\\Downloads", + ]); + + await withPlatform("win32", () => + withContext({ organizationId, userId }, () => + repositoriesService.restoreSnapshot(repositoryShortId, "snapshot-restore", { + include: ["/C/Users/Nicolas/Downloads/DumpStack.log"], + selectedItemKind: "file", + }), + ), + ); + + await waitForExpect(() => { + expect(restoreMock).toHaveBeenCalledWith( + "local", + expect.objectContaining({ + payload: expect.objectContaining({ + request: expect.objectContaining({ + location: { kind: "original" }, + include: ["/C/Users/Nicolas/Downloads/DumpStack.log"], + selectedItemKind: "file", + }), + }), + }), + ); + }); + }); + + test("uses the target agent platform for target-scoped restore plans", async () => { + const organizationId = session.organizationId; + const agentId = `agent-${randomUUID()}`; + const repository = await createTestRepository(organizationId, { + type: "s3", + config: { + backend: "s3", + endpoint: "https://s3.example.com", + bucket: "bucket", + accessKeyId: "access-key", + secretAccessKey: "secret-key", + }, + }); + await db.insert(agentsTable).values({ + id: agentId, + organizationId, + name: "Windows Agent", + kind: "remote", + status: "online", + capabilities: { platform: "win32" }, + updatedAt: Date.now(), + }); + vi.spyOn(restic, "snapshots").mockReturnValue( + Effect.succeed([ + { + id: "snapshot-restore", + short_id: "snapshot-restore", + time: new Date().toISOString(), + paths: ["C:\\Users\\nicolas\\Photos", "C:\\Users\\nicolas\\Documents"], + hostname: "host", + }, + ]), + ); + const plan = await withPlatform("linux", () => + withContext({ organizationId, userId: session.user.id }, () => + repositoriesService.getSnapshotRestorePlan(repository.shortId, "snapshot-restore", { + targetAgentId: agentId, + }), + ), + ); + + expect(plan).toEqual({ + queryBasePath: "/C/Users/nicolas", + requiresCustomTarget: false, + }); + }); + + test("allows restore-all to a custom target for snapshots with native Windows source paths", async () => { const { organizationId, userId, repositoryShortId, restoreMock } = await setupRestoreSnapshotScenario([ "d:\\some\\path", ]); @@ -934,11 +1051,10 @@ describe("repositoriesService.restoreSnapshot", () => { expect.objectContaining({ payload: expect.objectContaining({ snapshotId: "snapshot-restore", - target: targetPath, + snapshotPaths: ["d:\\some\\path"], repositoryConfig: expect.objectContaining({ backend: "local" }), - options: expect.objectContaining({ - organizationId, - basePath: "/", + request: expect.objectContaining({ + location: { kind: "custom", targetPath }, }), }), }), diff --git a/app/server/modules/repositories/helpers/dump.ts b/app/server/modules/repositories/helpers/dump.ts index d59fdb63..3250ef93 100644 --- a/app/server/modules/repositories/helpers/dump.ts +++ b/app/server/modules/repositories/helpers/dump.ts @@ -1,6 +1,6 @@ import { BadRequestError } from "http-errors-enhanced"; import path from "node:path"; -import { findCommonAncestor, normalizeAbsolutePath } from "@zerobyte/core/utils"; +import { getSnapshotSourcePathPlan, normalizeAbsolutePath } from "@zerobyte/core/utils"; const sanitizeFilenamePart = (value: string): string => { const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, ""); @@ -16,8 +16,7 @@ export const prepareSnapshotDump = (params: { const archiveFilename = `snapshot-${sanitizeFilenamePart(snapshotId)}.tar`; const normalizedRequestedPath = normalizeAbsolutePath(requestedPath); - const hasNonPosixSnapshotPaths = snapshotPaths.some((snapshotPath) => !snapshotPath.startsWith("/")); - const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshotPaths); + const { queryBasePath: basePath } = getSnapshotSourcePathPlan({ snapshotPaths }); if (basePath === "/") { return { diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index 66f656d5..d2852f68 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -16,6 +16,8 @@ import { getRepositoryStatsDto, refreshRepositoryStatsDto, getSnapshotDetailsDto, + getSnapshotRestorePlanDto, + getSnapshotRestorePlanQuery, refreshSnapshotsDto, listRcloneRemotesDto, listRepositoriesDto, @@ -43,6 +45,7 @@ import { type GetRepositoryStatsDto, type RefreshRepositoryStatsDto, type GetSnapshotDetailsDto, + type GetSnapshotRestorePlanDto, type RefreshSnapshotsDto, type ListRepositoriesDto, type ListSnapshotFilesDto, @@ -168,6 +171,19 @@ export const repositoriesController = new Hono() return c.json(response, 200); }) + .get( + "/:shortId/snapshots/:snapshotId/restore-plan", + getSnapshotRestorePlanDto, + validator("query", getSnapshotRestorePlanQuery), + async (c) => { + const shortId = asShortId(c.req.param("shortId")); + const snapshotId = c.req.param("snapshotId"); + const query = c.req.valid("query"); + const result = await repositoriesService.getSnapshotRestorePlan(shortId, snapshotId, query); + + return c.json(result, 200); + }, + ) .get( "/:shortId/snapshots/:snapshotId/files", listSnapshotFilesDto, diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index d0704ccc..594ee7b9 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -205,6 +205,15 @@ const snapshotSchema = z.object({ summary: resticSnapshotSummarySchema.optional(), }); +const snapshotSourcePathPlanSchema = z.object({ + queryBasePath: z.string(), + requiresCustomTarget: z.boolean(), +}); + +export const getSnapshotRestorePlanQuery = z.object({ + targetAgentId: z.string().optional(), +}); + const listSnapshotsResponse = snapshotSchema.array(); export type ListSnapshotsDto = z.infer; @@ -249,6 +258,35 @@ export const getSnapshotDetailsDto = describeRoute({ }, }); +const getSnapshotRestorePlanResponse = snapshotSourcePathPlanSchema; + +export type GetSnapshotRestorePlanDto = z.infer; + +export const getSnapshotRestorePlanDto = describeRoute({ + description: "Get a target-scoped restore plan for a snapshot", + tags: ["Repositories"], + operationId: "getSnapshotRestorePlan", + parameters: [ + { + in: "query", + name: "targetAgentId", + required: false, + schema: { type: "string" }, + description: "Agent that will execute the restore. Defaults to the local agent.", + }, + ], + responses: { + 200: { + description: "Target-scoped snapshot restore plan", + content: { + "application/json": { + schema: resolver(getSnapshotRestorePlanResponse), + }, + }, + }, + }, +}); + const snapshotFileNodeSchema = z.object({ name: z.string(), type: z.string(), diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index c0343ede..3756fad0 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -8,7 +8,11 @@ import { type RepositoryConfig, type ResticDumpStream, type ResticStatsDto, + createSnapshotRestoreExecutionPlan, + getSnapshotRestoreTargetPlan, + isSnapshotRestorePlanningError, repositoryConfigSchema, + type SnapshotRestoreRequest, } from "@zerobyte/core/restic"; import { isPathWithin } from "@zerobyte/core/utils"; import { config as appConfig } from "~/server/core/config"; @@ -27,7 +31,6 @@ import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@ze import { restic, resticDeps } from "../../core/restic"; import { safeSpawn } from "@zerobyte/core/node"; import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto"; -import { findCommonAncestor } from "@zerobyte/core/utils"; import { prepareSnapshotDump } from "./helpers/dump"; import { executeDoctor } from "./helpers/doctor"; import { restoreExecutor } from "./restore-executor"; @@ -236,13 +239,70 @@ const finishRestoreExecution = async (restoreId: string, resultPromise: Promise< const assertAllowedRestoreAgent = async (agentId: string, organizationId: string) => { if (agentId === LOCAL_AGENT_ID) { - return; + return null; } const agent = await agentsService.getAgent(agentId); if (!agent || agent.organizationId !== organizationId) { throw new NotFoundError("Restore target agent not found"); } + + return agent; +}; + +const getRestoreAgentPlatform = (agentId: string, agent: Awaited>) => { + if (agentId === LOCAL_AGENT_ID) { + return process.platform; + } + + if (!agent) { + throw new Error("Remote restore agent should be available after restore-agent authorization"); + } + + const platform = agent.capabilities.platform; + if (typeof platform !== "string") { + throw new BadRequestError("Restore target agent platform is unavailable"); + } + + return platform; +}; + +const createSnapshotRestoreRequest = ({ + targetPath, + ...options +}: { + include?: string[]; + selectedItemKind?: "file" | "dir"; + exclude?: string[]; + excludeXattr?: string[]; + delete?: boolean; + targetPath?: string; + overwrite?: OverwriteMode; +}): SnapshotRestoreRequest => ({ + location: targetPath ? { kind: "custom", targetPath } : { kind: "original" }, + ...(options.include ? { include: options.include } : {}), + ...(options.selectedItemKind ? { selectedItemKind: options.selectedItemKind } : {}), + ...(options.exclude ? { exclude: options.exclude } : {}), + ...(options.excludeXattr ? { excludeXattr: options.excludeXattr } : {}), + ...(options.delete !== undefined ? { delete: options.delete } : {}), + ...(options.overwrite ? { overwrite: options.overwrite } : {}), +}); + +const assertAllowedControllerLocalRestoreRequest = (snapshotPaths: string[], request: SnapshotRestoreRequest) => { + try { + const plan = createSnapshotRestoreExecutionPlan({ + snapshotPaths, + platform: process.platform, + request, + }); + assertAllowedControllerLocalRestoreTarget(plan.target); + } catch (error) { + if (isSnapshotRestorePlanningError(error)) { + throw new BadRequestError(error.message); + } + + throw error; + } }; const findRepository = async (shortId: ShortId) => { @@ -541,21 +601,16 @@ const restoreSnapshot = async ( } const { targetAgentId, targetPath, ...restoreExecutionOptions } = options ?? {}; - const target = targetPath || "/"; - - const snapshot = await getSnapshotDetails(repository.shortId, snapshotId); - const hasNonPosixSnapshotPaths = snapshot.paths.some((path) => !path.startsWith("/")); - - if (hasNonPosixSnapshotPaths && !targetPath) { - throw new BadRequestError( - "Original location restore is unavailable for this snapshot. Restore it to a custom location instead.", - ); - } - - const basePath = hasNonPosixSnapshotPaths ? "/" : findCommonAncestor(snapshot.paths); const executionAgentId = targetAgentId ?? LOCAL_AGENT_ID; - const useControllerLocalRestoreFallback = executionAgentId === LOCAL_AGENT_ID && !appConfig.flags.enableLocalAgent; await assertAllowedRestoreAgent(executionAgentId, organizationId); + const snapshot = await getSnapshotDetails(repository.shortId, snapshotId); + const restoreRequest = createSnapshotRestoreRequest({ targetPath, ...restoreExecutionOptions }); + + const useControllerLocalRestoreFallback = executionAgentId === LOCAL_AGENT_ID && !appConfig.flags.enableLocalAgent; + let taskTargetAgentId: string | null = executionAgentId; + if (useControllerLocalRestoreFallback) { + taskTargetAgentId = null; + } if (!useControllerLocalRestoreFallback && repository.type === "local" && executionAgentId !== LOCAL_AGENT_ID) { throw new BadRequestError( @@ -564,7 +619,7 @@ const restoreSnapshot = async ( } if (executionAgentId === LOCAL_AGENT_ID) { - assertAllowedControllerLocalRestoreTarget(target); + assertAllowedControllerLocalRestoreRequest(snapshot.paths, restoreRequest); } const activeRestore = findActiveRestoreTask(organizationId, repository.shortId, snapshotId); @@ -576,8 +631,14 @@ const restoreSnapshot = async ( organizationId, resourceType: RESTORE_TASK_RESOURCE_TYPE, resourceId: repository.shortId, - targetAgentId: useControllerLocalRestoreFallback ? null : executionAgentId, - input: { kind: "restore", repositoryId: repository.shortId, snapshotId, target }, + targetAgentId: taskTargetAgentId, + input: { + kind: "restore", + repositoryId: repository.shortId, + snapshotId, + restoreLocation: restoreRequest.location.kind, + ...(restoreRequest.location.kind === "custom" ? { targetPath: restoreRequest.location.targetPath } : {}), + }, }); const restoreId = task.id; try { @@ -589,12 +650,9 @@ const restoreSnapshot = async ( repositoryShortId: repository.shortId, repositoryConfig, snapshotId, - target, + snapshotPaths: snapshot.paths, + restoreRequest, executionAgentId, - options: { - basePath, - ...restoreExecutionOptions, - }, onStarted: () => markRestoreStarted(restoreId), onProgress: (progress) => updateRestoreProgress(restoreId, progress), }); @@ -704,6 +762,17 @@ const getSnapshotDetails = async (shortId: ShortId, snapshotId: string) => { return snapshot; }; +const getSnapshotRestorePlan = async (shortId: ShortId, snapshotId: string, options?: { targetAgentId?: string }) => { + const organizationId = getOrganizationId(); + const executionAgentId = options?.targetAgentId ?? LOCAL_AGENT_ID; + const targetAgent = await assertAllowedRestoreAgent(executionAgentId, organizationId); + const platform = getRestoreAgentPlatform(executionAgentId, targetAgent); + + const snapshot = await getSnapshotDetails(shortId, snapshotId); + + return getSnapshotRestoreTargetPlan({ snapshotPaths: snapshot.paths, platform }); +}; + const checkHealth = async (shortId: ShortId) => { const organizationId = getOrganizationId(); const repository = await findRepository(shortId); @@ -1089,6 +1158,7 @@ export const repositoriesService = { restoreSnapshot, dumpSnapshot, getSnapshotDetails, + getSnapshotRestorePlan, checkHealth, startDoctor, cancelDoctor, diff --git a/app/server/modules/repositories/restore-executor.ts b/app/server/modules/repositories/restore-executor.ts index d631938f..71247d5f 100644 --- a/app/server/modules/repositories/restore-executor.ts +++ b/app/server/modules/repositories/restore-executor.ts @@ -1,4 +1,8 @@ -import type { RepositoryConfig } from "@zerobyte/core/restic"; +import { + createSnapshotRestoreExecutionPlan, + type RepositoryConfig, + type SnapshotRestoreRequest, +} from "@zerobyte/core/restic"; import type { RestoreRunPayload } from "@zerobyte/contracts/agent-protocol"; import { config as appConfig } from "~/server/core/config"; import { repoMutex } from "../../core/repository-mutex"; @@ -7,8 +11,6 @@ import { runEffectPromise, toMessage } from "../../utils/errors"; import { agentManager, type RestoreExecutionProgress, type RestoreExecutionResult } from "../agents/agents-manager"; import { LOCAL_AGENT_ID } from "../agents/constants"; -type RestoreExecutionOptions = Omit[3], "organizationId" | "signal" | "onProgress">; - type RestoreExecutionRequest = { restoreId: string; organizationId: string; @@ -16,9 +18,9 @@ type RestoreExecutionRequest = { repositoryShortId: string; repositoryConfig: RepositoryConfig; snapshotId: string; - target: string; + snapshotPaths: string[]; + restoreRequest: SnapshotRestoreRequest; executionAgentId: string; - options: RestoreExecutionOptions; onStarted: () => void; onProgress: (progress: RestoreExecutionProgress) => void; }; @@ -38,13 +40,10 @@ const createRestoreRunPayload = async (request: RestoreExecutionRequest): Promis organizationId: request.organizationId, repositoryId: request.repositoryShortId, snapshotId: request.snapshotId, - target: request.target, + snapshotPaths: request.snapshotPaths, repositoryConfig: request.repositoryConfig, runtime: { password: resticPassword }, - options: { - ...request.options, - organizationId: request.organizationId, - }, + request: request.restoreRequest, }; }; @@ -56,12 +55,18 @@ const executeControllerRestore = async ( return { status: "cancelled", message: "Restore was cancelled" }; } - request.onStarted(); - try { + const plan = createSnapshotRestoreExecutionPlan({ + snapshotPaths: request.snapshotPaths, + platform: process.platform, + request: request.restoreRequest, + }); + + request.onStarted(); + const result = await runEffectPromise( - restic.restore(request.repositoryConfig, request.snapshotId, request.target, { - ...request.options, + restic.restore(request.repositoryConfig, request.snapshotId, plan.target, { + ...plan.options, organizationId: request.organizationId, signal, onProgress: request.onProgress, diff --git a/app/server/modules/tasks/__tests__/tasks.store.test.ts b/app/server/modules/tasks/__tests__/tasks.store.test.ts index 3a6ed237..f1cc4a56 100644 --- a/app/server/modules/tasks/__tests__/tasks.store.test.ts +++ b/app/server/modules/tasks/__tests__/tasks.store.test.ts @@ -55,7 +55,13 @@ const createRestoreTask = (overrides: Partial = {}) => organizationId: "org-1", repositoryId: "repo-1", snapshotId: "snapshot-1", - target: `${process.cwd()}/restore-target`, + snapshotPaths: [`${process.cwd()}/source`], repositoryConfig: { backend: "local", path: "/tmp/repository" }, runtime: { password: "password" }, - options: { organizationId: "org-1", basePath: "/" }, + request: { location: { kind: "custom", targetPath: `${process.cwd()}/restore-target` } }, ...overrides, }); diff --git a/apps/agent/src/commands/__tests__/volume.test.ts b/apps/agent/src/commands/__tests__/volume.test.ts index 07527838..2c69b1b0 100644 --- a/apps/agent/src/commands/__tests__/volume.test.ts +++ b/apps/agent/src/commands/__tests__/volume.test.ts @@ -58,7 +58,11 @@ test("runs backend-backed volume commands on the agent host", async () => { commandId: "command-1", command: { name: "volume.mount", - volume: { id: 1, config: { backend: "directory", path: "/tmp/source" }, provisioningId: undefined }, + volume: { + id: 1, + config: { backend: "directory", path: "/tmp/source" }, + provisioningId: undefined, + }, }, }), ); diff --git a/apps/agent/src/commands/restore.ts b/apps/agent/src/commands/restore.ts index ccb6bdc4..b000ca81 100644 --- a/apps/agent/src/commands/restore.ts +++ b/apps/agent/src/commands/restore.ts @@ -2,6 +2,7 @@ import os from "node:os"; import path from "node:path"; import { Effect, Runtime } from "effect"; import { createAgentMessage, type RestoreRunPayload } from "@zerobyte/contracts/agent-protocol"; +import { createSnapshotRestoreExecutionPlan } from "@zerobyte/core/restic"; import { createRestic } from "@zerobyte/core/restic/server"; import { isPathWithin, toMessage } from "@zerobyte/core/utils"; import { logger } from "@zerobyte/core/node"; @@ -52,15 +53,21 @@ export const handleRestoreRunCommand = (context: ControllerCommandContext, paylo yield* Effect.fork( Effect.gen(function* () { - assertAllowedRestoreTarget(payload.target); + const plan = createSnapshotRestoreExecutionPlan({ + snapshotPaths: payload.snapshotPaths, + platform: process.platform, + request: payload.request, + }); + assertAllowedRestoreTarget(plan.target); const runtime = yield* Effect.runtime(); const restic = createRestic(resticDeps(payload.runtime.password)); yield* context.offerOutbound(createAgentMessage("restore.started", restoreContext)); - const result = yield* restic.restore(payload.repositoryConfig, payload.snapshotId, payload.target, { - ...payload.options, + const result = yield* restic.restore(payload.repositoryConfig, payload.snapshotId, plan.target, { + ...plan.options, + organizationId: payload.organizationId, signal: abortController.signal, onProgress: (progress) => { void Runtime.runPromise( diff --git a/apps/agent/src/controller-session.ts b/apps/agent/src/controller-session.ts index bb1e9ca0..87a23d4f 100644 --- a/apps/agent/src/controller-session.ts +++ b/apps/agent/src/controller-session.ts @@ -145,7 +145,12 @@ export const createControllerSession = (ws: WebSocket): ControllerSession => { protocolVersion: AGENT_PROTOCOL_VERSION, hostname: resolveResticHostname(), platform: process.platform, - capabilities: { backup: true, restore: true, volume: true, restic: true }, + capabilities: { + backup: true, + restore: true, + volume: true, + restic: true, + }, }), ), ).catch((error) => { diff --git a/apps/agent/src/volume-host/__tests__/operations.test.ts b/apps/agent/src/volume-host/__tests__/operations.test.ts index d5dd756e..49a5fc47 100644 --- a/apps/agent/src/volume-host/__tests__/operations.test.ts +++ b/apps/agent/src/volume-host/__tests__/operations.test.ts @@ -62,5 +62,5 @@ test("listVolumeFiles rejects traversal outside the volume", async () => { test("listVolumeFiles reports missing directories consistently", async () => { const volume = await createDirectoryVolume(); - await expect(listVolumeFiles(volume, "missing", 0, 10)).rejects.toThrow("Directory not found"); + await expect(listVolumeFiles(volume, "/missing", 0, 10)).rejects.toThrow("Directory not found"); }); diff --git a/apps/agent/src/volume-host/operations.ts b/apps/agent/src/volume-host/operations.ts index 80c66189..66601317 100644 --- a/apps/agent/src/volume-host/operations.ts +++ b/apps/agent/src/volume-host/operations.ts @@ -2,13 +2,28 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { BackendConfig, Volume as AgentVolume } from "@zerobyte/contracts/volumes"; -import { toMessage } from "@zerobyte/core/utils"; +import { normalizeAbsolutePath, toMessage } from "@zerobyte/core/utils"; import { Data, Effect } from "effect"; import { createVolumeBackend, getVolumePath, isNodeJSErrnoException } from "."; const DEFAULT_PAGE_SIZE = 500; const MAX_PAGE_SIZE = 500; +const normalizeVolumePath = (value: string) => { + if (!value.startsWith("/")) { + throw new Error("Invalid path"); + } + + return normalizeAbsolutePath(value); +}; + +const resolveVolumePath = (volumePath: string, volumeRelativePath: string) => { + const segments = volumeRelativePath.slice(1).split("/").filter(Boolean); + return path.join(volumePath, ...segments); +}; + +const toVolumePath = (nativeRelativePath: string) => normalizeVolumePath(`/${nativeRelativePath.replace(/\\/g, "/")}`); + export const listVolumeFiles = async ( volume: AgentVolume, subPath?: string, @@ -16,7 +31,13 @@ export const listVolumeFiles = async ( limit: number = DEFAULT_PAGE_SIZE, ) => { const volumePath = getVolumePath(volume); - const requestedPath = subPath ? path.join(volumePath, subPath) : volumePath; + let requestPath: string; + try { + requestPath = normalizeVolumePath(subPath ?? "/"); + } catch { + throw new Error("Invalid path"); + } + const requestedPath = resolveVolumePath(volumePath, requestPath); const normalizedPath = path.normalize(requestedPath); const requestedRelativePath = path.relative(volumePath, normalizedPath); @@ -66,7 +87,7 @@ export const listVolumeFiles = async ( return { name: dirent.name, - path: `/${relativePath}`, + path: toVolumePath(relativePath), type: dirent.isDirectory() ? ("directory" as const) : ("file" as const), size: dirent.isFile() ? stats.size : undefined, modifiedAt: stats.mtimeMs, @@ -80,7 +101,7 @@ export const listVolumeFiles = async ( return { files: entries, - path: subPath || "/", + path: requestPath, offset: startOffset, limit: pageSize, total, diff --git a/packages/contracts/src/agent-protocol.ts b/packages/contracts/src/agent-protocol.ts index 295d2e5d..90b19214 100644 --- a/packages/contracts/src/agent-protocol.ts +++ b/packages/contracts/src/agent-protocol.ts @@ -8,6 +8,7 @@ import { resticRestoreOutputSchema, restoreProgressSchema, type CompressionMode, + type SnapshotRestoreRequest, } from "@zerobyte/core/restic"; import { browseFilesystemResponseSchema, @@ -126,22 +127,28 @@ const restoreIdentitySchema = z.object({ snapshotId: z.string(), }); +const overwriteModeSchema = z.enum(["always", "if-changed", "if-newer", "never"]); + +const snapshotRestoreRequestSchema = z.object({ + location: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("original") }), + z.object({ kind: z.literal("custom"), targetPath: z.string() }), + ]), + include: z.array(z.string()).optional(), + selectedItemKind: z.enum(["file", "dir"]).optional(), + exclude: z.array(z.string()).optional(), + excludeXattr: z.array(z.string()).optional(), + delete: z.boolean().optional(), + overwrite: overwriteModeSchema.optional(), +}) satisfies z.ZodType; + const restoreRunSchema = z.object({ type: z.literal("restore.run"), payload: restoreIdentitySchema.extend({ - target: z.string(), + snapshotPaths: z.array(z.string()), repositoryConfig: repositoryConfigSchema, runtime: commandRuntimeSchema, - options: z.object({ - basePath: z.string().optional(), - organizationId: z.string(), - include: z.array(z.string()).optional(), - selectedItemKind: z.enum(["file", "dir"]).optional(), - exclude: z.array(z.string()).optional(), - excludeXattr: z.array(z.string()).optional(), - delete: z.boolean().optional(), - overwrite: z.enum(["always", "if-changed", "if-newer", "never"]).optional(), - }), + request: snapshotRestoreRequestSchema, }), }); diff --git a/packages/core/src/restic/commands/__tests__/restore.test.ts b/packages/core/src/restic/commands/__tests__/restore.test.ts index 3fc8dea8..e8b0d1c3 100644 --- a/packages/core/src/restic/commands/__tests__/restore.test.ts +++ b/packages/core/src/restic/commands/__tests__/restore.test.ts @@ -186,6 +186,80 @@ describe("restore command", () => { expect(separatorIndex).toBeGreaterThan(-1); expect(getRestoreArg()).toBe("--help:/var/lib/zerobyte/volumes/vol123/_data"); }); + + 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\\nicolas", + }, + mockDeps, + ); + + expect(getOptionValues("--target")).toEqual(["C:\\"]); + expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas"); + }); + + 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/nicolas", + }, + mockDeps, + ); + + expect(getOptionValues("--target")).toEqual(["C:\\Users"]); + expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas"); + }); + + test("keeps the precomputed Windows original-location target for selections", async () => { + const { getRestoreArg, getOptionValues } = setup(); + + await runRestore( + config, + "snapshot-windows", + "C:\\Users\\nicolas", + { + organizationId: "org-1", + include: ["/C/Users/nicolas/Downloads"], + selectedItemKind: "dir", + }, + mockDeps, + ); + + expect(getOptionValues("--target")).toEqual(["C:\\Users\\nicolas"]); + expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas/Downloads"); + }); + + 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\\nicolas\\Documents"], + selectedItemKind: "dir", + }, + mockDeps, + ); + + expect(getRestoreArg()).toBe("snapshot-windows:/C/Users/nicolas/Documents"); + expect(getOptionValues("--include")).toEqual([]); + }); }); describe("output handling", () => { diff --git a/packages/core/src/restic/commands/restore.ts b/packages/core/src/restic/commands/restore.ts index 35b48860..26c831a7 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,14 +14,13 @@ import { import type { ResticDeps } from "../types"; import { Data, Effect } from "effect"; import { toMessage } from "../../utils"; +import { createRestorePathArgs } from "../helpers/restore-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, @@ -50,12 +47,13 @@ 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, + include: options.include, + selectedItemKind: options.selectedItemKind, + }); const args = ["--repo", repoUrl, "restore", "--target", target]; @@ -63,25 +61,8 @@ 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) { @@ -97,7 +78,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__/snapshot-restore-plan.test.ts b/packages/core/src/restic/helpers/__tests__/snapshot-restore-plan.test.ts new file mode 100644 index 00000000..e5bccde7 --- /dev/null +++ b/packages/core/src/restic/helpers/__tests__/snapshot-restore-plan.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "vitest"; +import { createSnapshotRestoreExecutionPlan, SnapshotRestorePlanningError } from "../snapshot-restore-plan"; + +describe("snapshot restore planning", () => { + test("plans a POSIX custom-target restore from the snapshot source base", () => { + const plan = createSnapshotRestoreExecutionPlan({ + snapshotPaths: ["/var/lib/zerobyte/volumes/vol123/_data"], + platform: "linux", + request: { location: { kind: "custom", targetPath: "/restore-target" } }, + }); + + expect(plan).toMatchObject({ + target: "/restore-target", + options: { basePath: "/var/lib/zerobyte/volumes/vol123/_data" }, + }); + }); + + test("plans Windows original-location restores on Windows hosts", () => { + const plan = createSnapshotRestoreExecutionPlan({ + snapshotPaths: ["C:\\Users\\nicolas\\Photos", "C:\\Users\\nicolas\\Documents"], + platform: "win32", + request: { location: { kind: "original" } }, + }); + + expect(plan).toMatchObject({ + target: "C:\\Users\\nicolas", + options: { basePath: "/C/Users/nicolas" }, + }); + }); + + test("requires custom targets for Windows original-location restores on POSIX hosts", () => { + expect(() => + createSnapshotRestoreExecutionPlan({ + snapshotPaths: ["C:\\Users\\nicolas\\Photos"], + platform: "linux", + request: { location: { kind: "original" } }, + }), + ).toThrow(SnapshotRestorePlanningError); + }); +}); 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..fb23baab --- /dev/null +++ b/packages/core/src/restic/helpers/restore-paths.ts @@ -0,0 +1,99 @@ +import path from "node:path"; +import { findCommonAncestor } from "../../utils/common-ancestor"; +import { windowsHostPathToResticSnapshotPath } from "../../utils/path"; + +type RestorePathInput = { + snapshotId: string; + target: string; + basePath?: string; + include?: string[]; + selectedItemKind?: "file" | "dir"; +}; + +type RestorePathArgs = { + restoreArg: string; + includePatterns: string[]; +}; + +const escapeResticIncludePattern = (pattern: string): string => pattern.replace(/[\\*?[\]]/g, "\\$&"); + +const toResticSnapshotPath = (snapshotPath: string): string => { + return windowsHostPathToResticSnapshotPath(snapshotPath) ?? snapshotPath; +}; + +export const findResticCommonAncestor = (snapshotPaths: string[]): string => { + const resticPaths = snapshotPaths.map(toResticSnapshotPath); + if (resticPaths.every((snapshotPath) => snapshotPath.startsWith("/"))) { + return findCommonAncestor(resticPaths); + } + + if (resticPaths.some((snapshotPath) => snapshotPath.startsWith("/"))) { + throw new Error("Snapshot paths must use a single path format."); + } + + if (resticPaths.length === 0) return "/"; + if (resticPaths.length === 1) return resticPaths[0] || "/"; + + const splitPaths = resticPaths.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 getResticRestoreRoot = (includes: string[], selectedItemKind?: "file" | "dir"): string => { + if (selectedItemKind === "file" && includes.length === 1) { + return path.posix.dirname(toResticSnapshotPath(includes[0] ?? "/")); + } + + return findResticCommonAncestor(includes); +}; + +const getIncludePatterns = (include: string[] | undefined, target: string, restoreRoot: string): string[] => { + if (!include?.length) return []; + + if (target === "/") { + return include.map((pattern) => escapeResticIncludePattern(toResticSnapshotPath(pattern))); + } + + const strippedIncludes = include.map((pattern) => path.posix.relative(restoreRoot, toResticSnapshotPath(pattern))); + const includesCoverRestoreRoot = strippedIncludes.some((pattern) => pattern === "" || pattern === "."); + + if (includesCoverRestoreRoot) { + return []; + } + + return strippedIncludes.map(escapeResticIncludePattern); +}; + +export const createRestorePathArgs = ({ + snapshotId, + target, + basePath, + include, + selectedItemKind, +}: RestorePathInput): RestorePathArgs => { + const includes = include?.length ? include : [basePath ?? "/"]; + const restoreRoot = getResticRestoreRoot(includes, selectedItemKind); + const restoreArg = target === "/" ? snapshotId : `${snapshotId}:${restoreRoot}`; + + return { + restoreArg, + includePatterns: getIncludePatterns(include, target, restoreRoot), + }; +}; diff --git a/packages/core/src/restic/helpers/snapshot-restore-plan.ts b/packages/core/src/restic/helpers/snapshot-restore-plan.ts new file mode 100644 index 00000000..eab235d8 --- /dev/null +++ b/packages/core/src/restic/helpers/snapshot-restore-plan.ts @@ -0,0 +1,117 @@ +import { + getOriginalRestoreTargetForRoot, + getSnapshotSourcePathPlan, + hostPathKindFromPlatform, + type SnapshotSourcePathPlan, +} from "../../utils"; +import type { OverwriteMode } from "../schemas"; +import { getResticRestoreRoot } from "./restore-paths"; + +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; + include?: string[]; + selectedItemKind?: "file" | "dir"; + exclude?: string[]; + excludeXattr?: string[]; + delete?: boolean; + overwrite?: OverwriteMode; + }; + sourcePathPlan: SnapshotSourcePathPlan; +}; + +export type SnapshotRestoreTargetPlan = Pick; + +export class SnapshotRestorePlanningError extends Error { + constructor(message: string) { + super(message); + this.name = "SnapshotRestorePlanningError"; + } +} + +export const isSnapshotRestorePlanningError = (error: unknown): error is SnapshotRestorePlanningError => { + return error instanceof SnapshotRestorePlanningError; +}; + +export const getSnapshotRestoreTargetPlan = ({ + snapshotPaths, + platform, +}: { + snapshotPaths: string[]; + platform: string; +}): SnapshotRestoreTargetPlan => { + const sourcePathPlan = getSnapshotSourcePathPlan({ + snapshotPaths, + hostPathKind: hostPathKindFromPlatform(platform), + }); + + return { + queryBasePath: sourcePathPlan.queryBasePath, + requiresCustomTarget: sourcePathPlan.requiresCustomTarget, + }; +}; + +export const createSnapshotRestoreExecutionPlan = ({ + snapshotPaths, + platform, + request, +}: { + snapshotPaths: string[]; + platform: string; + request: SnapshotRestoreRequest; +}): SnapshotRestoreExecutionPlan => { + const sourcePathPlan = getSnapshotSourcePathPlan({ + snapshotPaths, + hostPathKind: hostPathKindFromPlatform(platform), + }); + 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 && sourcePathPlan.requiresCustomTarget) { + throw new SnapshotRestorePlanningError( + "Original location restore is unavailable for this snapshot. Restore it to a custom location instead.", + ); + } + + const basePath = isCustomLocation ? sourcePathPlan.customRestoreBasePath : sourcePathPlan.originalRestoreBasePath; + const restoreIncludes = request.include?.length ? request.include : [basePath]; + const restoreRoot = getResticRestoreRoot(restoreIncludes, request.selectedItemKind); + const target = + location.kind === "custom" + ? location.targetPath + : getOriginalRestoreTargetForRoot({ + restoreRoot, + sourcePathKind: sourcePathPlan.sourcePathKind, + }); + + return { + target, + options: { + basePath, + ...(request.include ? { include: request.include } : {}), + ...(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, + }; +}; diff --git a/packages/core/src/restic/index.ts b/packages/core/src/restic/index.ts index 2f975a25..48a7a0af 100644 --- a/packages/core/src/restic/index.ts +++ b/packages/core/src/restic/index.ts @@ -1,6 +1,18 @@ export * from "./schemas"; export * from "./restic-dto"; export { isResticError, ResticError, ResticLockError } from "./error"; +export { + createSnapshotRestoreExecutionPlan, + getSnapshotRestoreTargetPlan, + isSnapshotRestorePlanningError, + SnapshotRestorePlanningError, +} from "./helpers/snapshot-restore-plan"; +export type { + SnapshotRestoreExecutionPlan, + SnapshotRestoreLocation, + SnapshotRestoreRequest, + SnapshotRestoreTargetPlan, +} from "./helpers/snapshot-restore-plan"; 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..532e397b 100644 --- a/packages/core/src/utils/__tests__/path.test.ts +++ b/packages/core/src/utils/__tests__/path.test.ts @@ -1,7 +1,13 @@ import path from "node:path"; import fc from "fast-check"; import { describe, expect, test } from "vitest"; -import { hasPathListSeparator, isPathWithin, normalizeAbsolutePath } from "../path"; +import { + hasPathListSeparator, + isPathWithin, + normalizeAbsolutePath, + windowsHostPathToResticSnapshotPath, + windowsResticSnapshotPathToHostPath, +} from "../path"; const safePathSegmentArb = fc .array(fc.constantFrom("a", "b", "c", "x", "y", "z", "0", "1", "2", "-", "_", ".", " "), { @@ -94,6 +100,18 @@ describe("isPathWithin", () => { }); }); +describe("Windows restic snapshot path conversion", () => { + test("maps native Windows host paths to restic snapshot paths", () => { + expect(windowsHostPathToResticSnapshotPath("C:\\Users\\nicolas")).toBe("/C/Users/nicolas"); + expect(windowsHostPathToResticSnapshotPath("c:/Users/nicolas/")).toBe("/C/Users/nicolas"); + }); + + test("maps restic snapshot paths to native Windows host paths only when called explicitly", () => { + expect(windowsResticSnapshotPathToHostPath("/C/Users/nicolas")).toBe("C:\\Users\\nicolas"); + 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/__tests__/snapshot-source-paths.test.ts b/packages/core/src/utils/__tests__/snapshot-source-paths.test.ts new file mode 100644 index 00000000..dd546da9 --- /dev/null +++ b/packages/core/src/utils/__tests__/snapshot-source-paths.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, test } from "vitest"; +import { getOriginalRestoreTargetForRoot, getSnapshotSourcePathPlan } from "../snapshot-source-paths"; + +describe("getSnapshotSourcePathPlan", () => { + test("keeps POSIX snapshots restorable to original by default", () => { + expect( + getSnapshotSourcePathPlan({ + snapshotPaths: ["/mnt/source/photos", "/mnt/source/docs"], + }), + ).toMatchObject({ + sourcePathKind: "posix", + queryBasePath: "/mnt/source", + originalRestoreBasePath: "/mnt/source", + customRestoreBasePath: "/mnt/source", + requiresCustomTarget: false, + }); + }); + + test("keeps Windows snapshots on custom-target restore for POSIX hosts", () => { + expect( + getSnapshotSourcePathPlan({ + snapshotPaths: ["C:\\Users\\nicolas\\Photos"], + }), + ).toMatchObject({ + sourcePathKind: "windows", + queryBasePath: "/", + originalRestoreBasePath: "/", + customRestoreBasePath: "/", + requiresCustomTarget: true, + }); + }); + + test("keeps Windows snapshots restorable to original on Windows hosts", () => { + expect( + getSnapshotSourcePathPlan({ + snapshotPaths: ["C:\\Users\\nicolas\\Photos", "c:/Users/nicolas/Documents"], + hostPathKind: "windows", + }), + ).toMatchObject({ + sourcePathKind: "windows", + queryBasePath: "/C/Users/nicolas", + originalRestoreBasePath: "/C/Users/nicolas", + customRestoreBasePath: "/", + requiresCustomTarget: false, + }); + }); + + test("targets the selected Windows restore root itself for original-location restores", () => { + expect( + getOriginalRestoreTargetForRoot({ + restoreRoot: "/C/Users/Nicolas/Downloads", + sourcePathKind: "windows", + }), + ).toBe("C:\\Users\\Nicolas\\Downloads"); + }); + + test("requires custom target for unsupported native paths", () => { + expect( + getSnapshotSourcePathPlan({ + snapshotPaths: ["relative\\path"], + }), + ).toMatchObject({ + sourcePathKind: "unsupported", + queryBasePath: "/", + originalRestoreBasePath: "/", + customRestoreBasePath: "/", + requiresCustomTarget: true, + }); + }); + + test("requires custom target for mixed POSIX and Windows paths", () => { + expect( + getSnapshotSourcePathPlan({ + snapshotPaths: ["C:\\some\\path", "/mnt/source"], + }), + ).toMatchObject({ + sourcePathKind: "unsupported", + queryBasePath: "/", + originalRestoreBasePath: "/", + customRestoreBasePath: "/", + requiresCustomTarget: true, + }); + }); + + test("requires custom target for Windows paths spanning drives", () => { + expect( + getSnapshotSourcePathPlan({ + snapshotPaths: ["C:\\some\\path", "D:\\other\\path"], + hostPathKind: "windows", + }), + ).toMatchObject({ + queryBasePath: "/", + originalRestoreBasePath: "/", + customRestoreBasePath: "/", + requiresCustomTarget: true, + }); + }); +}); diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 889fd77f..9ffb272f 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -1,6 +1,20 @@ 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 { + getOriginalRestoreTargetForRoot, + getSnapshotSourcePathPlan, + hostPathKindFromPath, + hostPathKindFromPlatform, +} from "./snapshot-source-paths.js"; +export type { HostPathKind, SnapshotSourcePathKind, SnapshotSourcePathPlan } from "./snapshot-source-paths.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..071ac35b 100644 --- a/packages/core/src/utils/path.ts +++ b/packages/core/src/utils/path.ts @@ -1,3 +1,89 @@ +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(2) + .replace(/\\/g, "/") + .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 findWindowsHostCommonAncestor = (paths: string[]): string | undefined => { + const normalizedPaths: string[] = []; + for (const path of paths) { + const normalizedPath = normalizeWindowsHostPath(path); + if (!normalizedPath) return undefined; + normalizedPaths.push(normalizedPath); + } + + const drive = normalizedPaths[0]?.slice(0, 2); + if ( + !drive || + normalizedPaths.some((normalizedPath) => normalizedPath.slice(0, 2).toLowerCase() !== drive.toLowerCase()) + ) { + return undefined; + } + + const splitPaths = normalizedPaths.map((normalizedPath) => normalizedPath.slice(3).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 `${drive.toUpperCase()}\\${commonParts.join("\\")}`; +}; + +export const getWindowsHostParentPath = (value: string): string | undefined => { + const normalized = normalizeWindowsHostPath(value); + if (!normalized) return undefined; + + const driveRoot = `${normalized.slice(0, 2)}\\`; + const withoutTrailingSlash = normalized.replace(/\\+$/, ""); + const lastSlashIndex = withoutTrailingSlash.lastIndexOf("\\"); + + if (lastSlashIndex <= 2) { + return driveRoot; + } + + return withoutTrailingSlash.slice(0, lastSlashIndex); +}; + export const normalizeAbsolutePath = (value?: string): string => { if (!value?.trim()) return "/"; diff --git a/packages/core/src/utils/snapshot-source-paths.ts b/packages/core/src/utils/snapshot-source-paths.ts new file mode 100644 index 00000000..c573bca1 --- /dev/null +++ b/packages/core/src/utils/snapshot-source-paths.ts @@ -0,0 +1,99 @@ +import { findCommonAncestor } from "./common-ancestor.js"; +import { + findWindowsHostCommonAncestor, + isWindowsHostPath, + windowsHostPathToResticSnapshotPath, + windowsResticSnapshotPathToHostPath, +} from "./path.js"; + +export type HostPathKind = "posix" | "windows"; +export type SnapshotSourcePathKind = HostPathKind | "unsupported"; + +export type SnapshotSourcePathPlan = { + sourcePathKind: SnapshotSourcePathKind; + queryBasePath: string; + originalRestoreBasePath: string; + customRestoreBasePath: string; + requiresCustomTarget: boolean; +}; + +const rootPlan = ( + requiresCustomTarget = false, + sourcePathKind: SnapshotSourcePathKind = requiresCustomTarget ? "unsupported" : "posix", +): SnapshotSourcePathPlan => ({ + sourcePathKind, + queryBasePath: "/", + originalRestoreBasePath: "/", + customRestoreBasePath: "/", + requiresCustomTarget, +}); + +export const hostPathKindFromPath = (hostPath?: string): HostPathKind => + hostPath && isWindowsHostPath(hostPath) ? "windows" : "posix"; + +export const hostPathKindFromPlatform = (platform: string): HostPathKind => + platform === "win32" ? "windows" : "posix"; + +export const getSnapshotSourcePathPlan = ({ + snapshotPaths, + hostPathKind = "posix", +}: { + snapshotPaths: string[]; + hostPathKind?: HostPathKind; +}): SnapshotSourcePathPlan => { + if (snapshotPaths.length === 0) return rootPlan(); + + const hasWindowsPaths = snapshotPaths.some(isWindowsHostPath); + const hasPosixPaths = snapshotPaths.some((path) => path.startsWith("/")); + const hasUnsupportedNativePaths = snapshotPaths.some((path) => !path.startsWith("/") && !isWindowsHostPath(path)); + + if (hasUnsupportedNativePaths || (hasWindowsPaths && hasPosixPaths)) { + return rootPlan(true); + } + + if (hasWindowsPaths) { + if (hostPathKind !== "windows") { + return rootPlan(true, "windows"); + } + + const basePath = findWindowsHostCommonAncestor(snapshotPaths); + if (!basePath) return rootPlan(true); + const resticBasePath = windowsHostPathToResticSnapshotPath(basePath); + if (!resticBasePath) return rootPlan(true, "windows"); + + return { + sourcePathKind: "windows", + queryBasePath: resticBasePath, + originalRestoreBasePath: resticBasePath, + customRestoreBasePath: "/", + requiresCustomTarget: false, + }; + } + + const basePath = findCommonAncestor(snapshotPaths); + + return { + sourcePathKind: "posix", + queryBasePath: basePath, + originalRestoreBasePath: basePath, + customRestoreBasePath: basePath, + requiresCustomTarget: false, + }; +}; + +export 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; +};