diff --git a/docs/api/methods.md b/docs/api/methods.md
index d61e48c7..3ad8a9ec 100644
--- a/docs/api/methods.md
+++ b/docs/api/methods.md
@@ -2299,6 +2299,9 @@ None.
| systemDefaults | [SystemDefault](#system-default-object)[] | Yes | Per-system overrides for default launcher and exit ZapScript. |
| profilesRequireForLaunch | boolean | Yes | Whether media launches are blocked while no personal profile is active. |
| profilesSwapData | boolean | Yes | Whether profile switches also swap profile-scoped data (saves, save states) on supported platforms. Defaults to true. |
+| backupRemoteEnabled | boolean | No | Whether automatic remote backup scheduling is enabled. Only returned to localhost and paired admin clients. |
+| backupRemoteSchedule | string | No | Remote backup schedule: `daily`, `weekly`, or `manual`. Only returned to localhost and paired admin clients. |
+| backupRemoteBaseUrl | string | No | Configured backup remote server base URL (read-only). Only returned to localhost and paired admin clients. |
##### Reader connection object
@@ -2381,6 +2384,8 @@ An object containing any of the following optional keys:
| systemDefaults | [SystemDefault](#system-default-object)[] | No | Replace the full list of per-system launcher/exit-script overrides. Each `launcher` value, if non-empty, must match a known launcher ID or group (case-insensitive). |
| profilesRequireForLaunch | boolean | No | Whether media launches are blocked while no personal profile is active. |
| profilesSwapData | boolean | No | Whether profile switches also swap profile-scoped data. Turning it off converges data back to the shared state immediately. |
+| backupRemoteEnabled | boolean | No | Enable automatic remote backup scheduling. Requires a localhost or paired admin client. |
+| backupRemoteSchedule | string | No | Remote backup schedule: `daily`, `weekly`, or `manual`. Requires a localhost or paired admin client. |
#### Result
@@ -2449,7 +2454,7 @@ Returns `null` on success.
Redeem a claim token against a remote auth server and store the resulting credentials in `auth.toml`.
-This method performs trust discovery using the `.well-known/zaparoo` protocol. It first verifies that the claim URL's root domain supports auth (`auth: 1` in the well-known response), then redeems the claim token to obtain a bearer credential. If the root domain's well-known response includes a `trusted` list, each related domain is checked for bidirectional trust confirmation before extending the credential.
+This method performs trust discovery using the `.well-known/zaparoo` protocol. It first verifies that the claim URL's root domain supports auth (`auth: 1` in the well-known response), then redeems the claim token to obtain a bearer credential. If the root domain's well-known response includes a `trusted` list, each related domain is checked for bidirectional trust confirmation before extending the credential. Production claim URLs must use HTTPS. Plain HTTP is accepted only for loopback, private-network, or link-local development endpoints; public HTTP endpoints are rejected.
#### Parameters
@@ -2457,7 +2462,7 @@ An object:
| Key | Type | Required | Description |
| :------- | :----- | :------- | :------------------------------------------------------------- |
-| claimUrl | string | Yes | HTTPS URL of the claim endpoint to redeem the token against. |
+| claimUrl | string | Yes | HTTPS claim URL. HTTP is allowed only for loopback, private, or link-local development endpoints. |
| token | string | Yes | The one-time claim token to redeem. |
#### Result
@@ -2497,6 +2502,222 @@ An object:
}
```
+### settings.auth.status
+
+Report whether Core holds a stored bearer credential for an auth server URL. The check is local only: the token is never validated against the server and no token material is returned.
+
+Status probes are only answered for official Zaparoo API hosts over HTTPS and for the configured backup remote base URL. Any other URL returns `linked: false` without revealing whether a credential exists.
+
+#### Parameters
+
+An object:
+
+| Key | Type | Required | Description |
+| :-- | :----- | :------- | :------------------------------------- |
+| url | string | Yes | Auth server URL to check link state for. |
+
+#### Result
+
+| Key | Type | Required | Description |
+| :----- | :------ | :------- | :------------------------------------------------------- |
+| linked | boolean | Yes | Whether a stored bearer credential exists for the URL. |
+
+#### Example
+
+##### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "b2c3d4e5-auth-status-example",
+ "method": "settings.auth.status",
+ "params": {
+ "url": "https://api.zaparoo.com"
+ }
+}
+```
+
+##### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "b2c3d4e5-auth-status-example",
+ "result": {
+ "linked": true
+ }
+}
+```
+
+### settings.auth.unlink
+
+Remove the device's Zaparoo Online credentials — the inverse of `settings.auth.link`. The claim/link flow tags every credential it stores with the root domain that created it (`linked_via` in `auth.toml`), so unlink removes the configured backup remote server's entry plus every entry tagged with it, whatever domains the server's trusted list contained at link time. Credentials for other domains, hand-written basic-auth entries, and API keys are untouched. Remote backup is marked unlinked so the status UI prompts a re-link and the scheduler stops attempting remote backups. Removal is local only: the server has no revoke endpoint and invalidates the old token when the device links again.
+
+Requires a localhost client or a paired admin client.
+
+#### Parameters
+
+None.
+
+#### Result
+
+| Key | Type | Required | Description |
+| :------ | :------- | :------- | :---------------------------------------------- |
+| domains | string[] | Yes | Domains whose stored credentials were removed. |
+
+#### Example
+
+##### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "c3d4e5f6-auth-unlink-example",
+ "method": "settings.auth.unlink"
+}
+```
+
+##### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "c3d4e5f6-auth-unlink-example",
+ "result": {
+ "domains": ["https://api.zaparoo.com", "https://zpr.au"]
+ }
+}
+```
+
+### settings.auth.link
+
+Start a reverse device link flow (device-authorization style): Core requests a link from the auth server, returns a user code and verification URLs to display, then polls in the background until the user approves the link in their account. On approval the resulting claim token is redeemed through the same pipeline as `settings.auth.claim` and the credential is stored in `auth.toml`.
+
+Requires a localhost client or a paired admin client. Only one link flow can be pending at a time; starting another while one is pending returns an error. Progress is pushed via the [`auth.link.status`](./notifications.md#authlinkstatus) notification (with user code and verification URLs omitted) and can be polled with `settings.auth.link.status`.
+
+#### Parameters
+
+An object (optional):
+
+| Key | Type | Required | Description |
+| :-- | :----- | :------- | :--------------------------------------------------------------------------------------------------- |
+| url | string | No | Auth server base URL. Defaults to the official Zaparoo API. HTTP is allowed only for loopback, private, or link-local development endpoints. |
+
+#### Result
+
+A link status object:
+
+| Key | Type | Required | Description |
+| :---------------------- | :----- | :------- | :---------------------------------------------------------------- |
+| status | string | Yes | One of `none`, `pending`, `approved`, `failed`, or `cancelled`. |
+| userCode | string | No | Short code the user enters at the verification URL. |
+| verificationUrl | string | No | URL where the user approves the link. |
+| verificationUrlComplete | string | No | Verification URL with the user code included, for QR display. |
+| expiresAt | string | No | RFC 3339 time when the link request expires. |
+| error | string | No | Human-readable reason when `status` is `failed`. |
+
+#### Example
+
+##### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "c3d4e5f6-auth-link-example",
+ "method": "settings.auth.link"
+}
+```
+
+##### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "c3d4e5f6-auth-link-example",
+ "result": {
+ "status": "pending",
+ "userCode": "ABCD-1234",
+ "verificationUrl": "https://online.zaparoo.com/link",
+ "verificationUrlComplete": "https://online.zaparoo.com/link?code=ABCD1234",
+ "expiresAt": "2026-06-24T15:14:05Z"
+ }
+}
+```
+
+### settings.auth.link.status
+
+Return the state of the active link flow as a link status object (see `settings.auth.link`). When no flow has been started, `status` is `none`.
+
+Access is tiered: localhost clients and paired admin clients receive the full object including `userCode` and verification URLs; unpaired remote clients receive only the redacted state; paired member clients are forbidden.
+
+#### Parameters
+
+None.
+
+#### Result
+
+A link status object (see `settings.auth.link`).
+
+#### Example
+
+##### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "d4e5f6a7-auth-link-status-example",
+ "method": "settings.auth.link.status"
+}
+```
+
+##### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "d4e5f6a7-auth-link-status-example",
+ "result": {
+ "status": "approved"
+ }
+}
+```
+
+### settings.auth.link.cancel
+
+Cancel the pending link flow. Requires a localhost client or a paired admin client. Returns the terminal `cancelled` status object with user code and verification URLs omitted. When no flow is pending, returns an error (`no active link request`).
+
+#### Parameters
+
+None.
+
+#### Result
+
+A link status object (see `settings.auth.link`) with `status` set to `cancelled`.
+
+#### Example
+
+##### Request
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "e5f6a7b8-auth-link-cancel-example",
+ "method": "settings.auth.link.cancel"
+}
+```
+
+##### Response
+
+```json
+{
+ "jsonrpc": "2.0",
+ "id": "e5f6a7b8-auth-link-cancel-example",
+ "result": {
+ "status": "cancelled"
+ }
+}
+```
+
### settings.logs.download
Download the current log file as base64-encoded content.
@@ -2539,6 +2760,26 @@ None.
}
```
+### Device backup methods
+
+Backup methods operate on portable full-device ZIP snapshots. Portable and cloud snapshots omit paired-client credentials; restoring one restarts Core and requires clients to pair again. Mutating, listing, inspecting, and restoring methods require a localhost client or a paired admin client. Restore is refused while media is active.
+
+| Method | Parameters | Result |
+| :----- | :--------- | :----- |
+| `settings.backup` | None | Creates a bounded local ZIP and returns backup metadata. `integrity` is `valid` after creation. |
+| `settings.backup.list` | None | Lists local ZIP metadata without reading manifests. |
+| `settings.backup.inspect` | `{ "name": string }` | Reads manifest metadata. Payload `integrity` is `unchecked`; restore verifies every payload before mutation. |
+| `settings.backup.delete` | `{ "name": string }` | Deletes local ZIP and returns `null`. |
+| `settings.backup.restore` | `{ "name": string }` | Transactionally restores local ZIP, returns restore metadata, then restarts Core after response is written. |
+| `settings.backup.status` | None | Returns local/remote status, partial warnings, active operation, link state, linked device identity (`deviceName`, `linkedAt`), and Warp availability. A stale availability value triggers a background refresh; the response never blocks on the cloud API. |
+| `settings.backup.remote.run` | None | Creates manual cloud snapshot and returns upload/dedup metadata. |
+| `settings.backup.remote.list` | None | Lists cloud snapshots. Backup and device IDs are opaque strings. |
+| `settings.backup.remote.restore` | `{ "id": string }` | Transactionally restores cloud snapshot, reports completion, then restarts Core. |
+
+`backup.remote.enabled` enables automatic scheduling only. Linked devices may manually upload, list, and restore while scheduling is disabled. Warp availability gates upload and scheduling; listing and restoring existing snapshots remain available. A cloud API `401` immediately marks device unlinked until a fresh link succeeds.
+
+Archives use known categories (`zaparoo`, `settings`, `inputs`, `saves`, and `savestates`), exact platform matching, SHA-256 payload verification, and configured size limits. `partial` status means snapshot completed but one or more unsafe or unavailable source paths were skipped; inspect/status responses include structured `warnings`. Archives that fail ZIP header, manifest, or platform-policy validation return an RPC error without backup metadata. Successful inspection reports `unchecked` because payload hashes are verified during restore.
+
## Playtime
### playtime
diff --git a/docs/api/notifications.md b/docs/api/notifications.md
index 810cbc93..5f981f03 100644
--- a/docs/api/notifications.md
+++ b/docs/api/notifications.md
@@ -538,3 +538,77 @@ data side.
}
}
```
+
+## Auth
+
+### auth.link.status
+
+Sent on every state transition of a device link flow started with `settings.auth.link`. Notification payloads always omit the user code and verification URLs; clients that need them read the `settings.auth.link` result or poll `settings.auth.link.status`.
+
+#### Parameters
+
+| Key | Type | Required | Description |
+| :-------- | :----- | :------- | :-------------------------------------------------------------- |
+| status | string | Yes | One of `pending`, `approved`, `failed`, or `cancelled`. |
+| expiresAt | string | No | RFC 3339 time when the link request expires. |
+| error | string | No | Human-readable reason when `status` is `failed`. |
+
+#### Example
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "auth.link.status",
+ "params": {
+ "status": "approved"
+ }
+}
+```
+
+## Backup
+
+### backup.state
+
+Sent while a backup operation (local create, cloud upload, or restore) is running, whenever its pause/throttle state changes because a game started or stopped, and once with `finished` true when the operation ends. Backup work follows the same policy as media indexing: most games throttle it, storage-sensitive CD-based cores pause it entirely, and it resumes when the game stops. A notification with `paused`, `throttled`, and `finished` all false means the operation returned to full speed.
+
+The `finished` notification is terminal for that operation, whatever its outcome — use it to clear any paused/slowed indicator, and read `settings.backup.status` for the result.
+
+#### Parameters
+
+| Key | Type | Required | Description |
+| :-------- | :------ | :------- | :--------------------------------------------------------------------------------------- |
+| operation | string | No | The active operation kind, matching `activeOperation` from `settings.backup.status`. |
+| paused | boolean | Yes | True if the operation is fully paused until the running game stops. |
+| throttled | boolean | Yes | True if the operation is running slowed to stay out of the running game's way. |
+| finished | boolean | No | True when the operation has ended; no further `backup.state` events follow for it. |
+
+#### Examples
+
+##### Upload paused by a running game
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "backup.state",
+ "params": {
+ "operation": "remote-upload",
+ "paused": true,
+ "throttled": false
+ }
+}
+```
+
+##### Operation finished
+
+```json
+{
+ "jsonrpc": "2.0",
+ "method": "backup.state",
+ "params": {
+ "operation": "remote-upload",
+ "paused": false,
+ "throttled": false,
+ "finished": true
+ }
+}
+```
diff --git a/pkg/api/client/client.go b/pkg/api/client/client.go
index ddfc07e9..0fdde5a3 100644
--- a/pkg/api/client/client.go
+++ b/pkg/api/client/client.go
@@ -181,18 +181,16 @@ func LocalClient(
return "", fmt.Errorf("failed to write json to websocket: %w", err)
}
- timeout := config.APIRequestTimeout
- if deadline, ok := ctx.Deadline(); ok {
- remaining := time.Until(deadline)
- if remaining < timeout {
- timeout = remaining
- }
+ var timerC <-chan time.Time
+ if timeout, bounded := requestWaitTimeout(ctx, method); bounded {
+ timer := time.NewTimer(timeout)
+ defer timer.Stop()
+ timerC = timer.C
}
- timer := time.NewTimer(timeout)
select {
case <-done:
- case <-timer.C:
+ case <-timerC:
return "", ErrRequestTimeout
case <-ctx.Done():
return "", ErrRequestCancelled
@@ -215,6 +213,25 @@ func LocalClient(
return string(b), nil
}
+// requestWaitTimeout returns how long to wait for a response and whether the
+// wait is bounded at all. A caller deadline always wins. Without one,
+// unbounded-runtime backup methods wait on cancellation alone — the server
+// runs them with no deadline either — and every other method falls back to
+// the default request timeout.
+func requestWaitTimeout(ctx context.Context, method string) (timeout time.Duration, bounded bool) {
+ if deadline, ok := ctx.Deadline(); ok {
+ remaining := time.Until(deadline)
+ if remaining > 0 {
+ return remaining, true
+ }
+ return 0, true
+ }
+ if models.MethodHasUnboundedRuntime(method) {
+ return 0, false
+ }
+ return config.APIRequestTimeout, true
+}
+
func WaitNotification(
ctx context.Context,
timeout time.Duration,
diff --git a/pkg/api/client/client_test.go b/pkg/api/client/client_test.go
index a541d0c1..10c6e542 100644
--- a/pkg/api/client/client_test.go
+++ b/pkg/api/client/client_test.go
@@ -31,6 +31,7 @@ import (
"testing"
"time"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers"
"github.com/gorilla/websocket"
@@ -236,6 +237,43 @@ func TestLocalClient_Timeout(t *testing.T) {
assert.True(t, errors.Is(err, ErrRequestTimeout) || errors.Is(err, ErrRequestCancelled))
}
+func TestRequestWaitTimeout(t *testing.T) {
+ t.Parallel()
+
+ timeout, bounded := requestWaitTimeout(context.Background(), models.MethodVersion)
+ assert.True(t, bounded)
+ assert.Equal(t, config.APIRequestTimeout, timeout)
+
+ shortCtx, shortCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer shortCancel()
+ timeout, bounded = requestWaitTimeout(shortCtx, models.MethodVersion)
+ assert.True(t, bounded)
+ assert.LessOrEqual(t, timeout, 100*time.Millisecond)
+
+ longCtx, longCancel := context.WithTimeout(context.Background(), config.APIRequestTimeout+time.Minute)
+ defer longCancel()
+ timeout, bounded = requestWaitTimeout(longCtx, models.MethodVersion)
+ assert.True(t, bounded)
+ assert.Greater(t, timeout, config.APIRequestTimeout)
+
+ // Backup methods have no whole-operation deadline: without a caller
+ // deadline the wait is unbounded, but an explicit deadline still wins.
+ for _, method := range []string{
+ models.MethodSettingsBackup,
+ models.MethodSettingsBackupRestore,
+ models.MethodSettingsBackupRemoteRun,
+ models.MethodSettingsBackupRemoteRestore,
+ } {
+ _, bounded = requestWaitTimeout(context.Background(), method)
+ assert.False(t, bounded, method)
+ timeout, bounded = requestWaitTimeout(shortCtx, method)
+ assert.True(t, bounded, method)
+ assert.LessOrEqual(t, timeout, 100*time.Millisecond, method)
+ }
+ _, bounded = requestWaitTimeout(context.Background(), models.MethodSettingsBackupList)
+ assert.True(t, bounded)
+}
+
func TestLocalClient_IgnoresMismatchedIDs(t *testing.T) {
t.Parallel()
diff --git a/pkg/api/methods/auth.go b/pkg/api/methods/auth.go
index 1f7511d3..9f3104f8 100644
--- a/pkg/api/methods/auth.go
+++ b/pkg/api/methods/auth.go
@@ -29,6 +29,8 @@ import (
"net/http"
"net/url"
"runtime"
+ "slices"
+ "sort"
"strings"
"time"
@@ -38,6 +40,8 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/rs/zerolog/log"
)
@@ -48,6 +52,15 @@ var claimClient = &http.Client{
Timeout: 10 * time.Second,
}
+var revokeRemoteDevice = func(ctx context.Context, manager *backupsvc.Manager) error {
+ return manager.RevokeRemoteLink(ctx)
+}
+
+// headerZaparooDeviceHint carries the device ID from config on claim
+// redemption and link-request creation, so re-linking reuses the same
+// server-side device record instead of creating a duplicate.
+const headerZaparooDeviceHint = "Zaparoo-Device-Hint"
+
// claimRequest is the body sent to the claim endpoint.
type claimRequest struct {
Token string `json:"token"`
@@ -61,6 +74,93 @@ type claimResponse struct {
// wellKnownFetcher fetches and parses a .well-known/zaparoo file from a base URL.
type wellKnownFetcher func(baseURL string) (*zapscript.WellKnown, error)
+// HandleSettingsAuthStatus reports whether Core has a local bearer for an allowed auth URL.
+// It does not validate the token remotely and never exposes token material or credential domains.
+//
+//nolint:gocritic // single-use parameter in API handler
+func HandleSettingsAuthStatus(env requests.RequestEnv) (any, error) {
+ var params models.SettingsAuthStatusParams
+ if len(env.Params) > 0 {
+ if err := json.Unmarshal(env.Params, ¶ms); err != nil {
+ return nil, models.ClientErrf("invalid params: %w", err)
+ }
+ }
+ if params.URL == "" {
+ return nil, models.ClientErrf("invalid params: url is required")
+ }
+ configuredBackupURL := env.Config.BackupRemoteBaseURL()
+ if !authStatusProbeAllowed(params.URL, configuredBackupURL) {
+ return models.SettingsAuthStatusResponse{Linked: false}, nil
+ }
+ entry := config.LookupAuth(config.GetAuthCfg(), config.BackupAuthLookupURL(params.URL))
+ return models.SettingsAuthStatusResponse{Linked: entry != nil && entry.Bearer != ""}, nil
+}
+
+func authStatusProbeAllowed(rawURL, configuredBackupURL string) bool {
+ parsed, err := url.Parse(rawURL)
+ if err != nil || parsed.Scheme == "" || parsed.Host == "" {
+ return false
+ }
+ host := strings.ToLower(parsed.Hostname())
+ if parsed.Scheme == "https" && slices.Contains(config.OfficialAuthHosts, host) {
+ return true
+ }
+ configured, err := url.Parse(configuredBackupURL)
+ if err != nil || configured.Scheme == "" || configured.Host == "" {
+ return false
+ }
+ return strings.EqualFold(parsed.Scheme, configured.Scheme) && strings.EqualFold(parsed.Host, configured.Host)
+}
+
+// HandleSettingsAuthUnlink removes the device's Zaparoo Online credentials
+// and marks remote backup unlinked. The claim/link flow tags every entry it
+// creates with the root domain that created it (linked_via), so unlink
+// removes the configured backup server's entry plus everything tagged with
+// it — whatever the server's trusted list contained at link time.
+// Credentials for other domains and API keys are untouched. The official
+// backup-server device is revoked before local credentials are removed, so
+// unlink never leaves a usable bearer behind.
+//
+//nolint:gocritic // single-use parameter in API handler
+func HandleSettingsAuthUnlink(env requests.RequestEnv) (any, error) {
+ if !isLocalOrAdmin(&env) {
+ return nil, models.ClientErrf("unlink requires a local or admin client")
+ }
+
+ backupManager := backupsvc.NewManager(env.Config, env.Platform, env.Database)
+ if env.State != nil {
+ backupManager.WithCoordinator(env.State.BackupCoordinator())
+ }
+ if err := revokeRemoteDevice(env.Context, backupManager); err != nil {
+ return nil, fmt.Errorf("failed to revoke remote device link: %w", err)
+ }
+
+ creds := config.GetAuthCfg()
+ lookup := config.BackupAuthLookupURL(env.Config.BackupRemoteBaseURL())
+ removed := []string{}
+ for domain, stored := range creds {
+ switch {
+ case stored.LinkedVia != "" && strings.EqualFold(stored.LinkedVia, lookup):
+ removed = append(removed, domain)
+ case stored.Bearer == "":
+ // Hand-written basic-auth entries are never part of a link.
+ case strings.EqualFold(domain, lookup):
+ removed = append(removed, domain)
+ }
+ }
+ if len(removed) > 0 {
+ sort.Strings(removed)
+ if err := env.Config.DeleteAuthEntries(removed); err != nil {
+ return nil, fmt.Errorf("failed to remove credentials: %w", err)
+ }
+ }
+
+ backupManager.MarkRemoteUnlinked()
+
+ log.Info().Strs("domains", removed).Msg("settings.auth.unlink completed")
+ return models.SettingsAuthUnlinkResponse{Domains: removed}, nil
+}
+
// HandleSettingsAuthClaim redeems a claim token against a remote auth server and stores
// the resulting credentials in auth.toml. It uses .well-known/zaparoo trust
// discovery to extend the credential to additional trusted domains.
@@ -72,15 +172,49 @@ func HandleSettingsAuthClaim(env requests.RequestEnv, fetchWK wellKnownFetcher)
return nil, models.ClientErrf("invalid params: %w", err)
}
+ var backupCoordinator *backupsvc.Coordinator
+ if env.State != nil {
+ backupCoordinator = env.State.BackupCoordinator()
+ }
+ storedDomains, err := performClaim(
+ env.Context, env.Config, env.Database, env.Platform,
+ params.ClaimURL, params.Token, fetchWK, backupCoordinator,
+ )
+ if err != nil {
+ return nil, err
+ }
+
+ log.Info().Strs("domains", storedDomains).Msg("settings.auth.claim completed")
+ return models.SettingsAuthClaimResponse{Domains: storedDomains}, nil
+}
+
+// performClaim is the shared claim-redemption pipeline used by the
+// App-driven forward flow (settings.auth.claim) and the device-driven
+// reverse link flow (settings.auth.link): well-known validation, one-shot
+// token redemption, credential persistence, and trusted-domain extension.
+func performClaim(
+ ctx context.Context,
+ cfg *config.Instance,
+ db *database.Database,
+ pl platforms.Platform,
+ rawClaimURL, token string,
+ fetchWK wellKnownFetcher,
+ backupCoordinator *backupsvc.Coordinator,
+) ([]string, error) {
// Extract root domain (scheme + host) from the claim URL
- claimURL, err := url.Parse(params.ClaimURL)
+ claimURL, err := url.Parse(rawClaimURL)
if err != nil {
return nil, models.ClientErrf("invalid claim URL: %w", err)
}
+ // HTTPS only, with the same private/localhost HTTP allowance the backup
+ // base URL gets — for developing against a locally-run API.
if claimURL.Scheme != "https" {
- return nil, models.ClientErrf("claim URL must use HTTPS")
+ rootURL := claimURL.Scheme + "://" + claimURL.Host
+ if validateErr := config.ValidateBackupRemoteBaseURL(rootURL); validateErr != nil {
+ return nil, models.ClientErrf("claim URL must use HTTPS")
+ }
}
- rootDomain := "https://" + claimURL.Host
+ rootDomain := claimURL.Scheme + "://" + claimURL.Host
// Validate the root domain supports auth before redeeming the claim
// token. This avoids consuming a one-shot token when the domain can't
@@ -95,8 +229,8 @@ func HandleSettingsAuthClaim(env requests.RequestEnv, fetchWK wellKnownFetcher)
}
// Update ZapLink cache for root domain
- if env.Database != nil {
- if updateErr := env.Database.UserDB.UpdateZapLinkHost(
+ if db != nil {
+ if updateErr := db.UserDB.UpdateZapLinkHost(
rootDomain, wk.ZapScript,
); updateErr != nil {
return nil, fmt.Errorf("failed to update zaplink host cache: %w", updateErr)
@@ -104,17 +238,17 @@ func HandleSettingsAuthClaim(env requests.RequestEnv, fetchWK wellKnownFetcher)
}
// Redeem the claim token now that the domain is validated
- platform := env.Platform.ID()
- bearer, err := redeemClaimToken(env.Context, params.ClaimURL, params.Token, platform)
+ bearer, err := redeemClaimToken(ctx, rawClaimURL, token, pl.ID(), cfg.DeviceID())
if err != nil {
return nil, fmt.Errorf("failed to redeem claim token: %w", err)
}
- // Persist the credential
- entry := config.CredentialEntry{Bearer: bearer}
+ // Persist the credential, tagged with the root that created it so
+ // unlink can later remove the whole family by provenance.
+ entry := config.CredentialEntry{Bearer: bearer, LinkedVia: rootDomain}
storedDomains := []string{rootDomain}
- saveErr := env.Config.SaveAuthEntry(rootDomain, entry)
+ saveErr := cfg.SaveAuthEntry(rootDomain, entry)
if saveErr != nil {
return nil, fmt.Errorf("failed to save auth entry: %w", saveErr)
}
@@ -127,8 +261,8 @@ func HandleSettingsAuthClaim(env requests.RequestEnv, fetchWK wellKnownFetcher)
}
for _, related := range trusted {
relatedDomain := "https://" + related
- if confirmRelatedTrust(relatedDomain, rootDomain, env.Database, fetchWK) {
- if saveErr := env.Config.SaveAuthEntry(relatedDomain, entry); saveErr != nil {
+ if confirmRelatedTrust(relatedDomain, rootDomain, db, fetchWK) {
+ if saveErr := cfg.SaveAuthEntry(relatedDomain, entry); saveErr != nil {
log.Warn().Err(saveErr).Str("related", related).
Msg("failed to save auth entry for related domain")
continue
@@ -137,17 +271,40 @@ func HandleSettingsAuthClaim(env requests.RequestEnv, fetchWK wellKnownFetcher)
}
}
- log.Info().Strs("domains", storedDomains).Msg("settings.auth.claim completed")
- return models.SettingsAuthClaimResponse{Domains: storedDomains}, nil
+ // A fresh credential for the backup API supersedes any recorded
+ // revocation (a 401-triggered unlinked marker).
+ backupLookup := config.BackupAuthLookupURL(cfg.BackupRemoteBaseURL())
+ for _, domain := range storedDomains {
+ if !strings.EqualFold(domain, backupLookup) {
+ continue
+ }
+ backupManager := backupsvc.NewManager(cfg, pl, db)
+ if backupCoordinator != nil {
+ backupManager.WithCoordinator(backupCoordinator)
+ }
+ backupManager.MarkRemoteLinked()
+ refreshCtx, cancelRefresh := context.WithTimeout(ctx, 5*time.Second)
+ _, refreshErr := backupManager.RefreshRemoteAvailability(refreshCtx)
+ cancelRefresh()
+ if refreshErr != nil {
+ log.Debug().Err(refreshErr).Msg("remote backup availability not refreshed after link")
+ }
+ break
+ }
+
+ return storedDomains, nil
}
// redeemClaimToken sends the claim token to the claim URL and returns the
-// bearer token from the response.
+// bearer token from the response. deviceHint is the persistent device ID
+// from config: the server uses it to reuse the same device record when a
+// device re-links, instead of creating a duplicate.
func redeemClaimToken(
ctx context.Context,
claimURL string,
token string,
platform string,
+ deviceHint string,
) (string, error) {
body, err := json.Marshal(claimRequest{Token: token})
if err != nil {
@@ -164,6 +321,9 @@ func redeemClaimToken(
req.Header.Set(zapscript.HeaderZaparooOS, runtime.GOOS)
req.Header.Set(zapscript.HeaderZaparooArch, runtime.GOARCH)
req.Header.Set(zapscript.HeaderZaparooPlatform, platform)
+ if deviceHint != "" {
+ req.Header.Set(headerZaparooDeviceHint, deviceHint)
+ }
resp, err := claimClient.Do(req) //nolint:gosec // G107: claim URL from user input, validated as HTTPS
if err != nil {
diff --git a/pkg/api/methods/auth_link.go b/pkg/api/methods/auth_link.go
new file mode 100644
index 00000000..9538fc4f
--- /dev/null
+++ b/pkg/api/methods/auth_link.go
@@ -0,0 +1,440 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package methods
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "runtime"
+ "strings"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/notifications"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
+ "github.com/rs/zerolog/log"
+)
+
+// Reverse device link flow (RFC 8628 device-authorization style): the device
+// starts a link request, displays a user code / QR URL, and polls until the
+// user approves it in Zaparoo Online. On approval, the poll returns a claim
+// token that goes through the same redemption pipeline as the forward flow.
+
+const (
+ // deviceLinkDefaultBaseURL is the auth server the link flow targets when
+ // no explicit url param is given. Linking is an account concern, so this
+ // is fixed to the official API rather than inheriting the backup base
+ // URL from config; local development passes url explicitly.
+ deviceLinkDefaultBaseURL = "https://api.zaparoo.com"
+ deviceLinkCreatePath = "/v1/device-link-requests"
+ deviceLinkPollPath = "/v1/device-link-requests/poll"
+ deviceLinkDefaultInterval = 5 * time.Second
+ deviceLinkDefaultTTL = 10 * time.Minute
+)
+
+//nolint:tagliatelle // Remote API contract uses snake_case JSON fields.
+type deviceLinkCreateResponse struct {
+ ExpiresAt time.Time `json:"expires_at"`
+ DeviceCode string `json:"device_code"`
+ UserCode string `json:"user_code"`
+ VerificationURL string `json:"verification_url"`
+ VerificationURLComplete string `json:"verification_url_complete"`
+ Interval int `json:"interval"`
+}
+
+//nolint:tagliatelle // Remote API contract uses snake_case JSON fields.
+type deviceLinkPollRequest struct {
+ DeviceCode string `json:"device_code"`
+}
+
+//nolint:tagliatelle // Remote API contract uses snake_case JSON fields.
+type deviceLinkPollResponse struct {
+ Status string `json:"status"`
+ Token string `json:"token,omitempty"`
+ ClaimURL string `json:"claim_url,omitempty"`
+ Interval int `json:"interval"`
+}
+
+// authLinkSession is the single active reverse-link flow. The device code is
+// held by the polling goroutine only and never exposed through the API.
+type authLinkSession struct {
+ cancel context.CancelFunc
+ status models.AuthLinkStatusResponse
+}
+
+var (
+ authLinkMu syncutil.Mutex
+ activeAuthLink *authLinkSession
+ authLinkStarting bool
+)
+
+// authLinkDeps are the long-lived dependencies the polling goroutine
+// captures; all of them outlive the originating API request.
+type authLinkDeps struct {
+ cfg *config.Instance
+ db *database.Database
+ pl platforms.Platform
+ backupCoordinator *backupsvc.Coordinator
+ ns chan<- models.Notification
+ fetchWK wellKnownFetcher
+}
+
+// authLinkTerminalError marks poll failures that end the flow (as opposed to
+// transient network errors, which are retried until expiry).
+type authLinkTerminalError struct {
+ reason string
+}
+
+func (e *authLinkTerminalError) Error() string { return e.reason }
+
+// HandleSettingsAuthLink starts a reverse link flow against the official
+// Zaparoo API (or an explicit url param) and returns the user code and
+// verification URLs to display. Progress is pushed via auth.link.status
+// notifications and pollable via settings.auth.link.status.
+//
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleSettingsAuthLink(env requests.RequestEnv, fetchWK wellKnownFetcher) (any, error) {
+ if !isLocalOrAdmin(&env) {
+ return nil, models.ClientErrf("device linking requires a local or admin client")
+ }
+ var params models.SettingsAuthLinkParams
+ if len(env.Params) > 0 {
+ if err := json.Unmarshal(env.Params, ¶ms); err != nil {
+ return nil, models.ClientErrf("invalid params: %w", err)
+ }
+ }
+ baseURL := params.URL
+ if baseURL == "" {
+ baseURL = deviceLinkDefaultBaseURL
+ }
+ if err := config.ValidateBackupRemoteBaseURL(baseURL); err != nil {
+ return nil, models.ClientErrf("invalid link URL: %w", err)
+ }
+ baseURL = strings.TrimRight(baseURL, "/")
+
+ if err := beginAuthLinkStart(); err != nil {
+ return nil, err
+ }
+ created, err := createDeviceLinkRequest(env.Context, baseURL, env.Platform.ID(), env.Config.DeviceID())
+ if err != nil {
+ finishAuthLinkStart(nil)
+ return nil, err
+ }
+
+ interval := time.Duration(created.Interval) * time.Second
+ if interval <= 0 {
+ interval = deviceLinkDefaultInterval
+ }
+ expiresAt := created.ExpiresAt
+ if expiresAt.IsZero() {
+ expiresAt = time.Now().Add(deviceLinkDefaultTTL)
+ }
+
+ status := models.AuthLinkStatusResponse{
+ Status: models.AuthLinkStatusPending,
+ UserCode: created.UserCode,
+ VerificationURL: created.VerificationURL,
+ VerificationURLComplete: created.VerificationURLComplete,
+ ExpiresAt: &expiresAt,
+ }
+
+ // The flow must outlive this request: derive from the app context, with
+ // a deadline just past the link request's server-side expiry.
+ appCtx := context.Background()
+ if env.State != nil {
+ appCtx = env.State.GetContext()
+ }
+ //nolint:gosec // G118: session and polling goroutine both invoke the shared cancel function.
+ linkCtx, cancel := context.WithDeadline(appCtx, expiresAt.Add(interval))
+
+ deps := &authLinkDeps{
+ cfg: env.Config,
+ db: env.Database,
+ pl: env.Platform,
+ fetchWK: fetchWK,
+ }
+ if env.State != nil {
+ deps.backupCoordinator = env.State.BackupCoordinator()
+ deps.ns = env.State.Notifications
+ }
+
+ session := &authLinkSession{cancel: cancel, status: status}
+ finishAuthLinkStart(session)
+ if deps.ns != nil {
+ payload := status
+ redactAuthLinkStatus(&payload)
+ notifications.AuthLinkStatus(deps.ns, &payload)
+ }
+ go func() {
+ defer cancel()
+ pollDeviceLink(linkCtx, session, deps, baseURL, created.DeviceCode, interval)
+ }()
+
+ log.Info().Msg("settings.auth.link started")
+ return status, nil
+}
+
+// HandleSettingsAuthLinkStatus returns the current link flow state.
+//
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleSettingsAuthLinkStatus(env requests.RequestEnv) (any, error) {
+ unpairedRemote := !env.IsLocal && env.ClientRole == ""
+ if !env.IsLocal && !unpairedRemote {
+ if err := requireCapability(&env, permissions.CapSettingsWrite); err != nil {
+ return nil, err
+ }
+ }
+ authLinkMu.Lock()
+ defer authLinkMu.Unlock()
+ if activeAuthLink == nil {
+ return models.AuthLinkStatusResponse{Status: models.AuthLinkStatusNone}, nil
+ }
+ status := activeAuthLink.status
+ if unpairedRemote {
+ redactAuthLinkStatus(&status)
+ }
+ return status, nil
+}
+
+// HandleSettingsAuthLinkCancel stops the active link flow, if any.
+//
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleSettingsAuthLinkCancel(env requests.RequestEnv) (any, error) {
+ if !isLocalOrAdmin(&env) {
+ return nil, models.ClientErrf("device linking requires a local or admin client")
+ }
+ authLinkMu.Lock()
+ if activeAuthLink == nil || activeAuthLink.status.Status != models.AuthLinkStatusPending {
+ authLinkMu.Unlock()
+ return nil, models.ClientErrf("no active link request")
+ }
+ activeAuthLink.cancel()
+ activeAuthLink.status.Status = models.AuthLinkStatusCancelled
+ redactAuthLinkStatus(&activeAuthLink.status)
+ payload := activeAuthLink.status
+ authLinkMu.Unlock()
+
+ if env.State != nil && env.State.Notifications != nil {
+ notifications.AuthLinkStatus(env.State.Notifications, &payload)
+ }
+ return payload, nil
+}
+
+func beginAuthLinkStart() error {
+ authLinkMu.Lock()
+ defer authLinkMu.Unlock()
+ if authLinkStarting || (activeAuthLink != nil && activeAuthLink.status.Status == models.AuthLinkStatusPending) {
+ return models.ClientErrf("device linking is already pending")
+ }
+ authLinkStarting = true
+ return nil
+}
+
+func finishAuthLinkStart(session *authLinkSession) {
+ authLinkMu.Lock()
+ defer authLinkMu.Unlock()
+ authLinkStarting = false
+ if session == nil {
+ return
+ }
+ if activeAuthLink != nil && activeAuthLink.cancel != nil {
+ activeAuthLink.cancel()
+ }
+ activeAuthLink = session
+}
+
+func redactAuthLinkStatus(status *models.AuthLinkStatusResponse) {
+ status.UserCode = ""
+ status.VerificationURL = ""
+ status.VerificationURLComplete = ""
+}
+
+// createDeviceLinkRequest starts a link request on the auth server.
+func createDeviceLinkRequest(
+ ctx context.Context,
+ baseURL, platform, deviceHint string,
+) (*deviceLinkCreateResponse, error) {
+ req, err := http.NewRequestWithContext(
+ ctx, http.MethodPost, baseURL+deviceLinkCreatePath, http.NoBody,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create link request: %w", err)
+ }
+ req.Header.Set(zapscript.HeaderZaparooOS, runtime.GOOS)
+ req.Header.Set(zapscript.HeaderZaparooArch, runtime.GOARCH)
+ req.Header.Set(zapscript.HeaderZaparooPlatform, platform)
+ if deviceHint != "" {
+ req.Header.Set(headerZaparooDeviceHint, deviceHint)
+ }
+
+ resp, err := claimClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to contact link server: %w", err)
+ }
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Msg("error closing link response body")
+ }
+ }()
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, helpers.MaxResponseBodySize))
+ return nil, fmt.Errorf(
+ "link server returned status %d: %s",
+ resp.StatusCode, strings.TrimSpace(string(body)),
+ )
+ }
+
+ var created deviceLinkCreateResponse
+ if err := json.NewDecoder(io.LimitReader(resp.Body, helpers.MaxResponseBodySize)).Decode(&created); err != nil {
+ return nil, fmt.Errorf("failed to decode link response: %w", err)
+ }
+ if created.DeviceCode == "" || created.UserCode == "" {
+ return nil, errors.New("link response missing device or user code")
+ }
+ return &created, nil
+}
+
+// pollDeviceLink polls the link request until it is approved, expires, or is
+// cancelled. On approval the returned claim token is redeemed through the
+// same pipeline as the forward flow.
+func pollDeviceLink(
+ ctx context.Context,
+ session *authLinkSession,
+ deps *authLinkDeps,
+ baseURL, deviceCode string,
+ interval time.Duration,
+) {
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ errMsg := "device linking stopped, start over to link this device"
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ errMsg = "link request expired, start over to link this device"
+ }
+ finishAuthLink(session, deps, models.AuthLinkStatusFailed, errMsg)
+ return
+ case <-ticker.C:
+ }
+
+ poll, err := pollDeviceLinkOnce(ctx, baseURL, deviceCode)
+ if err != nil {
+ var terminal *authLinkTerminalError
+ if errors.As(err, &terminal) {
+ finishAuthLink(session, deps, models.AuthLinkStatusFailed, terminal.reason)
+ return
+ }
+ // Transient (network) errors: keep polling until expiry.
+ log.Debug().Err(err).Msg("device link poll failed, retrying")
+ continue
+ }
+ if poll.Status != "approved" {
+ continue
+ }
+
+ _, err = performClaim(
+ ctx, deps.cfg, deps.db, deps.pl, poll.ClaimURL, poll.Token, deps.fetchWK, deps.backupCoordinator,
+ )
+ if err != nil {
+ log.Warn().Err(err).Msg("device link claim redemption failed")
+ finishAuthLink(session, deps, models.AuthLinkStatusFailed,
+ "linking failed, start over to link this device")
+ return
+ }
+ log.Info().Msg("settings.auth.link completed")
+ finishAuthLink(session, deps, models.AuthLinkStatusApproved, "")
+ return
+ }
+}
+
+func pollDeviceLinkOnce(ctx context.Context, baseURL, deviceCode string) (*deviceLinkPollResponse, error) {
+ body, err := json.Marshal(deviceLinkPollRequest{DeviceCode: deviceCode})
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal poll request: %w", err)
+ }
+ req, err := http.NewRequestWithContext(
+ ctx, http.MethodPost, baseURL+deviceLinkPollPath, bytes.NewReader(body),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create poll request: %w", err)
+ }
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := claimClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("failed to contact link server: %w", err)
+ }
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Msg("error closing poll response body")
+ }
+ }()
+
+ switch resp.StatusCode {
+ case http.StatusOK:
+ var poll deviceLinkPollResponse
+ if err := json.NewDecoder(io.LimitReader(resp.Body, helpers.MaxResponseBodySize)).Decode(&poll); err != nil {
+ return nil, fmt.Errorf("failed to decode poll response: %w", err)
+ }
+ return &poll, nil
+ case http.StatusUnauthorized:
+ // Expired, never created, or the claim was already collected: the
+ // request is consumed server-side and the flow must start over.
+ return nil, &authLinkTerminalError{reason: "link request expired, start over to link this device"}
+ case http.StatusTooManyRequests:
+ return nil, errors.New("link server rate limited poll")
+ default:
+ return nil, fmt.Errorf("link server returned status %d", resp.StatusCode)
+ }
+}
+
+// finishAuthLink records a terminal state and notifies clients, unless the
+// session was superseded or explicitly cancelled before the transition won.
+func finishAuthLink(session *authLinkSession, deps *authLinkDeps, status, errMsg string) {
+ authLinkMu.Lock()
+ if activeAuthLink != session || activeAuthLink.status.Status != models.AuthLinkStatusPending {
+ authLinkMu.Unlock()
+ return
+ }
+ activeAuthLink.status.Status = status
+ activeAuthLink.status.Error = errMsg
+ redactAuthLinkStatus(&activeAuthLink.status)
+ payload := activeAuthLink.status
+ authLinkMu.Unlock()
+
+ if deps.ns != nil {
+ notifications.AuthLinkStatus(deps.ns, &payload)
+ }
+}
diff --git a/pkg/api/methods/auth_link_test.go b/pkg/api/methods/auth_link_test.go
new file mode 100644
index 00000000..b84f8256
--- /dev/null
+++ b/pkg/api/methods/auth_link_test.go
@@ -0,0 +1,437 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package methods
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/permissions"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
+ "github.com/spf13/afero"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// resetAuthLinkState clears the package-level link session between tests.
+func resetAuthLinkState(t *testing.T) {
+ t.Helper()
+ t.Cleanup(func() {
+ authLinkMu.Lock()
+ if activeAuthLink != nil && activeAuthLink.cancel != nil {
+ activeAuthLink.cancel()
+ }
+ activeAuthLink = nil
+ authLinkStarting = false
+ authLinkMu.Unlock()
+ })
+ authLinkMu.Lock()
+ activeAuthLink = nil
+ authLinkStarting = false
+ authLinkMu.Unlock()
+}
+
+func readAuthLinkNotification(
+ t *testing.T, notifications <-chan models.Notification,
+) models.AuthLinkStatusResponse {
+ t.Helper()
+ select {
+ case notification := <-notifications:
+ require.Equal(t, models.NotificationAuthLinkStatus, notification.Method)
+ var status models.AuthLinkStatusResponse
+ require.NoError(t, json.Unmarshal(notification.Params, &status))
+ return status
+ case <-time.After(time.Second):
+ t.Fatal("timed out waiting for auth link notification")
+ return models.AuthLinkStatusResponse{}
+ }
+}
+
+func assertAuthLinkNotificationRedacted(t *testing.T, status *models.AuthLinkStatusResponse) {
+ t.Helper()
+ assert.Empty(t, status.UserCode)
+ assert.Empty(t, status.VerificationURL)
+ assert.Empty(t, status.VerificationURLComplete)
+}
+
+func waitForAuthLinkStatus(t *testing.T, want string, timeout time.Duration) models.AuthLinkStatusResponse {
+ t.Helper()
+ deadline := time.Now().Add(timeout)
+ for time.Now().Before(deadline) {
+ result, err := HandleSettingsAuthLinkStatus(requests.RequestEnv{})
+ require.NoError(t, err)
+ status, ok := result.(models.AuthLinkStatusResponse)
+ require.True(t, ok)
+ if status.Status == want {
+ return status
+ }
+ time.Sleep(50 * time.Millisecond)
+ }
+ t.Fatalf("link flow never reached status %q", want)
+ return models.AuthLinkStatusResponse{}
+}
+
+func TestSettingsAuthLink_RequiresLocalOrAdminClient(t *testing.T) {
+ // Not parallel: uses package-level link session state.
+ resetAuthLinkState(t)
+
+ _, err := HandleSettingsAuthLink(requests.RequestEnv{IsLocal: false}, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "local or admin client")
+
+ memberEnv := requests.RequestEnv{IsLocal: false, ClientRole: string(permissions.RoleMember)}
+ _, err = HandleSettingsAuthLink(memberEnv, nil)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "local or admin client")
+
+ _, err = HandleSettingsAuthLinkCancel(requests.RequestEnv{IsLocal: false})
+ require.Error(t, err)
+
+ // A paired admin client passes the access gate; with no active flow
+ // cancel reports "no active link request" instead of forbidden.
+ adminEnv := requests.RequestEnv{IsLocal: false, ClientRole: string(permissions.RoleAdmin)}
+ _, err = HandleSettingsAuthLinkCancel(adminEnv)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "no active link request")
+}
+
+func TestSettingsAuthLinkStatus_RedactsUnpairedRemoteAndRequiresAdminForDetails(t *testing.T) {
+ // Not parallel: uses package-level link session state.
+ resetAuthLinkState(t)
+
+ authLinkMu.Lock()
+ activeAuthLink = &authLinkSession{status: models.AuthLinkStatusResponse{
+ Status: models.AuthLinkStatusPending,
+ UserCode: "ABCD-1234",
+ VerificationURL: "https://online.example/link",
+ VerificationURLComplete: "https://online.example/link?code=ABCD-1234",
+ }}
+ authLinkMu.Unlock()
+
+ result, err := HandleSettingsAuthLinkStatus(requests.RequestEnv{})
+ require.NoError(t, err)
+ status, ok := result.(models.AuthLinkStatusResponse)
+ require.True(t, ok)
+ assert.Equal(t, models.AuthLinkStatusPending, status.Status)
+ assert.Empty(t, status.UserCode)
+ assert.Empty(t, status.VerificationURL)
+ assert.Empty(t, status.VerificationURLComplete)
+
+ _, err = HandleSettingsAuthLinkStatus(requests.RequestEnv{
+ ClientRole: string(permissions.RoleMember),
+ })
+ require.ErrorIs(t, err, ErrForbidden)
+
+ result, err = HandleSettingsAuthLinkStatus(requests.RequestEnv{
+ ClientRole: string(permissions.RoleAdmin),
+ })
+ require.NoError(t, err)
+ status, ok = result.(models.AuthLinkStatusResponse)
+ require.True(t, ok)
+ assert.Equal(t, "ABCD-1234", status.UserCode)
+ assert.Equal(t, "https://online.example/link", status.VerificationURL)
+ assert.Equal(t, "https://online.example/link?code=ABCD-1234", status.VerificationURLComplete)
+}
+
+func TestSettingsAuthLink_RejectsSecondPendingStart(t *testing.T) {
+ // Not parallel: uses package-level link session state.
+ resetAuthLinkState(t)
+
+ require.NoError(t, beginAuthLinkStart())
+ err := beginAuthLinkStart()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "already pending")
+ finishAuthLinkStart(nil)
+
+ authLinkMu.Lock()
+ activeAuthLink = &authLinkSession{status: models.AuthLinkStatusResponse{
+ Status: models.AuthLinkStatusPending,
+ }}
+ authLinkMu.Unlock()
+ err = beginAuthLinkStart()
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "already pending")
+}
+
+func TestSettingsAuthLink_HappyPath(t *testing.T) {
+ // Not parallel: swaps package-level claimClient and link session state.
+ resetAuthLinkState(t)
+
+ var polls atomic.Int32
+ var mux http.ServeMux
+ server := httptest.NewServer(&mux)
+ defer server.Close()
+
+ mux.HandleFunc("POST /v1/device-link-requests", func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "test-platform", r.Header.Get(zapscript.HeaderZaparooPlatform))
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(deviceLinkCreateResponse{
+ DeviceCode: "zpl1_secret",
+ UserCode: "ABCD-1234",
+ VerificationURL: "https://online.example/link",
+ VerificationURLComplete: "https://online.example/link?code=ABCD1234",
+ ExpiresAt: time.Now().Add(10 * time.Minute),
+ Interval: 1,
+ })
+ })
+ mux.HandleFunc("POST /v1/device-link-requests/poll", func(w http.ResponseWriter, r *http.Request) {
+ var req deviceLinkPollRequest
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ assert.Equal(t, "zpl1_secret", req.DeviceCode)
+ if polls.Add(1) == 1 {
+ _ = json.NewEncoder(w).Encode(deviceLinkPollResponse{Status: "pending", Interval: 1})
+ return
+ }
+ _ = json.NewEncoder(w).Encode(deviceLinkPollResponse{
+ Status: "approved",
+ Interval: 1,
+ Token: "zpc1_claim", //nolint:gosec // test fixture claim token
+ ClaimURL: server.URL + "/v1/device-claims/redeem",
+ })
+ })
+ mux.HandleFunc("POST /v1/device-claims/redeem", func(w http.ResponseWriter, r *http.Request) {
+ var req claimRequest
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ assert.Equal(t, "zpc1_claim", req.Token)
+ _ = json.NewEncoder(w).Encode(claimResponse{Bearer: "zpd1_device_token"}) //nolint:gosec // test fixture
+ })
+
+ origClient := claimClient
+ claimClient = server.Client()
+ t.Cleanup(func() { claimClient = origClient })
+
+ config.SetAuthCfgForTesting(map[string]config.CredentialEntry{})
+ t.Cleanup(config.ClearAuthCfgForTesting)
+
+ cfg, err := config.NewConfigWithFs(t.TempDir(), config.BaseDefaults, afero.NewMemMapFs())
+ require.NoError(t, err)
+ require.NoError(t, cfg.SetBackupRemoteBaseURL(server.URL))
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test-platform").Maybe()
+ mockPlatform.On("Settings").Return(platforms.Settings{
+ DataDir: t.TempDir(), ConfigDir: t.TempDir(),
+ })
+ st, notificationCh := state.NewState(mockPlatform, "test-boot")
+ t.Cleanup(st.StopService)
+ st.BackupCoordinator().SetRemoteUnlinked(true)
+
+ mockFetchWK := func(_ string) (*zapscript.WellKnown, error) {
+ return &zapscript.WellKnown{Auth: 1}, nil
+ }
+
+ params, err := json.Marshal(models.SettingsAuthLinkParams{URL: server.URL})
+ require.NoError(t, err)
+ env := requests.RequestEnv{
+ Context: context.Background(),
+ Config: cfg,
+ Platform: mockPlatform,
+ State: st,
+ Params: params,
+ IsLocal: true,
+ }
+
+ result, err := HandleSettingsAuthLink(env, mockFetchWK)
+ require.NoError(t, err)
+ started, ok := result.(models.AuthLinkStatusResponse)
+ require.True(t, ok)
+ assert.Equal(t, models.AuthLinkStatusPending, started.Status)
+ assert.Equal(t, "ABCD-1234", started.UserCode)
+ assert.Equal(t, "https://online.example/link", started.VerificationURL)
+ require.NotNil(t, started.ExpiresAt)
+ pendingNotification := readAuthLinkNotification(t, notificationCh)
+ assert.Equal(t, models.AuthLinkStatusPending, pendingNotification.Status)
+ assertAuthLinkNotificationRedacted(t, &pendingNotification)
+
+ approved := waitForAuthLinkStatus(t, models.AuthLinkStatusApproved, 15*time.Second)
+ assert.Empty(t, approved.UserCode)
+ assert.Empty(t, approved.VerificationURL)
+ assert.Empty(t, approved.VerificationURLComplete)
+ approvedNotification := readAuthLinkNotification(t, notificationCh)
+ assert.Equal(t, models.AuthLinkStatusApproved, approvedNotification.Status)
+ assertAuthLinkNotificationRedacted(t, &approvedNotification)
+
+ entry := config.LookupAuth(config.GetAuthCfg(), config.BackupAuthLookupURL(server.URL))
+ require.NotNil(t, entry, "the approved claim stores the credential")
+ assert.Equal(t, "zpd1_device_token", entry.Bearer)
+ assert.False(t, st.BackupCoordinator().RemoteUnlinked())
+}
+
+func TestSettingsAuthLink_ExpiredPollFails(t *testing.T) {
+ // Not parallel: swaps package-level claimClient and link session state.
+ resetAuthLinkState(t)
+
+ var mux http.ServeMux
+ server := httptest.NewServer(&mux)
+ defer server.Close()
+
+ mux.HandleFunc("POST /v1/device-link-requests", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(deviceLinkCreateResponse{
+ DeviceCode: "zpl1_secret",
+ UserCode: "ABCD-1234",
+ ExpiresAt: time.Now().Add(10 * time.Minute),
+ Interval: 1,
+ })
+ })
+ mux.HandleFunc("POST /v1/device-link-requests/poll", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusUnauthorized)
+ _, _ = w.Write([]byte(`{"error":{"code":"invalid_link_code"}}`))
+ })
+
+ origClient := claimClient
+ claimClient = server.Client()
+ t.Cleanup(func() { claimClient = origClient })
+
+ cfg, err := config.NewConfigWithFs(t.TempDir(), config.BaseDefaults, afero.NewMemMapFs())
+ require.NoError(t, err)
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test-platform").Maybe()
+
+ params, err := json.Marshal(models.SettingsAuthLinkParams{URL: server.URL})
+ require.NoError(t, err)
+ env := requests.RequestEnv{
+ Context: context.Background(),
+ Config: cfg,
+ Platform: mockPlatform,
+ Params: params,
+ IsLocal: true,
+ }
+
+ _, err = HandleSettingsAuthLink(env, nil)
+ require.NoError(t, err)
+
+ failed := waitForAuthLinkStatus(t, models.AuthLinkStatusFailed, 15*time.Second)
+ assert.Contains(t, failed.Error, "start over")
+}
+
+func TestPollDeviceLink_ParentCancellationTerminalizesPendingSession(t *testing.T) {
+ // Not parallel: uses package-level link session state.
+ resetAuthLinkState(t)
+ ctx, cancel := context.WithCancel(context.Background())
+ notificationCh := make(chan models.Notification, 1)
+ session := &authLinkSession{
+ cancel: cancel,
+ status: models.AuthLinkStatusResponse{
+ Status: models.AuthLinkStatusPending, UserCode: "secret-code",
+ VerificationURL: "https://online.example/secret",
+ },
+ }
+ authLinkMu.Lock()
+ activeAuthLink = session
+ authLinkMu.Unlock()
+
+ go pollDeviceLink(ctx, session, &authLinkDeps{ns: notificationCh}, "", "", time.Hour)
+ cancel()
+ failedNotification := readAuthLinkNotification(t, notificationCh)
+ assert.Equal(t, models.AuthLinkStatusFailed, failedNotification.Status)
+ assert.Contains(t, failedNotification.Error, "linking stopped")
+ assertAuthLinkNotificationRedacted(t, &failedNotification)
+ status := waitForAuthLinkStatus(t, models.AuthLinkStatusFailed, time.Second)
+ assertAuthLinkNotificationRedacted(t, &status)
+ require.NoError(t, beginAuthLinkStart(), "terminalized session must not block a new link flow")
+}
+
+func TestSettingsAuthLink_Cancel(t *testing.T) {
+ // Not parallel: swaps package-level claimClient and link session state.
+ resetAuthLinkState(t)
+
+ var mux http.ServeMux
+ server := httptest.NewServer(&mux)
+ defer server.Close()
+
+ mux.HandleFunc("POST /v1/device-link-requests", func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusCreated)
+ _ = json.NewEncoder(w).Encode(deviceLinkCreateResponse{
+ DeviceCode: "zpl1_secret",
+ UserCode: "ABCD-1234",
+ ExpiresAt: time.Now().Add(10 * time.Minute),
+ Interval: 5,
+ })
+ })
+ mux.HandleFunc("POST /v1/device-link-requests/poll", func(w http.ResponseWriter, _ *http.Request) {
+ _ = json.NewEncoder(w).Encode(deviceLinkPollResponse{Status: "pending", Interval: 5})
+ })
+
+ origClient := claimClient
+ claimClient = server.Client()
+ t.Cleanup(func() { claimClient = origClient })
+
+ cfg, err := config.NewConfigWithFs(t.TempDir(), config.BaseDefaults, afero.NewMemMapFs())
+ require.NoError(t, err)
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test-platform").Maybe()
+ st, notificationCh := state.NewState(mockPlatform, "cancel-test-boot")
+ t.Cleanup(st.StopService)
+
+ params, err := json.Marshal(models.SettingsAuthLinkParams{URL: server.URL})
+ require.NoError(t, err)
+ env := requests.RequestEnv{
+ Context: context.Background(),
+ Config: cfg,
+ Platform: mockPlatform,
+ State: st,
+ Params: params,
+ IsLocal: true,
+ }
+
+ _, err = HandleSettingsAuthLink(env, nil)
+ require.NoError(t, err)
+ pendingNotification := readAuthLinkNotification(t, notificationCh)
+ assert.Equal(t, models.AuthLinkStatusPending, pendingNotification.Status)
+ assertAuthLinkNotificationRedacted(t, &pendingNotification)
+
+ result, err := HandleSettingsAuthLinkCancel(env)
+ require.NoError(t, err)
+ cancelled, ok := result.(models.AuthLinkStatusResponse)
+ require.True(t, ok)
+ assert.Equal(t, models.AuthLinkStatusCancelled, cancelled.Status)
+ cancelledNotification := readAuthLinkNotification(t, notificationCh)
+ assert.Equal(t, models.AuthLinkStatusCancelled, cancelledNotification.Status)
+ assertAuthLinkNotificationRedacted(t, &cancelledNotification)
+ select {
+ case notification := <-notificationCh:
+ t.Fatalf("explicit cancellation must remain terminal, got extra notification %s", notification.Method)
+ case <-time.After(100 * time.Millisecond):
+ }
+ status := waitForAuthLinkStatus(t, models.AuthLinkStatusCancelled, time.Second)
+ assert.Equal(t, models.AuthLinkStatusCancelled, status.Status)
+
+ // Cancelling again reports no active request.
+ _, err = HandleSettingsAuthLinkCancel(env)
+ require.Error(t, err)
+}
diff --git a/pkg/api/methods/auth_test.go b/pkg/api/methods/auth_test.go
index 46fb99c1..473681e3 100644
--- a/pkg/api/methods/auth_test.go
+++ b/pkg/api/methods/auth_test.go
@@ -26,12 +26,17 @@ import (
"io"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"runtime"
"testing"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
"github.com/spf13/afero"
@@ -39,6 +44,67 @@ import (
"github.com/stretchr/testify/require"
)
+func TestSettingsAuthStatus_RequiresURL(t *testing.T) {
+ cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults)
+ require.NoError(t, err)
+
+ _, err = HandleSettingsAuthStatus(requests.RequestEnv{Config: cfg})
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "url is required")
+}
+
+func TestSettingsAuthStatus_LinkedOfficialURL(t *testing.T) {
+ config.SetAuthCfgForTesting(map[string]config.CredentialEntry{
+ "https://api.zaparoo.com": {Bearer: "token"},
+ })
+ t.Cleanup(config.ClearAuthCfgForTesting)
+ cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults)
+ require.NoError(t, err)
+ params, err := json.Marshal(models.SettingsAuthStatusParams{URL: "https://api.zaparoo.com"})
+ require.NoError(t, err)
+
+ result, err := HandleSettingsAuthStatus(requests.RequestEnv{Config: cfg, Params: params})
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsAuthStatusResponse)
+ require.True(t, ok)
+ assert.True(t, resp.Linked)
+}
+
+func TestSettingsAuthStatus_RejectsUnsupportedURL(t *testing.T) {
+ config.SetAuthCfgForTesting(map[string]config.CredentialEntry{
+ "https://other.example.com": {Bearer: "token"},
+ })
+ t.Cleanup(config.ClearAuthCfgForTesting)
+ cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults)
+ require.NoError(t, err)
+ params, err := json.Marshal(models.SettingsAuthStatusParams{URL: "https://other.example.com"})
+ require.NoError(t, err)
+
+ result, err := HandleSettingsAuthStatus(requests.RequestEnv{Config: cfg, Params: params})
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsAuthStatusResponse)
+ require.True(t, ok)
+ assert.False(t, resp.Linked)
+}
+
+func TestSettingsAuthStatus_AllowsConfiguredBackupURL(t *testing.T) {
+ config.SetAuthCfgForTesting(map[string]config.CredentialEntry{
+ "http://127.0.0.1:8787": {Bearer: "token"},
+ })
+ t.Cleanup(config.ClearAuthCfgForTesting)
+ cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults)
+ require.NoError(t, err)
+ require.NoError(t, cfg.SetBackupRemoteBaseURL("http://127.0.0.1:8787"))
+ params, err := json.Marshal(models.SettingsAuthStatusParams{URL: "http://127.0.0.1:8787"})
+ require.NoError(t, err)
+
+ result, err := HandleSettingsAuthStatus(requests.RequestEnv{Config: cfg, Params: params})
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsAuthStatusResponse)
+ require.True(t, ok)
+ assert.True(t, resp.Linked)
+}
+
func TestSettingsAuthClaim_MissingParams(t *testing.T) {
t.Parallel()
@@ -202,6 +268,13 @@ func TestSettingsAuthClaim_HappyPath(t *testing.T) {
cfg, err := config.NewConfigWithFs(t.TempDir(), config.BaseDefaults, afero.NewMemMapFs())
require.NoError(t, err)
+ require.NoError(t, cfg.SetBackupRemoteBaseURL(claimServer.URL))
+ mockPlatform.On("Settings").Return(platforms.Settings{
+ DataDir: t.TempDir(), ConfigDir: t.TempDir(),
+ })
+ st, _ := state.NewState(mockPlatform, "test-boot")
+ t.Cleanup(st.StopService)
+ st.BackupCoordinator().SetRemoteUnlinked(true)
// Mock well-known fetcher: root has auth and trusts spoke.example.com,
// related domain confirms trust back to root
@@ -236,6 +309,7 @@ func TestSettingsAuthClaim_HappyPath(t *testing.T) {
Context: context.Background(),
Platform: mockPlatform,
Config: cfg,
+ State: st,
Params: paramsJSON,
}
@@ -249,6 +323,7 @@ func TestSettingsAuthClaim_HappyPath(t *testing.T) {
assert.Contains(t, resp.Domains, claimServer.URL)
assert.Contains(t, resp.Domains, "https://spoke.example.com")
assert.Len(t, resp.Domains, 2)
+ assert.False(t, st.BackupCoordinator().RemoteUnlinked())
}
func TestSettingsAuthClaim_NoRelatedTrust(t *testing.T) {
@@ -298,6 +373,12 @@ func TestSettingsAuthClaim_NoRelatedTrust(t *testing.T) {
// Only root domain stored
assert.Equal(t, []string{claimServer.URL}, resp.Domains)
+
+ // The stored entry carries its provenance so unlink can find it.
+ t.Cleanup(config.ClearAuthCfgForTesting)
+ entry := config.LookupAuth(config.GetAuthCfg(), config.BackupAuthLookupURL(claimServer.URL))
+ require.NotNil(t, entry)
+ assert.Equal(t, claimServer.URL, entry.LinkedVia)
}
func TestRedeemClaimToken_Success(t *testing.T) {
@@ -326,7 +407,9 @@ func TestRedeemClaimToken_Success(t *testing.T) {
}))
defer server.Close()
- bearer, err := redeemClaimToken(context.Background(), server.URL+"/claim", "test-token-123", "test-platform")
+ bearer, err := redeemClaimToken(
+ context.Background(), server.URL+"/claim", "test-token-123", "test-platform", "hint-uuid",
+ )
require.NoError(t, err)
assert.Equal(t, "real-api-key", bearer)
}
@@ -340,7 +423,7 @@ func TestRedeemClaimToken_EmptyBearer(t *testing.T) {
}))
defer server.Close()
- _, err := redeemClaimToken(context.Background(), server.URL+"/claim", "token", "test-platform")
+ _, err := redeemClaimToken(context.Background(), server.URL+"/claim", "token", "test-platform", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "missing bearer token")
}
@@ -354,7 +437,7 @@ func TestRedeemClaimToken_ServerError(t *testing.T) {
}))
defer server.Close()
- _, err := redeemClaimToken(context.Background(), server.URL+"/claim", "token", "test-platform")
+ _, err := redeemClaimToken(context.Background(), server.URL+"/claim", "token", "test-platform", "")
require.Error(t, err)
assert.Contains(t, err.Error(), "500")
}
@@ -419,3 +502,165 @@ func TestConfirmRelatedTrust_ServerDown(t *testing.T) {
result := confirmRelatedTrust("http://127.0.0.1:1", "https://root.example.com", nil, zapscript.FetchWellKnown)
assert.False(t, result)
}
+
+func newAuthUnlinkTestEnv(t *testing.T) requests.RequestEnv {
+ t.Helper()
+ dataDir := t.TempDir()
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test-platform").Maybe()
+ mockPlatform.On("Settings").Return(platforms.Settings{DataDir: dataDir}).Maybe()
+ cfg, err := config.NewConfigWithFs(t.TempDir(), config.BaseDefaults, afero.NewMemMapFs())
+ require.NoError(t, err)
+ t.Cleanup(config.ClearAuthCfgForTesting)
+ originalRevoke := revokeRemoteDevice
+ revokeRemoteDevice = func(context.Context, *backupsvc.Manager) error { return nil }
+ t.Cleanup(func() { revokeRemoteDevice = originalRevoke })
+ return requests.RequestEnv{
+ Context: context.Background(),
+ Config: cfg,
+ Platform: mockPlatform,
+ IsLocal: true,
+ }
+}
+
+func TestSettingsAuthUnlink_RemovesTaggedFamily(t *testing.T) {
+ // Not parallel: SaveAuthEntry updates the global auth config.
+ env := newAuthUnlinkTestEnv(t)
+
+ // The claim flow tags the root and each trusted-domain copy with the
+ // root that created them — including domains on no static list
+ // (zaparoo.run). Bearers deliberately differ to prove the provenance
+ // tag decides, not credential contents.
+ root := "https://api.zaparoo.com"
+ require.NoError(t, env.Config.SaveAuthEntry(root,
+ config.CredentialEntry{Bearer: "b1", LinkedVia: root}))
+ require.NoError(t, env.Config.SaveAuthEntry("https://edge.zaparoo.com",
+ config.CredentialEntry{Bearer: "b2", LinkedVia: root}))
+ require.NoError(t, env.Config.SaveAuthEntry("https://zaparoo.run",
+ config.CredentialEntry{Bearer: "b3", LinkedVia: root}))
+ // Tagged with a different root: belongs to another link, must survive.
+ require.NoError(t, env.Config.SaveAuthEntry("https://service.example.com",
+ config.CredentialEntry{Bearer: "b4", LinkedVia: "https://other-root.example.com"}))
+ // Hand-written entry: must survive.
+ require.NoError(t, env.Config.SaveAuthEntry("https://other.example.com",
+ config.CredentialEntry{Bearer: "b5"}))
+
+ result, err := HandleSettingsAuthUnlink(env)
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsAuthUnlinkResponse)
+ require.True(t, ok)
+ assert.Equal(t, []string{
+ "https://api.zaparoo.com", "https://edge.zaparoo.com", "https://zaparoo.run",
+ }, resp.Domains)
+
+ creds := config.GetAuthCfg()
+ require.Len(t, creds, 2)
+ assert.Equal(t, "b4", creds["https://service.example.com"].Bearer)
+ assert.Equal(t, "b5", creds["https://other.example.com"].Bearer)
+}
+
+func TestSettingsAuthUnlink_CustomServerScopedToItsOwnFamily(t *testing.T) {
+ // Not parallel: SaveAuthEntry updates the global auth config.
+ env := newAuthUnlinkTestEnv(t)
+
+ // Unlinking the configured custom server removes only its entry;
+ // credentials for other domains are untouched.
+ require.NoError(t, env.Config.SetBackupRemoteBaseURL("http://127.0.0.1:8787"))
+ require.NoError(t, env.Config.SaveAuthEntry("http://127.0.0.1:8787",
+ config.CredentialEntry{Bearer: "t1", LinkedVia: "http://127.0.0.1:8787"}))
+ require.NoError(t, env.Config.SaveAuthEntry("https://api.zaparoo.com",
+ config.CredentialEntry{Bearer: "t2", LinkedVia: "https://api.zaparoo.com"}))
+
+ result, err := HandleSettingsAuthUnlink(env)
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsAuthUnlinkResponse)
+ require.True(t, ok)
+ assert.Equal(t, []string{"http://127.0.0.1:8787"}, resp.Domains)
+
+ creds := config.GetAuthCfg()
+ require.Len(t, creds, 1)
+ assert.Equal(t, "t2", creds["https://api.zaparoo.com"].Bearer)
+}
+
+func TestSettingsAuthUnlink_NotLinkedIsNoOp(t *testing.T) {
+ // Not parallel: SaveAuthEntry updates the global auth config.
+ env := newAuthUnlinkTestEnv(t)
+
+ require.NoError(t, env.Config.SaveAuthEntry("https://other.example.com", config.CredentialEntry{Bearer: "t3"}))
+
+ result, err := HandleSettingsAuthUnlink(env)
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsAuthUnlinkResponse)
+ require.True(t, ok)
+ assert.Empty(t, resp.Domains)
+
+ creds := config.GetAuthCfg()
+ require.Len(t, creds, 1)
+ assert.Equal(t, "t3", creds["https://other.example.com"].Bearer)
+}
+
+func TestSettingsAuthUnlink_RemovesConfiguredBackupServerCredential(t *testing.T) {
+ // Not parallel: SaveAuthEntry updates the global auth config.
+ env := newAuthUnlinkTestEnv(t)
+
+ require.NoError(t, env.Config.SetBackupRemoteBaseURL("http://127.0.0.1:8787"))
+ require.NoError(t, env.Config.SaveAuthEntry("http://127.0.0.1:8787", config.CredentialEntry{Bearer: "t1"}))
+
+ result, err := HandleSettingsAuthUnlink(env)
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsAuthUnlinkResponse)
+ require.True(t, ok)
+ assert.Equal(t, []string{"http://127.0.0.1:8787"}, resp.Domains)
+ assert.Empty(t, config.GetAuthCfg())
+}
+
+func TestSettingsAuthUnlink_RevokeFailureKeepsLocalCredential(t *testing.T) {
+ // Not parallel: SaveAuthEntry and revokeRemoteDevice are process globals.
+ env := newAuthUnlinkTestEnv(t)
+ revokeRemoteDevice = func(context.Context, *backupsvc.Manager) error {
+ return errors.New("server unavailable")
+ }
+
+ require.NoError(t, env.Config.SaveAuthEntry(
+ "https://api.zaparoo.com", config.CredentialEntry{Bearer: "t1"},
+ ))
+
+ _, err := HandleSettingsAuthUnlink(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "failed to revoke remote device link")
+ entry := config.LookupAuth(config.GetAuthCfg(), "https://api.zaparoo.com")
+ require.NotNil(t, entry)
+ assert.Equal(t, "t1", entry.Bearer)
+}
+
+func TestSettingsAuthUnlink_MarksRemoteUnlinked(t *testing.T) {
+ // Not parallel: SaveAuthEntry updates the global auth config.
+ env := newAuthUnlinkTestEnv(t)
+
+ require.NoError(t, env.Config.SaveAuthEntry("https://api.zaparoo.com", config.CredentialEntry{Bearer: "t1"}))
+
+ _, err := HandleSettingsAuthUnlink(env)
+ require.NoError(t, err)
+
+ statusPath := filepath.Join(env.Platform.Settings().DataDir, "backups", "status.json")
+ data, err := os.ReadFile(statusPath) //nolint:gosec // test-owned temp path
+ require.NoError(t, err)
+ assert.Contains(t, string(data), `"unlinked": true`)
+}
+
+func TestSettingsAuthUnlink_RejectsRemoteClients(t *testing.T) {
+ t.Parallel()
+
+ env := requests.RequestEnv{
+ Context: context.Background(),
+ IsLocal: false,
+ }
+ _, err := HandleSettingsAuthUnlink(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "local or admin")
+
+ env.ClientRole = "member"
+ _, err = HandleSettingsAuthUnlink(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "local or admin")
+}
diff --git a/pkg/api/methods/backup.go b/pkg/api/methods/backup.go
index 37800e81..b75d53ba 100644
--- a/pkg/api/methods/backup.go
+++ b/pkg/api/methods/backup.go
@@ -26,26 +26,83 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
)
func requireBackupAccess(env *requests.RequestEnv) error {
if env.Database == nil || env.Database.UserDB == nil {
return errors.New("database is not available")
}
- if !env.IsLocal {
- return models.ClientErrf("backup actions require a local client")
+ if !isLocalOrAdmin(env) {
+ return models.ClientErrf("backup actions require a local or admin client")
+ }
+ if env.Config == nil || env.Platform == nil {
+ return errors.New("backup runtime is not available")
+ }
+ return nil
+}
+
+func requireBackupRuntime(env *requests.RequestEnv) error {
+ if env.Config == nil || env.Platform == nil {
+ return errors.New("backup runtime is not available")
}
return nil
}
+func backupMethodError(action string, err error) error {
+ if errors.Is(err, backupsvc.ErrPlatformBackupUnsupported) {
+ return models.ClientErrf("full-device backup is not supported on this platform")
+ }
+ if errors.Is(err, backupsvc.ErrRestoreMediaActive) {
+ return models.ClientErrf("cannot restore backup while media is active")
+ }
+ if errors.Is(err, backupsvc.ErrRestoreLaunchInProgress) {
+ return models.ClientErrf("cannot restore backup while media is launching or restart is pending")
+ }
+ var busy *backupsvc.BusyError
+ if errors.As(err, &busy) {
+ return models.ClientErrf("backup is busy with %s", busy.Kind)
+ }
+ return fmt.Errorf("failed to %s: %w", action, err)
+}
+
+func backupRestoreResponse(env *requests.RequestEnv, result any) any {
+ if env.State == nil {
+ return result
+ }
+ return models.ResponseWithCallback{
+ Result: result,
+ AfterWrite: func() {
+ env.State.RestartService()
+ },
+ }
+}
+
+func newBackupManager(env *requests.RequestEnv) *backupsvc.Manager {
+ mgr := backupsvc.NewManager(env.Config, env.Platform, env.Database)
+ if env.State != nil {
+ mgr.WithCoordinator(env.State.BackupCoordinator()).
+ WithInbox(env.State.Inbox()).
+ WithActiveMedia(env.State.ActiveMedia).
+ WithRestoreGate(env.State.BeginRestoreGate)
+ }
+ mgr.WithPauser(env.BackupPauser)
+ return mgr
+}
+
//nolint:gocritic // API dispatch requires RequestEnv by value.
func HandleBackup(env requests.RequestEnv) (any, error) {
if err := requireBackupAccess(&env); err != nil {
return nil, err
}
- backup, err := env.Database.UserDB.Backup("manual", true)
+ // Reconcile with current primary-media state before starting, clearing
+ // stale pause state left by non-primary media events (same as indexing).
+ if env.State != nil {
+ syncMediaWorkPauserWithActiveMedia(env.Config, env.State.ActiveMedia(), env.BackupPauser)
+ }
+ backup, err := newBackupManager(&env).Create(env.Context)
if err != nil {
- return nil, fmt.Errorf("failed to create backup: %w", err)
+ return nil, backupMethodError("create backup", err)
}
return backup, nil
}
@@ -55,28 +112,128 @@ func HandleBackupList(env requests.RequestEnv) (any, error) {
if err := requireBackupAccess(&env); err != nil {
return nil, err
}
- backups, err := env.Database.UserDB.ListBackups()
+ backups, err := newBackupManager(&env).List()
if err != nil {
return nil, fmt.Errorf("failed to list backups: %w", err)
}
return backups, nil
}
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleBackupInspect(env requests.RequestEnv) (any, error) {
+ if err := requireBackupAccess(&env); err != nil {
+ return nil, err
+ }
+ var params models.BackupNameParams
+ if err := json.Unmarshal(env.Params, ¶ms); err != nil {
+ return nil, models.ClientErrf("invalid params: %w", err)
+ }
+ if params.Name == "" {
+ return nil, models.ClientErrf("invalid params: name is required")
+ }
+ backup, err := newBackupManager(&env).Inspect(env.Context, params.Name)
+ if err != nil {
+ return nil, backupMethodError("inspect backup", err)
+ }
+ return backup, nil
+}
+
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleBackupDelete(env requests.RequestEnv) (any, error) {
+ if err := requireBackupAccess(&env); err != nil {
+ return nil, err
+ }
+ var params models.BackupNameParams
+ if err := json.Unmarshal(env.Params, ¶ms); err != nil {
+ return nil, models.ClientErrf("invalid params: %w", err)
+ }
+ if params.Name == "" {
+ return nil, models.ClientErrf("invalid params: name is required")
+ }
+ if err := newBackupManager(&env).Delete(env.Context, params.Name); err != nil {
+ return nil, backupMethodError("delete backup", err)
+ }
+ return NoContent{}, nil
+}
+
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleBackupStatus(env requests.RequestEnv) (any, error) {
+ if err := requireBackupRuntime(&env); err != nil {
+ return nil, err
+ }
+ mgr := newBackupManager(&env)
+ if isLocalOrAdmin(&env) {
+ // Refresh in the background: a stale availability check is a network
+ // round trip and must not block the status response.
+ mgr.RefreshRemoteAvailabilityIfStaleAsync()
+ }
+ return mgr.Status(), nil
+}
+
//nolint:gocritic // API dispatch requires RequestEnv by value.
func HandleBackupRestore(env requests.RequestEnv) (any, error) {
if err := requireBackupAccess(&env); err != nil {
return nil, err
}
- var params models.BackupRestoreParams
+ var params models.BackupNameParams
if err := json.Unmarshal(env.Params, ¶ms); err != nil {
return nil, models.ClientErrf("invalid params: %w", err)
}
if params.Name == "" {
return nil, models.ClientErrf("invalid params: name is required")
}
- restore, err := env.Database.UserDB.RestoreBackup(params.Name)
+ restore, err := newBackupManager(&env).Restore(env.Context, params.Name)
+ if err != nil {
+ return nil, backupMethodError("restore backup", err)
+ }
+ return backupRestoreResponse(&env, restore), nil
+}
+
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleBackupRemoteRun(env requests.RequestEnv) (any, error) {
+ if err := requireBackupAccess(&env); err != nil {
+ return nil, err
+ }
+ // Reconcile with current primary-media state before starting, clearing
+ // stale pause state left by non-primary media events (same as indexing).
+ if env.State != nil {
+ syncMediaWorkPauserWithActiveMedia(env.Config, env.State.ActiveMedia(), env.BackupPauser)
+ }
+ mgr := newBackupManager(&env)
+ backup, err := mgr.RunRemote(env.Context, backupsvc.RemoteBackupTypeManual)
+ if err != nil {
+ return nil, backupMethodError("run remote backup", err)
+ }
+ return backup, nil
+}
+
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleBackupRemoteList(env requests.RequestEnv) (any, error) {
+ if err := requireBackupAccess(&env); err != nil {
+ return nil, err
+ }
+ backups, err := newBackupManager(&env).ListRemote(env.Context)
+ if err != nil {
+ return nil, fmt.Errorf("failed to list remote backups: %w", err)
+ }
+ return backups, nil
+}
+
+//nolint:gocritic // API dispatch requires RequestEnv by value.
+func HandleBackupRemoteRestore(env requests.RequestEnv) (any, error) {
+ if err := requireBackupAccess(&env); err != nil {
+ return nil, err
+ }
+ var params models.BackupRemoteRestoreParams
+ if err := json.Unmarshal(env.Params, ¶ms); err != nil {
+ return nil, models.ClientErrf("invalid params: %w", err)
+ }
+ if params.ID == "" {
+ return nil, models.ClientErrf("invalid params: id is required")
+ }
+ restore, err := newBackupManager(&env).RestoreRemote(env.Context, params.ID)
if err != nil {
- return nil, fmt.Errorf("failed to restore backup: %w", err)
+ return nil, backupMethodError("restore remote backup", err)
}
- return restore, nil
+ return backupRestoreResponse(&env, restore), nil
}
diff --git a/pkg/api/methods/backup_test.go b/pkg/api/methods/backup_test.go
index 74c45a06..8b730bff 100644
--- a/pkg/api/methods/backup_test.go
+++ b/pkg/api/methods/backup_test.go
@@ -22,164 +22,339 @@ package methods
import (
"context"
"encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
"testing"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
+ testinghelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/stretchr/testify/assert"
+ testifymock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
-func TestHandleBackup_Success(t *testing.T) {
- t.Parallel()
+type backupCapableTestPlatform struct {
+ *mocks.MockPlatform
+}
- mockUserDB := helpers.NewMockUserDBI()
- mockUserDB.On("Backup", "manual", true).Return(database.BackupInfo{
- Name: "backup-20260624-150405-000000001-manual.db",
+func (*backupCapableTestPlatform) BackupDefinitions() []platforms.BackupDefinition {
+ return nil
+}
+
+func newBackupTestEnv(t *testing.T) requests.RequestEnv {
+ t.Helper()
+ rootDir := t.TempDir()
+ configDir := filepath.Join(rootDir, "config")
+ dataDir := filepath.Join(rootDir, "data")
+ tempDir := filepath.Join(rootDir, "tmp")
+ logDir := filepath.Join(rootDir, "logs")
+ require.NoError(t, os.MkdirAll(configDir, 0o750))
+ require.NoError(t, os.MkdirAll(dataDir, 0o750))
+ require.NoError(t, os.MkdirAll(tempDir, 0o750))
+ require.NoError(t, os.MkdirAll(logDir, 0o750))
+ cfg, err := config.NewConfig(configDir, config.BaseDefaults)
+ require.NoError(t, err)
+ require.NoError(t, os.WriteFile(filepath.Join(dataDir, "frontend.toml"), []byte("enabled=true\n"), 0o600))
+ require.NoError(t, os.WriteFile(filepath.Join(dataDir, config.TUIFile), []byte("theme=\"default\"\n"), 0o600))
+ userDBPath := filepath.Join(dataDir, config.UserDbFile)
+ require.NoError(t, os.WriteFile(userDBPath, []byte("test user db snapshot"), 0o600))
+ mockUserDB := testinghelpers.NewMockUserDBI()
+ mockUserDB.On(
+ "BackupForTransfer", testifymock.Anything, testifymock.AnythingOfType("string"),
+ ).Return(database.BackupInfo{
+ Name: "backup-20260624-150405-000000001-auto.db",
+ Path: userDBPath,
Valid: true,
+ }, func() error { return nil }, nil)
+ mockUserDB.On("Backup", "restore-rollback", false).Return(database.BackupInfo{
+ Name: "backup-20260624-150405-000000002-auto.db", Path: userDBPath, Valid: true,
+ }, nil).Maybe()
+ mockUserDB.On("GetDBPath").Return(userDBPath)
+ mockUserDB.On("RestoreBackup", testifymock.AnythingOfType("string")).Return(database.RestoreInfo{
+ RestoredFrom: database.BackupInfo{Name: "staged.db", Valid: true},
}, nil)
-
- env := requests.RequestEnv{
+ mockUserDB.On("ListClients").Return([]database.Client{}, nil).Maybe()
+ mockUserDB.On("ReplaceAllClients", testifymock.Anything).Return(nil).Maybe()
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("mock-platform")
+ mockPlatform.On("Settings").Return(platforms.Settings{
+ DataDir: dataDir, ConfigDir: configDir, TempDir: tempDir, LogDir: logDir,
+ })
+ pl := &backupCapableTestPlatform{MockPlatform: mockPlatform}
+ return requests.RequestEnv{
Context: context.Background(),
+ Config: cfg,
+ Platform: pl,
Database: &database.Database{UserDB: mockUserDB},
IsLocal: true,
}
+}
+
+func TestHandleBackup_RejectsPlatformWithoutFullDeviceProvider(t *testing.T) {
+ env := newBackupTestEnv(t)
+ platformWithBackup, ok := env.Platform.(*backupCapableTestPlatform)
+ require.True(t, ok)
+ env.Platform = platformWithBackup.MockPlatform
+
+ _, err := HandleBackup(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "not supported on this platform")
+}
+
+func TestHandleBackup_Success(t *testing.T) {
+ env := newBackupTestEnv(t)
result, err := HandleBackup(env)
require.NoError(t, err)
- info, ok := result.(database.BackupInfo)
+ info, ok := result.(backupsvc.Info)
require.True(t, ok)
- assert.Equal(t, "backup-20260624-150405-000000001-manual.db", info.Name)
- mockUserDB.AssertExpectations(t)
+ assert.Equal(t, backupsvc.IntegrityValid, info.Integrity)
+ assert.Contains(t, info.Name, "backup-")
+ assert.Contains(t, info.Name, ".zip")
+ assert.NotZero(t, info.Categories[backupsvc.CategoryZaparoo].Files)
}
func TestHandleBackupList_Success(t *testing.T) {
- t.Parallel()
+ env := newBackupTestEnv(t)
+ _, err := HandleBackup(env)
+ require.NoError(t, err)
- mockUserDB := helpers.NewMockUserDBI()
- mockUserDB.On("ListBackups").Return([]database.BackupInfo{
- {Name: "backup-a-manual.db", Valid: true},
- {Name: "backup-b-auto.db", Valid: true},
- }, nil)
+ result, err := HandleBackupList(env)
+ require.NoError(t, err)
+ backups, ok := result.([]backupsvc.ListInfo)
+ require.True(t, ok)
+ require.Len(t, backups, 1)
+ assert.NotEmpty(t, backups[0].Name)
+ assert.NotZero(t, backups[0].Size)
+}
- env := requests.RequestEnv{
- Context: context.Background(),
- Database: &database.Database{UserDB: mockUserDB},
- IsLocal: true,
- }
+func TestHandleBackupInspect_Success(t *testing.T) {
+ env := newBackupTestEnv(t)
+ created, err := HandleBackup(env)
+ require.NoError(t, err)
+ backupInfo, ok := created.(backupsvc.Info)
+ require.True(t, ok)
- result, err := HandleBackupList(env)
+ params, err := json.Marshal(map[string]string{"name": backupInfo.Name})
require.NoError(t, err)
- backups, ok := result.([]database.BackupInfo)
+ env.Params = params
+
+ result, err := HandleBackupInspect(env)
+ require.NoError(t, err)
+ inspected, ok := result.(backupsvc.Info)
require.True(t, ok)
- require.Len(t, backups, 2)
- mockUserDB.AssertExpectations(t)
+ assert.Equal(t, backupsvc.IntegrityUnchecked, inspected.Integrity)
+ assert.NotEmpty(t, inspected.Categories)
+}
+
+func TestHandleBackupDelete_Success(t *testing.T) {
+ env := newBackupTestEnv(t)
+ created, err := HandleBackup(env)
+ require.NoError(t, err)
+ backupInfo, ok := created.(backupsvc.Info)
+ require.True(t, ok)
+
+ params, err := json.Marshal(map[string]string{"name": backupInfo.Name})
+ require.NoError(t, err)
+ env.Params = params
+
+ result, err := HandleBackupDelete(env)
+ require.NoError(t, err)
+ assert.Equal(t, NoContent{}, result)
+
+ _, err = backupsvc.NewManager(env.Config, env.Platform, env.Database).
+ Inspect(context.Background(), backupInfo.Name)
+ require.Error(t, err)
}
func TestHandleBackupRestore_Success(t *testing.T) {
- t.Parallel()
+ env := newBackupTestEnv(t)
+ created, err := HandleBackup(env)
+ require.NoError(t, err)
+ backupInfo, ok := created.(backupsvc.Info)
+ require.True(t, ok)
- mockUserDB := helpers.NewMockUserDBI()
- mockUserDB.On("RestoreBackup", "backup-a-manual.db").Return(database.RestoreInfo{
- RestoredFrom: database.BackupInfo{Name: "backup-a-manual.db", Valid: true},
- }, nil)
+ params, err := json.Marshal(map[string]string{"name": backupInfo.Name})
+ require.NoError(t, err)
+ env.Params = params
- params, err := json.Marshal(map[string]string{"name": "backup-a-manual.db"})
+ result, err := HandleBackupRestore(env)
require.NoError(t, err)
+ info, ok := result.(backupsvc.RestoreInfo)
+ require.True(t, ok)
+ assert.Equal(t, backupInfo.Name, info.RestoredFrom.Name)
+ require.NotNil(t, info.PreRestoreBackup)
+ assert.Equal(t, backupsvc.IntegrityValid, info.PreRestoreBackup.Integrity)
+}
- env := requests.RequestEnv{
- Context: context.Background(),
- Database: &database.Database{UserDB: mockUserDB},
- IsLocal: true,
- Params: params,
- }
+func TestHandleBackupRestore_RejectsActiveMedia(t *testing.T) {
+ env := newBackupTestEnv(t)
+ created, err := HandleBackup(env)
+ require.NoError(t, err)
+ backupInfo, ok := created.(backupsvc.Info)
+ require.True(t, ok)
+ params, err := json.Marshal(map[string]string{"name": backupInfo.Name})
+ require.NoError(t, err)
+ env.Params = params
+ env.State, _ = state.NewState(env.Platform, "test-boot")
+ env.State.SetActiveMedia(&models.ActiveMedia{})
+
+ _, err = HandleBackupRestore(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "while media is active")
+}
+
+func TestHandleBackupRestore_RestartsAfterResponse(t *testing.T) {
+ env := newBackupTestEnv(t)
+ created, err := HandleBackup(env)
+ require.NoError(t, err)
+ backupInfo, ok := created.(backupsvc.Info)
+ require.True(t, ok)
+ params, err := json.Marshal(map[string]string{"name": backupInfo.Name})
+ require.NoError(t, err)
+ env.Params = params
+ env.State, _ = state.NewState(env.Platform, "test-boot")
result, err := HandleBackupRestore(env)
require.NoError(t, err)
- info, ok := result.(database.RestoreInfo)
+ response, ok := result.(models.ResponseWithCallback)
require.True(t, ok)
- assert.Equal(t, "backup-a-manual.db", info.RestoredFrom.Name)
- mockUserDB.AssertExpectations(t)
+ assert.False(t, env.State.RestartRequested())
+ response.AfterWrite()
+ assert.True(t, env.State.RestartRequested())
}
func TestHandleBackupRestore_MissingName(t *testing.T) {
- t.Parallel()
-
- mockUserDB := helpers.NewMockUserDBI()
+ env := newBackupTestEnv(t)
params, err := json.Marshal(map[string]string{})
require.NoError(t, err)
-
- env := requests.RequestEnv{
- Context: context.Background(),
- Database: &database.Database{UserDB: mockUserDB},
- IsLocal: true,
- Params: params,
- }
+ env.Params = params
_, err = HandleBackupRestore(env)
require.Error(t, err)
- mockUserDB.AssertNotCalled(t, "RestoreBackup")
}
-func TestHandleBackup_RejectsNonLocal(t *testing.T) {
- t.Parallel()
+func TestHandleBackupStatus_AllowsNonLocal(t *testing.T) {
+ env := newBackupTestEnv(t)
+ env.IsLocal = false
+ env.Database = nil
- mockUserDB := helpers.NewMockUserDBI()
- env := requests.RequestEnv{
- Context: context.Background(),
- Database: &database.Database{UserDB: mockUserDB},
- IsLocal: false,
+ result, err := HandleBackupStatus(env)
+ require.NoError(t, err)
+ status, ok := result.(models.BackupStatusResponse)
+ require.True(t, ok)
+ assert.True(t, status.Local.Enabled)
+ assert.Equal(t, config.DefaultBackupRemoteSchedule, status.Remote.Schedule)
+}
+
+func TestHandleBackupRemoteList_MapsOpaqueIDsAndSources(t *testing.T) {
+ env := newBackupTestEnv(t)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodGet, r.Method)
+ assert.Equal(t, "/v1/device/backups", r.URL.Path)
+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
+ w.Header().Set("Content-Type", "application/json")
+ _, writeErr := w.Write([]byte(`{
+ "items":[{
+ "id":"save/a%b ?\u96ea",
+ "backup_type":"manual",
+ "schema_version":1,
+ "created_at":"2026-07-10T12:00:00Z",
+ "source_device":{"id":"dev-2","name":"Bedroom","linked":true,"current":false}
+ }],
+ "storage_used_bytes":42,
+ "storage_quota_bytes":100
+ }`))
+ assert.NoError(t, writeErr)
+ }))
+ defer server.Close()
+ require.NoError(t, env.Config.SetBackupRemoteBaseURL(server.URL))
+ config.SetAuthCfgForTesting(map[string]config.CredentialEntry{
+ config.BackupAuthLookupURL(server.URL): {Bearer: "test-token"},
+ })
+ t.Cleanup(config.ClearAuthCfgForTesting)
+
+ result, err := HandleBackupRemoteList(env)
+ require.NoError(t, err)
+ list, ok := result.(backupsvc.RemoteListInfo)
+ require.True(t, ok)
+ require.Len(t, list.Items, 1)
+ assert.Equal(t, "save/a%b ?\u96ea", list.Items[0].ID)
+ require.NotNil(t, list.Items[0].SourceDevice)
+ assert.Equal(t, "dev-2", list.Items[0].SourceDevice.ID)
+ assert.Equal(t, int64(42), list.StorageUsedBytes)
+}
+
+func TestHandleBackupRemoteRestore_RequiresID(t *testing.T) {
+ env := newBackupTestEnv(t)
+ env.Params = json.RawMessage(`{}`)
+
+ _, err := HandleBackupRemoteRestore(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "id is required")
+}
+
+func TestBackupMethodErrorMapsExpectedConditions(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ err error
+ contains string
+ }{
+ {err: backupsvc.ErrPlatformBackupUnsupported, contains: "not supported on this platform"},
+ {err: backupsvc.ErrRestoreMediaActive, contains: "while media is active"},
+ {err: backupsvc.ErrRestoreLaunchInProgress, contains: "media is launching or restart is pending"},
+ {err: &backupsvc.BusyError{Kind: backupsvc.OperationRemoteUpload}, contains: "busy with remote-upload"},
}
+ for _, tt := range tests {
+ t.Run(tt.contains, func(t *testing.T) {
+ t.Parallel()
+ err := backupMethodError("test action", tt.err)
+ var clientErr *models.ClientError
+ require.ErrorAs(t, err, &clientErr)
+ assert.Contains(t, err.Error(), tt.contains)
+ })
+ }
+}
+
+func TestHandleBackup_RejectsNonLocal(t *testing.T) {
+ env := newBackupTestEnv(t)
+ env.IsLocal = false
_, err := HandleBackup(env)
require.Error(t, err)
- mockUserDB.AssertNotCalled(t, "Backup")
}
func TestHandleBackupList_RejectsNonLocal(t *testing.T) {
- t.Parallel()
-
- mockUserDB := helpers.NewMockUserDBI()
- env := requests.RequestEnv{
- Context: context.Background(),
- Database: &database.Database{UserDB: mockUserDB},
- IsLocal: false,
- }
+ env := newBackupTestEnv(t)
+ env.IsLocal = false
_, err := HandleBackupList(env)
require.Error(t, err)
- mockUserDB.AssertNotCalled(t, "ListBackups")
}
func TestHandleBackupRestore_RejectsNonLocal(t *testing.T) {
- t.Parallel()
-
- mockUserDB := helpers.NewMockUserDBI()
- params, err := json.Marshal(map[string]string{"name": "backup-a-manual.db"})
+ env := newBackupTestEnv(t)
+ env.IsLocal = false
+ params, err := json.Marshal(map[string]string{"name": "backup-a-manual.zip"})
require.NoError(t, err)
-
- env := requests.RequestEnv{
- Context: context.Background(),
- Database: &database.Database{UserDB: mockUserDB},
- IsLocal: false,
- Params: params,
- }
+ env.Params = params
_, err = HandleBackupRestore(env)
require.Error(t, err)
- mockUserDB.AssertNotCalled(t, "RestoreBackup")
}
func TestHandleBackup_RejectsUnavailableDatabase(t *testing.T) {
- t.Parallel()
-
- // A nil UserDB must be rejected by the access gate rather than panicking.
- env := requests.RequestEnv{
- Context: context.Background(),
- Database: &database.Database{UserDB: nil},
- IsLocal: true,
- }
+ env := newBackupTestEnv(t)
+ env.Database = &database.Database{UserDB: nil}
_, err := HandleBackup(env)
require.Error(t, err)
diff --git a/pkg/api/methods/permissions.go b/pkg/api/methods/permissions.go
index 05f4f745..3d9644c0 100644
--- a/pkg/api/methods/permissions.go
+++ b/pkg/api/methods/permissions.go
@@ -57,3 +57,13 @@ func requireProfileManagement(env *requests.RequestEnv) error {
}
return models.ClientErrf("%w", ErrForbidden)
}
+
+// isLocalOrAdmin reports whether the request is a local connection or a
+// paired admin client. A ClientRole only exists on encrypted paired
+// sessions, so an admin role here means an authenticated encrypted
+// client — as privileged as a local connection. This deliberately does
+// not use Grant.EffectiveRole, whose unpaired-remote-is-admin fallback
+// must not open device-management methods to plaintext remote clients.
+func isLocalOrAdmin(env *requests.RequestEnv) bool {
+ return env.IsLocal || env.ClientRole == string(permissions.RoleAdmin)
+}
diff --git a/pkg/api/methods/settings.go b/pkg/api/methods/settings.go
index 10f7ebc8..b776601a 100644
--- a/pkg/api/methods/settings.go
+++ b/pkg/api/methods/settings.go
@@ -81,6 +81,14 @@ func HandleSettings(env requests.RequestEnv) (any, error) { //nolint:gocritic //
}
resp.ReadersScanIgnoreSystem = append(resp.ReadersScanIgnoreSystem, env.Config.ReadersScan().IgnoreSystem...)
+ if isLocalOrAdmin(&env) {
+ backupRemoteEnabled := env.Config.BackupRemoteEnabled()
+ backupRemoteSchedule := env.Config.BackupRemoteSchedule()
+ backupRemoteBaseURL := env.Config.BackupRemoteBaseURL()
+ resp.BackupRemoteEnabled = &backupRemoteEnabled
+ resp.BackupRemoteSchedule = &backupRemoteSchedule
+ resp.BackupRemoteBaseURL = &backupRemoteBaseURL
+ }
return resp, nil
}
@@ -141,6 +149,12 @@ func HandleSettingsUpdate(env requests.RequestEnv) (any, error) {
}
}
+ if params.BackupRemoteEnabled != nil || params.BackupRemoteSchedule != nil {
+ if !isLocalOrAdmin(&env) {
+ return nil, models.ClientErrf("backup settings require a local or admin client")
+ }
+ }
+
// Pre-flight validation of inputs that depend on runtime state. Run before
// any mutations are applied so a validation failure here does not leave
// the in-memory config partially updated.
@@ -204,6 +218,16 @@ func HandleSettingsUpdate(env requests.RequestEnv) (any, error) {
env.Config.SetEncryptionEnabled(*params.Encryption)
}
+ if params.BackupRemoteEnabled != nil {
+ log.Debug().Bool("backupRemoteEnabled", *params.BackupRemoteEnabled).Msg("updating setting")
+ env.Config.SetBackupRemoteEnabled(*params.BackupRemoteEnabled)
+ }
+
+ if params.BackupRemoteSchedule != nil {
+ log.Debug().Str("backupRemoteSchedule", *params.BackupRemoteSchedule).Msg("updating setting")
+ env.Config.SetBackupRemoteSchedule(*params.BackupRemoteSchedule)
+ }
+
if params.ReadersScanMode != nil {
log.Debug().Str("readersScanMode", *params.ReadersScanMode).Msg("updating setting")
// empty string defaults to tap mode
diff --git a/pkg/api/methods/settings_test.go b/pkg/api/methods/settings_test.go
index 9854ced6..a99e3038 100644
--- a/pkg/api/methods/settings_test.go
+++ b/pkg/api/methods/settings_test.go
@@ -599,6 +599,57 @@ func TestHandleSettingsUpdate_ReaderConnectionsWithIDSource(t *testing.T) {
// TestHandleSettings_ReaderConnectionsEnabled tests that the enabled field
// is passed through in the settings response.
+func TestHandleSettingsUpdate_NonLocalBackupSettingsRejectBeforeMutation(t *testing.T) {
+ t.Parallel()
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test-platform").Maybe()
+
+ tmpDir := t.TempDir()
+ cfg, err := config.NewConfig(tmpDir, config.Values{})
+ require.NoError(t, err)
+ cfg.SetDebugLogging(false)
+
+ appState, ns := state.NewState(mockPlatform, "test-boot-uuid")
+ t.Cleanup(func() { drainCh(ns) })
+
+ debugLogging := true
+ backupRemoteEnabled := true
+ params := models.UpdateSettingsParams{
+ DebugLogging: &debugLogging,
+ BackupRemoteEnabled: &backupRemoteEnabled,
+ }
+ paramsJSON, err := json.Marshal(params)
+ require.NoError(t, err)
+
+ env := requests.RequestEnv{
+ Context: context.Background(),
+ Platform: mockPlatform,
+ Config: cfg,
+ State: appState,
+ Params: paramsJSON,
+ IsLocal: false,
+ }
+
+ _, err = HandleSettingsUpdate(env)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "backup settings require a local or admin client")
+ assert.False(t, cfg.DebugLogging(), "non-local rejection must happen before any mutation")
+ assert.False(t, cfg.BackupRemoteEnabled())
+
+ // A remote member client is rejected the same way.
+ env.ClientRole = string(permissions.RoleMember)
+ _, err = HandleSettingsUpdate(env)
+ require.Error(t, err)
+ assert.False(t, cfg.BackupRemoteEnabled())
+
+ // A paired admin client is as privileged as a local connection.
+ env.ClientRole = string(permissions.RoleAdmin)
+ _, err = HandleSettingsUpdate(env)
+ require.NoError(t, err)
+ assert.True(t, cfg.BackupRemoteEnabled())
+}
+
func TestHandleSettings_ReaderConnectionsEnabled(t *testing.T) {
t.Parallel()
@@ -1236,3 +1287,26 @@ func TestHandleSettingsUpdate_SystemDefaults_AllowsEmptyLauncher(t *testing.T) {
assert.Empty(t, got[0].Launcher)
assert.Equal(t, "echo bye", got[0].BeforeExit)
}
+
+func TestHandleSettings_BackupRemoteBaseURLGatedToLocal(t *testing.T) {
+ t.Parallel()
+
+ cfg, err := config.NewConfig(t.TempDir(), config.BaseDefaults)
+ require.NoError(t, err)
+ mockPlatform := mocks.NewMockPlatform()
+ appState, ns := state.NewState(mockPlatform, "test-boot-uuid")
+ t.Cleanup(func() { drainCh(ns) })
+
+ result, err := HandleSettings(requests.RequestEnv{Config: cfg, State: appState, IsLocal: true})
+ require.NoError(t, err)
+ resp, ok := result.(models.SettingsResponse)
+ require.True(t, ok)
+ require.NotNil(t, resp.BackupRemoteBaseURL)
+ assert.Equal(t, config.DefaultBackupRemoteBaseURL, *resp.BackupRemoteBaseURL)
+
+ result, err = HandleSettings(requests.RequestEnv{Config: cfg, State: appState, IsLocal: false})
+ require.NoError(t, err)
+ resp, ok = result.(models.SettingsResponse)
+ require.True(t, ok)
+ assert.Nil(t, resp.BackupRemoteBaseURL)
+}
diff --git a/pkg/api/models/models.go b/pkg/api/models/models.go
index 54f6fdbf..9d5ea231 100644
--- a/pkg/api/models/models.go
+++ b/pkg/api/models/models.go
@@ -21,6 +21,7 @@ package models
import (
"encoding/json"
+ "strings"
)
const (
@@ -42,6 +43,8 @@ const (
NotificationProfilesActive = "profiles.active"
NotificationProfilesData = "profiles.data"
NotificationUIChanged = "ui.changed"
+ NotificationAuthLinkStatus = "auth.link.status"
+ NotificationBackupState = "backup.state"
)
// Profile data swap statuses reported by the profiles.data notification.
@@ -87,87 +90,115 @@ const (
)
const (
- MethodLaunch = "launch" // DEPRECATED
- MethodRun = "run"
- MethodConfirm = "confirm"
- MethodUI = "ui"
- MethodUIRespond = "ui.respond"
- MethodRunScript = "run.script"
- MethodStop = "stop"
- MethodTokens = "tokens"
- MethodMedia = "media"
- MethodMediaGenerate = "media.generate"
- MethodMediaGenerateCancel = "media.generate.cancel"
- MethodMediaGenerateResume = "media.generate.resume"
- MethodMediaIndex = "media.index" // DEPRECATED
- MethodMediaSearch = "media.search"
- MethodMediaTags = "media.tags"
- MethodMediaTagsUpdate = "media.tags.update"
- MethodMediaMetaUpdate = "media.meta.update"
- MethodMediaActive = "media.active"
- MethodMediaHistory = "media.history"
- MethodMediaHistoryLatest = "media.history.latest"
- MethodMediaHistoryTop = "media.history.top"
- MethodMediaLookup = "media.lookup"
- MethodMediaMeta = "media.meta"
- MethodMediaImage = "media.image"
- MethodScrapers = "scrapers"
- MethodMediaScrape = "media.scrape"
- MethodMediaScrapeStatus = "media.scrape.status"
- MethodMediaScrapeCancel = "media.scrape.cancel"
- MethodMediaScrapeResume = "media.scrape.resume"
- MethodMediaBrowse = "media.browse"
- MethodMediaBrowseIndex = "media.browse.index"
- MethodMediaControl = "media.control"
- MethodMediaActiveUpdate = "media.active.update"
- MethodMediaCleanOrphans = "media.clean.orphans"
- MethodSettings = "settings"
- MethodSettingsUpdate = "settings.update"
- MethodSettingsReload = "settings.reload"
- MethodSettingsLogsDownload = "settings.logs.download"
- MethodSettingsBackup = "settings.backup"
- MethodSettingsBackupList = "settings.backup.list"
- MethodSettingsBackupRestore = "settings.backup.restore"
- MethodPlaytimeLimits = "settings.playtime.limits"
- MethodPlaytimeLimitsUpdate = "settings.playtime.limits.update"
- MethodPlaytime = "playtime"
- MethodClients = "clients"
- MethodClientsDelete = "clients.delete"
- MethodClientsPairStart = "clients.pair.start"
- MethodClientsPairCancel = "clients.pair.cancel"
- MethodProfiles = "profiles"
- MethodProfilesNew = "profiles.new"
- MethodProfilesUpdate = "profiles.update"
- MethodProfilesDelete = "profiles.delete"
- MethodProfilesActive = "profiles.active"
- MethodProfilesSwitch = "profiles.switch"
- MethodProfilesVerify = "profiles.verify"
- MethodSystems = "systems"
- MethodLaunchers = "launchers"
- MethodLaunchersRefresh = "launchers.refresh"
- MethodHistory = "tokens.history"
- MethodMappings = "mappings"
- MethodMappingsNew = "mappings.new"
- MethodMappingsDelete = "mappings.delete"
- MethodMappingsUpdate = "mappings.update"
- MethodMappingsReload = "mappings.reload"
- MethodReaders = "readers"
- MethodReadersWrite = "readers.write"
- MethodReadersWriteCancel = "readers.write.cancel"
- MethodVersion = "version"
- MethodHealthCheck = "health"
- MethodInbox = "inbox"
- MethodInboxDelete = "inbox.delete"
- MethodInboxClear = "inbox.clear"
- MethodSettingsAuthClaim = "settings.auth.claim"
- MethodUpdateCheck = "update.check"
- MethodUpdateApply = "update.apply"
- MethodInputKeyboard = "input.keyboard"
- MethodInputGamepad = "input.gamepad"
- MethodScreenshot = "screenshot"
- MethodMediaTitleParse = "media.title.parse"
+ MethodLaunch = "launch" // DEPRECATED
+ MethodRun = "run"
+ MethodConfirm = "confirm"
+ MethodUI = "ui"
+ MethodUIRespond = "ui.respond"
+ MethodRunScript = "run.script"
+ MethodStop = "stop"
+ MethodTokens = "tokens"
+ MethodMedia = "media"
+ MethodMediaGenerate = "media.generate"
+ MethodMediaGenerateCancel = "media.generate.cancel"
+ MethodMediaGenerateResume = "media.generate.resume"
+ MethodMediaIndex = "media.index" // DEPRECATED
+ MethodMediaSearch = "media.search"
+ MethodMediaTags = "media.tags"
+ MethodMediaTagsUpdate = "media.tags.update"
+ MethodMediaMetaUpdate = "media.meta.update"
+ MethodMediaActive = "media.active"
+ MethodMediaHistory = "media.history"
+ MethodMediaHistoryLatest = "media.history.latest"
+ MethodMediaHistoryTop = "media.history.top"
+ MethodMediaLookup = "media.lookup"
+ MethodMediaMeta = "media.meta"
+ MethodMediaImage = "media.image"
+ MethodScrapers = "scrapers"
+ MethodMediaScrape = "media.scrape"
+ MethodMediaScrapeStatus = "media.scrape.status"
+ MethodMediaScrapeCancel = "media.scrape.cancel"
+ MethodMediaScrapeResume = "media.scrape.resume"
+ MethodMediaBrowse = "media.browse"
+ MethodMediaBrowseIndex = "media.browse.index"
+ MethodMediaControl = "media.control"
+ MethodMediaActiveUpdate = "media.active.update"
+ MethodMediaCleanOrphans = "media.clean.orphans"
+ MethodSettings = "settings"
+ MethodSettingsUpdate = "settings.update"
+ MethodSettingsReload = "settings.reload"
+ MethodSettingsLogsDownload = "settings.logs.download"
+ MethodSettingsBackup = "settings.backup"
+ MethodSettingsBackupList = "settings.backup.list"
+ MethodSettingsBackupInspect = "settings.backup.inspect"
+ MethodSettingsBackupDelete = "settings.backup.delete"
+ MethodSettingsBackupRestore = "settings.backup.restore"
+ MethodSettingsBackupStatus = "settings.backup.status"
+ MethodSettingsBackupRemoteRun = "settings.backup.remote.run"
+ MethodSettingsBackupRemoteList = "settings.backup.remote.list"
+ MethodSettingsBackupRemoteRestore = "settings.backup.remote.restore"
+ MethodPlaytimeLimits = "settings.playtime.limits"
+ MethodPlaytimeLimitsUpdate = "settings.playtime.limits.update"
+ MethodPlaytime = "playtime"
+ MethodClients = "clients"
+ MethodClientsDelete = "clients.delete"
+ MethodClientsPairStart = "clients.pair.start"
+ MethodClientsPairCancel = "clients.pair.cancel"
+ MethodProfiles = "profiles"
+ MethodProfilesNew = "profiles.new"
+ MethodProfilesUpdate = "profiles.update"
+ MethodProfilesDelete = "profiles.delete"
+ MethodProfilesActive = "profiles.active"
+ MethodProfilesSwitch = "profiles.switch"
+ MethodProfilesVerify = "profiles.verify"
+ MethodSystems = "systems"
+ MethodLaunchers = "launchers"
+ MethodLaunchersRefresh = "launchers.refresh"
+ MethodHistory = "tokens.history"
+ MethodMappings = "mappings"
+ MethodMappingsNew = "mappings.new"
+ MethodMappingsDelete = "mappings.delete"
+ MethodMappingsUpdate = "mappings.update"
+ MethodMappingsReload = "mappings.reload"
+ MethodReaders = "readers"
+ MethodReadersWrite = "readers.write"
+ MethodReadersWriteCancel = "readers.write.cancel"
+ MethodVersion = "version"
+ MethodHealthCheck = "health"
+ MethodInbox = "inbox"
+ MethodInboxDelete = "inbox.delete"
+ MethodInboxClear = "inbox.clear"
+ MethodSettingsAuthClaim = "settings.auth.claim"
+ MethodSettingsAuthStatus = "settings.auth.status"
+ MethodSettingsAuthUnlink = "settings.auth.unlink"
+ MethodSettingsAuthLink = "settings.auth.link"
+ MethodSettingsAuthLinkStatus = "settings.auth.link.status"
+ MethodSettingsAuthLinkCancel = "settings.auth.link.cancel"
+ MethodUpdateCheck = "update.check"
+ MethodUpdateApply = "update.apply"
+ MethodInputKeyboard = "input.keyboard"
+ MethodInputGamepad = "input.gamepad"
+ MethodScreenshot = "screenshot"
+ MethodMediaTitleParse = "media.title.parse"
)
+// MethodHasUnboundedRuntime reports whether a method may run without a fixed
+// whole-operation deadline. Full-device backup and restore work is bounded by
+// caller cancellation, shutdown, and per-transfer timeouts instead; both the
+// server request context and the local client wait consult this list so the
+// two cannot disagree.
+func MethodHasUnboundedRuntime(method string) bool {
+ switch strings.ToLower(method) {
+ case MethodSettingsBackup,
+ MethodSettingsBackupRestore,
+ MethodSettingsBackupRemoteRun,
+ MethodSettingsBackupRemoteRestore:
+ return true
+ default:
+ return false
+ }
+}
+
// ResponseWithCallback wraps a method result with a function that should be
// called after the response has been written to the client. This allows
// handlers to defer side effects (like triggering a restart) until the client
diff --git a/pkg/api/models/params.go b/pkg/api/models/params.go
index a4604184..af0b8391 100644
--- a/pkg/api/models/params.go
+++ b/pkg/api/models/params.go
@@ -116,10 +116,24 @@ type ReaderWriteParams struct {
Text string `json:"text" validate:"required"`
}
-type BackupRestoreParams struct {
+type BackupNameParams struct {
Name string `json:"name" validate:"required"`
}
+type BackupRemoteRestoreParams struct {
+ ID string `json:"id" validate:"required"`
+}
+
+type SettingsAuthStatusParams struct {
+ URL string `json:"url,omitempty" validate:"omitempty,url"`
+}
+
+type SettingsAuthLinkParams struct {
+ // URL overrides the auth server base URL; defaults to the official
+ // Zaparoo API.
+ URL string `json:"url,omitempty" validate:"omitempty,url"`
+}
+
type ReaderWriteCancelParams struct {
ReaderID *string `json:"readerId,omitempty"`
}
@@ -150,7 +164,9 @@ type UpdateSettingsParams struct {
ReadersAutoDetect *bool `json:"readersAutoDetect"`
ErrorReporting *bool `json:"errorReporting"`
Encryption *bool `json:"encryption"`
+ BackupRemoteEnabled *bool `json:"backupRemoteEnabled"`
UpdateChannel *string `json:"updateChannel" validate:"omitempty,oneof=stable beta"`
+ BackupRemoteSchedule *string `json:"backupRemoteSchedule" validate:"omitempty,oneof=daily weekly manual"`
ReadersScanMode *string `json:"readersScanMode" validate:"omitempty,oneof=tap hold"`
ReadersScanExitDelay *float32 `json:"readersScanExitDelay" validate:"omitempty,gte=0"`
ReadersScanIgnoreSystem *[]string `json:"readersScanIgnoreSystems" validate:"omitempty,dive,system"`
diff --git a/pkg/api/models/requests/requests.go b/pkg/api/models/requests/requests.go
index 88548109..355c8e0b 100644
--- a/pkg/api/models/requests/requests.go
+++ b/pkg/api/models/requests/requests.go
@@ -56,6 +56,7 @@ type RequestEnv struct {
ConfirmQueue chan<- chan error
IndexPauser *syncutil.Pauser
ScrapePauser *syncutil.Pauser
+ BackupPauser *syncutil.Pauser
ClientID string
// ClientRole is the paired client's permission role ("admin" or
// "member"), or "" when the request carries no paired identity (local
diff --git a/pkg/api/models/responses.go b/pkg/api/models/responses.go
index 5d1efb6f..3803eb39 100644
--- a/pkg/api/models/responses.go
+++ b/pkg/api/models/responses.go
@@ -137,6 +137,9 @@ type BrowseIndexResults struct {
}
type SettingsResponse struct {
+ BackupRemoteEnabled *bool `json:"backupRemoteEnabled,omitempty"`
+ BackupRemoteSchedule *string `json:"backupRemoteSchedule,omitempty"`
+ BackupRemoteBaseURL *string `json:"backupRemoteBaseUrl,omitempty"`
UpdateChannel string `json:"updateChannel"`
ReadersScanMode string `json:"readersScanMode"`
ReadersScanIgnoreSystem []string `json:"readersScanIgnoreSystems"`
@@ -737,6 +740,87 @@ type SettingsAuthClaimResponse struct {
Domains []string `json:"domains"`
}
+type SettingsAuthStatusResponse struct {
+ Linked bool `json:"linked"`
+}
+
+// SettingsAuthUnlinkResponse lists the domains whose credentials were
+// removed by settings.auth.unlink.
+type SettingsAuthUnlinkResponse struct {
+ Domains []string `json:"domains"`
+}
+
+// Auth link session statuses (settings.auth.link).
+const (
+ AuthLinkStatusNone = "none"
+ AuthLinkStatusPending = "pending"
+ AuthLinkStatusApproved = "approved"
+ AuthLinkStatusFailed = "failed"
+ AuthLinkStatusCancelled = "cancelled"
+)
+
+// AuthLinkStatusResponse is the state of the reverse (QR / user code) device
+// link flow. It is returned by settings.auth.link and
+// settings.auth.link.status, and pushed as the auth.link.status notification
+// on every transition. Notifications omit user codes and verification URLs.
+type AuthLinkStatusResponse struct {
+ ExpiresAt *time.Time `json:"expiresAt,omitempty"`
+ Status string `json:"status"`
+ UserCode string `json:"userCode,omitempty"`
+ VerificationURL string `json:"verificationUrl,omitempty"`
+ VerificationURLComplete string `json:"verificationUrlComplete,omitempty"`
+ Error string `json:"error,omitempty"`
+}
+
+type BackupCategoryStatus struct {
+ Files int64 `json:"files"`
+ Bytes int64 `json:"bytes"`
+ Enabled bool `json:"enabled"`
+}
+
+type BackupWarning struct {
+ Category string `json:"category"`
+ Path string `json:"path"`
+ Reason string `json:"reason"`
+}
+
+type BackupStatusEntry struct {
+ LastRunAt *string `json:"lastRunAt,omitempty"`
+ LastSuccessAt *string `json:"lastSuccessAt,omitempty"`
+ AvailabilityCheckedAt *string `json:"availabilityCheckedAt,omitempty"`
+ DeviceName *string `json:"deviceName,omitempty"`
+ LinkedAt *string `json:"linkedAt,omitempty"`
+ Categories map[string]BackupCategoryStatus `json:"categories,omitempty"`
+ Schedule string `json:"schedule,omitempty"`
+ LastError string `json:"lastError,omitempty"`
+ Availability string `json:"availability,omitempty"`
+ LastStatus string `json:"lastStatus"`
+ Warnings []BackupWarning `json:"warnings,omitempty"`
+ LastBackupSize int64 `json:"lastBackupSize"`
+ SkippedFiles int `json:"skippedFiles,omitempty"`
+ Linked bool `json:"linked,omitempty"`
+ Enabled bool `json:"enabled"`
+}
+
+type BackupStatusResponse struct {
+ ActiveSince *string `json:"activeSince,omitempty"`
+ ActiveOperation string `json:"activeOperation,omitempty"`
+ Local BackupStatusEntry `json:"local"`
+ Remote BackupStatusEntry `json:"remote"`
+}
+
+// BackupStateNotification is the payload for the backup.state notification,
+// sent while a backup operation is running whenever its pause/throttle state
+// changes in response to a game starting or stopping, and once with Finished
+// set when the operation ends (whatever its outcome). Operation is the
+// active operation kind from settings.backup.status (e.g. "remote-upload").
+type BackupStateNotification struct {
+ Operation string `json:"operation,omitempty"`
+ Paused bool `json:"paused"`
+ Throttled bool `json:"throttled"`
+ Finished bool `json:"finished,omitempty"`
+}
+
type UpdateCheckResponse struct {
CurrentVersion string `json:"currentVersion"`
LatestVersion string `json:"latestVersion,omitempty"`
diff --git a/pkg/api/notifications/notifications.go b/pkg/api/notifications/notifications.go
index ff06c1e9..55a6d0c6 100644
--- a/pkg/api/notifications/notifications.go
+++ b/pkg/api/notifications/notifications.go
@@ -157,3 +157,13 @@ func ProfilesActiveChanged(ns chan<- models.Notification, payload models.Profile
func ProfilesDataChanged(ns chan<- models.Notification, payload models.ProfilesDataNotification) {
sendNotification(ns, models.NotificationProfilesData, payload)
}
+
+func AuthLinkStatus(ns chan<- models.Notification, payload *models.AuthLinkStatusResponse) {
+ sendNotification(ns, models.NotificationAuthLinkStatus, payload)
+}
+
+// BackupState reports a running backup operation's pause/throttle state
+// changing in response to a game starting or stopping.
+func BackupState(ns chan<- models.Notification, payload models.BackupStateNotification) {
+ sendNotification(ns, models.NotificationBackupState, payload)
+}
diff --git a/pkg/api/request_priority.go b/pkg/api/request_priority.go
index e0e7fd78..ad046454 100644
--- a/pkg/api/request_priority.go
+++ b/pkg/api/request_priority.go
@@ -20,10 +20,13 @@
package api
import (
+ "context"
"encoding/json"
"strings"
+ "time"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/rs/zerolog/log"
)
@@ -35,6 +38,25 @@ const (
apiPriorityLow
)
+func requestTimeoutForAPIMethod(method string) time.Duration {
+ if models.MethodHasUnboundedRuntime(method) {
+ return 0
+ }
+ return config.APIRequestTimeout
+}
+
+func requestContextForAPIMethod(
+ parent context.Context, method string,
+) (context.Context, context.CancelFunc) {
+ timeout := requestTimeoutForAPIMethod(method)
+ if timeout == 0 {
+ //nolint:gosec // Caller receives and owns the cancellation function.
+ return context.WithCancel(parent)
+ }
+ //nolint:gosec // Caller receives and owns the cancellation function.
+ return context.WithTimeout(parent, timeout)
+}
+
func classifyAPIMethod(method string) apiRequestPriority {
method = strings.ToLower(method)
diff --git a/pkg/api/request_priority_test.go b/pkg/api/request_priority_test.go
index 9b7e2d29..56d75663 100644
--- a/pkg/api/request_priority_test.go
+++ b/pkg/api/request_priority_test.go
@@ -20,12 +20,52 @@
package api
import (
+ "context"
"testing"
+ "time"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/stretchr/testify/assert"
)
+func TestRequestTimeoutForAPIMethod(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ method string
+ want time.Duration
+ }{
+ {"backup create", models.MethodSettingsBackup, 0},
+ {"backup restore", models.MethodSettingsBackupRestore, 0},
+ {"remote backup", models.MethodSettingsBackupRemoteRun, 0},
+ {"remote restore", models.MethodSettingsBackupRemoteRestore, 0},
+ {"case insensitive", "SETTINGS.BACKUP", 0},
+ {"backup list", models.MethodSettingsBackupList, config.APIRequestTimeout},
+ {"unknown", "custom.method", config.APIRequestTimeout},
+ {"empty", "", config.APIRequestTimeout},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, requestTimeoutForAPIMethod(tt.method))
+ })
+ }
+}
+
+func TestRequestContextForBackupHasNoDeadlineAndPreservesCancellation(t *testing.T) {
+ t.Parallel()
+ parent, parentCancel := context.WithCancel(context.Background())
+ ctx, cancel := requestContextForAPIMethod(parent, models.MethodSettingsBackup)
+ defer cancel()
+ _, hasDeadline := ctx.Deadline()
+ assert.False(t, hasDeadline)
+ parentCancel()
+ assert.ErrorIs(t, ctx.Err(), context.Canceled)
+}
+
func TestClassifyAPIMethod(t *testing.T) {
t.Parallel()
diff --git a/pkg/api/server.go b/pkg/api/server.go
index ca3d4333..5ed468dc 100644
--- a/pkg/api/server.go
+++ b/pkg/api/server.go
@@ -274,16 +274,22 @@ func NewMethodMap() *MethodMap {
models.MethodMediaControl: methods.HandleMediaControl,
models.MethodMediaTitleParse: methods.HandleMediaTitleParse,
// settings
- models.MethodSettings: methods.HandleSettings,
- models.MethodSettingsUpdate: methods.HandleSettingsUpdate,
- models.MethodSettingsReload: methods.HandleSettingsReload,
- models.MethodSettingsLogsDownload: methods.HandleLogsDownload,
- models.MethodSettingsBackup: methods.HandleBackup,
- models.MethodSettingsBackupList: methods.HandleBackupList,
- models.MethodSettingsBackupRestore: methods.HandleBackupRestore,
- models.MethodPlaytimeLimits: methods.HandlePlaytimeLimits,
- models.MethodPlaytimeLimitsUpdate: methods.HandlePlaytimeLimitsUpdate,
- models.MethodPlaytime: methods.HandlePlaytime,
+ models.MethodSettings: methods.HandleSettings,
+ models.MethodSettingsUpdate: methods.HandleSettingsUpdate,
+ models.MethodSettingsReload: methods.HandleSettingsReload,
+ models.MethodSettingsLogsDownload: methods.HandleLogsDownload,
+ models.MethodSettingsBackup: methods.HandleBackup,
+ models.MethodSettingsBackupList: methods.HandleBackupList,
+ models.MethodSettingsBackupInspect: methods.HandleBackupInspect,
+ models.MethodSettingsBackupDelete: methods.HandleBackupDelete,
+ models.MethodSettingsBackupRestore: methods.HandleBackupRestore,
+ models.MethodSettingsBackupStatus: methods.HandleBackupStatus,
+ models.MethodSettingsBackupRemoteRun: methods.HandleBackupRemoteRun,
+ models.MethodSettingsBackupRemoteList: methods.HandleBackupRemoteList,
+ models.MethodSettingsBackupRemoteRestore: methods.HandleBackupRemoteRestore,
+ models.MethodPlaytimeLimits: methods.HandlePlaytimeLimits,
+ models.MethodPlaytimeLimitsUpdate: methods.HandlePlaytimeLimitsUpdate,
+ models.MethodPlaytime: methods.HandlePlaytime,
// systems
models.MethodSystems: methods.HandleSystems,
// launchers
@@ -341,6 +347,13 @@ func NewMethodMap() *MethodMap {
models.MethodSettingsAuthClaim: func(env requests.RequestEnv) (any, error) {
return methods.HandleSettingsAuthClaim(env, zapscript.FetchWellKnown)
},
+ models.MethodSettingsAuthStatus: methods.HandleSettingsAuthStatus,
+ models.MethodSettingsAuthUnlink: methods.HandleSettingsAuthUnlink,
+ models.MethodSettingsAuthLink: func(env requests.RequestEnv) (any, error) {
+ return methods.HandleSettingsAuthLink(env, zapscript.FetchWellKnown)
+ },
+ models.MethodSettingsAuthLinkStatus: methods.HandleSettingsAuthLinkStatus,
+ models.MethodSettingsAuthLinkCancel: methods.HandleSettingsAuthLinkCancel,
// update
models.MethodUpdateCheck: func(env requests.RequestEnv) (any, error) {
return methods.HandleUpdateCheck(env, updater.Check)
@@ -382,6 +395,16 @@ func newIdleTrackMiddleware(tracker RequestTracker) func(http.Handler) http.Hand
}
}
+// apiMethodManagesRestoreAccess lists methods that coordinate with the
+// backup-restore gate themselves instead of taking the shared read lock:
+// the restore methods hold the write side, and media.active.update resolves
+// its own access so an active game can cancel an in-flight restore.
+func apiMethodManagesRestoreAccess(method string) bool {
+ return method == models.MethodSettingsBackupRestore ||
+ method == models.MethodSettingsBackupRemoteRestore ||
+ method == models.MethodMediaActiveUpdate
+}
+
// handleRequest validates a client request and forwards it to the
// appropriate method handler. Returns the method's result object.
//
@@ -399,6 +422,16 @@ func handleRequest(
return nil, &JSONRPCErrorMethodNotFound
}
+ if env.State != nil && !apiMethodManagesRestoreAccess(req.Method) {
+ release, accessErr := env.State.TryAcquireRestoreAccess()
+ if accessErr != nil {
+ log.Warn().Err(accessErr).Str("method", req.Method).Msg("API request rejected during backup restore")
+ rpcError := makeJSONRPCError(1, accessErr.Error())
+ return nil, &rpcError
+ }
+ defer release()
+ }
+
env.Params = req.Params
resp, err := fn(env)
@@ -986,6 +1019,7 @@ func handleWSMessage(
playbackManager audio.PlaybackManager,
indexPauser *syncutil.Pauser,
scrapePauser *syncutil.Pauser,
+ backupPauser *syncutil.Pauser,
encGateway *apimiddleware.EncryptionGateway,
lastSeenTracker *apimiddleware.LastSeenTracker,
tracker RequestTracker,
@@ -1101,6 +1135,7 @@ func handleWSMessage(
ConfirmQueue: confirmQueue,
IndexPauser: indexPauser,
ScrapePauser: scrapePauser,
+ BackupPauser: backupPauser,
IsLocal: isLocal,
ClientID: session.Request.RemoteAddr,
}
@@ -1302,6 +1337,7 @@ func handlePostRequest(
playbackManager audio.PlaybackManager,
indexPauser *syncutil.Pauser,
scrapePauser *syncutil.Pauser,
+ backupPauser *syncutil.Pauser,
tracker RequestTracker,
) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
@@ -1340,11 +1376,15 @@ func handlePostRequest(
return
}
- // Derive from r.Context() (already has APIRequestTimeout from middleware)
- // but also cancel on app shutdown via st.GetContext().
- reqCtx, reqCancel := context.WithCancel(r.Context())
- context.AfterFunc(st.GetContext(), reqCancel)
- defer reqCancel()
+ // Apply a method-specific deadline while preserving client-disconnect
+ // cancellation from the HTTP request and app-shutdown cancellation.
+ method := methodFromAPIRequestPayload(body)
+ reqCtx, reqCancel := requestContextForAPIMethod(r.Context(), method)
+ stopAppCancel := context.AfterFunc(st.GetContext(), reqCancel)
+ defer func() {
+ stopAppCancel()
+ reqCancel()
+ }()
env := requests.RequestEnv{
Context: reqCtx,
@@ -1362,6 +1402,7 @@ func handlePostRequest(
ConfirmQueue: confirmQueue,
IndexPauser: indexPauser,
ScrapePauser: scrapePauser,
+ BackupPauser: backupPauser,
IsLocal: apimiddleware.IsLoopbackAddr(r.RemoteAddr),
ClientID: r.RemoteAddr,
}
@@ -1431,11 +1472,12 @@ func Start(
playbackManager audio.PlaybackManager,
indexPauser *syncutil.Pauser,
scrapePauser *syncutil.Pauser,
+ backupPauser *syncutil.Pauser,
tracker RequestTracker,
) error {
return StartWithReady(
platform, cfg, st, inTokenQueue, confirmQueue, db, limitsManager, profilesSvc,
- notifBroker, mdnsHostname, player, playbackManager, indexPauser, scrapePauser, tracker, nil,
+ notifBroker, mdnsHostname, player, playbackManager, indexPauser, scrapePauser, backupPauser, tracker, nil,
)
}
@@ -1457,6 +1499,7 @@ func StartWithReady(
playbackManager audio.PlaybackManager,
indexPauser *syncutil.Pauser,
scrapePauser *syncutil.Pauser,
+ backupPauser *syncutil.Pauser,
tracker RequestTracker,
ready chan<- error,
) error {
@@ -1716,13 +1759,14 @@ func StartWithReady(
r.Use(apimiddleware.HTTPAuthMiddleware(authConfig))
r.Use(apiRateLimitMiddleware)
r.Use(middleware.NoCache)
- r.Use(middleware.Timeout(config.APIRequestTimeout))
+ // Method handlers apply their own deadline after parsing JSON-RPC.
+ // Server ReadTimeout bounds body reads without limiting backup work.
postHandler := handlePostRequest(
methodMap, platform, cfg, st,
inTokenQueue, confirmQueue,
db, limitsManager, profilesSvc, player, playbackManager,
- indexPauser, scrapePauser, tracker,
+ indexPauser, scrapePauser, backupPauser, tracker,
)
r.Post("/api", postHandler)
r.Post("/api/v0", postHandler)
@@ -1764,8 +1808,8 @@ func StartWithReady(
rateLimiter,
handleWSMessage(
methodMap, platform, cfg, st, inTokenQueue, confirmQueue,
- db, limitsManager, profilesSvc, player, playbackManager, indexPauser, scrapePauser, encGateway,
- lastSeenTracker, tracker,
+ db, limitsManager, profilesSvc, player, playbackManager, indexPauser, scrapePauser, backupPauser,
+ encGateway, lastSeenTracker, tracker,
),
))
@@ -1794,6 +1838,7 @@ func StartWithReady(
Addr: cfg.APIListen(),
Handler: r,
ReadHeaderTimeout: 10 * time.Second,
+ ReadTimeout: config.APIRequestTimeout,
}
serverDone := make(chan error, 1)
diff --git a/pkg/api/server_post_test.go b/pkg/api/server_post_test.go
index e6c58c70..e5463827 100644
--- a/pkg/api/server_post_test.go
+++ b/pkg/api/server_post_test.go
@@ -33,6 +33,7 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/audio"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
@@ -117,11 +118,62 @@ func createTestPostHandler(t *testing.T) (http.HandlerFunc, *MethodMap, *fakeReq
handler := handlePostRequest(
methodMap, platform, cfg, st,
tokenQueue, confirmQueue, db,
- nil, nil, nil, playbackManager, nil, nil, tracker,
+ nil, nil, nil, playbackManager, nil, nil, nil, tracker,
)
return handler, methodMap, tracker
}
+func TestHandlePostRequest_AppliesMethodSpecificTimeout(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ method string
+ timeout time.Duration
+ }{
+ {name: "normal request", method: "test.timeout", timeout: config.APIRequestTimeout},
+ {name: "backup request", method: models.MethodSettingsBackup, timeout: 0},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ handler, methodMap, _ := createTestPostHandler(t)
+ deadlineCh := make(chan time.Time, 1)
+ methodMap.Store(tt.method, func(env requests.RequestEnv) (any, error) {
+ deadline, ok := env.Context.Deadline()
+ if !ok {
+ deadline = time.Time{}
+ }
+ deadlineCh <- deadline
+ return map[string]bool{"ok": true}, nil
+ })
+
+ reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"` + tt.method + `"}`
+ //nolint:noctx // test helper, no context needed
+ req := httptest.NewRequest(http.MethodPost, "/api", strings.NewReader(reqBody))
+ req.Header.Set("Content-Type", "application/json")
+ rr := httptest.NewRecorder()
+
+ beforeRequest := time.Now()
+ handler(rr, req)
+ require.Equal(t, http.StatusOK, rr.Code)
+
+ select {
+ case deadline := <-deadlineCh:
+ if tt.timeout == 0 {
+ assert.True(t, deadline.IsZero(), "backup request must not have a whole-operation deadline")
+ } else {
+ assert.WithinDuration(t, beforeRequest.Add(tt.timeout), deadline, time.Second)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("handler did not report request deadline")
+ }
+ })
+ }
+}
+
func TestHandlePostRequest_InjectsPlaybackManager(t *testing.T) {
t.Parallel()
@@ -145,7 +197,7 @@ func TestHandlePostRequest_InjectsPlaybackManager(t *testing.T) {
confirmQueue := make(chan chan error, 1)
handler := handlePostRequest(
methodMap, platform, cfg, st, tokenQueue, confirmQueue, db,
- nil, nil, nil, playbackManager, nil, nil, nil,
+ nil, nil, nil, playbackManager, nil, nil, nil, nil,
)
reqBody := `{"jsonrpc":"2.0","id":"` + uuid.New().String() + `","method":"test.playback"}`
diff --git a/pkg/api/server_restore_gate_test.go b/pkg/api/server_restore_gate_test.go
new file mode 100644
index 00000000..c977eae5
--- /dev/null
+++ b/pkg/api/server_restore_gate_test.go
@@ -0,0 +1,90 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package api
+
+import (
+ "context"
+ "testing"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestHandleRequestRejectsOtherMethodsDuringRestore(t *testing.T) {
+ t.Parallel()
+ st, _ := state.NewState(nil, "test-boot")
+ defer st.StopService()
+ methodMap := &MethodMap{}
+ called := false
+ require.NoError(t, methodMap.AddMethod(models.MethodSettingsUpdate, func(requests.RequestEnv) (any, error) {
+ called = true
+ return "ok", nil
+ }))
+ finishRestore, err := st.BeginRestoreGate()
+ require.NoError(t, err)
+
+ type handleResult struct {
+ result any
+ err *models.ErrorObject
+ }
+ blocked := make(chan handleResult, 1)
+ go func() {
+ result, rpcErr := handleRequest(methodMap, requests.RequestEnv{
+ Context: context.Background(), State: st,
+ }, models.RequestObject{JSONRPC: "2.0", Method: models.MethodSettingsUpdate})
+ blocked <- handleResult{result: result, err: rpcErr}
+ }()
+ blockedResult := <-blocked
+ assert.Nil(t, blockedResult.result)
+ require.NotNil(t, blockedResult.err)
+ assert.Equal(t, "backup restore is in progress", blockedResult.err.Message)
+ assert.False(t, called)
+
+ finishRestore(false)
+ result, rpcErr := handleRequest(methodMap, requests.RequestEnv{
+ Context: context.Background(), State: st,
+ }, models.RequestObject{JSONRPC: "2.0", Method: models.MethodSettingsUpdate})
+ require.Nil(t, rpcErr)
+ assert.Equal(t, "ok", result)
+ assert.True(t, called)
+}
+
+func TestHandleRequestAllowsRestoreMethodToOwnExclusiveGate(t *testing.T) {
+ t.Parallel()
+ st, _ := state.NewState(nil, "test-boot")
+ defer st.StopService()
+ methodMap := &MethodMap{}
+ require.NoError(t, methodMap.AddMethod(
+ models.MethodSettingsBackupRestore,
+ func(requests.RequestEnv) (any, error) { return "restore", nil },
+ ))
+ finishRestore, err := st.BeginRestoreGate()
+ require.NoError(t, err)
+ defer finishRestore(false)
+
+ result, rpcErr := handleRequest(methodMap, requests.RequestEnv{
+ Context: context.Background(), State: st,
+ }, models.RequestObject{JSONRPC: "2.0", Method: models.MethodSettingsBackupRestore})
+ require.Nil(t, rpcErr)
+ assert.Equal(t, "restore", result)
+}
diff --git a/pkg/api/server_startup_test.go b/pkg/api/server_startup_test.go
index 9ddfd183..9a45efc5 100644
--- a/pkg/api/server_startup_test.go
+++ b/pkg/api/server_startup_test.go
@@ -78,7 +78,7 @@ func TestStartWithReadyReportsBindFailure(t *testing.T) {
go func() {
serverErr <- StartWithReady(
platform, cfg, st, tokenQueue, nil, db,
- nil, nil, notifBroker, "", nil, nil, nil, nil, nil, ready,
+ nil, nil, notifBroker, "", nil, nil, nil, nil, nil, nil, ready,
)
}()
@@ -143,7 +143,7 @@ func TestServerStartupConcurrency(t *testing.T) {
defer close(serverDone)
serverErr <- StartWithReady(
platform, cfg, st, tokenQueue, nil, db,
- nil, nil, notifBroker, "", nil, nil, nil, nil, nil, ready,
+ nil, nil, notifBroker, "", nil, nil, nil, nil, nil, nil, ready,
)
}()
// Cleanup: stop service first, then wait for server goroutine to fully exit
@@ -225,7 +225,9 @@ func TestServerStartupImmediateConnection(t *testing.T) {
serverErr := make(chan error, 1)
go func() {
defer close(serverDone)
- serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil)
+ serverErr <- Start(
+ platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil, nil,
+ )
}()
// Cleanup: stop service first, then wait for server goroutine to fully exit
defer func() {
@@ -310,7 +312,9 @@ func TestServerListenContextCancellation(t *testing.T) {
go func() {
defer close(done)
- serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil)
+ serverErr <- Start(
+ platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil, nil,
+ )
}()
// Wait for completion or timeout
@@ -675,7 +679,7 @@ func TestServerBindFailureStopsService(t *testing.T) {
go func() {
defer close(server1Done)
server1Err <- Start(
- platform1, cfg1, st1, tokenQueue1, nil, db1, nil, nil, notifBroker1, "", nil, nil, nil, nil, nil,
+ platform1, cfg1, st1, tokenQueue1, nil, db1, nil, nil, notifBroker1, "", nil, nil, nil, nil, nil, nil,
)
}()
@@ -721,7 +725,7 @@ func TestServerBindFailureStopsService(t *testing.T) {
go func() {
defer close(server2Done)
server2Err <- Start(
- platform2, cfg2, st2, tokenQueue2, nil, db2, nil, nil, notifBroker2, "", nil, nil, nil, nil, nil,
+ platform2, cfg2, st2, tokenQueue2, nil, db2, nil, nil, notifBroker2, "", nil, nil, nil, nil, nil, nil,
)
}()
@@ -995,7 +999,9 @@ func TestSSE_ReceivesNotifications(t *testing.T) {
serverErr := make(chan error, 1)
go func() {
defer close(serverDone)
- serverErr <- Start(platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil)
+ serverErr <- Start(
+ platform, cfg, st, tokenQueue, nil, db, nil, nil, notifBroker, "", nil, nil, nil, nil, nil, nil,
+ )
}()
defer func() {
st.StopService()
diff --git a/pkg/api/server_ws_e2e_test.go b/pkg/api/server_ws_e2e_test.go
index 3f449c87..9222723e 100644
--- a/pkg/api/server_ws_e2e_test.go
+++ b/pkg/api/server_ws_e2e_test.go
@@ -155,7 +155,7 @@ func TestWSInjectsPlaybackManager(t *testing.T) {
m := newWebSocketSession()
m.HandleMessage(handleWSMessage(
methodMap, platform, cfg, st, make(chan tokens.Token, 1), make(chan chan error, 1), db,
- nil, nil, nil, playbackManager, nil, nil, nil, nil, nil,
+ nil, nil, nil, playbackManager, nil, nil, nil, nil, nil, nil,
))
mux := http.NewServeMux()
diff --git a/pkg/api/ws_dispatcher.go b/pkg/api/ws_dispatcher.go
index 8b422d85..a4ee0a60 100644
--- a/pkg/api/ws_dispatcher.go
+++ b/pkg/api/ws_dispatcher.go
@@ -27,7 +27,6 @@ import (
apimiddleware "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/middleware"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models/requests"
- "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
"github.com/olahol/melody"
"github.com/rs/zerolog/log"
@@ -218,7 +217,7 @@ func (d *wsSessionDispatcher) worker(queue <-chan *wsRequestJob) {
func (d *wsSessionDispatcher) runJob(job *wsRequestJob) {
//nolint:gosec // Cancellation is transferred to job and invoked when response handling completes.
- ctx, cancel := context.WithTimeout(d.ctx, config.APIRequestTimeout)
+ ctx, cancel := requestContextForAPIMethod(d.ctx, job.method)
job.env.Context = ctx
job.cancel = cancel
diff --git a/pkg/api/ws_dispatcher_test.go b/pkg/api/ws_dispatcher_test.go
index 471be13d..aab11d3b 100644
--- a/pkg/api/ws_dispatcher_test.go
+++ b/pkg/api/ws_dispatcher_test.go
@@ -62,7 +62,7 @@ func startPriorityWSServer(t *testing.T, methodMap *MethodMap) (wsURL string, cl
m.HandleMessage(handleWSMessage(
methodMap, nil, cfg, st, nil, nil,
nil, nil, nil, nil, nil, nil,
- nil, nil, nil, nil,
+ nil, nil, nil, nil, nil,
))
mux := http.NewServeMux()
@@ -372,55 +372,79 @@ func TestWebSocketPriorityDispatcherNotificationsDoNotReply(t *testing.T) {
require.Error(t, err, "JSON-RPC notifications must not receive responses")
}
-func TestWebSocketRunJobStartsTimeoutAtExecution(t *testing.T) {
+func TestWebSocketRunJobStartsMethodTimeoutAtExecution(t *testing.T) {
t.Parallel()
- parentCtx, parentCancel := context.WithCancel(t.Context())
- defer parentCancel()
- d := &wsSessionDispatcher{
- ctx: parentCtx,
- responses: make(chan *wsResponseJob, 1),
+ tests := []struct {
+ name string
+ method string
+ timeout time.Duration
+ }{
+ {name: "normal request", method: "test.timeout", timeout: config.APIRequestTimeout},
+ {name: "backup request", method: models.MethodSettingsBackup, timeout: 0},
}
- deadlineCh := make(chan time.Time, 1)
- var methodMap MethodMap
- require.NoError(t, methodMap.AddMethod("test.timeout", func(env requests.RequestEnv) (any, error) {
- deadline, ok := env.Context.Deadline()
- require.True(t, ok, "runJob should install per-request deadline")
- deadlineCh <- deadline
- return map[string]string{"ok": "true"}, nil
- }))
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
- enqueuedCtx, enqueuedCancel := context.WithTimeout(parentCtx, time.Millisecond)
- defer enqueuedCancel()
- job := &wsRequestJob{
- methodMap: &methodMap,
- env: &requests.RequestEnv{Context: enqueuedCtx},
- method: "test.timeout",
- msg: []byte(`{"jsonrpc":"2.0","method":"test.timeout","id":1}`),
- }
- time.Sleep(25 * time.Millisecond)
- require.Error(t, enqueuedCtx.Err(), "pre-existing enqueue-time context should be expired")
+ parentCtx, parentCancel := context.WithCancel(context.Background())
+ defer parentCancel()
+ d := &wsSessionDispatcher{
+ ctx: parentCtx,
+ responses: make(chan *wsResponseJob, 1),
+ }
+
+ deadlineCh := make(chan time.Time, 1)
+ var methodMap MethodMap
+ require.NoError(t, methodMap.AddMethod(tt.method, func(env requests.RequestEnv) (any, error) {
+ deadline, ok := env.Context.Deadline()
+ if !ok {
+ deadline = time.Time{}
+ }
+ deadlineCh <- deadline
+ return map[string]string{"ok": "true"}, nil
+ }))
- beforeRun := time.Now()
- d.runJob(job)
- require.NotNil(t, job.cancel, "runJob should install cancel func")
- defer job.cancel()
+ enqueuedCtx, enqueuedCancel := context.WithTimeout(parentCtx, time.Millisecond)
+ defer enqueuedCancel()
+ job := &wsRequestJob{
+ methodMap: &methodMap,
+ env: &requests.RequestEnv{Context: enqueuedCtx},
+ method: tt.method,
+ msg: []byte(fmt.Sprintf(
+ `{"jsonrpc":"2.0","method":%q,"id":1}`,
+ tt.method,
+ )),
+ }
+ time.Sleep(25 * time.Millisecond)
+ require.Error(t, enqueuedCtx.Err(), "pre-existing enqueue-time context should be expired")
- select {
- case deadline := <-deadlineCh:
- assert.True(t, deadline.After(beforeRun), "deadline should not reuse expired enqueue-time context")
- assert.True(t, deadline.Before(beforeRun.Add(config.APIRequestTimeout+time.Second)))
- case <-time.After(time.Second):
- t.Fatal("handler did not run")
- }
+ beforeRun := time.Now()
+ d.runJob(job)
+ require.NotNil(t, job.cancel, "runJob should install cancel func")
+ defer job.cancel()
- select {
- case resp := <-d.responses:
- require.NotNil(t, resp.cancel)
- assert.True(t, resp.result.ShouldReply)
- case <-time.After(time.Second):
- t.Fatal("response was not queued")
+ select {
+ case deadline := <-deadlineCh:
+ if tt.timeout == 0 {
+ assert.True(t, deadline.IsZero(), "backup request must not have a whole-operation deadline")
+ } else {
+ assert.True(t, deadline.After(beforeRun), "deadline should not reuse expired enqueue-time context")
+ assert.WithinDuration(t, beforeRun.Add(tt.timeout), deadline, time.Second)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("handler did not run")
+ }
+
+ select {
+ case resp := <-d.responses:
+ require.NotNil(t, resp.cancel)
+ assert.True(t, resp.result.ShouldReply)
+ case <-time.After(time.Second):
+ t.Fatal("response was not queued")
+ }
+ })
}
}
diff --git a/pkg/cli/cli.go b/pkg/cli/cli.go
index 04cf64f0..a420b581 100644
--- a/pkg/cli/cli.go
+++ b/pkg/cli/cli.go
@@ -113,17 +113,17 @@ func SetupFlags() *Flags {
Backup: flag.Bool(
"backup",
false,
- "create a backup of the database",
+ "create a portable full-device backup ZIP",
),
Backups: flag.Bool(
"backups",
false,
- "list available database backups",
+ "list available full-device backup ZIPs",
),
Restore: flag.String(
"restore",
"",
- "restore the database from the named backup",
+ "restore a full-device backup ZIP and restart Core",
),
Profiles: flag.Bool(
"profiles",
@@ -403,7 +403,7 @@ func (f *Flags) Post(cfg *config.Instance, _ platforms.Platform) {
_, _ = fmt.Fprint(os.Stderr, "Error: restore flag requires a backup name\n")
os.Exit(1)
}
- data, err := json.Marshal(&models.BackupRestoreParams{Name: *f.Restore})
+ data, err := json.Marshal(&models.BackupNameParams{Name: *f.Restore})
if err != nil {
_, _ = fmt.Fprintf(os.Stderr, "Error encoding params: %v\n", err)
os.Exit(1)
diff --git a/pkg/config/auth.go b/pkg/config/auth.go
index 0b1cc7c9..b4e78f44 100644
--- a/pkg/config/auth.go
+++ b/pkg/config/auth.go
@@ -31,9 +31,14 @@ import (
// CredentialEntry holds authentication credentials for a URL.
type CredentialEntry struct {
- Username string `toml:"username"`
- Password string `toml:"password"` //nolint:gosec // G117: auth config struct field
- Bearer string `toml:"bearer"`
+ Username string `toml:"username,omitempty"`
+ Password string `toml:"password,omitempty"` //nolint:gosec // G117: auth config struct field
+ Bearer string `toml:"bearer,omitempty"`
+ // LinkedVia records which auth root domain created this entry during a
+ // claim/link flow — the root tags itself and every trusted-domain copy
+ // it extends to. Unlink removes entries by this provenance. Empty for
+ // hand-written entries.
+ LinkedVia string `toml:"linked_via,omitempty"`
}
// schemeAliases maps protocol variants to their canonical form.
diff --git a/pkg/config/auth_test.go b/pkg/config/auth_test.go
index 6e89e14b..543e3d3a 100644
--- a/pkg/config/auth_test.go
+++ b/pkg/config/auth_test.go
@@ -800,3 +800,79 @@ bearer = "existing-token"
assert.Equal(t, "existing-token", creds["https://existing.com"].Bearer)
assert.Equal(t, "new-token", creds["https://api.zaparoo.com"].Bearer)
}
+
+func TestDeleteAuthEntries_RemovesOnlyTargetedDomains(t *testing.T) {
+ t.Parallel()
+
+ cfg := newTestConfigInstance(t)
+
+ require.NoError(t, cfg.SaveAuthEntry("https://api.zaparoo.com", CredentialEntry{Bearer: "token1"}))
+ require.NoError(t, cfg.SaveAuthEntry("https://zpr.au", CredentialEntry{Bearer: "token2"}))
+ require.NoError(t, cfg.SaveAuthEntry("https://other.example.com", CredentialEntry{Bearer: "token3"}))
+
+ err := cfg.DeleteAuthEntries([]string{"https://api.zaparoo.com", "https://zpr.au"})
+ require.NoError(t, err)
+
+ creds := readAuthFromInstance(t, cfg)
+ require.Len(t, creds, 1)
+ assert.Equal(t, "token3", creds["https://other.example.com"].Bearer)
+}
+
+func TestDeleteAuthEntries_MatchesCaseInsensitively(t *testing.T) {
+ t.Parallel()
+
+ cfg := newTestConfigInstance(t)
+
+ require.NoError(t, cfg.SaveAuthEntry("https://API.Zaparoo.com", CredentialEntry{Bearer: "token1"}))
+
+ err := cfg.DeleteAuthEntries([]string{"https://api.zaparoo.com"})
+ require.NoError(t, err)
+
+ creds := readAuthFromInstance(t, cfg)
+ assert.Empty(t, creds)
+}
+
+func TestDeleteAuthEntries_PreservesAPIKeys(t *testing.T) {
+ t.Parallel()
+
+ cfg := newTestConfigInstance(t)
+
+ existingData := []byte(`api_keys = ["key1", "key2"]
+
+[creds."https://api.zaparoo.com"]
+bearer = "token"
+`)
+ require.NoError(t, afero.WriteFile(cfg.getFs(), cfg.authPath, existingData, 0o600))
+
+ err := cfg.DeleteAuthEntries([]string{"https://api.zaparoo.com"})
+ require.NoError(t, err)
+
+ keys := readAPIKeysFromInstance(t, cfg)
+ assert.Equal(t, []string{"key1", "key2"}, keys)
+ creds := readAuthFromInstance(t, cfg)
+ assert.Empty(t, creds)
+}
+
+func TestDeleteAuthEntries_MissingDomainIsNoOp(t *testing.T) {
+ t.Parallel()
+
+ cfg := newTestConfigInstance(t)
+
+ require.NoError(t, cfg.SaveAuthEntry("https://api.zaparoo.com", CredentialEntry{Bearer: "token"}))
+
+ err := cfg.DeleteAuthEntries([]string{"https://unrelated.example.com"})
+ require.NoError(t, err)
+
+ creds := readAuthFromInstance(t, cfg)
+ require.Len(t, creds, 1)
+ assert.Equal(t, "token", creds["https://api.zaparoo.com"].Bearer)
+}
+
+func TestDeleteAuthEntries_NoAuthFileIsNoOp(t *testing.T) {
+ t.Parallel()
+
+ cfg := newTestConfigInstance(t)
+
+ err := cfg.DeleteAuthEntries([]string{"https://api.zaparoo.com"})
+ require.NoError(t, err)
+}
diff --git a/pkg/config/config.go b/pkg/config/config.go
index b8c9f2a4..b2e3fa09 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -54,12 +54,48 @@ const configHeader = `# Zaparoo Core configuration file.
# preserved on next save, but comments will be lost.
`
+// PreserveRestoreOverrides forces destination-owned service values into
+// restored config data: the device identity and the encryption requirement.
+// Everything else in the restored config wins.
+func PreserveRestoreOverrides(data []byte, deviceID string, encryption bool) ([]byte, error) {
+ values := make(map[string]any)
+ if err := toml.Unmarshal(data, &values); err != nil {
+ return nil, fmt.Errorf("failed to parse restored config: %w", err)
+ }
+ service, ok := values["service"].(map[string]any)
+ if !ok {
+ service = make(map[string]any)
+ values["service"] = service
+ }
+ // An empty destination ID is left unset so Save generates a fresh
+ // UUID, exactly as it would for any config without one.
+ if deviceID == "" {
+ delete(service, "device_id")
+ } else {
+ service["device_id"] = deviceID
+ }
+ // The encryption requirement belongs to the destination's paired
+ // clients (which restore preserves), so the backup's value must not
+ // silently reopen or lock down the device.
+ if encryption {
+ service["encryption"] = true
+ } else {
+ delete(service, "encryption")
+ }
+ encoded, err := toml.Marshal(values)
+ if err != nil {
+ return nil, fmt.Errorf("failed to apply restore overrides to config: %w", err)
+ }
+ return append([]byte(configHeader), encoded...), nil
+}
+
type Values struct {
Groovy Groovy `toml:"groovy,omitempty"`
Input Input `toml:"input,omitempty"`
AutoUpdate *bool `toml:"auto_update,omitempty"`
UpdateChannel *string `toml:"update_channel,omitempty"`
Audio Audio `toml:"audio"`
+ Backup Backup `toml:"backup,omitempty"`
Service Service `toml:"service,omitempty"`
Launchers Launchers `toml:"launchers,omitempty"`
Playtime Playtime `toml:"playtime,omitempty"`
@@ -115,6 +151,12 @@ var BaseDefaults = Values{
Audio: Audio{
ScanFeedback: true,
},
+ Backup: Backup{
+ Remote: BackupRemote{
+ BaseURL: DefaultBackupRemoteBaseURL,
+ Schedule: DefaultBackupRemoteSchedule,
+ },
+ },
Readers: Readers{
AutoDetect: true,
Scan: ReadersScan{
@@ -468,6 +510,50 @@ func (c *Instance) SaveAuthEntry(domain string, entry CredentialEntry) error {
return nil
}
+// DeleteAuthEntries removes the credential entries for the given domains from
+// auth.toml, matching stored keys case-insensitively. API keys and entries
+// for other domains are preserved. Missing domains are a no-op; the in-memory
+// auth config is reloaded when anything was removed.
+func (c *Instance) DeleteAuthEntries(domains []string) error {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+
+ fs := c.getFs()
+
+ data, err := afero.ReadFile(fs, c.authPath)
+ if err != nil {
+ // No auth file means there is nothing to delete.
+ return nil
+ }
+ existing := LoadAuthFromData(data)
+ existingKeys := LoadAPIKeysFromData(data)
+
+ removed := false
+ for stored := range existing {
+ for _, domain := range domains {
+ if strings.EqualFold(stored, domain) {
+ delete(existing, stored)
+ removed = true
+ break
+ }
+ }
+ }
+ if !removed {
+ return nil
+ }
+
+ out, err := marshalAuthFile(existing, existingKeys)
+ if err != nil {
+ return err
+ }
+ if err := afero.WriteFile(fs, c.authPath, out, 0o600); err != nil {
+ return fmt.Errorf("failed to write auth file: %w", err)
+ }
+
+ c.reloadAuth()
+ return nil
+}
+
func (c *Instance) AudioFeedback() bool {
c.mu.RLock()
defer c.mu.RUnlock()
diff --git a/pkg/config/configbackup.go b/pkg/config/configbackup.go
new file mode 100644
index 00000000..03490a76
--- /dev/null
+++ b/pkg/config/configbackup.go
@@ -0,0 +1,238 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package config
+
+import (
+ "errors"
+ "fmt"
+ "net/netip"
+ "net/url"
+ "strings"
+)
+
+const (
+ DefaultBackupRemoteBaseURL = "https://api.zaparoo.com"
+ DefaultBackupRemoteSchedule = "daily"
+ DefaultBackupMaxSizeBytes = int64(5 << 30)
+)
+
+// OfficialAuthHosts are the hosts of the official Zaparoo Online API
+// services. settings.auth.status only answers link probes for these hosts
+// (over HTTPS) and the configured backup server; other URLs report
+// linked=false without revealing whether a credential exists. The claim
+// flow's trusted-domain extension may store credentials under further
+// domains — those are found at unlink time by linked_via provenance tags,
+// never by this list.
+var OfficialAuthHosts = []string{
+ "api.zaparoo.com",
+ "edge.zaparoo.com",
+ "zpr.au",
+}
+
+// Backup scopes select what a backup job collects.
+const (
+ // BackupScopePlatform includes platform files (settings, inputs,
+ // saves, savestates) alongside Zaparoo's own data.
+ BackupScopePlatform = "platform"
+ // BackupScopeZaparoo restricts backups to Zaparoo's own data:
+ // user.db, Core config, frontend/TUI config, launchers, mappings.
+ BackupScopeZaparoo = "zaparoo"
+)
+
+type Backup struct {
+ LocalDir string `toml:"local_dir,omitempty"`
+ Scope string `toml:"scope,omitempty"`
+ Remote BackupRemote `toml:"remote,omitempty"`
+ MaxSizeBytes int64 `toml:"max_size_bytes,omitempty"`
+}
+
+type BackupRemote struct {
+ BaseURL string `toml:"base_url,omitempty"`
+ Schedule string `toml:"schedule,omitempty"`
+ Enabled bool `toml:"enabled,omitempty"`
+}
+
+func (c *Instance) BackupLocalDir() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.vals.Backup.LocalDir
+}
+
+func (c *Instance) SetBackupLocalDir(localDir string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.vals.Backup.LocalDir = localDir
+}
+
+// BackupScope returns the configured backup scope. Only an explicit
+// "zaparoo" value narrows the scope; anything else (including unset)
+// means the full platform scope.
+func (c *Instance) BackupScope() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if strings.EqualFold(c.vals.Backup.Scope, BackupScopeZaparoo) {
+ return BackupScopeZaparoo
+ }
+ return BackupScopePlatform
+}
+
+func (c *Instance) SetBackupScope(scope string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.vals.Backup.Scope = scope
+}
+
+func (c *Instance) BackupMaxSizeBytes() int64 {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if c.vals.Backup.MaxSizeBytes <= 0 {
+ return DefaultBackupMaxSizeBytes
+ }
+ return c.vals.Backup.MaxSizeBytes
+}
+
+func (c *Instance) SetBackupMaxSizeBytes(maxSize int64) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.vals.Backup.MaxSizeBytes = maxSize
+}
+
+func (c *Instance) BackupRemoteEnabled() bool {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.vals.Backup.Remote.Enabled
+}
+
+func (c *Instance) SetBackupRemoteEnabled(enabled bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.vals.Backup.Remote.Enabled = enabled
+}
+
+func (c *Instance) BackupRemoteSchedule() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if c.vals.Backup.Remote.Schedule == "" {
+ return DefaultBackupRemoteSchedule
+ }
+ return c.vals.Backup.Remote.Schedule
+}
+
+func (c *Instance) SetBackupRemoteSchedule(schedule string) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.vals.Backup.Remote.Schedule = schedule
+}
+
+func (c *Instance) BackupRemoteBaseURL() string {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ if c.vals.Backup.Remote.BaseURL == "" {
+ return DefaultBackupRemoteBaseURL
+ }
+ return c.vals.Backup.Remote.BaseURL
+}
+
+func (c *Instance) SetBackupRemoteBaseURL(rawURL string) error {
+ if err := ValidateBackupRemoteBaseURL(rawURL); err != nil {
+ return err
+ }
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.vals.Backup.Remote.BaseURL = normalizeBackupBaseURL(rawURL)
+ return nil
+}
+
+func ValidateBackupRemoteBaseURL(rawURL string) error {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return fmt.Errorf("invalid backup remote base URL: %w", err)
+ }
+ if parsed.Scheme != "http" && parsed.Scheme != "https" {
+ return errors.New("backup remote base URL must use http or https")
+ }
+ if parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" {
+ return errors.New("backup remote base URL must include only scheme, host, optional port, and optional path")
+ }
+ if parsed.Scheme == "https" {
+ return nil
+ }
+
+ host := parsed.Hostname()
+ if strings.EqualFold(host, "localhost") {
+ return nil
+ }
+ addr, err := netip.ParseAddr(host)
+ if err != nil {
+ return errors.New("http backup remote base URL must use localhost or a private IP literal")
+ }
+ if isAllowedHTTPBackupAddr(addr) {
+ return nil
+ }
+ return errors.New("http backup remote base URL must use localhost or a private IP literal")
+}
+
+func normalizeBackupBaseURL(rawURL string) string {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return rawURL
+ }
+ parsed.RawQuery = ""
+ parsed.Fragment = ""
+ parsed.Path = strings.TrimRight(parsed.Path, "/")
+ return parsed.String()
+}
+
+func isAllowedHTTPBackupAddr(addr netip.Addr) bool {
+ if addr.IsLoopback() || addr.IsLinkLocalUnicast() {
+ return true
+ }
+ if addr.Is4() {
+ privateBlocks := []string{"10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16", "169.254.0.0/16"}
+ for _, block := range privateBlocks {
+ prefix := netip.MustParsePrefix(block)
+ if prefix.Contains(addr) {
+ return true
+ }
+ }
+ return false
+ }
+ if addr.Is6() {
+ for _, block := range []string{"fc00::/7", "fe80::/10"} {
+ prefix := netip.MustParsePrefix(block)
+ if prefix.Contains(addr) {
+ return true
+ }
+ }
+ }
+ return false
+}
+
+func BackupAuthLookupURL(rawURL string) string {
+ parsed, err := url.Parse(rawURL)
+ if err != nil {
+ return rawURL
+ }
+ host := parsed.Host
+ if host == "" {
+ return rawURL
+ }
+ return parsed.Scheme + "://" + host
+}
diff --git a/pkg/config/configbackup_test.go b/pkg/config/configbackup_test.go
new file mode 100644
index 00000000..a206aef8
--- /dev/null
+++ b/pkg/config/configbackup_test.go
@@ -0,0 +1,118 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package config
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBackupDefaults(t *testing.T) {
+ t.Parallel()
+ cfg, err := NewConfig(t.TempDir(), BaseDefaults)
+ require.NoError(t, err)
+
+ assert.Empty(t, cfg.BackupLocalDir())
+ assert.Equal(t, DefaultBackupMaxSizeBytes, cfg.BackupMaxSizeBytes())
+ assert.False(t, cfg.BackupRemoteEnabled())
+ assert.Equal(t, DefaultBackupRemoteBaseURL, cfg.BackupRemoteBaseURL())
+ assert.Equal(t, DefaultBackupRemoteSchedule, cfg.BackupRemoteSchedule())
+ assert.Equal(t, BackupScopePlatform, cfg.BackupScope())
+}
+
+func TestBackupScope(t *testing.T) {
+ t.Parallel()
+ cfg, err := NewConfig(t.TempDir(), BaseDefaults)
+ require.NoError(t, err)
+
+ cfg.SetBackupScope("zaparoo")
+ assert.Equal(t, BackupScopeZaparoo, cfg.BackupScope())
+
+ cfg.SetBackupScope("ZAPAROO")
+ assert.Equal(t, BackupScopeZaparoo, cfg.BackupScope())
+
+ cfg.SetBackupScope("platform")
+ assert.Equal(t, BackupScopePlatform, cfg.BackupScope())
+
+ // Unknown values fall back to the full platform scope.
+ cfg.SetBackupScope("everything")
+ assert.Equal(t, BackupScopePlatform, cfg.BackupScope())
+}
+
+func TestSetBackupLocalDir(t *testing.T) {
+ t.Parallel()
+ cfg, err := NewConfig(t.TempDir(), BaseDefaults)
+ require.NoError(t, err)
+
+ cfg.SetBackupLocalDir("/media/usb/zaparoo-backups")
+ assert.Equal(t, "/media/usb/zaparoo-backups", cfg.BackupLocalDir())
+}
+
+func TestSetBackupMaxSizeBytes(t *testing.T) {
+ t.Parallel()
+ cfg, err := NewConfig(t.TempDir(), BaseDefaults)
+ require.NoError(t, err)
+
+ cfg.SetBackupMaxSizeBytes(1024)
+ assert.Equal(t, int64(1024), cfg.BackupMaxSizeBytes())
+}
+
+func TestValidateBackupRemoteBaseURL(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ rawURL string
+ wantErr bool
+ }{
+ {name: "https public", rawURL: "https://example.com"},
+ {name: "https path", rawURL: "https://example.com/backups"},
+ {name: "localhost http", rawURL: "http://localhost:8787"},
+ {name: "loopback http", rawURL: "http://127.0.0.1:8787"},
+ {name: "private http", rawURL: "http://192.168.1.5:8787"},
+ {name: "ipv6 local http", rawURL: "http://[fc00::1]:8787"},
+ {name: "public http", rawURL: "http://example.com", wantErr: true},
+ {name: "public ip http", rawURL: "http://8.8.8.8", wantErr: true},
+ {name: "query rejected", rawURL: "https://example.com?token=1", wantErr: true},
+ {name: "userinfo rejected", rawURL: "https://user@example.com", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ err := ValidateBackupRemoteBaseURL(tt.rawURL)
+ if tt.wantErr {
+ require.Error(t, err)
+ return
+ }
+ require.NoError(t, err)
+ })
+ }
+}
+
+func TestSetBackupRemoteBaseURLNormalizes(t *testing.T) {
+ t.Parallel()
+ cfg, err := NewConfig(t.TempDir(), BaseDefaults)
+ require.NoError(t, err)
+
+ require.NoError(t, cfg.SetBackupRemoteBaseURL("https://example.com/backups/"))
+ assert.Equal(t, "https://example.com/backups", cfg.BackupRemoteBaseURL())
+}
diff --git a/pkg/config/configlaunchers.go b/pkg/config/configlaunchers.go
index 1ba4327b..3731e9a2 100644
--- a/pkg/config/configlaunchers.go
+++ b/pkg/config/configlaunchers.go
@@ -248,7 +248,13 @@ func (c *Instance) LoadCustomLaunchers(launchersDir string) error {
launchersDir,
func(path string, info os.FileInfo, err error) error {
if err != nil {
- return err
+ // One unreadable entry must not abort the walk and drop
+ // every other launcher file.
+ log.Warn().Err(err).Str("path", path).Msg("skipping unreadable path in launchers directory")
+ if info != nil && info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
}
if info.IsDir() {
diff --git a/pkg/config/configlaunchers_test.go b/pkg/config/configlaunchers_test.go
index 86586a1a..2d1609c0 100644
--- a/pkg/config/configlaunchers_test.go
+++ b/pkg/config/configlaunchers_test.go
@@ -765,6 +765,30 @@ func TestLoadCustomLaunchers_MissingDirectory(t *testing.T) {
assert.Contains(t, err.Error(), "failed to stat launchers directory")
}
+func TestLoadCustomLaunchers_SkipsUnreadablePaths(t *testing.T) {
+ t.Parallel()
+ if os.Geteuid() == 0 {
+ t.Skip("permission bits are not enforced for root")
+ }
+
+ fs := afero.NewOsFs()
+ launchersDir := t.TempDir()
+ blocked := filepath.Join(launchersDir, "blocked")
+ require.NoError(t, fs.MkdirAll(blocked, 0o750))
+ require.NoError(t, afero.WriteFile(fs, filepath.Join(launchersDir, "good.toml"), []byte(`
+[[launchers.custom]]
+id = "GoodLauncher"
+execute = "echo good"
+`), 0o600))
+ require.NoError(t, os.Chmod(blocked, 0o000))
+
+ cfg := &Instance{fs: fs}
+ require.NoError(t, cfg.LoadCustomLaunchers(launchersDir))
+ customs := cfg.CustomLaunchers()
+ require.Len(t, customs, 1)
+ assert.Equal(t, "GoodLauncher", customs[0].ID)
+}
+
func TestLoadCustomLaunchers_SkipsInvalidTOML(t *testing.T) {
t.Parallel()
diff --git a/pkg/config/configmappings.go b/pkg/config/configmappings.go
index 00044dff..616a7c2d 100644
--- a/pkg/config/configmappings.go
+++ b/pkg/config/configmappings.go
@@ -58,7 +58,13 @@ func (c *Instance) LoadMappings(mappingsDir string) error {
mappingsDir,
func(path string, info os.FileInfo, err error) error {
if err != nil {
- return err
+ // One unreadable entry must not abort the walk and drop
+ // every other mapping file.
+ log.Warn().Err(err).Str("path", path).Msg("skipping unreadable path in mappings directory")
+ if info != nil && info.IsDir() {
+ return filepath.SkipDir
+ }
+ return nil
}
if info.IsDir() {
diff --git a/pkg/config/configmappings_test.go b/pkg/config/configmappings_test.go
index 0fa68eff..27a2f1f8 100644
--- a/pkg/config/configmappings_test.go
+++ b/pkg/config/configmappings_test.go
@@ -20,6 +20,8 @@
package config
import (
+ "os"
+ "path/filepath"
"testing"
"github.com/spf13/afero"
@@ -27,6 +29,28 @@ import (
"github.com/stretchr/testify/require"
)
+func TestLoadMappings_SkipsUnreadablePaths(t *testing.T) {
+ t.Parallel()
+ if os.Geteuid() == 0 {
+ t.Skip("permission bits are not enforced for root")
+ }
+
+ fs := afero.NewOsFs()
+ mappingsDir := t.TempDir()
+ blocked := filepath.Join(mappingsDir, "blocked")
+ require.NoError(t, fs.MkdirAll(blocked, 0o750))
+ require.NoError(t, afero.WriteFile(fs, filepath.Join(mappingsDir, "good.toml"), []byte(`
+[[mappings.entry]]
+match_pattern = "*.sfc"
+zapscript = "**launch:snes/{match}"
+`), 0o600))
+ require.NoError(t, os.Chmod(blocked, 0o000))
+
+ cfg := &Instance{fs: fs}
+ require.NoError(t, cfg.LoadMappings(mappingsDir))
+ require.Len(t, cfg.Mappings(), 1)
+}
+
func TestLoadMappings_LoadsFromAferoFS(t *testing.T) {
t.Parallel()
diff --git a/pkg/config/configservice_test.go b/pkg/config/configservice_test.go
index 7521d4a0..3073809c 100644
--- a/pkg/config/configservice_test.go
+++ b/pkg/config/configservice_test.go
@@ -20,12 +20,49 @@
package config
import (
+ "strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
+func TestPreserveRestoreOverrides(t *testing.T) {
+ t.Parallel()
+ source := []byte(`[service]
+device_id = "source-device"
+api_port = 7497
+
+[future]
+value = "preserved"
+`)
+ data, err := PreserveRestoreOverrides(source, "destination-device", false)
+ require.NoError(t, err)
+ assert.True(t, strings.HasPrefix(string(data), configHeader))
+ assert.Contains(t, string(data), `device_id = 'destination-device'`)
+ assert.NotContains(t, string(data), "source-device")
+ assert.Contains(t, string(data), "[future]")
+ assert.Contains(t, string(data), `value = 'preserved'`)
+ assert.NotContains(t, string(data), "encryption")
+
+ data, err = PreserveRestoreOverrides(source, "destination-device", true)
+ require.NoError(t, err)
+ assert.Contains(t, string(data), "encryption = true")
+
+ // A backup made with encryption on must not lock down a destination
+ // that has it off.
+ data, err = PreserveRestoreOverrides([]byte("[service]\nencryption = true\n"), "dest", false)
+ require.NoError(t, err)
+ assert.NotContains(t, string(data), "encryption")
+
+ // No destination device ID: leave it unset so the next Save
+ // generates one, and never keep the source's identity.
+ data, err = PreserveRestoreOverrides(source, "", false)
+ require.NoError(t, err)
+ assert.NotContains(t, string(data), "device_id")
+ assert.NotContains(t, string(data), "source-device")
+}
+
func TestServiceHooks(t *testing.T) {
t.Parallel()
diff --git a/pkg/config/tui.go b/pkg/config/tui.go
index 9c5e97c0..542b21cd 100644
--- a/pkg/config/tui.go
+++ b/pkg/config/tui.go
@@ -38,6 +38,7 @@ type TUIConfig struct {
CRTMode bool `toml:"crt_mode"`
OnScreenKeyboard bool `toml:"on_screen_keyboard"`
ErrorReportingPrompted bool `toml:"error_reporting_prompted"`
+ EncryptionPrompted bool `toml:"encryption_prompted"`
}
// tuiConfigRaw is used for TOML unmarshalling with pointer fields
@@ -49,6 +50,7 @@ type tuiConfigRaw struct {
CRTMode *bool `toml:"crt_mode"`
OnScreenKeyboard *bool `toml:"on_screen_keyboard"`
ErrorReportingPrompted *bool `toml:"error_reporting_prompted"`
+ EncryptionPrompted *bool `toml:"encryption_prompted"`
}
const (
@@ -114,6 +116,9 @@ func applyTUIDefaults(raw tuiConfigRaw, platformID string) TUIConfig {
if raw.ErrorReportingPrompted != nil {
cfg.ErrorReportingPrompted = *raw.ErrorReportingPrompted
}
+ if raw.EncryptionPrompted != nil {
+ cfg.EncryptionPrompted = *raw.EncryptionPrompted
+ }
return cfg
}
diff --git a/pkg/config/tui_test.go b/pkg/config/tui_test.go
index aac19a22..25bbe587 100644
--- a/pkg/config/tui_test.go
+++ b/pkg/config/tui_test.go
@@ -40,6 +40,7 @@ func TestApplyTUIDefaults_AllNil(t *testing.T) {
assert.False(t, cfg.CRTMode)
assert.False(t, cfg.OnScreenKeyboard)
assert.False(t, cfg.ErrorReportingPrompted)
+ assert.False(t, cfg.EncryptionPrompted)
}
func TestApplyTUIDefaults_AllSet(t *testing.T) {
@@ -50,7 +51,8 @@ func TestApplyTUIDefaults_AllSet(t *testing.T) {
mouse := false
crt := true
osk := true
- prompted := true
+ errorReportingPrompted := false
+ encryptionPrompted := true
raw := tuiConfigRaw{
Theme: &theme,
@@ -58,7 +60,8 @@ func TestApplyTUIDefaults_AllSet(t *testing.T) {
Mouse: &mouse,
CRTMode: &crt,
OnScreenKeyboard: &osk,
- ErrorReportingPrompted: &prompted,
+ ErrorReportingPrompted: &errorReportingPrompted,
+ EncryptionPrompted: &encryptionPrompted,
}
cfg := applyTUIDefaults(raw, "generic")
@@ -68,7 +71,8 @@ func TestApplyTUIDefaults_AllSet(t *testing.T) {
assert.False(t, cfg.Mouse)
assert.True(t, cfg.CRTMode)
assert.True(t, cfg.OnScreenKeyboard)
- assert.True(t, cfg.ErrorReportingPrompted)
+ assert.False(t, cfg.ErrorReportingPrompted)
+ assert.True(t, cfg.EncryptionPrompted)
}
func TestApplyTUIDefaults_MisterPlatformDefaults(t *testing.T) {
diff --git a/pkg/database/database.go b/pkg/database/database.go
index 17a7881b..1f11a35b 100644
--- a/pkg/database/database.go
+++ b/pkg/database/database.go
@@ -822,6 +822,7 @@ type UserDBI interface {
CreateClient(c *Client) error
GetClientByToken(authToken string) (*Client, error)
ListClients() ([]Client, error)
+ ReplaceAllClients(clients []Client) error
DeleteClient(clientID string) error
UpdateClientLastSeen(authToken string, lastSeenAt int64) error
CountClients() (int, error)
@@ -836,6 +837,7 @@ type UserDBI interface {
GetDeviceState(key string) (string, bool, error)
DeleteDeviceState(key string) error
Backup(reason string, manual bool) (BackupInfo, error)
+ BackupForTransfer(ctx context.Context, reason string) (BackupInfo, func() error, error)
EnsureRecentBackup(maxAge time.Duration) (BackupInfo, bool, error)
ListBackups() ([]BackupInfo, error)
RestoreBackup(name string) (RestoreInfo, error)
diff --git a/pkg/database/userdb/backup.go b/pkg/database/userdb/backup.go
index afbb5b35..a7353c3b 100644
--- a/pkg/database/userdb/backup.go
+++ b/pkg/database/userdb/backup.go
@@ -71,6 +71,10 @@ func isBackupName(name string) bool {
}
func backupInfo(path string, quickCheck bool) (database.BackupInfo, error) {
+ return backupInfoContext(context.Background(), path, quickCheck)
+}
+
+func backupInfoContext(ctx context.Context, path string, quickCheck bool) (database.BackupInfo, error) {
info, err := os.Stat(path)
if err != nil {
return database.BackupInfo{}, fmt.Errorf("failed to stat user database backup: %w", err)
@@ -83,7 +87,7 @@ func backupInfo(path string, quickCheck bool) (database.BackupInfo, error) {
Manual: strings.HasSuffix(filepath.Base(path), "-manual"+backupExt),
}
if quickCheck {
- valid, check, checkErr := quickCheckDB(path)
+ valid, check, checkErr := quickCheckDBContext(ctx, path)
result.Valid = valid
result.QuickCheck = check
if checkErr != nil {
@@ -95,8 +99,8 @@ func backupInfo(path string, quickCheck bool) (database.BackupInfo, error) {
return result, nil
}
-func quickCheckDB(path string) (valid bool, result string, err error) {
- ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+func quickCheckDBContext(parent context.Context, path string) (valid bool, result string, err error) {
+ ctx, cancel := context.WithTimeout(parent, 30*time.Second)
defer cancel()
checkDB, err := sql.Open("sqlite3", path+"?mode=ro&_query_only=ON&_mmap_size=0")
@@ -137,6 +141,10 @@ func (db *UserDB) pruneAutoBackups() error {
}
func (db *UserDB) Backup(reason string, manual bool) (database.BackupInfo, error) {
+ return db.createBackup(db.ctx, reason, manual)
+}
+
+func (db *UserDB) createBackup(ctx context.Context, reason string, manual bool) (database.BackupInfo, error) {
if db.sql.Load() == nil {
return database.BackupInfo{}, ErrNullSQL
}
@@ -145,12 +153,12 @@ func (db *UserDB) Backup(reason string, manual bool) (database.BackupInfo, error
}
backupPath := filepath.Join(db.backupDir(), backupName(manual, time.Now()))
- if _, err := db.sql.Load().ExecContext(db.ctx, "VACUUM INTO ?", backupPath); err != nil {
+ if _, err := db.sql.Load().ExecContext(ctx, "VACUUM INTO ?", backupPath); err != nil {
db.NoteCorruption(err)
return database.BackupInfo{}, fmt.Errorf("failed to back up user database: %w", err)
}
- info, err := backupInfo(backupPath, true)
+ info, err := backupInfoContext(ctx, backupPath, true)
if err != nil {
// The VACUUM INTO file exists but could not be inspected; don't leave it behind.
_ = os.Remove(backupPath)
@@ -177,6 +185,80 @@ func (db *UserDB) Backup(reason string, manual bool) (database.BackupInfo, error
return info, nil
}
+// BackupForTransfer creates a validated portable snapshot without paired-client
+// credentials. Cleanup owns the private temporary directory and is safe to call
+// more than once.
+func (db *UserDB) BackupForTransfer(
+ ctx context.Context, reason string,
+) (database.BackupInfo, func() error, error) {
+ full, err := db.createBackup(ctx, reason, false)
+ if err != nil {
+ return database.BackupInfo{}, nil, err
+ }
+
+ transferDir, err := os.MkdirTemp(db.backupDir(), ".transfer-*")
+ if err != nil {
+ return database.BackupInfo{}, nil, fmt.Errorf("failed to create transfer backup directory: %w", err)
+ }
+ cleanup := func() error {
+ if removeErr := os.RemoveAll(transferDir); removeErr != nil {
+ return fmt.Errorf("failed to remove transfer backup directory: %w", removeErr)
+ }
+ return nil
+ }
+ fail := func(cause error) (database.BackupInfo, func() error, error) {
+ return database.BackupInfo{}, nil, errors.Join(cause, cleanup())
+ }
+
+ transferPath := filepath.Join(transferDir, "user.db")
+ if err = copyFileSyncContext(ctx, full.Path, transferPath, 0o600); err != nil {
+ return fail(fmt.Errorf("failed to copy transfer backup: %w", err))
+ }
+ if err = sanitizeTransferBackup(ctx, transferPath); err != nil {
+ return fail(err)
+ }
+ if err = os.Chmod(transferPath, 0o600); err != nil {
+ return fail(fmt.Errorf("failed to secure transfer backup permissions: %w", err))
+ }
+
+ info, err := backupInfoContext(ctx, transferPath, true)
+ if err != nil {
+ return fail(err)
+ }
+ info.Reason = reason
+ if !info.Valid {
+ return fail(fmt.Errorf("transfer backup failed validation: %s", info.QuickCheck))
+ }
+ return info, cleanup, nil
+}
+
+func sanitizeTransferBackup(parent context.Context, path string) (err error) {
+ ctx, cancel := context.WithTimeout(parent, 30*time.Second)
+ defer cancel()
+
+ transferDB, err := sql.Open("sqlite3", path+"?_busy_timeout=5000&_journal_mode=DELETE&_mmap_size=0")
+ if err != nil {
+ return fmt.Errorf("failed to open transfer backup: %w", err)
+ }
+ transferDB.SetMaxOpenConns(1)
+ defer func() {
+ if closeErr := transferDB.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("failed to close transfer backup: %w", closeErr))
+ }
+ }()
+
+ if _, err = transferDB.ExecContext(ctx, "PRAGMA secure_delete=ON"); err != nil {
+ return fmt.Errorf("failed to enable secure deletion in transfer backup: %w", err)
+ }
+ if _, err = transferDB.ExecContext(ctx, "DELETE FROM Clients"); err != nil {
+ return fmt.Errorf("failed to remove clients from transfer backup: %w", err)
+ }
+ if _, err = transferDB.ExecContext(ctx, "VACUUM"); err != nil {
+ return fmt.Errorf("failed to compact transfer backup: %w", err)
+ }
+ return nil
+}
+
func (db *UserDB) EnsureRecentBackup(maxAge time.Duration) (database.BackupInfo, bool, error) {
backups, err := db.ListBackups()
if err != nil {
@@ -233,7 +315,30 @@ func (db *UserDB) resolveBackupPath(name string) (string, error) {
return path, nil
}
+type contextReader struct {
+ ctx context.Context
+ reader io.Reader
+}
+
+func (r *contextReader) Read(p []byte) (int, error) {
+ if err := r.ctx.Err(); err != nil {
+ return 0, fmt.Errorf("copy canceled: %w", err)
+ }
+ n, err := r.reader.Read(p)
+ if errors.Is(err, io.EOF) {
+ return n, io.EOF
+ }
+ if err != nil {
+ return n, fmt.Errorf("reading backup copy source: %w", err)
+ }
+ return n, nil
+}
+
func copyFileSync(src, dst string, mode os.FileMode) error {
+ return copyFileSyncContext(context.Background(), src, dst, mode)
+}
+
+func copyFileSyncContext(ctx context.Context, src, dst string, mode os.FileMode) error {
//nolint:gosec // src is selected by backup validation/recovery code, not raw user input.
in, err := os.Open(src)
if err != nil {
@@ -246,7 +351,7 @@ func copyFileSync(src, dst string, mode os.FileMode) error {
if err != nil {
return fmt.Errorf("failed to open destination file: %w", err)
}
- _, copyErr := io.Copy(out, in)
+ _, copyErr := io.Copy(out, &contextReader{ctx: ctx, reader: in})
syncErr := out.Sync()
closeErr := out.Close()
if copyErr != nil {
@@ -262,7 +367,8 @@ func copyFileSync(src, dst string, mode os.FileMode) error {
}
func replaceDatabaseFromBackup(fs afero.Fs, backupPath, dbPath string) (err error) {
- tmp, err := afero.TempFile(fs, filepath.Dir(dbPath), ".userdb-restore-*")
+ dbDir := filepath.Dir(dbPath)
+ tmp, err := afero.TempFile(fs, dbDir, ".userdb-restore-*")
if err != nil {
return fmt.Errorf("failed to create staged restore file: %w", err)
}
@@ -283,20 +389,72 @@ func replaceDatabaseFromBackup(fs afero.Fs, backupPath, dbPath string) (err erro
return fmt.Errorf("failed to preserve user database before restore: %w", err)
}
originalPreserved := err == nil
+ if originalPreserved {
+ if syncErr := syncAferoDirectory(fs, dbDir); syncErr != nil {
+ rollbackErr := restoreDatabaseRollback(fs, rollbackPath, dbPath)
+ return errors.Join(
+ fmt.Errorf("failed to sync preserved user database: %w", syncErr),
+ rollbackErr,
+ )
+ }
+ }
database.RemoveSidecars(dbPath)
if err = fs.Rename(tmpPath, dbPath); err != nil {
if originalPreserved {
- if rollbackErr := fs.Rename(rollbackPath, dbPath); rollbackErr != nil {
- return errors.Join(
- fmt.Errorf("failed to install staged user database backup: %w", err),
- fmt.Errorf("failed to restore original user database: %w", rollbackErr),
- )
- }
+ rollbackErr := restoreDatabaseRollback(fs, rollbackPath, dbPath)
+ return errors.Join(
+ fmt.Errorf("failed to install staged user database backup: %w", err),
+ rollbackErr,
+ )
}
return fmt.Errorf("failed to install staged user database backup: %w", err)
}
- _ = fs.Remove(rollbackPath)
+ if syncErr := syncAferoDirectory(fs, dbDir); syncErr != nil {
+ if originalPreserved {
+ rollbackErr := restoreDatabaseRollback(fs, rollbackPath, dbPath)
+ return errors.Join(
+ fmt.Errorf("failed to sync installed user database: %w", syncErr),
+ rollbackErr,
+ )
+ }
+ return fmt.Errorf("failed to sync installed user database: %w", syncErr)
+ }
+ if originalPreserved {
+ if removeErr := fs.Remove(rollbackPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
+ return fmt.Errorf("failed to remove user database rollback: %w", removeErr)
+ }
+ if syncErr := syncAferoDirectory(fs, dbDir); syncErr != nil {
+ return fmt.Errorf("failed to sync user database rollback removal: %w", syncErr)
+ }
+ }
+ return nil
+}
+
+func restoreDatabaseRollback(fs afero.Fs, rollbackPath, dbPath string) error {
+ _ = fs.Remove(dbPath)
+ if err := fs.Rename(rollbackPath, dbPath); err != nil {
+ return fmt.Errorf("failed to restore original user database: %w", err)
+ }
+ if err := syncAferoDirectory(fs, filepath.Dir(dbPath)); err != nil {
+ return fmt.Errorf("failed to sync restored original user database: %w", err)
+ }
+ return nil
+}
+
+func syncAferoDirectory(fs afero.Fs, path string) error {
+ dir, err := fs.Open(path)
+ if err != nil {
+ return fmt.Errorf("failed to open directory for sync: %w", err)
+ }
+ syncErr := dir.Sync()
+ closeErr := dir.Close()
+ if syncErr != nil {
+ return fmt.Errorf("failed to sync directory: %w", syncErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("failed to close synced directory: %w", closeErr)
+ }
return nil
}
diff --git a/pkg/database/userdb/backup_test.go b/pkg/database/userdb/backup_test.go
index 28c9da56..11b42a73 100644
--- a/pkg/database/userdb/backup_test.go
+++ b/pkg/database/userdb/backup_test.go
@@ -20,6 +20,8 @@
package userdb
import (
+ "context"
+ "database/sql"
"errors"
"fmt"
"os"
@@ -41,6 +43,40 @@ type failStagedInstallFs struct {
failRollback bool
}
+type failDirectorySyncFile struct {
+ afero.File
+ fail bool
+}
+
+func (f *failDirectorySyncFile) Sync() error {
+ if f.fail {
+ return errors.New("injected directory sync failure")
+ }
+ if err := f.File.Sync(); err != nil {
+ return fmt.Errorf("sync test directory: %w", err)
+ }
+ return nil
+}
+
+type failDirectorySyncFs struct {
+ afero.Fs
+ dir string
+ calls int
+ failAt int
+}
+
+func (fs *failDirectorySyncFs) Open(name string) (afero.File, error) {
+ file, err := fs.Fs.Open(name)
+ if err != nil {
+ return nil, fmt.Errorf("open test directory: %w", err)
+ }
+ if filepath.Clean(name) != filepath.Clean(fs.dir) {
+ return file, nil
+ }
+ fs.calls++
+ return &failDirectorySyncFile{File: file, fail: fs.calls == fs.failAt}, nil
+}
+
func (fs failStagedInstallFs) Rename(oldPath, newPath string) error {
if newPath == fs.dbPath {
switch {
@@ -76,6 +112,29 @@ func TestReplaceDatabaseFromBackup_RestoresOriginalAfterInstallFailure(t *testin
assert.True(t, os.IsNotExist(rollbackErr))
}
+func TestReplaceDatabaseFromBackup_RestoresOriginalAfterDirectorySyncFailure(t *testing.T) {
+ t.Parallel()
+
+ for _, failAt := range []int{1, 2} {
+ t.Run(fmt.Sprintf("sync %d", failAt), func(t *testing.T) {
+ t.Parallel()
+ dir := t.TempDir()
+ dbPath := filepath.Join(dir, "user.db")
+ backupPath := filepath.Join(dir, "backup.db")
+ require.NoError(t, os.WriteFile(dbPath, []byte("original"), 0o600))
+ require.NoError(t, os.WriteFile(backupPath, []byte("replacement"), 0o600))
+
+ fs := &failDirectorySyncFs{Fs: afero.NewOsFs(), dir: dir, failAt: failAt}
+ err := replaceDatabaseFromBackup(fs, backupPath, dbPath)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "sync")
+ contents, readErr := os.ReadFile(dbPath) // #nosec G304 -- test-owned path.
+ require.NoError(t, readErr)
+ assert.Equal(t, []byte("original"), contents)
+ })
+ }
+}
+
func TestReplaceDatabaseFromBackup_RetainsRollbackAfterRestoreFailure(t *testing.T) {
t.Parallel()
@@ -95,6 +154,69 @@ func TestReplaceDatabaseFromBackup_RetainsRollbackAfterRestoreFailure(t *testing
assert.Equal(t, []byte("original"), rollbackContents)
}
+func TestUserDBBackupForTransferRemovesClientCredentials(t *testing.T) {
+ userDB, closeDB := setupTempUserDB(t)
+ defer closeDB()
+
+ const authToken = "portable-backup-must-not-contain-this-token"
+ pairingKey := []byte("0123456789abcdef0123456789abcdef")
+ require.NoError(t, userDB.CreateClient(&database.Client{
+ ClientID: "client-1",
+ ClientName: "Admin",
+ AuthToken: authToken,
+ PairingKey: pairingKey,
+ Role: "admin",
+ CreatedAt: time.Now().Unix(),
+ }))
+
+ portable, cleanup, err := userDB.BackupForTransfer(context.Background(), "test-transfer")
+ require.NoError(t, err)
+ require.NotNil(t, cleanup)
+
+ portableDB, err := sql.Open("sqlite3", portable.Path+"?mode=ro&_query_only=ON")
+ require.NoError(t, err)
+ var clientCount int
+ require.NoError(t, portableDB.QueryRowContext(
+ context.Background(), "SELECT COUNT(*) FROM Clients",
+ ).Scan(&clientCount))
+ require.NoError(t, portableDB.Close())
+ assert.Zero(t, clientCount)
+
+ portableBytes, err := os.ReadFile(portable.Path)
+ require.NoError(t, err)
+ assert.NotContains(t, string(portableBytes), authToken)
+ assert.NotContains(t, string(portableBytes), string(pairingKey))
+
+ fullBackups, err := userDB.ListBackups()
+ require.NoError(t, err)
+ require.NotEmpty(t, fullBackups)
+ fullDB, err := sql.Open("sqlite3", fullBackups[0].Path+"?mode=ro&_query_only=ON")
+ require.NoError(t, err)
+ require.NoError(t, fullDB.QueryRowContext(
+ context.Background(), "SELECT COUNT(*) FROM Clients",
+ ).Scan(&clientCount))
+ require.NoError(t, fullDB.Close())
+ assert.Equal(t, 1, clientCount)
+
+ portablePath := portable.Path
+ require.NoError(t, cleanup())
+ _, err = os.Stat(portablePath)
+ require.ErrorIs(t, err, os.ErrNotExist)
+ require.NoError(t, cleanup(), "cleanup must be idempotent")
+}
+
+func TestUserDBBackupForTransferHonorsCancellation(t *testing.T) {
+ t.Parallel()
+ userDB, closeDB := setupTempUserDB(t)
+ defer closeDB()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, cleanup, err := userDB.BackupForTransfer(ctx, "canceled-transfer")
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Nil(t, cleanup)
+}
+
func TestUserDBRestoreBackupFailsWhenCorruptMarkerCannotBeCleared(t *testing.T) {
userDB, cleanup := setupTempUserDB(t)
defer cleanup()
diff --git a/pkg/database/userdb/clients.go b/pkg/database/userdb/clients.go
index 2222da24..8828413b 100644
--- a/pkg/database/userdb/clients.go
+++ b/pkg/database/userdb/clients.go
@@ -40,6 +40,14 @@ func (db *UserDB) ListClients() ([]database.Client, error) {
return sqlListClients(db.ctx, db.sql.Load())
}
+// ReplaceAllClients atomically replaces every paired client row. Device
+// backup restore uses this to carry the destination's paired clients across
+// a restored user database, since portable snapshots are created without
+// client rows.
+func (db *UserDB) ReplaceAllClients(clients []database.Client) error {
+ return sqlReplaceAllClients(db.ctx, db.sql.Load(), clients)
+}
+
func (db *UserDB) DeleteClient(clientID string) error {
return sqlDeleteClient(db.ctx, db.sql.Load(), clientID)
}
diff --git a/pkg/database/userdb/sql.go b/pkg/database/userdb/sql.go
index 5f88934e..8fc15517 100644
--- a/pkg/database/userdb/sql.go
+++ b/pkg/database/userdb/sql.go
@@ -762,6 +762,39 @@ func sqlCreateClient(ctx context.Context, db *sql.DB, c *database.Client) error
return nil
}
+func sqlReplaceAllClients(ctx context.Context, db *sql.DB, clients []database.Client) (err error) {
+ tx, err := db.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("failed to begin client replace transaction: %w", err)
+ }
+ defer func() {
+ if err != nil {
+ if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
+ err = errors.Join(err, fmt.Errorf("failed to roll back client replace: %w", rbErr))
+ }
+ }
+ }()
+ if _, err = tx.ExecContext(ctx, `DELETE FROM Clients;`); err != nil {
+ return fmt.Errorf("failed to clear clients: %w", err)
+ }
+ for i := range clients {
+ c := &clients[i]
+ if strings.ContainsRune(c.AuthToken, ':') {
+ return ErrInvalidAuthToken
+ }
+ if _, err = tx.ExecContext(ctx, `
+ INSERT INTO Clients (ClientID, ClientName, AuthToken, Role, PairingKey, CreatedAt, LastSeenAt)
+ VALUES (?, ?, ?, ?, ?, ?, ?);
+ `, c.ClientID, c.ClientName, c.AuthToken, c.Role, c.PairingKey, c.CreatedAt, c.LastSeenAt); err != nil {
+ return fmt.Errorf("failed to insert replacement client: %w", err)
+ }
+ }
+ if err = tx.Commit(); err != nil {
+ return fmt.Errorf("failed to commit client replace: %w", err)
+ }
+ return nil
+}
+
func sqlGetClientByToken(ctx context.Context, db *sql.DB, authToken string) (*database.Client, error) {
row := db.QueryRowContext(ctx, `
SELECT DBID, ClientID, ClientName, AuthToken, Role, PairingKey, CreatedAt, LastSeenAt
diff --git a/pkg/platforms/mister/backup_test.go b/pkg/platforms/mister/backup_test.go
new file mode 100644
index 00000000..d47daa1a
--- /dev/null
+++ b/pkg/platforms/mister/backup_test.go
@@ -0,0 +1,56 @@
+//go:build linux
+
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package mister
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBackupDefinitions(t *testing.T) {
+ t.Parallel()
+ rootDir := t.TempDir()
+ definitions := BackupDefinitions(platforms.Settings{DataDir: filepath.Join(rootDir, "zaparoo")})
+ require.Len(t, definitions, 9)
+
+ assert.Equal(t, rootDir, definitions[0].SourceRoot)
+ assert.Equal(t, "settings", definitions[0].Category)
+ assert.True(t, definitions[0].NonRecursive)
+ assert.Contains(t, definitions[0].Include, platforms.BackupPattern{Glob: "MiSTer.ini"})
+ assert.Contains(t, definitions[0].Exclude, platforms.BackupPattern{Glob: "MiSTer_example.ini"})
+
+ assert.Equal(t, filepath.Join(rootDir, "config"), definitions[1].SourceRoot)
+ assert.Equal(t, "config", definitions[1].RestoreRoot)
+ assert.Contains(t, definitions[1].Include, platforms.BackupPattern{Glob: "*.cfg"})
+ assert.Contains(t, definitions[1].Exclude, platforms.BackupPattern{Contains: "_recent"})
+
+ assert.Equal(t, rootDir, definitions[2].SourceRoot)
+ assert.True(t, definitions[2].NonRecursive)
+ assert.Equal(t, filepath.Join(rootDir, "linux"), definitions[3].SourceRoot)
+ assert.True(t, definitions[3].NonRecursive)
+
+ assert.Equal(t, filepath.Join(rootDir, "config", "inputs"), definitions[4].SourceRoot)
+ assert.Equal(t, filepath.Join("config", "inputs"), definitions[4].RestoreRoot)
+ assert.Equal(t, "inputs", definitions[4].Category)
+
+ profileRoot := filepath.Join(rootDir, "zaparoo", "profiles")
+ assert.Equal(t, profileRoot, definitions[5].SourceRoot)
+ assert.Equal(t, "saves", definitions[5].Category)
+ assert.Contains(t, definitions[5].Include, platforms.BackupPattern{Contains: "/saves/"})
+ assert.Equal(t, profileRoot, definitions[6].SourceRoot)
+ assert.Equal(t, "savestates", definitions[6].Category)
+ assert.Contains(t, definitions[6].Include, platforms.BackupPattern{Contains: "/savestates/"})
+ assert.Equal(t, filepath.Join(rootDir, "saves"), definitions[7].SourceRoot)
+ assert.Equal(t, "saves", definitions[7].Category)
+ assert.Equal(t, filepath.Join(rootDir, "savestates"), definitions[8].SourceRoot)
+ assert.Equal(t, "savestates", definitions[8].Category)
+ assert.Equal(t, []platforms.BackupPattern{{All: true}}, definitions[8].Include)
+}
diff --git a/pkg/platforms/mister/platform.go b/pkg/platforms/mister/platform.go
index 48c54e08..4920d1e3 100644
--- a/pkg/platforms/mister/platform.go
+++ b/pkg/platforms/mister/platform.go
@@ -593,6 +593,116 @@ func stopTrackedProcess(proc *os.Process, done chan struct{}, processGroup bool,
killRemainingProcessGroup(proc, processGroup)
}
+func (p *Platform) BackupDefinitions() []platforms.BackupDefinition {
+ return BackupDefinitions(p.Settings())
+}
+
+func (p *Platform) BackupPlan() platforms.BackupPlan {
+ definitions := BackupDefinitions(p.Settings())
+ if p.profileData == nil {
+ return platforms.BackupPlan{Definitions: definitions}
+ }
+ return p.profileData.backupPlan(p.Settings(), definitions)
+}
+
+func (p *Platform) PrepareBackupRestore() (func(bool) error, error) {
+ if p.profileData == nil {
+ return func(bool) error { return nil }, nil
+ }
+ return p.profileData.prepareBackupRestore()
+}
+
+func (p *Platform) BackupRestoreRoot() string {
+ return BackupRestoreRoot(p.Settings())
+}
+
+func BackupRestoreRoot(settings platforms.Settings) string {
+ return filepath.Dir(settings.DataDir)
+}
+
+func BackupDefinitions(settings platforms.Settings) []platforms.BackupDefinition {
+ root := BackupRestoreRoot(settings)
+ return []platforms.BackupDefinition{
+ {
+ Category: "settings",
+ SourceRoot: root,
+ RestoreRoot: "",
+ NonRecursive: true,
+ Include: []platforms.BackupPattern{
+ {Glob: "MiSTer.ini"},
+ {Glob: "MiSTer_alt_*.ini"},
+ {Glob: "MiSTer_*.ini"},
+ {Glob: "MiSTer.ini.*"},
+ {Glob: "downloader.ini"},
+ },
+ Exclude: []platforms.BackupPattern{{Glob: "MiSTer_example.ini"}},
+ },
+ {
+ Category: "settings",
+ SourceRoot: filepath.Join(root, "config"),
+ RestoreRoot: "config",
+ Include: []platforms.BackupPattern{
+ {Glob: "*.cfg"},
+ {Glob: "*.dat"},
+ {Glob: "*.f2"},
+ },
+ Exclude: []platforms.BackupPattern{{Contains: "_recent"}},
+ },
+ {
+ Category: "inputs",
+ SourceRoot: root,
+ RestoreRoot: "",
+ NonRecursive: true,
+ Include: []platforms.BackupPattern{
+ {Glob: "gamecontrollerdb_user.txt"},
+ },
+ },
+ {
+ Category: "inputs",
+ SourceRoot: filepath.Join(root, "linux"),
+ RestoreRoot: "linux",
+ NonRecursive: true,
+ Include: []platforms.BackupPattern{
+ {Glob: "gamecontrollerdb_user.txt"},
+ },
+ },
+ {
+ Category: "inputs",
+ SourceRoot: filepath.Join(root, "config", "inputs"),
+ RestoreRoot: filepath.Join("config", "inputs"),
+ Include: []platforms.BackupPattern{
+ {Glob: "*.map"},
+ {Glob: "*.zip"},
+ },
+ Exclude: []platforms.BackupPattern{{Glob: filepath.Join("renamed", "*")}},
+ },
+ {
+ Category: "saves",
+ SourceRoot: filepath.Join(root, "zaparoo", "profiles"),
+ RestoreRoot: filepath.Join("zaparoo", "profiles"),
+ Include: []platforms.BackupPattern{{Contains: "/saves/"}},
+ },
+ {
+ Category: "savestates",
+ SourceRoot: filepath.Join(root, "zaparoo", "profiles"),
+ RestoreRoot: filepath.Join("zaparoo", "profiles"),
+ Include: []platforms.BackupPattern{{Contains: "/savestates/"}},
+ },
+ {
+ Category: "saves",
+ SourceRoot: filepath.Join(root, "saves"),
+ RestoreRoot: "saves",
+ Include: []platforms.BackupPattern{{All: true}},
+ },
+ {
+ Category: "savestates",
+ SourceRoot: filepath.Join(root, "savestates"),
+ RestoreRoot: "savestates",
+ Include: []platforms.BackupPattern{{All: true}},
+ },
+ }
+}
+
func (p *Platform) StopActiveLauncher(intent platforms.StopIntent) error {
p.processMu.Lock()
p.stopIntent = intent
diff --git a/pkg/platforms/mister/profiledata_backup.go b/pkg/platforms/mister/profiledata_backup.go
new file mode 100644
index 00000000..7bd8773a
--- /dev/null
+++ b/pkg/platforms/mister/profiledata_backup.go
@@ -0,0 +1,211 @@
+//go:build linux
+
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package mister
+
+// Backup-facing adaptation of profile data mounts. Active profile binds
+// hide the shared saves/savestates underneath them, so the backup plan
+// must describe what is actually visible (and warn about what is not),
+// and restore must temporarily remove Zaparoo's own binds so writes reach
+// real storage instead of a profile pool.
+
+import (
+ "errors"
+ "fmt"
+ "path/filepath"
+ "strings"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+)
+
+type profileRestoreMount struct {
+ ref platforms.ProfileRef
+ item string
+}
+
+func removeBackupDefinition(
+ definitions []platforms.BackupDefinition, sourceRoot, restoreRoot, category string,
+) []platforms.BackupDefinition {
+ filtered := make([]platforms.BackupDefinition, 0, len(definitions))
+ for i := range definitions {
+ definition := definitions[i]
+ if definition.Category == category &&
+ filepath.Clean(definition.SourceRoot) == filepath.Clean(sourceRoot) &&
+ filepath.Clean(definition.RestoreRoot) == filepath.Clean(restoreRoot) {
+ continue
+ }
+ filtered = append(filtered, definition)
+ }
+ return filtered
+}
+
+func profileBindUsesNAS(entry *mountLedgerEntry) bool {
+ root := filepath.ToSlash(entry.Root)
+ return strings.Contains(root, "/"+nasPoolDirName+"/")
+}
+
+func (d *profileDataManager) backupPlan(
+ settings platforms.Settings, definitions []platforms.BackupDefinition,
+) platforms.BackupPlan {
+ d.mu.Lock()
+ defer d.mu.Unlock()
+ plan := platforms.BackupPlan{Definitions: definitions}
+ mounts, err := d.m.Mounts()
+ if err != nil {
+ for _, item := range []string{profileDataItemSaves, profileDataItemSavestates} {
+ plan.Definitions = removeBackupDefinition(
+ plan.Definitions, filepath.Join(BackupRestoreRoot(settings), item), item, item,
+ )
+ plan.Warnings = append(plan.Warnings, platforms.BackupWarning{
+ Category: item, Path: item, Reason: "profile mount state unavailable",
+ })
+ }
+ return plan
+ }
+ d.ledger.prune(mounts)
+ root, err := d.resolveStorageRoot(mounts)
+ if err != nil {
+ for _, item := range []string{profileDataItemSaves, profileDataItemSavestates} {
+ plan.Definitions = removeBackupDefinition(
+ plan.Definitions, filepath.Join(BackupRestoreRoot(settings), item), item, item,
+ )
+ plan.Warnings = append(plan.Warnings, platforms.BackupWarning{
+ Category: item, Path: item, Reason: "profile storage root unavailable",
+ })
+ }
+ return plan
+ }
+ baseRoot := BackupRestoreRoot(settings)
+ if filepath.Clean(root) != filepath.Clean(baseRoot) {
+ for _, item := range []string{profileDataItemSaves, profileDataItemSavestates} {
+ plan.Definitions = removeBackupDefinition(
+ plan.Definitions, filepath.Join(baseRoot, item), item, item,
+ )
+ plan.Definitions = removeBackupDefinition(
+ plan.Definitions, filepath.Join(baseRoot, "zaparoo", "profiles"),
+ filepath.Join("zaparoo", "profiles"), item,
+ )
+ plan.Definitions = append(plan.Definitions,
+ platforms.BackupDefinition{
+ Category: item, SourceRoot: filepath.Join(root, item), RestoreRoot: item,
+ Include: []platforms.BackupPattern{{All: true}},
+ },
+ platforms.BackupDefinition{
+ Category: item, SourceRoot: filepath.Join(root, "zaparoo", "profiles"),
+ RestoreRoot: filepath.Join("zaparoo", "profiles"),
+ Include: []platforms.BackupPattern{{Contains: "/" + item + "/"}},
+ },
+ )
+ }
+ }
+ for _, item := range []string{profileDataItemSaves, profileDataItemSavestates} {
+ target := filepath.Join(root, item)
+ stack := mountsAt(mounts, target)
+ if len(stack) == 0 {
+ continue
+ }
+ entry := d.ledger.find(&stack[len(stack)-1])
+ if entry == nil {
+ continue
+ }
+ plan.Definitions = removeBackupDefinition(plan.Definitions, target, item, item)
+ if profileBindUsesNAS(entry) {
+ plan.Definitions = append(plan.Definitions, platforms.BackupDefinition{
+ Category: item, SourceRoot: target,
+ RestoreRoot: filepath.Join(item, nasPoolDirName, entry.ProfileID, item),
+ SourceTrustedRoots: []string{target}, Include: []platforms.BackupPattern{{All: true}},
+ })
+ plan.Warnings = append(plan.Warnings, platforms.BackupWarning{
+ Category: item, Path: filepath.ToSlash(filepath.Join(item, nasPoolDirName)),
+ Reason: "shared and inactive NAS profile data hidden by active profile mount",
+ })
+ continue
+ }
+ plan.Warnings = append(plan.Warnings, platforms.BackupWarning{
+ Category: item, Path: item, Reason: "shared profile data hidden by active profile mount",
+ })
+ }
+ return plan
+}
+
+func (d *profileDataManager) restoreProfileMounts(root string, mounts []profileRestoreMount) error {
+ var errs []error
+ for _, mount := range mounts {
+ plan, err := d.prepareItem(root, mount.item, mount.ref)
+ if err == nil {
+ err = d.applyItem(&plan)
+ }
+ if err != nil {
+ errs = append(errs, fmt.Errorf("restoring %s profile mount: %w", mount.item, err))
+ }
+ }
+ return errors.Join(errs...)
+}
+
+func (d *profileDataManager) prepareBackupRestore() (func(bool) error, error) {
+ d.mu.Lock()
+ mounts, err := d.m.Mounts()
+ if err != nil {
+ d.mu.Unlock()
+ return nil, fmt.Errorf("reading profile mounts before backup restore: %w", err)
+ }
+ d.ledger.prune(mounts)
+ root, err := d.resolveStorageRoot(mounts)
+ if err != nil {
+ d.mu.Unlock()
+ return nil, err
+ }
+ previous := make([]profileRestoreMount, 0, 2)
+ for _, item := range []string{profileDataItemSaves, profileDataItemSavestates} {
+ target := filepath.Join(root, item)
+ stack := mountsAt(mounts, target)
+ if len(stack) > 0 {
+ if entry := d.ledger.find(&stack[len(stack)-1]); entry != nil {
+ previous = append(previous, profileRestoreMount{
+ ref: platforms.ProfileRef{ID: entry.ProfileID}, item: item,
+ })
+ }
+ }
+ if err = d.applyItem(&profileItemPlan{item: item, target: target}); err != nil {
+ restoreErr := d.restoreProfileMounts(root, previous)
+ d.mu.Unlock()
+ return nil, errors.Join(fmt.Errorf("exposing %s for backup restore: %w", item, err), restoreErr)
+ }
+ mounts, err = d.m.Mounts()
+ if err != nil {
+ restoreErr := d.restoreProfileMounts(root, previous)
+ d.mu.Unlock()
+ return nil, errors.Join(fmt.Errorf("refreshing profile mounts: %w", err), restoreErr)
+ }
+ }
+ finished := false
+ return func(success bool) error {
+ if finished {
+ return nil
+ }
+ finished = true
+ defer d.mu.Unlock()
+ if success {
+ return nil
+ }
+ return d.restoreProfileMounts(root, previous)
+ }, nil
+}
diff --git a/pkg/platforms/mister/profiledata_backup_test.go b/pkg/platforms/mister/profiledata_backup_test.go
new file mode 100644
index 00000000..5cc6e473
--- /dev/null
+++ b/pkg/platforms/mister/profiledata_backup_test.go
@@ -0,0 +1,149 @@
+//go:build linux
+
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package mister
+
+import (
+ "errors"
+ "path/filepath"
+ "testing"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ misterconfig "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mister/config"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestProfileBackupPlanOmitsLiveAliasAndIncludesProfilePools(t *testing.T) {
+ t.Parallel()
+ mounter := &fakeMounter{}
+ manager, _ := newTestManager(mounter)
+ require.NoError(t, manager.apply(kidA(), allItems()))
+ settings := platforms.Settings{DataDir: filepath.Join(misterconfig.SDRootDir, "zaparoo")}
+ plan := manager.backupPlan(settings, BackupDefinitions(settings))
+
+ for _, definition := range plan.Definitions {
+ assert.False(t, definition.Category == profileDataItemSaves &&
+ filepath.Clean(definition.SourceRoot) == filepath.Join(misterconfig.SDRootDir, "saves") &&
+ filepath.Clean(definition.RestoreRoot) == profileDataItemSaves)
+ }
+ assert.Contains(t, plan.Warnings, platforms.BackupWarning{
+ Category: profileDataItemSaves, Path: profileDataItemSaves,
+ Reason: "shared profile data hidden by active profile mount",
+ })
+ assert.Contains(t, plan.Warnings, platforms.BackupWarning{
+ Category: profileDataItemSavestates, Path: profileDataItemSavestates,
+ Reason: "shared profile data hidden by active profile mount",
+ })
+}
+
+func TestProfileBackupPlanWarnsWhenMountStateIsUnavailable(t *testing.T) {
+ t.Parallel()
+ mounter := &fakeMounter{mountsErr: errors.New("mount table unavailable")}
+ manager, _ := newTestManager(mounter)
+ settings := platforms.Settings{DataDir: filepath.Join(misterconfig.SDRootDir, "zaparoo")}
+ plan := manager.backupPlan(settings, BackupDefinitions(settings))
+
+ for _, item := range allItems() {
+ assert.Contains(t, plan.Warnings, platforms.BackupWarning{
+ Category: item, Path: item, Reason: "profile mount state unavailable",
+ })
+ for _, definition := range plan.Definitions {
+ assert.False(t,
+ definition.Category == item &&
+ filepath.Clean(definition.SourceRoot) == filepath.Join(misterconfig.SDRootDir, item) &&
+ filepath.Clean(definition.RestoreRoot) == item,
+ "live %s alias must be omitted when active mount state is unknown", item,
+ )
+ }
+ }
+}
+
+func TestProfileBackupPlanMapsActiveNASPoolToOwnedPath(t *testing.T) {
+ t.Parallel()
+ savesTarget := filepath.Join(misterconfig.SDRootDir, profileDataItemSaves)
+ statesTarget := filepath.Join(misterconfig.SDRootDir, profileDataItemSavestates)
+ mounter := &fakeMounter{mounts: []mountEntry{
+ {Root: "/", Mountpoint: savesTarget, FSType: "cifs", Source: "//nas/saves"},
+ {Root: "/", Mountpoint: statesTarget, FSType: "cifs", Source: "//nas/states"},
+ }}
+ manager, _ := newTestManager(mounter)
+ require.NoError(t, manager.apply(kidA(), allItems()))
+ settings := platforms.Settings{DataDir: filepath.Join(misterconfig.SDRootDir, "zaparoo")}
+ plan := manager.backupPlan(settings, BackupDefinitions(settings))
+
+ for _, item := range allItems() {
+ expectedRestoreRoot := filepath.Join(item, nasPoolDirName, kidA().ID, item)
+ assert.Contains(t, plan.Definitions, platforms.BackupDefinition{
+ Category: item, SourceRoot: filepath.Join(misterconfig.SDRootDir, item),
+ RestoreRoot: expectedRestoreRoot,
+ SourceTrustedRoots: []string{filepath.Join(misterconfig.SDRootDir, item)},
+ Include: []platforms.BackupPattern{{All: true}},
+ })
+ }
+}
+
+func TestPrepareBackupRestoreUnmountsAndRestoresProfileBinds(t *testing.T) {
+ t.Parallel()
+ mounter := &fakeMounter{}
+ manager, _ := newTestManager(mounter)
+ require.NoError(t, manager.apply(kidA(), allItems()))
+
+ finish, err := manager.prepareBackupRestore()
+ require.NoError(t, err)
+ for _, item := range allItems() {
+ stack := mountsAt(mounter.mounts, filepath.Join(misterconfig.SDRootDir, item))
+ assert.Empty(t, stack)
+ }
+ require.NoError(t, finish(false))
+ for _, item := range allItems() {
+ stack := mountsAt(mounter.mounts, filepath.Join(misterconfig.SDRootDir, item))
+ require.NotEmpty(t, stack)
+ entry := manager.ledger.find(&stack[len(stack)-1])
+ require.NotNil(t, entry)
+ assert.Equal(t, kidA().ID, entry.ProfileID)
+ }
+}
+
+func TestPrepareBackupRestoreFailsWhenMountStateIsUnavailable(t *testing.T) {
+ t.Parallel()
+ mounter := &fakeMounter{mountsErr: errors.New("mount table unavailable")}
+ manager, _ := newTestManager(mounter)
+
+ finish, err := manager.prepareBackupRestore()
+ require.Error(t, err)
+ assert.Nil(t, finish)
+ assert.Contains(t, err.Error(), "reading profile mounts before backup restore")
+}
+
+func TestPrepareBackupRestoreLeavesBindsUnmountedAfterSuccess(t *testing.T) {
+ t.Parallel()
+ mounter := &fakeMounter{}
+ manager, _ := newTestManager(mounter)
+ require.NoError(t, manager.apply(kidA(), allItems()))
+
+ finish, err := manager.prepareBackupRestore()
+ require.NoError(t, err)
+ require.NoError(t, finish(true))
+ for _, item := range allItems() {
+ assert.Empty(t, mountsAt(mounter.mounts, filepath.Join(misterconfig.SDRootDir, item)))
+ }
+}
diff --git a/pkg/platforms/mister/profiledata_test.go b/pkg/platforms/mister/profiledata_test.go
index 6d506818..2990f6f8 100644
--- a/pkg/platforms/mister/profiledata_test.go
+++ b/pkg/platforms/mister/profiledata_test.go
@@ -44,6 +44,7 @@ import (
type fakeMounter struct {
bindErr error
unmountErr error
+ mountsErr error
mounts []mountEntry
binds int
bindAttempts int
@@ -66,6 +67,9 @@ func (f failMkdirFS) MkdirAll(path string, perm iofs.FileMode) error {
}
func (f *fakeMounter) Mounts() ([]mountEntry, error) {
+ if f.mountsErr != nil {
+ return nil, f.mountsErr
+ }
out := make([]mountEntry, len(f.mounts))
copy(out, f.mounts)
return out, nil
diff --git a/pkg/platforms/mistex/platform.go b/pkg/platforms/mistex/platform.go
index 13bb2dcf..8b8fdab1 100644
--- a/pkg/platforms/mistex/platform.go
+++ b/pkg/platforms/mistex/platform.go
@@ -216,6 +216,14 @@ func (*Platform) Settings() platforms.Settings {
}
}
+func (p *Platform) BackupDefinitions() []platforms.BackupDefinition {
+ return mister.BackupDefinitions(p.Settings())
+}
+
+func (p *Platform) BackupRestoreRoot() string {
+ return mister.BackupRestoreRoot(p.Settings())
+}
+
func LaunchMenu() error {
if _, err := os.Stat(misterconfig.CmdInterface); err != nil {
return fmt.Errorf("command interface not accessible: %w", err)
diff --git a/pkg/platforms/platforms.go b/pkg/platforms/platforms.go
index 8c2fb4e8..5a9b8a54 100644
--- a/pkg/platforms/platforms.go
+++ b/pkg/platforms/platforms.go
@@ -149,19 +149,20 @@ type CmdEnv struct {
LauncherCtx context.Context
// ServiceCtx is canceled during full service shutdown or process stop. Use it
// for work tied to service lifetime rather than the current launcher lifetime.
- ServiceCtx context.Context
- WaitForMediaReady func(context.Context) error
- PlaybackManager audio.PlaybackManager
- UI *uievents.Service
- Playlist playlists.PlaylistController
- Cfg *config.Instance
- Database *database.Database
- ExprEnv *zapscript.ArgExprEnv
- Source string
- Cmd zapscript.Command
- TotalCommands int
- CurrentIndex int
- Unsafe bool
+ ServiceCtx context.Context
+ WaitForMediaReady func(context.Context) error
+ AcquireMediaLaunch func() (func(), error)
+ PlaybackManager audio.PlaybackManager
+ UI *uievents.Service
+ Playlist playlists.PlaylistController
+ Cfg *config.Instance
+ Database *database.Database
+ ExprEnv *zapscript.ArgExprEnv
+ Source string
+ Cmd zapscript.Command
+ TotalCommands int
+ CurrentIndex int
+ Unsafe bool
}
// ProfileSwitchRequest asks the script runner to change the device's
@@ -310,6 +311,49 @@ type Launcher struct {
Available bool
}
+type BackupPattern struct {
+ Glob string
+ Contains string
+ All bool
+}
+
+type BackupDefinition struct {
+ SourceRoot string
+ RestoreRoot string
+ Category string
+ Include []BackupPattern
+ Exclude []BackupPattern
+ SourceTrustedRoots []string
+ NonRecursive bool
+}
+
+type BackupWarning struct {
+ Category string
+ Path string
+ Reason string
+}
+
+type BackupPlan struct {
+ Definitions []BackupDefinition
+ Warnings []BackupWarning
+}
+
+type BackupProvider interface {
+ BackupDefinitions() []BackupDefinition
+}
+
+type BackupPlanningProvider interface {
+ BackupPlan() BackupPlan
+}
+
+type BackupRestoreRootProvider interface {
+ BackupRestoreRoot() string
+}
+
+type BackupRestorePreparer interface {
+ PrepareBackupRestore() (func(bool) error, error)
+}
+
// Settings defines all simple settings/configuration values available for a
// platform.
type Settings struct {
diff --git a/pkg/service/backup/backup.go b/pkg/service/backup/backup.go
new file mode 100644
index 00000000..6650356b
--- /dev/null
+++ b/pkg/service/backup/backup.go
@@ -0,0 +1,1726 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package backup
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "io/fs"
+ "math"
+ "os"
+ "path"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ inboxservice "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/inbox"
+ "github.com/rs/zerolog/log"
+)
+
+var (
+ ErrPlatformBackupUnsupported = errors.New("platform does not support full-device backup")
+ statusMu syncutil.RWMutex
+)
+
+const (
+ CategoryZaparoo = "zaparoo"
+ CategorySettings = "settings"
+ CategoryInputs = "inputs"
+ CategorySaves = "saves"
+ CategorySavestates = "savestates"
+
+ StatusNever = "never"
+ StatusRunning = "running"
+ StatusSuccess = "success"
+ StatusPartial = "partial"
+ StatusFailed = "failed"
+
+ // statusErrorInterrupted records a run cut short by power loss or a hard
+ // shutdown, detected at the next service start.
+ statusErrorInterrupted = "backup interrupted before completion"
+
+ IntegrityUnchecked = "unchecked"
+ IntegrityValid = "valid"
+
+ RemoteAvailabilityUnknown = "unknown"
+ RemoteAvailabilityAvailable = "available"
+ RemoteAvailabilityUnavailable = "unavailable"
+
+ maxManifestBytes = int64(32 << 20)
+ maxRestoreConfigSize = int64(4 << 20)
+ maxArchiveEntries = 100_000
+ maxArchivePathLen = 512
+
+ manifestName = "manifest.json"
+ filesRoot = "files"
+ zaparooRoot = "zaparoo"
+ platformRoot = "platform"
+ backupDirName = "files"
+)
+
+type sourceOpener func(context.Context, *FileRef) (io.ReadCloser, error)
+
+type Manager struct {
+ cfg *config.Instance
+ pl platforms.Platform
+ database *database.Database
+ inbox *inboxservice.Service
+ coordinator *Coordinator
+ activeMedia func() *models.ActiveMedia
+ restoreGate func() (func(bool), error)
+ directorySync func(string) error
+ sourceOpener sourceOpener
+ pauser *syncutil.Pauser
+}
+
+type sourceIdentity struct {
+ info os.FileInfo
+ excludedIdentities []os.FileInfo
+}
+
+type FileRef struct {
+ sourceIdentity *sourceIdentity
+ SourceRoot string `json:"-"`
+ SourceRel string `json:"-"`
+ ArchivePath string `json:"archivePath"`
+ RestorePath string `json:"restorePath"`
+ Category string `json:"category"`
+ SHA256 string `json:"sha256"`
+ Size int64 `json:"size"`
+}
+
+type Manifest struct {
+ CreatedAt time.Time `json:"createdAt"`
+ Categories map[string]models.BackupCategoryStatus `json:"categories"`
+ Warnings []models.BackupWarning `json:"warnings,omitempty"`
+ Platform string `json:"platform"`
+ CoreVersion string `json:"coreVersion"`
+ Files []FileRef `json:"files"`
+ Version int `json:"version"`
+}
+
+type ListInfo struct {
+ CreatedAt time.Time `json:"createdAt"`
+ Name string `json:"name"`
+ Path string `json:"path,omitempty"`
+ Size int64 `json:"size"`
+}
+
+type Info struct {
+ CreatedAt time.Time `json:"createdAt"`
+ Categories map[string]models.BackupCategoryStatus `json:"categories,omitempty"`
+ Name string `json:"name"`
+ Path string `json:"path,omitempty"`
+ Status string `json:"status"`
+ Integrity string `json:"integrity"`
+ Error string `json:"error,omitempty"`
+ Warnings []models.BackupWarning `json:"warnings,omitempty"`
+ Size int64 `json:"size"`
+}
+
+type RestoreInfo struct {
+ PreRestoreBackup *Info `json:"preRestoreBackup,omitempty"`
+ RestoredFrom Info `json:"restoredFrom"`
+}
+
+type zipReadResult struct {
+ Info Info
+ Manifest Manifest
+}
+
+type fileCollection struct {
+ Cleanup func() error
+ Files []FileRef
+ Warnings []models.BackupWarning
+}
+
+type statusFile struct {
+ Local statusEntry `json:"local"`
+ Remote statusEntry `json:"remote"`
+}
+
+type statusEntry struct {
+ Categories map[string]models.BackupCategoryStatus `json:"categories,omitempty"`
+ LastRunAt string `json:"lastRunAt,omitempty"`
+ LastSuccessAt string `json:"lastSuccessAt,omitempty"`
+ AvailabilityCheckedAt string `json:"availabilityCheckedAt,omitempty"`
+ ScheduleEnabledSince string `json:"scheduleEnabledSince,omitempty"`
+ LastError string `json:"lastError,omitempty"`
+ LastStatus string `json:"lastStatus"`
+ Availability string `json:"availability,omitempty"`
+ DeviceName string `json:"deviceName,omitempty"`
+ LinkedAt string `json:"linkedAt,omitempty"`
+ Warnings []models.BackupWarning `json:"warnings,omitempty"`
+ LastBackupSize int64 `json:"lastBackupSize"`
+ SkippedFiles int `json:"skippedFiles,omitempty"`
+ Unlinked bool `json:"unlinked,omitempty"`
+}
+
+func NewManager(cfg *config.Instance, pl platforms.Platform, db *database.Database) *Manager {
+ return &Manager{
+ cfg: cfg, pl: pl, database: db, coordinator: NewCoordinator(), sourceOpener: openSourceContext,
+ }
+}
+
+func (m *Manager) WithInbox(inbox *inboxservice.Service) *Manager {
+ m.inbox = inbox
+ return m
+}
+
+func (m *Manager) WithActiveMedia(activeMedia func() *models.ActiveMedia) *Manager {
+ m.activeMedia = activeMedia
+ return m
+}
+
+func (m *Manager) WithRestoreGate(restoreGate func() (func(bool), error)) *Manager {
+ m.restoreGate = restoreGate
+ return m
+}
+
+func (m *Manager) WithCoordinator(coordinator *Coordinator) *Manager {
+ if coordinator != nil {
+ m.coordinator = coordinator
+ }
+ return m
+}
+
+// WithPauser subjects backup work to the shared media pause/throttle policy:
+// file collection, hashing, packing, and uploads checkpoint on the pauser so
+// they yield to a running game the same way media indexing does. A nil pauser
+// (the default) leaves backups unthrottled.
+func (m *Manager) WithPauser(pauser *syncutil.Pauser) *Manager {
+ m.pauser = pauser
+ return m
+}
+
+func (m *Manager) begin(ctx context.Context, kind OperationKind, mode OperationMode) (*Lease, error) {
+ if m.coordinator == nil {
+ m.coordinator = NewCoordinator()
+ }
+ lease, err := m.coordinator.Begin(ctx, kind, mode)
+ if err != nil {
+ return nil, fmt.Errorf("coordinating backup operation: %w", err)
+ }
+ return lease, nil
+}
+
+func (m *Manager) Create(ctx context.Context) (Info, error) {
+ lease, err := m.begin(ctx, OperationLocalCreate, OperationWrite)
+ if err != nil {
+ return Info{}, err
+ }
+ defer lease.Release()
+ ctx = lease.Context()
+ if err = ctx.Err(); err != nil {
+ return Info{}, fmt.Errorf("creating backup: %w", err)
+ }
+ started := time.Now().UTC()
+ _ = m.writeLocalStatus(&statusEntry{LastRunAt: formatTime(started), LastStatus: StatusRunning})
+
+ info, err := m.createBackup(ctx, false)
+ if err != nil {
+ _ = m.writeLocalStatus(&statusEntry{
+ LastRunAt: formatTime(started),
+ LastStatus: StatusFailed,
+ LastError: safeStatusError(err),
+ })
+ return Info{}, err
+ }
+ lastStatus := StatusSuccess
+ if len(info.Warnings) > 0 {
+ lastStatus = StatusPartial
+ }
+ _ = m.writeLocalStatus(&statusEntry{
+ LastRunAt: formatTime(started),
+ LastSuccessAt: formatTime(info.CreatedAt),
+ LastStatus: lastStatus,
+ LastBackupSize: info.Size,
+ Categories: info.Categories,
+ Warnings: info.Warnings,
+ SkippedFiles: len(info.Warnings),
+ })
+ return info, nil
+}
+
+func (m *Manager) createBackup(ctx context.Context, preRestore bool) (result Info, err error) {
+ reason := "local-zip"
+ scope := m.cfg.BackupScope()
+ if preRestore {
+ reason = "pre-restore-zip"
+ scope = m.preRestoreScope()
+ }
+ collection, err := m.collectFiles(ctx, reason, scope)
+ if err != nil {
+ return Info{}, err
+ }
+ defer func() {
+ if cleanupErr := collection.Cleanup(); cleanupErr != nil {
+ err = errors.Join(err, cleanupErr)
+ }
+ }()
+ files := collection.Files
+ if validateErr := validateFiles(files); validateErr != nil {
+ return Info{}, validateErr
+ }
+ files, unreadableWarnings, err := prepareSourceFiles(ctx, files, m.sourceOpener, m.pauser)
+ if err != nil {
+ return Info{}, err
+ }
+ collection.Warnings, err = appendBackupWarnings(collection.Warnings, unreadableWarnings)
+ if err != nil {
+ return Info{}, err
+ }
+ if err = ctx.Err(); err != nil {
+ return Info{}, fmt.Errorf("creating backup: %w", err)
+ }
+ now := time.Now().UTC()
+ backupDir := m.backupDir()
+ if mkdirErr := os.MkdirAll(backupDir, 0o750); mkdirErr != nil {
+ return Info{}, fmt.Errorf("creating backup directory: %w", mkdirErr)
+ }
+ name := backupName(preRestore, now)
+ finalPath := filepath.Join(backupDir, name)
+ tmpPath := finalPath + ".tmp"
+ manifest := m.newManifest(now, files, collection.Warnings)
+ // Self-check against the restore-side policy before writing anything:
+ // a backup the validator would reject is unusable, and failing here
+ // surfaces collector/validator drift at create time instead of leaving
+ // backups that can never be inspected or restored.
+ if policyErr := m.validateManifestPolicy(&manifest); policyErr != nil {
+ return Info{}, fmt.Errorf("backup would fail restore policy: %w", policyErr)
+ }
+ if writeErr := writeZip(ctx, tmpPath, files, &manifest, m.cfg.BackupMaxSizeBytes()); writeErr != nil {
+ _ = os.Remove(tmpPath)
+ return Info{}, writeErr
+ }
+ if renameErr := os.Rename(tmpPath, finalPath); renameErr != nil {
+ _ = os.Remove(tmpPath)
+ return Info{}, fmt.Errorf("finalizing backup ZIP: %w", renameErr)
+ }
+ info, err := infoFromManifest(finalPath, &manifest)
+ if err != nil {
+ return Info{}, err
+ }
+ log.Info().Str("path", finalPath).Int64("size", info.Size).Msg("created local backup ZIP")
+ if preRestore {
+ // Never fail the backup that was just created over a prune error.
+ if pruneErr := m.prunePreRestoreZips(); pruneErr != nil {
+ log.Warn().Err(pruneErr).Msg("failed to prune pre-restore backup ZIPs")
+ }
+ }
+ return info, nil
+}
+
+// preRestoreZipKeep is how many pre-restore safety ZIPs are retained. They
+// are system-generated before every restore and their protective value
+// decays immediately, so only the newest few matter. Manual backups are
+// user-managed and never pruned automatically.
+const preRestoreZipKeep = 3
+
+func (m *Manager) prunePreRestoreZips() error {
+ backups, err := m.List()
+ if err != nil {
+ return err
+ }
+ preRestore := make([]ListInfo, 0, len(backups))
+ for _, backup := range backups {
+ if strings.HasSuffix(backup.Name, "-pre-restore.zip") {
+ preRestore = append(preRestore, backup)
+ }
+ }
+ if len(preRestore) <= preRestoreZipKeep {
+ return nil
+ }
+ // Sort by name, newest first: names embed a zero-padded
+ // timestamp with nanoseconds, so lexical order is chronological even
+ // when List's second-precision CreatedAt ties.
+ sort.Slice(preRestore, func(i, j int) bool { return preRestore[i].Name > preRestore[j].Name })
+ for _, backup := range preRestore[preRestoreZipKeep:] {
+ if err := os.Remove(backup.Path); err != nil && !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("pruning pre-restore backup %s: %w", backup.Name, err)
+ }
+ log.Info().Str("name", backup.Name).Msg("pruned old pre-restore backup ZIP")
+ }
+ return nil
+}
+
+func (m *Manager) List() ([]ListInfo, error) {
+ entries, err := os.ReadDir(m.backupDir())
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("listing backup ZIPs: %w", err)
+ }
+ infos := make([]ListInfo, 0, len(entries))
+ for _, entry := range entries {
+ if entry.IsDir() || !isBackupZipName(entry.Name()) {
+ continue
+ }
+ info, infoErr := fastInfoFromDirEntry(m.backupDir(), entry)
+ if infoErr != nil {
+ log.Debug().Err(infoErr).Str("name", entry.Name()).Msg("failed to read backup ZIP metadata")
+ continue
+ }
+ infos = append(infos, info)
+ }
+ sort.Slice(infos, func(i, j int) bool { return infos[i].CreatedAt.After(infos[j].CreatedAt) })
+ return infos, nil
+}
+
+func (m *Manager) Inspect(ctx context.Context, name string) (Info, error) {
+ lease, err := m.begin(ctx, OperationLocalInspect, OperationRead)
+ if err != nil {
+ return Info{}, err
+ }
+ defer lease.Release()
+ backupPath, err := m.resolveBackupPath(name)
+ if err != nil {
+ return Info{}, err
+ }
+ if validateErr := m.validateLocalArchiveManifest(backupPath); validateErr != nil {
+ return Info{}, validateErr
+ }
+ info, err := inspectZipManifest(backupPath, m.cfg.BackupMaxSizeBytes())
+ if err != nil {
+ return Info{}, fmt.Errorf("inspecting backup ZIP: %w", err)
+ }
+ return info, nil
+}
+
+func (m *Manager) Delete(ctx context.Context, name string) error {
+ lease, err := m.begin(ctx, OperationLocalDelete, OperationWrite)
+ if err != nil {
+ return err
+ }
+ defer lease.Release()
+ backupPath, err := m.resolveBackupPath(name)
+ if err != nil {
+ return err
+ }
+ if err := os.Remove(backupPath); err != nil {
+ return fmt.Errorf("deleting backup ZIP: %w", err)
+ }
+ return nil
+}
+
+func (m *Manager) Restore(ctx context.Context, name string) (RestoreInfo, error) {
+ lease, err := m.begin(ctx, OperationLocalRestore, OperationWrite)
+ if err != nil {
+ return RestoreInfo{}, err
+ }
+ defer lease.Release()
+ ctx = lease.Context()
+ finishRestore, err := m.beginRestoreGate()
+ if err != nil {
+ return RestoreInfo{}, err
+ }
+ restoreSucceeded := false
+ defer func() { finishRestore(restoreSucceeded) }()
+ if idleErr := m.requireRestoreIdle(); idleErr != nil {
+ return RestoreInfo{}, idleErr
+ }
+ if recoveryErr := m.recoverRestoreLocked(ctx); recoveryErr != nil {
+ return RestoreInfo{}, recoveryErr
+ }
+ backupPath, err := m.resolveBackupPath(name)
+ if err != nil {
+ return RestoreInfo{}, err
+ }
+ if validateErr := m.validateLocalArchiveManifest(backupPath); validateErr != nil {
+ return RestoreInfo{}, validateErr
+ }
+ zipResult, err := readAndVerifyZipLimit(backupPath, m.cfg.BackupMaxSizeBytes())
+ if err != nil {
+ return RestoreInfo{}, err
+ }
+ if validateErr := validateFiles(zipResult.Manifest.Files); validateErr != nil {
+ return RestoreInfo{}, validateErr
+ }
+ pre, err := m.createBackup(ctx, true)
+ if err != nil {
+ return RestoreInfo{}, fmt.Errorf("creating pre-restore backup: %w", err)
+ }
+ if idleErr := m.requireRestoreIdle(); idleErr != nil {
+ return RestoreInfo{}, idleErr
+ }
+ finishPlatformRestore, err := m.preparePlatformRestore()
+ if err != nil {
+ return RestoreInfo{}, err
+ }
+ if err = m.applyRestoreFromZip(ctx, backupPath, &zipResult.Manifest); err != nil {
+ return RestoreInfo{}, errors.Join(err, finishPlatformRestore(false))
+ }
+ if finishErr := finishPlatformRestore(true); finishErr != nil {
+ log.Warn().Err(finishErr).Msg("committed restore profile cleanup deferred until restart")
+ }
+ restoreSucceeded = true
+ return RestoreInfo{PreRestoreBackup: &pre, RestoredFrom: zipResult.Info}, nil
+}
+
+func (m *Manager) Status() models.BackupStatusResponse {
+ stored := m.readStatus()
+ remoteLinked := false
+ lookupURL := config.BackupAuthLookupURL(m.cfg.BackupRemoteBaseURL())
+ if entry := config.LookupAuth(config.GetAuthCfg(), lookupURL); entry != nil && entry.Bearer != "" {
+ // A recorded 401 means the token was revoked server-side: report
+ // unlinked so the UI prompts a re-link instead of silently failing.
+ remoteLinked = !stored.Remote.Unlinked
+ }
+ local := toStatusEntry(&stored.Local, true, "")
+ remote := toStatusEntry(&stored.Remote, m.cfg.BackupRemoteEnabled(), m.cfg.BackupRemoteSchedule())
+ remote.Linked = remoteLinked
+ status := models.BackupStatusResponse{Local: local, Remote: remote}
+ if m.coordinator != nil {
+ kind, startedAt, active := m.coordinator.Active()
+ if active {
+ status.ActiveOperation = string(kind)
+ formatted := formatTime(startedAt)
+ status.ActiveSince = &formatted
+ }
+ }
+ return status
+}
+
+func (m *Manager) backupDir() string {
+ if localDir := m.cfg.BackupLocalDir(); localDir != "" {
+ return localDir
+ }
+ return filepath.Join(helpers.DataDir(m.pl), "backups", backupDirName)
+}
+
+func (m *Manager) statusPath() string {
+ return filepath.Join(helpers.DataDir(m.pl), "backups", "status.json")
+}
+
+func (m *Manager) readStatus() statusFile {
+ statusMu.RLock()
+ defer statusMu.RUnlock()
+
+ return m.readStatusLocked()
+}
+
+func (m *Manager) readStatusLocked() statusFile {
+ var st statusFile
+ data, err := os.ReadFile(m.statusPath())
+ switch {
+ case err == nil:
+ if decodeErr := json.Unmarshal(data, &st); decodeErr != nil {
+ log.Warn().Err(decodeErr).Msg("backup status is corrupt; remote access disabled until relink")
+ st.Remote.Unlinked = true
+ st.Remote.Availability = RemoteAvailabilityUnknown
+ st.Remote.AvailabilityCheckedAt = ""
+ }
+ case errors.Is(err, os.ErrNotExist):
+ // No status is normal before the first backup operation.
+ default:
+ log.Warn().Err(err).Msg("backup status is unreadable; remote access disabled until relink")
+ st.Remote.Unlinked = true
+ st.Remote.Availability = RemoteAvailabilityUnknown
+ }
+ return st
+}
+
+func (m *Manager) writeLocalStatus(local *statusEntry) error {
+ statusMu.Lock()
+ defer statusMu.Unlock()
+
+ st := m.readStatusLocked()
+ if local.Categories == nil && st.Local.Categories != nil {
+ local.Categories = st.Local.Categories
+ }
+ if local.LastSuccessAt == "" {
+ local.LastSuccessAt = st.Local.LastSuccessAt
+ }
+ st.Local = *local
+ return m.writeStatusLocked(&st)
+}
+
+// RecoverInterruptedRuns converts a persisted "running" status left behind by
+// an interrupted run (power loss, hard shutdown) into a failure. The
+// coordinator lease is in-memory, so at service startup no run can actually be
+// in flight: a lingering "running" is always stale. Recording it as failed
+// makes the scheduler retry on the short failure interval instead of waiting
+// out the full daily/weekly cadence.
+func (m *Manager) RecoverInterruptedRuns() {
+ // An operation holding the coordinator right now owns the "running"
+ // status (a run can start between the API coming up and this recovery
+ // pass); leave it to record its own outcome.
+ if m.coordinator != nil {
+ if _, _, active := m.coordinator.Active(); active {
+ return
+ }
+ }
+ statusMu.Lock()
+ defer statusMu.Unlock()
+
+ st := m.readStatusLocked()
+ changed := false
+ for _, entry := range []*statusEntry{&st.Local, &st.Remote} {
+ if entry.LastStatus != StatusRunning {
+ continue
+ }
+ entry.LastStatus = StatusFailed
+ entry.LastError = statusErrorInterrupted
+ changed = true
+ }
+ if !changed {
+ return
+ }
+ log.Info().Msg("marking interrupted backup run as failed")
+ if err := m.writeStatusLocked(&st); err != nil {
+ log.Warn().Err(err).Msg("failed to persist interrupted backup status")
+ }
+}
+
+// writeStatusLocked persists the status file. Callers must hold statusMu.
+func (m *Manager) writeStatusLocked(st *statusFile) error {
+ statusPath := m.statusPath()
+ dir := filepath.Dir(statusPath)
+ _, statErr := os.Stat(dir)
+ dirMissing := errors.Is(statErr, os.ErrNotExist)
+ if statErr != nil && !dirMissing {
+ return fmt.Errorf("checking backup status directory: %w", statErr)
+ }
+ if err := os.MkdirAll(dir, 0o750); err != nil {
+ return fmt.Errorf("creating backup status directory: %w", err)
+ }
+ if dirMissing {
+ if err := m.syncRestoreDirectory(filepath.Dir(dir)); err != nil {
+ return fmt.Errorf("syncing backup status parent directory: %w", err)
+ }
+ }
+ data, err := json.MarshalIndent(st, "", " ")
+ if err != nil {
+ return fmt.Errorf("encoding backup status: %w", err)
+ }
+ tmp, err := os.CreateTemp(dir, ".status-*")
+ if err != nil {
+ return fmt.Errorf("creating backup status temp file: %w", err)
+ }
+ tmpPath := tmp.Name()
+ defer func() { _ = os.Remove(tmpPath) }()
+ if err = tmp.Chmod(0o600); err == nil {
+ _, err = tmp.Write(data)
+ }
+ if err == nil {
+ err = tmp.Sync()
+ }
+ closeErr := tmp.Close()
+ if err != nil {
+ return fmt.Errorf("writing backup status temp file: %w", err)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing backup status temp file: %w", closeErr)
+ }
+ if err = os.Rename(tmpPath, statusPath); err != nil {
+ return fmt.Errorf("installing backup status: %w", err)
+ }
+ if err = m.syncRestoreDirectory(dir); err != nil {
+ return fmt.Errorf("syncing backup status directory: %w", err)
+ }
+ return nil
+}
+
+func (m *Manager) newManifest(
+ createdAt time.Time, files []FileRef, warnings []models.BackupWarning,
+) Manifest {
+ return Manifest{
+ Version: 1,
+ CreatedAt: createdAt,
+ Platform: m.pl.ID(),
+ CoreVersion: config.AppVersion,
+ Categories: summarize(files),
+ Warnings: warnings,
+ Files: files,
+ }
+}
+
+// preRestoreScope ignores the configured backup scope: the pre-restore
+// ZIP exists to preserve whatever a restore may overwrite, so it always
+// includes platform files when the platform can provide them.
+func (m *Manager) preRestoreScope() string {
+ if _, ok := m.pl.(platforms.BackupProvider); ok {
+ return config.BackupScopePlatform
+ }
+ return config.BackupScopeZaparoo
+}
+
+func (m *Manager) collectFiles(ctx context.Context, reason, scope string) (fileCollection, error) {
+ dataDir := helpers.DataDir(m.pl)
+ configDir := helpers.ConfigDir(m.pl)
+ var platformPlan platforms.BackupPlan
+ if scope == config.BackupScopePlatform {
+ provider, ok := m.pl.(platforms.BackupProvider)
+ if !ok {
+ return fileCollection{}, ErrPlatformBackupUnsupported
+ }
+ platformPlan = platforms.BackupPlan{Definitions: provider.BackupDefinitions()}
+ if planner, planning := m.pl.(platforms.BackupPlanningProvider); planning {
+ platformPlan = planner.BackupPlan()
+ }
+ }
+
+ if m.database == nil || m.database.UserDB == nil {
+ return fileCollection{}, errors.New("database is not available")
+ }
+ userBackup, cleanup, err := m.database.UserDB.BackupForTransfer(ctx, reason)
+ if err != nil {
+ return fileCollection{}, fmt.Errorf("snapshotting transfer user database: %w", err)
+ }
+
+ canonicalConfigRoots := canonicalCategoryRoots([]string{configDir}, "")
+ excludedSources := make(map[string]struct{}, 1)
+ for _, root := range canonicalConfigRoots {
+ excludedSources[filepath.Join(root, config.AuthFile)] = struct{}{}
+ }
+ excludedIdentities, err := sourceIdentities(excludedSources)
+ if err != nil {
+ return fileCollection{}, errors.Join(err, cleanup())
+ }
+ collector := newSourceCollector(ctx, m.cfg.BackupMaxSizeBytes(), excludedSources)
+ collector.excludedIdentities = excludedIdentities
+ collector.pauser = m.pauser
+ if err = collector.addTrustedFile(
+ userBackup.Path, CategoryZaparoo, zaparooArchive("user.db"), "user.db",
+ ); err != nil {
+ return fileCollection{}, errors.Join(err, cleanup())
+ }
+ zaparooDefs := zaparooBackupDefinitions(configDir, dataDir)
+ zaparooDefinitions := make([]collectorDefinition, 0, len(zaparooDefs))
+ for i := range zaparooDefs {
+ def := &zaparooDefs[i]
+ trusted := canonicalConfigRoots
+ if def.SourceRoot != configDir {
+ trusted = canonicalCategoryRoots([]string{dataDir}, def.RestoreRoot)
+ }
+ zaparooDefinitions = append(zaparooDefinitions, collectorDefinition{
+ definition: *def, trustedRoots: trusted, archive: zaparooArchive,
+ })
+ }
+ for i := range zaparooDefinitions {
+ collector.collect(&zaparooDefinitions[i])
+ }
+ m.collectPlatformDefinitions(collector, platformPlan.Definitions)
+ for _, warning := range platformPlan.Warnings {
+ collector.warn(warning.Category, warning.Path, warning.Reason)
+ }
+ if collector.err != nil {
+ return fileCollection{}, errors.Join(collector.err, cleanup())
+ }
+ return fileCollection{Files: collector.files, Warnings: collector.warnings, Cleanup: cleanup}, nil
+}
+
+// zaparooBackupDefinitions describes which Zaparoo-owned files backups
+// collect. validateManifestPolicy checks manifests against these same
+// definitions, so collection and restore policy cannot drift. user.db is
+// deliberately absent: it is a constructed entry staged from a database
+// snapshot, enforced separately as an exactly-once manifest payload.
+func zaparooBackupDefinitions(configDir, dataDir string) []platforms.BackupDefinition {
+ return []platforms.BackupDefinition{
+ {
+ SourceRoot: configDir, Category: CategoryZaparoo, NonRecursive: true,
+ Include: []platforms.BackupPattern{{Glob: config.CfgFile}},
+ },
+ {
+ SourceRoot: dataDir, Category: CategoryZaparoo, NonRecursive: true,
+ Include: []platforms.BackupPattern{{Glob: "frontend.toml"}, {Glob: config.TUIFile}},
+ },
+ {
+ SourceRoot: filepath.Join(dataDir, config.LaunchersDir),
+ RestoreRoot: config.LaunchersDir,
+ Category: CategoryZaparoo, Include: []platforms.BackupPattern{{Glob: "*.toml"}},
+ },
+ {
+ SourceRoot: filepath.Join(dataDir, config.MappingsDir),
+ RestoreRoot: config.MappingsDir,
+ Category: CategoryZaparoo, Include: []platforms.BackupPattern{{Glob: "*.toml"}},
+ },
+ }
+}
+
+func backupPatternsMatch(rel string, patterns []platforms.BackupPattern) bool {
+ if len(patterns) == 0 {
+ return false
+ }
+ rel = filepath.ToSlash(rel)
+ lowerRel := strings.ToLower(rel)
+ for _, pattern := range patterns {
+ if pattern.All {
+ return true
+ }
+ if pattern.Contains != "" && strings.Contains(lowerRel, strings.ToLower(pattern.Contains)) {
+ return true
+ }
+ if pattern.Glob == "" {
+ continue
+ }
+ glob := strings.ToLower(filepath.ToSlash(pattern.Glob))
+ target := lowerRel
+ if !strings.Contains(glob, "/") {
+ target = path.Base(lowerRel)
+ }
+ matched, err := path.Match(glob, target)
+ if err == nil && matched {
+ return true
+ }
+ }
+ return false
+}
+
+func (m *Manager) resolveBackupPath(name string) (string, error) {
+ base := filepath.Base(name)
+ if base != name || !isBackupZipName(base) {
+ return "", fmt.Errorf("invalid backup name: %s", name)
+ }
+ backupPath := filepath.Join(m.backupDir(), base)
+ if _, err := os.Stat(backupPath); err != nil {
+ return "", fmt.Errorf("finding backup ZIP: %w", err)
+ }
+ return backupPath, nil
+}
+
+func (m *Manager) applyRestoreFromZip(ctx context.Context, zipPath string, manifest *Manifest) error {
+ zr, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return fmt.Errorf("opening backup ZIP: %w", err)
+ }
+ defer func() {
+ if closeErr := zr.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Str("path", zipPath).Msg("failed to close backup ZIP")
+ }
+ }()
+ entries := zipEntriesByName(zr.File)
+ return m.applyRestore(ctx, manifest, func(file FileRef) (io.ReadCloser, error) {
+ entry, ok := entries[file.ArchivePath]
+ if !ok {
+ return nil, fmt.Errorf("backup ZIP missing payload for %s", file.ArchivePath)
+ }
+ opened, openErr := entry.Open()
+ if openErr != nil {
+ return nil, fmt.Errorf("opening backup ZIP payload %s: %w", file.ArchivePath, openErr)
+ }
+ return opened, nil
+ })
+}
+
+func readRestorePayload(
+ file *FileRef, openPayload func(FileRef) (io.ReadCloser, error), limit int64,
+) (data []byte, err error) {
+ if file.Size < 0 || file.Size > limit {
+ return nil, fmt.Errorf("restore payload %s exceeds %d bytes", file.RestorePath, limit)
+ }
+ payload, err := openPayload(*file)
+ if err != nil {
+ return nil, err
+ }
+ defer func() {
+ if closeErr := payload.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("closing restore payload %s: %w", file.RestorePath, closeErr))
+ }
+ }()
+ data, err = io.ReadAll(io.LimitReader(payload, file.Size+1))
+ if err != nil {
+ return nil, fmt.Errorf("reading restore payload %s: %w", file.RestorePath, err)
+ }
+ if int64(len(data)) != file.Size {
+ return nil, fmt.Errorf("restore payload size mismatch: %s", file.RestorePath)
+ }
+ hash := sha256.Sum256(data)
+ if hex.EncodeToString(hash[:]) != file.SHA256 {
+ return nil, fmt.Errorf("restore payload hash mismatch: %s", file.RestorePath)
+ }
+ return data, nil
+}
+
+func (m *Manager) preserveRestoreConfig(
+ manifest *Manifest, openPayload func(FileRef) (io.ReadCloser, error),
+) (*Manifest, func(FileRef) (io.ReadCloser, error), error) {
+ prepared := *manifest
+ prepared.Files = append([]FileRef(nil), manifest.Files...)
+ for i := range prepared.Files {
+ file := &prepared.Files[i]
+ if file.Category != CategoryZaparoo || file.RestorePath != config.CfgFile {
+ continue
+ }
+ data, err := readRestorePayload(file, openPayload, maxRestoreConfigSize)
+ if err != nil {
+ return nil, nil, err
+ }
+ data, err = config.PreserveRestoreOverrides(data, m.cfg.DeviceID(), m.cfg.EncryptionEnabled())
+ if err != nil {
+ return nil, nil, fmt.Errorf("preparing restored Core config: %w", err)
+ }
+ file.Size = int64(len(data))
+ hash := sha256.Sum256(data)
+ file.SHA256 = hex.EncodeToString(hash[:])
+ archivePath := file.ArchivePath
+ return &prepared, func(candidate FileRef) (io.ReadCloser, error) {
+ if candidate.ArchivePath == archivePath {
+ return io.NopCloser(bytes.NewReader(data)), nil
+ }
+ return openPayload(candidate)
+ }, nil
+ }
+ return &prepared, openPayload, nil
+}
+
+func (m *Manager) applyRestore(
+ ctx context.Context,
+ manifest *Manifest,
+ openPayload func(FileRef) (io.ReadCloser, error),
+) error {
+ prepared, preparedOpen, err := m.preserveRestoreConfig(manifest, openPayload)
+ if err != nil {
+ return err
+ }
+ return m.applyRestoreTransaction(ctx, prepared, preparedOpen)
+}
+
+func installVerifiedPayload(
+ ctx context.Context,
+ destination string,
+ file *FileRef,
+ payload io.ReadCloser,
+) (err error) {
+ defer func() {
+ if closeErr := payload.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("closing restore payload %s: %w", file.RestorePath, closeErr))
+ }
+ }()
+ if err = os.MkdirAll(filepath.Dir(destination), 0o750); err != nil {
+ return fmt.Errorf("creating restore directory: %w", err)
+ }
+ tmp, err := os.CreateTemp(filepath.Dir(destination), ".backup-restore-*")
+ if err != nil {
+ return fmt.Errorf("creating staged restore file for %s: %w", file.RestorePath, err)
+ }
+ tmpPath := tmp.Name()
+ defer func() { _ = os.Remove(tmpPath) }()
+ if err = tmp.Chmod(0o600); err != nil {
+ _ = tmp.Close()
+ return fmt.Errorf("securing staged restore file for %s: %w", file.RestorePath, err)
+ }
+
+ hash := sha256.New()
+ limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: payload}, N: file.Size + 1}
+ written, copyErr := io.Copy(io.MultiWriter(tmp, hash), limited)
+ syncErr := tmp.Sync()
+ closeErr := tmp.Close()
+ if copyErr != nil {
+ return fmt.Errorf("staging restore payload %s: %w", file.RestorePath, copyErr)
+ }
+ if syncErr != nil {
+ return fmt.Errorf("syncing restore payload %s: %w", file.RestorePath, syncErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing restore payload %s: %w", file.RestorePath, closeErr)
+ }
+ if written != file.Size {
+ return fmt.Errorf("restore payload size mismatch: %s", file.RestorePath)
+ }
+ if hex.EncodeToString(hash.Sum(nil)) != file.SHA256 {
+ return fmt.Errorf("restore payload hash mismatch: %s", file.RestorePath)
+ }
+ if err = os.Rename(tmpPath, destination); err != nil {
+ return fmt.Errorf("installing restore payload %s: %w", file.RestorePath, err)
+ }
+ return nil
+}
+
+type contextReader struct {
+ ctx context.Context
+ reader io.Reader
+}
+
+func (r *contextReader) Read(p []byte) (int, error) {
+ if err := r.ctx.Err(); err != nil {
+ return 0, err
+ }
+ n, err := r.reader.Read(p)
+ if errors.Is(err, io.EOF) {
+ return n, io.EOF
+ }
+ if err != nil {
+ return n, fmt.Errorf("reading restore payload: %w", err)
+ }
+ return n, nil
+}
+
+func appendBackupWarnings(
+ existing, additional []models.BackupWarning,
+) ([]models.BackupWarning, error) {
+ if len(additional) > maxArchiveEntries-len(existing) {
+ return nil, fmt.Errorf("backup has too many warnings: exceeds %d", maxArchiveEntries)
+ }
+ return append(existing, additional...), nil
+}
+
+func prepareSourceFiles(
+ ctx context.Context, files []FileRef, opener sourceOpener, pauser *syncutil.Pauser,
+) ([]FileRef, []models.BackupWarning, error) {
+ prepared := make([]FileRef, 0, len(files))
+ warnings := make([]models.BackupWarning, 0)
+ for i := range files {
+ if err := pauser.Wait(ctx); err != nil {
+ return nil, nil, fmt.Errorf("preparing backup sources: %w", err)
+ }
+ file := files[i]
+ hash, err := hashSourceFile(ctx, &file, opener)
+ if err == nil {
+ file.SHA256 = hash
+ prepared = append(prepared, file)
+ continue
+ }
+ if isSkippableSourceError(err) {
+ warnings = append(warnings, models.BackupWarning{
+ Category: file.Category,
+ Path: portableWarningPath(file.RestorePath),
+ Reason: "source unreadable during backup",
+ })
+ continue
+ }
+ return nil, nil, err
+ }
+ return prepared, warnings, nil
+}
+
+func hashSourceFile(ctx context.Context, file *FileRef, opener sourceOpener) (string, error) {
+ if err := ctx.Err(); err != nil {
+ return "", fmt.Errorf("reading backup source: %w", err)
+ }
+ if opener == nil {
+ opener = openSourceContext
+ }
+ source, err := opener(ctx, file)
+ if err != nil {
+ return "", err
+ }
+ hash := sha256.New()
+ limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: source}, N: file.Size + 1}
+ size, readErr := io.Copy(hash, limited)
+ closeErr := source.Close()
+ if readErr != nil {
+ return "", fmt.Errorf("reading backup source %s: %w", file.RestorePath, readErr)
+ }
+ if closeErr != nil {
+ return "", fmt.Errorf("closing backup source %s: %w", file.RestorePath, closeErr)
+ }
+ if size != file.Size {
+ return "", fmt.Errorf("%w: source size changed for %s", errSourceIdentityChanged, file.RestorePath)
+ }
+ return hex.EncodeToString(hash.Sum(nil)), nil
+}
+
+func isSkippableSourceError(err error) bool {
+ if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, errSourceIdentityChanged) {
+ return false
+ }
+ var pathErr *fs.PathError
+ return errors.As(err, &pathErr)
+}
+
+func (m *Manager) platformRestoreRoot(dataDir string) string {
+ if provider, ok := m.pl.(platforms.BackupRestoreRootProvider); ok {
+ return provider.BackupRestoreRoot()
+ }
+ return filepath.Dir(dataDir)
+}
+
+func zaparooArchive(rel string) string {
+ return path.Join(filesRoot, zaparooRoot, filepath.ToSlash(rel))
+}
+
+func platformArchive(rel string) string {
+ return path.Join(filesRoot, platformRoot, filepath.ToSlash(rel))
+}
+
+func validateFiles(files []FileRef) error {
+ archiveSeen := make(map[string]struct{}, len(files))
+ restoreSeen := make(map[string]struct{}, len(files))
+ for _, file := range files {
+ if !knownCategory(file.Category) {
+ return fmt.Errorf("unknown backup category: %s", file.Category)
+ }
+ if len(file.ArchivePath) > maxArchivePathLen || len(file.RestorePath) > maxArchivePathLen {
+ return fmt.Errorf("backup path exceeds %d bytes: %s", maxArchivePathLen, file.RestorePath)
+ }
+ if err := validateArchivePath(file.ArchivePath); err != nil {
+ return fmt.Errorf("invalid archive path %q: %w", file.ArchivePath, err)
+ }
+ if err := validateRestorePath(file.RestorePath); err != nil {
+ return fmt.Errorf("invalid restore path %q: %w", file.RestorePath, err)
+ }
+ if _, ok := archiveSeen[file.ArchivePath]; ok {
+ return fmt.Errorf("duplicate archive path: %s", file.ArchivePath)
+ }
+ archiveSeen[file.ArchivePath] = struct{}{}
+ restoreKey := file.Category + ":" + file.RestorePath
+ if isPlatformCategory(file.Category) {
+ restoreKey = "platform:" + file.RestorePath
+ }
+ if _, ok := restoreSeen[restoreKey]; ok {
+ return fmt.Errorf("duplicate restore path: %s", file.RestorePath)
+ }
+ restoreSeen[restoreKey] = struct{}{}
+ }
+ return nil
+}
+
+func knownCategory(category string) bool {
+ return category == CategoryZaparoo || isPlatformCategory(category)
+}
+
+func isPlatformCategory(category string) bool {
+ return category == CategorySettings || category == CategoryInputs ||
+ category == CategorySaves || category == CategorySavestates
+}
+
+func validateArchivePath(p string) error {
+ if p == manifestName {
+ return errors.New("reserved manifest path")
+ }
+ return validateSlashPath(p)
+}
+
+func validateRestorePath(p string) error { return validateSlashPath(p) }
+
+func validateSlashPath(p string) error {
+ if p == "" || strings.HasPrefix(p, "/") || strings.Contains(p, "\\") || strings.Contains(p, "\x00") {
+ return errors.New("path must be relative slash path")
+ }
+ cleaned := path.Clean(p)
+ if cleaned != p || cleaned == "." {
+ return errors.New("path must be clean")
+ }
+ for _, seg := range strings.Split(p, "/") {
+ if seg == "" || seg == "." || seg == ".." {
+ return errors.New("path contains unsafe segment")
+ }
+ }
+ return nil
+}
+
+func writeZip(
+ ctx context.Context, zipPath string, files []FileRef, manifest *Manifest, maxLogicalSize int64,
+) error {
+ if err := ctx.Err(); err != nil {
+ return fmt.Errorf("writing backup archive: %w", err)
+ }
+ if err := validateManifestMetadata(manifest, maxLogicalSize); err != nil {
+ return err
+ }
+ if len(files)+1 > maxArchiveEntries {
+ return fmt.Errorf("backup has too many entries: %d exceeds %d", len(files)+1, maxArchiveEntries)
+ }
+ expectedSize, err := validateLogicalSize(files, maxLogicalSize)
+ if err != nil {
+ return err
+ }
+ free, freeErr := helpers.FreeDiskSpace(filepath.Dir(zipPath))
+ if freeErr != nil {
+ return fmt.Errorf("checking disk space for backup: %w", freeErr)
+ }
+ required := uint64(expectedSize) + uint64(maxManifestBytes) //nolint:gosec // values are nonnegative and bounded.
+ if free < required {
+ return fmt.Errorf("insufficient disk space for backup: %d bytes available, need %d", free, required)
+ }
+
+ // #nosec G304 -- zipPath is resolved from configured backup directory and generated filename.
+ out, err := os.OpenFile(zipPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
+ if err != nil {
+ return fmt.Errorf("creating backup ZIP: %w", err)
+ }
+ zw := zip.NewWriter(out)
+ written := int64(0)
+ for i := range files {
+ remaining := maxLogicalSize - written
+ if err = writeZipFile(ctx, zw, &files[i], remaining); err != nil {
+ _ = zw.Close()
+ _ = out.Close()
+ return err
+ }
+ written += files[i].Size
+ }
+ manifest.Files = files
+ manifest.Categories = summarize(files)
+ manifestData, err := json.MarshalIndent(manifest, "", " ")
+ if err != nil {
+ _ = zw.Close()
+ _ = out.Close()
+ return fmt.Errorf("marshalling backup manifest: %w", err)
+ }
+ if int64(len(manifestData)) > maxManifestBytes {
+ _ = zw.Close()
+ _ = out.Close()
+ return fmt.Errorf("backup manifest exceeds %d bytes", maxManifestBytes)
+ }
+ if err = writeZipBytes(zw, manifestName, manifestData); err != nil {
+ _ = zw.Close()
+ _ = out.Close()
+ return err
+ }
+ if err = zw.Close(); err != nil {
+ _ = out.Close()
+ return fmt.Errorf("closing backup ZIP: %w", err)
+ }
+ if err = out.Sync(); err != nil {
+ _ = out.Close()
+ return fmt.Errorf("syncing backup ZIP: %w", err)
+ }
+ if err = out.Close(); err != nil {
+ return fmt.Errorf("closing backup ZIP file: %w", err)
+ }
+ return nil
+}
+
+func writeZipFile(ctx context.Context, zw *zip.Writer, file *FileRef, remaining int64) error {
+ if remaining < 0 {
+ return errors.New("backup exceeds logical size limit")
+ }
+ in, err := openSourceContext(ctx, file)
+ if err != nil {
+ return err
+ }
+ defer func() {
+ if closeErr := in.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Str("path", file.RestorePath).Msg("failed to close backup source")
+ }
+ }()
+
+ hdr := &zip.FileHeader{Name: file.ArchivePath, Method: zip.Deflate, Modified: time.Now().UTC()}
+ w, err := zw.CreateHeader(hdr)
+ if err != nil {
+ return fmt.Errorf("creating ZIP entry %s: %w", file.ArchivePath, err)
+ }
+ hash := sha256.New()
+ limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: in}, N: remaining + 1}
+ size, err := io.Copy(io.MultiWriter(w, hash), limited)
+ if err != nil {
+ return fmt.Errorf("writing ZIP entry %s: %w", file.ArchivePath, err)
+ }
+ if size > remaining {
+ return fmt.Errorf("backup exceeds logical size limit while reading %s", file.RestorePath)
+ }
+ if file.Size != size {
+ return fmt.Errorf("backup source changed size while reading %s", file.RestorePath)
+ }
+ actualHash := hex.EncodeToString(hash.Sum(nil))
+ if file.SHA256 != "" && file.SHA256 != actualHash {
+ return fmt.Errorf("%w: source content changed for %s", errSourceIdentityChanged, file.RestorePath)
+ }
+ file.SHA256 = actualHash
+ file.Size = size
+ return nil
+}
+
+func validateLogicalSize(files []FileRef, maxLogicalSize int64) (int64, error) {
+ if maxLogicalSize <= 0 || maxLogicalSize == math.MaxInt64 {
+ return 0, errors.New("backup logical size limit must be positive")
+ }
+ var total int64
+ for _, file := range files {
+ if file.Size < 0 || file.Size > maxLogicalSize-total {
+ return 0, fmt.Errorf("backup exceeds logical size limit of %d bytes", maxLogicalSize)
+ }
+ total += file.Size
+ }
+ return total, nil
+}
+
+func writeZipBytes(zw *zip.Writer, name string, data []byte) error {
+ hdr := &zip.FileHeader{Name: name, Method: zip.Deflate, Modified: time.Now().UTC()}
+ w, err := zw.CreateHeader(hdr)
+ if err != nil {
+ return fmt.Errorf("creating ZIP entry %s: %w", name, err)
+ }
+ if _, err = w.Write(data); err != nil {
+ return fmt.Errorf("writing ZIP entry %s: %w", name, err)
+ }
+ return nil
+}
+
+func fastInfoFromDirEntry(backupDir string, entry os.DirEntry) (ListInfo, error) {
+ fileInfo, err := entry.Info()
+ if err != nil {
+ return ListInfo{}, fmt.Errorf("stating backup ZIP: %w", err)
+ }
+ createdAt := fileInfo.ModTime().UTC()
+ if parsed, ok := parseBackupNameTime(entry.Name()); ok {
+ createdAt = parsed
+ }
+ return ListInfo{
+ Name: entry.Name(),
+ Path: filepath.Join(backupDir, entry.Name()),
+ CreatedAt: createdAt,
+ Size: fileInfo.Size(),
+ }, nil
+}
+
+func parseBackupNameTime(name string) (time.Time, bool) {
+ trimmed := strings.TrimPrefix(name, "backup-")
+ parts := strings.SplitN(trimmed, "-", 3)
+ if len(parts) < 2 {
+ return time.Time{}, false
+ }
+ parsed, err := time.ParseInLocation("20060102150405", parts[0]+parts[1], time.UTC)
+ if err != nil {
+ return time.Time{}, false
+ }
+ return parsed, true
+}
+
+func infoFromManifest(zipPath string, manifest *Manifest) (Info, error) {
+ st, err := os.Stat(zipPath)
+ if err != nil {
+ return Info{}, fmt.Errorf("stating backup ZIP: %w", err)
+ }
+ status := StatusSuccess
+ if len(manifest.Warnings) > 0 {
+ status = StatusPartial
+ }
+ return Info{
+ Name: filepath.Base(zipPath),
+ Path: zipPath,
+ CreatedAt: manifest.CreatedAt,
+ Size: st.Size(),
+ Status: status,
+ Integrity: IntegrityValid,
+ Categories: manifest.Categories,
+ Warnings: manifest.Warnings,
+ }, nil
+}
+
+func inspectZipManifest(zipPath string, maxLogicalSize int64) (Info, error) {
+ zr, openErr := zip.OpenReader(zipPath)
+ if openErr != nil {
+ return Info{}, fmt.Errorf("opening backup ZIP: %w", openErr)
+ }
+ defer func() {
+ if closeErr := zr.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Str("path", zipPath).Msg("failed to close backup ZIP")
+ }
+ }()
+ entries, err := validateZipHeaders(zr.File, maxLogicalSize)
+ if err != nil {
+ return Info{}, err
+ }
+ manifest, err := readManifestFromZipLimit(zr.File, entries, maxLogicalSize)
+ if err != nil {
+ return Info{}, err
+ }
+ info, err := infoFromManifest(zipPath, manifest)
+ if err != nil {
+ return Info{}, err
+ }
+ info.Integrity = IntegrityUnchecked
+ return info, nil
+}
+
+func (m *Manager) validateLocalArchiveManifest(zipPath string) error {
+ zr, err := zip.OpenReader(zipPath)
+ if err != nil {
+ return fmt.Errorf("opening backup ZIP: %w", err)
+ }
+ defer func() { _ = zr.Close() }()
+ entries, err := validateZipHeaders(zr.File, m.cfg.BackupMaxSizeBytes())
+ if err != nil {
+ return err
+ }
+ manifest, err := readManifestFromZipLimit(zr.File, entries, m.cfg.BackupMaxSizeBytes())
+ if err != nil {
+ return err
+ }
+ return m.validateManifestPolicy(manifest)
+}
+
+func readManifestFromZipLimit(
+ files []*zip.File,
+ entries map[string]*zip.File,
+ maxLogicalSize int64,
+) (*Manifest, error) {
+ manifestEntry, ok := entries[manifestName]
+ if !ok {
+ return nil, errors.New("backup ZIP missing manifest")
+ }
+ body, err := readZipEntryLimited(manifestEntry, maxManifestBytes)
+ if err != nil {
+ return nil, err
+ }
+ var manifest Manifest
+ if err = json.Unmarshal(body, &manifest); err != nil {
+ return nil, fmt.Errorf("decoding backup manifest: %w", err)
+ }
+ if manifest.Version != 1 {
+ return nil, fmt.Errorf("unsupported backup manifest version: %d", manifest.Version)
+ }
+ if validateErr := validateManifest(&manifest, files, entries, maxLogicalSize); validateErr != nil {
+ return nil, validateErr
+ }
+ return &manifest, nil
+}
+
+func readAndVerifyZipLimit(zipPath string, maxLogicalSize int64) (*zipReadResult, error) {
+ zr, openErr := zip.OpenReader(zipPath)
+ if openErr != nil {
+ return nil, fmt.Errorf("opening backup ZIP: %w", openErr)
+ }
+ defer func() {
+ if closeErr := zr.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Str("path", zipPath).Msg("failed to close backup ZIP")
+ }
+ }()
+ entries, err := validateZipHeaders(zr.File, maxLogicalSize)
+ if err != nil {
+ return nil, err
+ }
+ manifest, err := readManifestFromZipLimit(zr.File, entries, maxLogicalSize)
+ if err != nil {
+ return nil, err
+ }
+ for i := range manifest.Files {
+ file := &manifest.Files[i]
+ entry := entries[file.ArchivePath]
+ if verifyErr := verifyZipEntry(entry, file); verifyErr != nil {
+ return nil, verifyErr
+ }
+ }
+ info, err := infoFromManifest(zipPath, manifest)
+ if err != nil {
+ return nil, err
+ }
+ info.Integrity = IntegrityValid
+ return &zipReadResult{Manifest: *manifest, Info: info}, nil
+}
+
+func validateZipHeaders(files []*zip.File, maxLogicalSize int64) (map[string]*zip.File, error) {
+ if maxLogicalSize <= 0 || maxLogicalSize == math.MaxInt64 {
+ return nil, errors.New("backup logical size limit must be positive")
+ }
+ if len(files) > maxArchiveEntries {
+ return nil, fmt.Errorf("backup has too many entries: %d exceeds %d", len(files), maxArchiveEntries)
+ }
+ entries := make(map[string]*zip.File, len(files))
+ var total int64
+ for _, file := range files {
+ if len(file.Name) > maxArchivePathLen {
+ return nil, fmt.Errorf("ZIP entry path exceeds %d bytes", maxArchivePathLen)
+ }
+ if file.Name == manifestName {
+ if file.UncompressedSize64 > uint64(maxManifestBytes) {
+ return nil, fmt.Errorf("backup manifest exceeds %d bytes", maxManifestBytes)
+ }
+ } else if err := validateArchivePath(file.Name); err != nil {
+ return nil, fmt.Errorf("invalid ZIP entry %q: %w", file.Name, err)
+ }
+ if !file.Mode().IsRegular() {
+ return nil, fmt.Errorf("ZIP entry is not a regular file: %s", file.Name)
+ }
+ if _, exists := entries[file.Name]; exists {
+ return nil, fmt.Errorf("duplicate ZIP entry: %s", file.Name)
+ }
+ entries[file.Name] = file
+ if file.Name == manifestName {
+ continue
+ }
+ if file.UncompressedSize64 > uint64(maxLogicalSize) {
+ return nil, fmt.Errorf("backup exceeds logical size limit of %d bytes", maxLogicalSize)
+ }
+ size := int64(file.UncompressedSize64) //nolint:gosec // bounded by positive maxLogicalSize above.
+ if size > maxLogicalSize-total {
+ return nil, fmt.Errorf("backup exceeds logical size limit of %d bytes", maxLogicalSize)
+ }
+ total += size
+ }
+ return entries, nil
+}
+
+func validateManifestMetadata(manifest *Manifest, maxLogicalSize int64) error {
+ if manifest.Version != 1 {
+ return fmt.Errorf("unsupported backup manifest version: %d", manifest.Version)
+ }
+ if manifest.Platform == "" {
+ return errors.New("backup manifest platform is required")
+ }
+ if manifest.CreatedAt.IsZero() {
+ return errors.New("backup manifest creation time is required")
+ }
+ if err := validateFiles(manifest.Files); err != nil {
+ return err
+ }
+ if _, err := validateLogicalSize(manifest.Files, maxLogicalSize); err != nil {
+ return err
+ }
+ if !categorySummariesEqual(manifest.Categories, summarize(manifest.Files)) {
+ return errors.New("backup manifest category summary mismatch")
+ }
+ for _, warning := range manifest.Warnings {
+ if !knownCategory(warning.Category) || warning.Reason == "" ||
+ len(warning.Path) > maxArchivePathLen || validateRestorePath(warning.Path) != nil {
+ return errors.New("backup manifest contains invalid warning metadata")
+ }
+ }
+ return nil
+}
+
+func validateManifest(
+ manifest *Manifest,
+ files []*zip.File,
+ entries map[string]*zip.File,
+ maxLogicalSize int64,
+) error {
+ if err := validateManifestMetadata(manifest, maxLogicalSize); err != nil {
+ return err
+ }
+ if len(manifest.Files)+1 != len(files) {
+ return errors.New("backup ZIP payload entries do not match manifest")
+ }
+ declared := make(map[string]struct{}, len(manifest.Files))
+ for _, file := range manifest.Files {
+ entry, ok := entries[file.ArchivePath]
+ if !ok {
+ return fmt.Errorf("missing ZIP entry: %s", file.ArchivePath)
+ }
+ if entry.UncompressedSize64 > math.MaxInt64 || int64(entry.UncompressedSize64) != file.Size {
+ return fmt.Errorf("backup ZIP size mismatch: %s", file.ArchivePath)
+ }
+ hash, err := hex.DecodeString(file.SHA256)
+ if err != nil || len(hash) != sha256.Size {
+ return fmt.Errorf("invalid SHA-256 for %s", file.ArchivePath)
+ }
+ expectedPrefix := path.Join(filesRoot, platformRoot) + "/"
+ if file.Category == CategoryZaparoo {
+ expectedPrefix = path.Join(filesRoot, zaparooRoot) + "/"
+ }
+ if !strings.HasPrefix(file.ArchivePath, expectedPrefix) {
+ return fmt.Errorf("archive path does not match category %s: %s", file.Category, file.ArchivePath)
+ }
+ declared[file.ArchivePath] = struct{}{}
+ }
+ for name := range entries {
+ if name == manifestName {
+ continue
+ }
+ if _, ok := declared[name]; !ok {
+ return fmt.Errorf("undeclared ZIP entry: %s", name)
+ }
+ }
+ return nil
+}
+
+func categorySummariesEqual(
+ actual, expected map[string]models.BackupCategoryStatus,
+) bool {
+ if len(actual) != len(expected) {
+ return false
+ }
+ for category, want := range expected {
+ if got, ok := actual[category]; !ok || got != want {
+ return false
+ }
+ }
+ return true
+}
+
+func verifyZipEntry(entry *zip.File, file *FileRef) error {
+ r, err := entry.Open()
+ if err != nil {
+ return fmt.Errorf("opening ZIP entry %s: %w", entry.Name, err)
+ }
+ defer func() { _ = r.Close() }()
+
+ hash := sha256.New()
+ limited := &io.LimitedReader{R: r, N: file.Size + 1}
+ size, err := io.Copy(hash, limited)
+ if err != nil {
+ return fmt.Errorf("reading ZIP entry %s: %w", entry.Name, err)
+ }
+ if size != file.Size {
+ return fmt.Errorf("backup ZIP size mismatch: %s", entry.Name)
+ }
+ if hex.EncodeToString(hash.Sum(nil)) != file.SHA256 {
+ return fmt.Errorf("backup ZIP hash mismatch: %s", entry.Name)
+ }
+ return nil
+}
+
+func zipEntriesByName(files []*zip.File) map[string]*zip.File {
+ entries := make(map[string]*zip.File, len(files))
+ for _, file := range files {
+ entries[file.Name] = file
+ }
+ return entries
+}
+
+func readZipEntryLimited(f *zip.File, limit int64) ([]byte, error) {
+ if limit < 0 || f.UncompressedSize64 > uint64(limit) {
+ return nil, fmt.Errorf("ZIP entry %s exceeds %d bytes", f.Name, limit)
+ }
+ r, err := f.Open()
+ if err != nil {
+ return nil, fmt.Errorf("opening ZIP entry %s: %w", f.Name, err)
+ }
+ defer func() {
+ if closeErr := r.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Str("name", f.Name).Msg("failed to close ZIP entry")
+ }
+ }()
+ body, err := io.ReadAll(io.LimitReader(r, limit+1))
+ if err != nil {
+ return nil, fmt.Errorf("reading ZIP entry %s: %w", f.Name, err)
+ }
+ if int64(len(body)) > limit {
+ return nil, fmt.Errorf("ZIP entry %s exceeds %d bytes", f.Name, limit)
+ }
+ return body, nil
+}
+
+func summarize(files []FileRef) map[string]models.BackupCategoryStatus {
+ out := map[string]models.BackupCategoryStatus{
+ CategoryZaparoo: {Enabled: true},
+ CategorySettings: {Enabled: true},
+ CategoryInputs: {Enabled: true},
+ CategorySaves: {Enabled: true},
+ CategorySavestates: {Enabled: true},
+ }
+ for _, file := range files {
+ entry := out[file.Category]
+ entry.Files++
+ entry.Bytes += file.Size
+ entry.Enabled = true
+ out[file.Category] = entry
+ }
+ return out
+}
+
+func toStatusEntry(st *statusEntry, enabled bool, schedule string) models.BackupStatusEntry {
+ lastStatus := st.LastStatus
+ if lastStatus == "" {
+ lastStatus = StatusNever
+ }
+ availability := st.Availability
+ if schedule != "" && availability == "" {
+ availability = RemoteAvailabilityUnknown
+ }
+ return models.BackupStatusEntry{
+ LastRunAt: optionalString(st.LastRunAt),
+ LastSuccessAt: optionalString(st.LastSuccessAt),
+ AvailabilityCheckedAt: optionalString(st.AvailabilityCheckedAt),
+ DeviceName: optionalString(st.DeviceName),
+ LinkedAt: optionalString(st.LinkedAt),
+ Availability: availability,
+ LastError: safeStatusErrorString(st.LastError),
+ LastStatus: lastStatus,
+ LastBackupSize: st.LastBackupSize,
+ Categories: st.Categories,
+ Warnings: st.Warnings,
+ SkippedFiles: st.SkippedFiles,
+ Enabled: enabled,
+ Schedule: schedule,
+ }
+}
+
+func optionalString(s string) *string {
+ if s == "" {
+ return nil
+ }
+ return &s
+}
+
+func backupName(preRestore bool, now time.Time) string {
+ kind := "manual"
+ if preRestore {
+ kind = "pre-restore"
+ }
+ return fmt.Sprintf("backup-%s-%09d-%s.zip", now.Format("20060102-150405"), now.Nanosecond(), kind)
+}
+
+func isBackupZipName(name string) bool {
+ return strings.HasPrefix(name, "backup-") && strings.HasSuffix(name, ".zip")
+}
+
+func databaseBackupName(now time.Time) string {
+ return fmt.Sprintf("backup-%s-%09d-manual.db", now.Format("20060102-150405"), now.Nanosecond())
+}
+
+func formatTime(t time.Time) string { return t.UTC().Format(time.RFC3339Nano) }
+
+// safeStatusReasons are the only failure strings ever persisted or shown:
+// short, application-style reasons with no error internals.
+var safeStatusReasons = map[string]struct{}{
+ "not available for this account": {},
+ "storage quota exceeded": {},
+ "device not linked": {},
+ "rate limited": {},
+ "files changed during backup": {},
+ "requires newer Core version": {},
+ "another backup was running": {},
+ "backup failed": {},
+ statusErrorInterrupted: {},
+}
+
+func safeStatusError(err error) string {
+ var busy *BusyError
+ switch {
+ case err == nil:
+ return ""
+ case errors.Is(err, errRemoteNotAvailable):
+ return "not available for this account"
+ case errors.Is(err, errRemoteQuotaExceeded):
+ return "storage quota exceeded"
+ case errors.Is(err, errRemoteUnlinked):
+ return "device not linked"
+ case errors.Is(err, errRemoteRateLimited):
+ return "rate limited"
+ case errors.Is(err, errRemoteIntegrityRetry):
+ return "files changed during backup"
+ case errors.Is(err, errRemoteNewerSchema):
+ return "requires newer Core version"
+ case errors.As(err, &busy):
+ return "another backup was running"
+ default:
+ return "backup failed"
+ }
+}
+
+func safeStatusErrorString(msg string) string {
+ if msg == "" {
+ return ""
+ }
+ if _, ok := safeStatusReasons[msg]; ok {
+ return msg
+ }
+ return "backup failed"
+}
diff --git a/pkg/service/backup/backup_test.go b/pkg/service/backup/backup_test.go
new file mode 100644
index 00000000..c345d004
--- /dev/null
+++ b/pkg/service/backup/backup_test.go
@@ -0,0 +1,3569 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package backup
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path"
+ "path/filepath"
+ "runtime"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ platformids "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/ids"
+ inboxservice "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/inbox"
+ testinghelpers "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/assert"
+ testifymock "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+type errorReader struct {
+ err error
+}
+
+func (r *errorReader) Read([]byte) (int, error) {
+ return 0, r.err
+}
+
+type blockingReadCloser struct {
+ closed chan struct{}
+ once sync.Once
+}
+
+func (r *blockingReadCloser) Read([]byte) (int, error) {
+ <-r.closed
+ return 0, errors.New("reader closed")
+}
+
+func (r *blockingReadCloser) Close() error {
+ r.once.Do(func() { close(r.closed) })
+ return nil
+}
+
+type backupTestEnv struct {
+ Manager *Manager
+ UserDB *testinghelpers.MockUserDBI
+ RootDir string
+ ConfigDir string
+ DataDir string
+ UserSnapshot string
+}
+
+type backupPlatform struct {
+ *mocks.MockPlatform
+ definitions []platforms.BackupDefinition
+}
+
+type backupRestoreRootPlatform struct {
+ restoreRoot string
+ backupPlatform
+}
+
+type backupRestorePreparingPlatform struct {
+ finished *bool
+ prepared *bool
+ backupPlatform
+}
+
+type backupPlanningTestPlatform struct {
+ backupPlatform
+ plan platforms.BackupPlan
+}
+
+func (p backupPlatform) BackupDefinitions() []platforms.BackupDefinition {
+ return p.definitions
+}
+
+func (p backupRestoreRootPlatform) BackupRestoreRoot() string {
+ return p.restoreRoot
+}
+
+func (p *backupPlanningTestPlatform) BackupPlan() platforms.BackupPlan {
+ return p.plan
+}
+
+func (p *backupRestorePreparingPlatform) PrepareBackupRestore() (func(bool) error, error) {
+ *p.prepared = true
+ return func(success bool) error {
+ *p.finished = success
+ return nil
+ }, nil
+}
+
+func newBackupTestEnv(t *testing.T, platformID string) backupTestEnv {
+ t.Helper()
+ return newBackupTestEnvWithClients(t, platformID, nil)
+}
+
+func newBackupTestEnvWithClients(
+ t *testing.T, platformID string, pairedClients []database.Client,
+) backupTestEnv {
+ t.Helper()
+ rootDir := t.TempDir()
+ dataDir := filepath.Join(rootDir, "zaparoo")
+ configDir := filepath.Join(rootDir, "config-dir")
+ require.NoError(t, os.MkdirAll(dataDir, 0o750))
+ require.NoError(t, os.MkdirAll(configDir, 0o750))
+
+ cfg, err := config.NewConfig(configDir, config.BaseDefaults)
+ require.NoError(t, err)
+
+ writeTestFile(t, filepath.Join(configDir, config.CfgFile), "debug_logging = false\n")
+ writeTestFile(t, filepath.Join(dataDir, "frontend.toml"), "enabled = true\n")
+ writeTestFile(t, filepath.Join(dataDir, config.TUIFile), "theme = \"default\"\n")
+ writeTestFile(t, filepath.Join(dataDir, config.LaunchersDir, "custom.toml"), "[[launchers]]\n")
+ writeTestFile(t, filepath.Join(dataDir, config.MappingsDir, "tokens.toml"), "[[mappings]]\n")
+
+ writeTestFile(t, filepath.Join(rootDir, "MiSTer.ini"), "video_mode=0\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "core.cfg"), "setting=1\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "core_recent.cfg"), "recent=1\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "inputs", "pad.map"), "map\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "inputs", "ignored.txt"), "ignore\n")
+ writeTestFile(t, filepath.Join(rootDir, "saves", "game.sav"), "save-data\n")
+ writeTestFile(t, filepath.Join(rootDir, "savestates", "game.ss"), "state-data\n")
+
+ userSnapshot := filepath.Join(rootDir, "user-snapshot.db")
+ writeTestFile(t, userSnapshot, "user-db-snapshot\n")
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return(platformID)
+ mockPlatform.On("RootDirs", testifymock.Anything).Return([]string{
+ rootDir,
+ filepath.Join(rootDir, "usb"),
+ })
+ mockPlatform.On("Settings").Return(platforms.Settings{
+ DataDir: dataDir, ConfigDir: configDir, TempDir: filepath.Join(rootDir, "tmp"), LogDir: rootDir,
+ })
+ pl := backupPlatform{MockPlatform: mockPlatform, definitions: testPlatformDefinitions(rootDir)}
+
+ userDB := testinghelpers.NewMockUserDBI()
+ userDB.On(
+ "BackupForTransfer", testifymock.Anything, testifymock.AnythingOfType("string"),
+ ).Return(database.BackupInfo{
+ Name: "snapshot.db",
+ Path: userSnapshot,
+ Valid: true,
+ }, func() error { return nil }, nil)
+ userDB.On("Backup", "restore-rollback", false).Return(database.BackupInfo{
+ Name: "rollback.db", Path: userSnapshot, Valid: true,
+ }, nil).Maybe()
+ userDB.On("GetDBPath").Return(filepath.Join(dataDir, config.UserDbFile)).Maybe()
+ userDB.On("RestoreBackup", testifymock.AnythingOfType("string")).Return(database.RestoreInfo{
+ RestoredFrom: database.BackupInfo{Name: "staged.db", Valid: true},
+ }, nil).Maybe()
+ userDB.On("ListClients").Return(pairedClients, nil).Maybe()
+ userDB.On("ReplaceAllClients", testifymock.Anything).Return(nil).Maybe()
+
+ mgr := NewManager(cfg, pl, &database.Database{UserDB: userDB})
+ return backupTestEnv{
+ Manager: mgr,
+ UserDB: userDB,
+ RootDir: rootDir,
+ ConfigDir: configDir,
+ DataDir: dataDir,
+ UserSnapshot: userSnapshot,
+ }
+}
+
+// collectPlatformFiles runs the source collector over platform definitions
+// without a Manager, for asserting collection results in isolation.
+func collectPlatformFiles(files []FileRef, definitions []platforms.BackupDefinition) []FileRef {
+ collector := newSourceCollector(context.Background(), config.DefaultBackupMaxSizeBytes, nil)
+ for i := range files {
+ collector.appendFile(&files[i])
+ }
+ for i := range definitions {
+ def := &definitions[i]
+ spec := collectorDefinition{
+ definition: *def,
+ trustedRoots: definitionCategoryRoots(def, nil),
+ archive: platformArchive,
+ }
+ collector.collect(&spec)
+ }
+ return collector.files
+}
+
+func testPlatformDefinitions(rootDir string) []platforms.BackupDefinition {
+ return []platforms.BackupDefinition{
+ {
+ Category: CategorySettings,
+ SourceRoot: rootDir,
+ RestoreRoot: "",
+ NonRecursive: true,
+ Include: []platforms.BackupPattern{
+ {Glob: "MiSTer.ini"},
+ },
+ },
+ {
+ Category: CategorySettings,
+ SourceRoot: filepath.Join(rootDir, "config"),
+ RestoreRoot: "config",
+ Include: []platforms.BackupPattern{{Glob: "*.cfg"}},
+ Exclude: []platforms.BackupPattern{{Contains: "_recent"}},
+ },
+ {
+ Category: CategoryInputs,
+ SourceRoot: filepath.Join(rootDir, "config", "inputs"),
+ RestoreRoot: filepath.Join("config", "inputs"),
+ Include: []platforms.BackupPattern{{Glob: "*.map"}},
+ Exclude: []platforms.BackupPattern{{Glob: filepath.Join("renamed", "*")}},
+ },
+ {
+ Category: CategorySaves,
+ SourceRoot: filepath.Join(rootDir, "zaparoo", "profiles"),
+ RestoreRoot: filepath.Join("zaparoo", "profiles"),
+ Include: []platforms.BackupPattern{{Contains: "/saves/"}},
+ },
+ {
+ Category: CategorySavestates,
+ SourceRoot: filepath.Join(rootDir, "zaparoo", "profiles"),
+ RestoreRoot: filepath.Join("zaparoo", "profiles"),
+ Include: []platforms.BackupPattern{{Contains: "/savestates/"}},
+ },
+ {
+ Category: CategorySaves,
+ SourceRoot: filepath.Join(rootDir, "saves"),
+ RestoreRoot: "saves",
+ Include: []platforms.BackupPattern{{All: true}},
+ },
+ {
+ Category: CategorySavestates,
+ SourceRoot: filepath.Join(rootDir, "savestates"),
+ RestoreRoot: "savestates",
+ Include: []platforms.BackupPattern{{All: true}},
+ },
+ }
+}
+
+func TestManagerCreateReportsPlatformPlanWarningsAsPartial(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ basePlatform, ok := env.Manager.pl.(backupPlatform)
+ require.True(t, ok)
+ env.Manager.pl = &backupPlanningTestPlatform{
+ backupPlatform: basePlatform,
+ plan: platforms.BackupPlan{
+ Definitions: basePlatform.BackupDefinitions(),
+ Warnings: []platforms.BackupWarning{{
+ Category: CategorySaves, Path: "saves",
+ Reason: "shared profile data hidden by active profile mount",
+ }},
+ },
+ }
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, StatusPartial, info.Status)
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves, Path: "saves",
+ Reason: "shared profile data hidden by active profile mount",
+ })
+ status := env.Manager.Status()
+ assert.Equal(t, StatusPartial, status.Local.LastStatus)
+ assert.Equal(t, 1, status.Local.SkippedFiles)
+}
+
+func TestManagerCreateBackupSkipsUnreadableSource(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ defaultOpener := env.Manager.sourceOpener
+ env.Manager.sourceOpener = func(ctx context.Context, file *FileRef) (io.ReadCloser, error) {
+ if file.RestorePath == filepath.ToSlash(filepath.Join("saves", "game.sav")) {
+ return nil, &os.PathError{Op: "open", Path: file.RestorePath, Err: os.ErrPermission}
+ }
+ return defaultOpener(ctx, file)
+ }
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves,
+ Path: filepath.ToSlash(filepath.Join("saves", "game.sav")),
+ Reason: "source unreadable during backup",
+ })
+ status := env.Manager.Status()
+ assert.Equal(t, StatusPartial, status.Local.LastStatus)
+ assert.Equal(t, 1, status.Local.SkippedFiles)
+ result, err := readAndVerifyZipLimit(info.Path, env.Manager.cfg.BackupMaxSizeBytes())
+ require.NoError(t, err)
+ for _, file := range result.Manifest.Files {
+ assert.NotEqual(t, filepath.ToSlash(filepath.Join("saves", "game.sav")), file.RestorePath)
+ }
+}
+
+func TestManagerCreateBackupSkipsNonportablePathAndSelfInspects(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("Windows cannot create a filename containing a literal backslash")
+ }
+ env := newBackupTestEnv(t, platformids.Mister)
+ backslash := string(rune(0x5c))
+ nonportablePath := filepath.Join(env.RootDir, "saves", "literal"+backslash+"name.sav")
+ writeTestFile(t, nonportablePath, "nonportable")
+
+ info, err := env.Manager.createBackup(context.Background(), false)
+ require.NoError(t, err)
+ require.NotEmpty(t, info.Warnings)
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves,
+ Path: "saves/literal%5Cname.sav",
+ Reason: "source path is not portable",
+ })
+ inspected, err := inspectZipManifest(info.Path, env.Manager.cfg.BackupMaxSizeBytes())
+ require.NoError(t, err)
+ assert.Equal(t, info.Warnings, inspected.Warnings)
+}
+
+func TestWriteZipRejectsInvalidWarningBeforeCreatingArchive(t *testing.T) {
+ t.Parallel()
+ zipPath := filepath.Join(t.TempDir(), "invalid-warning.zip")
+ manifest := Manifest{
+ Version: 1, Platform: platformids.Mister, CreatedAt: time.Now().UTC(),
+ Categories: summarize(nil),
+ Warnings: []models.BackupWarning{{
+ Category: CategorySaves, Path: "saves/invalid\\name.sav", Reason: "unreadable",
+ }},
+ }
+
+ err := writeZip(context.Background(), zipPath, nil, &manifest, config.DefaultBackupMaxSizeBytes)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid warning metadata")
+ _, statErr := os.Stat(zipPath)
+ require.ErrorIs(t, statErr, os.ErrNotExist)
+}
+
+func TestManagerRestorePreservesDestinationDeviceID(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ configPath := filepath.Join(env.ConfigDir, config.CfgFile)
+ writeTestFile(t, configPath, "[service]\ndevice_id = \"source-device\"\napi_port = 7497\n")
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ destinationID := env.Manager.cfg.DeviceID()
+ require.NotEmpty(t, destinationID)
+ require.NotEqual(t, "source-device", destinationID)
+ writeTestFile(t, configPath, "[service]\ndevice_id = \"destination-on-disk\"\n")
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+ // #nosec G304 -- configPath is under a test-owned temporary root.
+ restoredData, err := os.ReadFile(configPath)
+ require.NoError(t, err)
+ restoredConfig := &config.Instance{}
+ require.NoError(t, restoredConfig.LoadTOML(string(restoredData)))
+ assert.Equal(t, destinationID, restoredConfig.DeviceID())
+ assert.NotContains(t, string(restoredData), "source-device")
+}
+
+func TestManagerPrunesPreRestoreZipsKeepsManual(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ manualInfo, err := env.Manager.createBackup(context.Background(), false)
+ require.NoError(t, err)
+ for range preRestoreZipKeep + 2 {
+ _, err = env.Manager.createBackup(context.Background(), true)
+ require.NoError(t, err)
+ }
+
+ backups, err := env.Manager.List()
+ require.NoError(t, err)
+ preRestoreCount := 0
+ manualCount := 0
+ for _, backup := range backups {
+ if strings.HasSuffix(backup.Name, "-pre-restore.zip") {
+ preRestoreCount++
+ } else {
+ manualCount++
+ }
+ }
+ assert.Equal(t, preRestoreZipKeep, preRestoreCount,
+ "pre-restore ZIPs beyond the keep count must be pruned")
+ assert.Equal(t, 1, manualCount, "manual backups must never be pruned")
+ names := make([]string, 0, len(backups))
+ for _, backup := range backups {
+ names = append(names, backup.Name)
+ }
+ assert.Contains(t, names, manualInfo.Name, "the manual backup must survive pruning")
+}
+
+func TestManagerRestorePreservesPairedClients(t *testing.T) {
+ t.Parallel()
+ clients := []database.Client{{
+ ClientID: "c1", ClientName: "Phone", AuthToken: "tok", Role: "admin",
+ PairingKey: []byte{0x01}, CreatedAt: 10, LastSeenAt: 20,
+ }}
+ env := newBackupTestEnvWithClients(t, platformids.Mister, clients)
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+ env.UserDB.AssertCalled(t, "ReplaceAllClients", clients)
+}
+
+func TestManagerRestorePreservesDestinationEncryption(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ configPath := filepath.Join(env.ConfigDir, config.CfgFile)
+
+ // Backup made while encryption was off; destination enables it later.
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ env.Manager.cfg.SetEncryptionEnabled(true)
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+ // #nosec G304 -- configPath is under a test-owned temporary root.
+ restoredData, err := os.ReadFile(configPath)
+ require.NoError(t, err)
+ restoredConfig := &config.Instance{}
+ require.NoError(t, restoredConfig.LoadTOML(string(restoredData)))
+ assert.True(t, restoredConfig.EncryptionEnabled(),
+ "restore must not silently disable required encryption")
+}
+
+func TestValidateManifestPolicyRequiresExactlyOneUserDB(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ manifest := &Manifest{Platform: platformids.Mister}
+
+ err := env.Manager.validateManifestPolicy(manifest)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "exactly one files/zaparoo/user.db payload")
+
+ userDB := FileRef{
+ ArchivePath: zaparooArchive(config.UserDbFile), RestorePath: config.UserDbFile,
+ Category: CategoryZaparoo,
+ }
+ manifest.Files = []FileRef{userDB, userDB}
+ err = env.Manager.validateManifestPolicy(manifest)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "exactly one files/zaparoo/user.db payload")
+}
+
+func TestManagerCreateListRestore(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ cfgPath := filepath.Join(env.ConfigDir, config.CfgFile)
+ launcherPath := filepath.Join(env.DataDir, config.LaunchersDir, "custom.toml")
+ mappingPath := filepath.Join(env.DataDir, config.MappingsDir, "tokens.toml")
+ profileSavePath := filepath.Join(
+ env.DataDir, "profiles", "11111111-aaaa-bbbb-cccc-000000000001", "saves", "profile.sav",
+ )
+ writeTestFile(t, profileSavePath, "profile-save\n")
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, IntegrityValid, info.Integrity)
+ assert.Equal(t, StatusSuccess, info.Status)
+ assert.Contains(t, info.Categories, CategoryZaparoo)
+ assert.Contains(t, info.Categories, CategorySavestates)
+ assert.Positive(t, info.Categories[CategorySavestates].Files)
+
+ backups, err := env.Manager.List()
+ require.NoError(t, err)
+ require.Len(t, backups, 1)
+ assert.Equal(t, info.Name, backups[0].Name)
+
+ writeTestFile(t, filepath.Join(env.RootDir, "saves", "game.sav"), "changed\n")
+ writeTestFile(t, cfgPath, "debug_logging = true\n")
+ writeTestFile(t, launcherPath, "[[changed_launchers]]\n")
+ writeTestFile(t, mappingPath, "[[changed_mappings]]\n")
+ require.NoError(t, os.RemoveAll(filepath.Dir(filepath.Dir(profileSavePath))))
+ restore, err := env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+ assert.Equal(t, info.Name, restore.RestoredFrom.Name)
+ require.NotNil(t, restore.PreRestoreBackup)
+
+ restoredSave, err := os.ReadFile(filepath.Join(env.RootDir, "saves", "game.sav"))
+ require.NoError(t, err)
+ assert.Equal(t, "save-data\n", string(restoredSave))
+ // #nosec G304 -- test reads a path created under this test's temp directory.
+ restoredCfg, err := os.ReadFile(cfgPath)
+ require.NoError(t, err)
+ assert.Contains(t, string(restoredCfg), "debug_logging = false")
+ restoredConfig := &config.Instance{}
+ require.NoError(t, restoredConfig.LoadTOML(string(restoredCfg)))
+ assert.Equal(t, env.Manager.cfg.DeviceID(), restoredConfig.DeviceID())
+ restoredLauncher, err := os.ReadFile(launcherPath) // #nosec G304 -- path belongs to test temp dir.
+ require.NoError(t, err)
+ assert.Equal(t, "[[launchers]]\n", string(restoredLauncher))
+ restoredMapping, err := os.ReadFile(mappingPath) // #nosec G304 -- path belongs to test temp dir.
+ require.NoError(t, err)
+ assert.Equal(t, "[[mappings]]\n", string(restoredMapping))
+ // #nosec G304 -- profileSavePath is under a test-owned temporary root.
+ restoredProfileSave, err := os.ReadFile(profileSavePath)
+ require.NoError(t, err)
+ assert.Equal(t, "profile-save\n", string(restoredProfileSave))
+ env.UserDB.AssertCalled(t, "RestoreBackup", testifymock.AnythingOfType("string"))
+ stagingEntries, err := os.ReadDir(filepath.Join(env.DataDir, "backups"))
+ if !errors.Is(err, os.ErrNotExist) {
+ require.NoError(t, err)
+ for _, entry := range stagingEntries {
+ assert.NotRegexp(t, `^backup-.*-manual\.db$`, entry.Name())
+ }
+ }
+}
+
+func TestZaparooRestorePolicyMirrorsCollector(t *testing.T) {
+ t.Parallel()
+ // Restore policy validates against the same definitions collection
+ // uses: globs match case-insensitively against basenames at any depth.
+ base := t.TempDir()
+ defs := zaparooBackupDefinitions(filepath.Join(base, "cfg"), filepath.Join(base, "data"))
+ allowed := func(restorePath string) bool {
+ return allowedRestorePath(&FileRef{Category: CategoryZaparoo, RestorePath: restorePath}, defs)
+ }
+ assert.True(t, allowed("Config.toml"))
+ assert.True(t, allowed("FRONTEND.TOML"))
+ assert.True(t, allowed("Tui.toml"))
+ assert.True(t, allowed("mappings/group/CARDS.TOML"))
+ assert.True(t, allowed("launchers/Custom.toml"))
+ assert.True(t, allowed("mappings/deep/nested/dirs/file.toml"))
+ // user.db is constructed with a fixed name, never collected by glob;
+ // validateManifestPolicy allows it by exact name only.
+ assert.False(t, allowed("USER.DB"))
+ assert.False(t, allowed("user.db"))
+ assert.False(t, allowed("mappings/notes.txt"))
+ assert.False(t, allowed("other/file.toml"))
+ assert.False(t, allowed("auth.toml"))
+}
+
+func TestManagerCreateInspectRestoreNestedConfigFiles(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ nestedMapping := filepath.Join(env.DataDir, config.MappingsDir, "group", "cards.toml")
+ nestedLauncher := filepath.Join(env.DataDir, config.LaunchersDir, "custom", "arcade.toml")
+ writeTestFile(t, nestedMapping, "[[nested_mappings]]\n")
+ writeTestFile(t, nestedLauncher, "[[nested_launchers]]\n")
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+
+ _, err = env.Manager.Inspect(context.Background(), info.Name)
+ require.NoError(t, err, "backups containing nested mapping/launcher files must pass policy")
+
+ writeTestFile(t, nestedMapping, "changed\n")
+ require.NoError(t, os.Remove(nestedLauncher))
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+
+ restoredMapping, err := os.ReadFile(nestedMapping) // #nosec G304 -- path belongs to test temp dir.
+ require.NoError(t, err)
+ assert.Equal(t, "[[nested_mappings]]\n", string(restoredMapping))
+ restoredLauncher, err := os.ReadFile(nestedLauncher) // #nosec G304 -- path belongs to test temp dir.
+ require.NoError(t, err)
+ assert.Equal(t, "[[nested_launchers]]\n", string(restoredLauncher))
+}
+
+func TestManagerCreateZaparooScopeExcludesPlatformFiles(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ env.Manager.cfg.SetBackupScope(config.BackupScopeZaparoo)
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, StatusSuccess, info.Status)
+ assert.Positive(t, info.Categories[CategoryZaparoo].Files)
+ assert.Zero(t, info.Categories[CategorySettings].Files)
+ assert.Zero(t, info.Categories[CategoryInputs].Files)
+ assert.Zero(t, info.Categories[CategorySaves].Files)
+ assert.Zero(t, info.Categories[CategorySavestates].Files)
+
+ zipResult, err := readAndVerifyZipLimit(info.Path, config.DefaultBackupMaxSizeBytes)
+ require.NoError(t, err)
+ for _, file := range zipResult.Manifest.Files {
+ assert.Equal(t, CategoryZaparoo, file.Category, file.RestorePath)
+ }
+}
+
+func TestManagerPreRestoreBackupIgnoresZaparooScope(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ env.Manager.cfg.SetBackupScope(config.BackupScopeZaparoo)
+
+ pre, err := env.Manager.createBackup(context.Background(), true)
+ require.NoError(t, err)
+ assert.Positive(t, pre.Categories[CategoryZaparoo].Files)
+ assert.Positive(t, pre.Categories[CategorySaves].Files)
+ assert.Positive(t, pre.Categories[CategorySavestates].Files)
+}
+
+func TestManagerCreateUnsupportedPlatformScopes(t *testing.T) {
+ t.Parallel()
+ rootDir := t.TempDir()
+ dataDir := filepath.Join(rootDir, "zaparoo")
+ configDir := filepath.Join(rootDir, "config-dir")
+ require.NoError(t, os.MkdirAll(dataDir, 0o750))
+ require.NoError(t, os.MkdirAll(configDir, 0o750))
+ cfg, err := config.NewConfig(configDir, config.BaseDefaults)
+ require.NoError(t, err)
+ writeTestFile(t, filepath.Join(configDir, config.CfgFile), "debug_logging = false\n")
+ userSnapshot := filepath.Join(rootDir, "user-snapshot.db")
+ writeTestFile(t, userSnapshot, "user-db-snapshot\n")
+
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return("test-unsupported")
+ mockPlatform.On("Settings").Return(platforms.Settings{
+ DataDir: dataDir, ConfigDir: configDir, TempDir: filepath.Join(rootDir, "tmp"), LogDir: rootDir,
+ })
+ userDB := testinghelpers.NewMockUserDBI()
+ userDB.On(
+ "BackupForTransfer", testifymock.Anything, testifymock.AnythingOfType("string"),
+ ).Return(database.BackupInfo{
+ Name: "snapshot.db", Path: userSnapshot, Valid: true,
+ }, func() error { return nil }, nil)
+ mgr := NewManager(cfg, mockPlatform, &database.Database{UserDB: userDB})
+
+ // Platform scope still requires a backup provider.
+ _, err = mgr.Create(context.Background())
+ require.ErrorIs(t, err, ErrPlatformBackupUnsupported)
+
+ // Zaparoo scope backs up Core data on any platform, and pre-restore
+ // collection degrades to it when no provider exists.
+ assert.Equal(t, config.BackupScopeZaparoo, mgr.preRestoreScope())
+ cfg.SetBackupScope(config.BackupScopeZaparoo)
+ info, err := mgr.Create(context.Background())
+ require.NoError(t, err)
+ assert.Positive(t, info.Categories[CategoryZaparoo].Files)
+}
+
+func TestManagerTrackScheduleStale(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ staleAfter := 7 * 24 * time.Hour
+ now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
+
+ // An unreliable clock never reports stale.
+ unreliable := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
+ assert.False(t, env.Manager.TrackScheduleStale(unreliable, true, staleAfter))
+
+ // The first active observation records the anchor; staleness starts
+ // only once a full window passes with no success.
+ assert.False(t, env.Manager.TrackScheduleStale(now, true, staleAfter))
+ assert.False(t, env.Manager.TrackScheduleStale(now.Add(6*24*time.Hour), true, staleAfter))
+ assert.True(t, env.Manager.TrackScheduleStale(now.Add(7*24*time.Hour), true, staleAfter))
+
+ // A success resets the window and survives the status merge.
+ successAt := now.Add(7 * 24 * time.Hour)
+ require.NoError(t, env.Manager.writeRemoteStatus(&statusEntry{
+ LastRunAt: formatTime(successAt),
+ LastSuccessAt: formatTime(successAt),
+ LastStatus: StatusSuccess,
+ }))
+ assert.False(t, env.Manager.TrackScheduleStale(now.Add(8*24*time.Hour), true, staleAfter))
+ assert.True(t, env.Manager.TrackScheduleStale(now.Add(15*24*time.Hour), true, staleAfter))
+
+ // Disabling clears the anchor; re-enabling restarts the window even
+ // though an old success is on record.
+ assert.False(t, env.Manager.TrackScheduleStale(now.Add(16*24*time.Hour), false, staleAfter))
+ assert.False(t, env.Manager.TrackScheduleStale(now.Add(30*24*time.Hour), true, staleAfter))
+}
+
+func TestManagerNotifyScheduleStaleAddsInboxNotice(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ ns := make(chan models.Notification, 1)
+ env.UserDB.On("AddInboxMessage", testifymock.MatchedBy(func(msg *database.InboxMessage) bool {
+ return msg.Title == "Remote backup is overdue" &&
+ msg.Category == inboxservice.CategoryBackupRemoteStale &&
+ msg.Severity == inboxservice.SeverityWarning
+ })).Return(&database.InboxMessage{
+ DBID: 1, Title: "Remote backup is overdue", Category: inboxservice.CategoryBackupRemoteStale,
+ }, nil).Once()
+ env.Manager.WithInbox(inboxservice.NewService(env.UserDB, ns))
+
+ env.Manager.NotifyScheduleStale()
+
+ env.UserDB.AssertNumberOfCalls(t, "AddInboxMessage", 1)
+ select {
+ case notification := <-ns:
+ assert.Equal(t, models.NotificationInboxAdded, notification.Method)
+ default:
+ t.Fatal("expected stale backup inbox notification")
+ }
+}
+
+func TestManagerSendHeartbeatRefreshesAvailability(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ var body map[string]any
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&body)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ capabilities, ok := body["capabilities"].(map[string]any)
+ if !assert.True(t, ok) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ assert.InDelta(t, 1, capabilities["backup"], 0)
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", Name: "Living Room", BackupActive: true})
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ require.NoError(t, env.Manager.SendHeartbeat(context.Background()))
+ status := env.Manager.Status()
+ assert.Equal(t, RemoteAvailabilityAvailable, status.Remote.Availability)
+ require.NotNil(t, status.Remote.DeviceName)
+ assert.Equal(t, "Living Room", *status.Remote.DeviceName)
+}
+
+func TestManagerRestoreHoldsExclusiveGateThroughSuccess(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+
+ gateHeld := false
+ finished := false
+ env.Manager.WithRestoreGate(func() (func(bool), error) {
+ gateHeld = true
+ return func(success bool) {
+ assert.True(t, success)
+ finished = true
+ gateHeld = false
+ }, nil
+ }).WithActiveMedia(func() *models.ActiveMedia {
+ assert.True(t, gateHeld)
+ return nil
+ })
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+ assert.True(t, finished)
+ assert.False(t, gateHeld)
+}
+
+func TestManagerRestorePreparesAndFinishesPlatformProfileData(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ basePlatform, ok := env.Manager.pl.(backupPlatform)
+ require.True(t, ok)
+ prepared := false
+ finished := false
+ env.Manager.pl = &backupRestorePreparingPlatform{
+ backupPlatform: basePlatform, prepared: &prepared, finished: &finished,
+ }
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+ assert.True(t, prepared)
+ assert.True(t, finished)
+}
+
+func TestManagerRestoreSucceedsWhenCommittedCleanupSyncFails(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ writeTestFile(t, filepath.Join(env.RootDir, "saves", "game.sav"), "changed\n")
+ transactionPath := env.Manager.restoreTransactionPath()
+ transactionParent := filepath.Dir(transactionPath)
+ cleanupSyncFailed := false
+ env.Manager.directorySync = func(path string) error {
+ if filepath.Clean(path) == filepath.Clean(transactionParent) && !cleanupSyncFailed {
+ _, statErr := os.Lstat(transactionPath)
+ if errors.Is(statErr, os.ErrNotExist) {
+ cleanupSyncFailed = true
+ return errors.New("injected committed cleanup sync failure")
+ }
+ }
+ return syncDirectory(path)
+ }
+ gateSucceeded := false
+ env.Manager.WithRestoreGate(func() (func(bool), error) {
+ return func(success bool) { gateSucceeded = success }, nil
+ })
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.NoError(t, err)
+ assert.True(t, cleanupSyncFailed)
+ assert.True(t, gateSucceeded, "durably committed restore must still request restart")
+ // #nosec G304 -- restored path is under a test-owned temporary root.
+ restored, err := os.ReadFile(filepath.Join(env.RootDir, "saves", "game.sav"))
+ require.NoError(t, err)
+ assert.Equal(t, "save-data\n", string(restored))
+}
+
+func TestManagerCreateFollowsTrustedSymlinksAndReportsUnsafeEntries(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ usbSaves := filepath.Join(env.RootDir, "usb", "saves")
+ writeTestFile(t, filepath.Join(usbSaves, "external.sav"), "external-save\n")
+ require.NoError(t, os.Symlink(usbSaves, filepath.Join(env.RootDir, "saves", "external")))
+ require.NoError(t, os.Symlink(
+ filepath.Join(env.RootDir, "saves", "missing.sav"),
+ filepath.Join(env.RootDir, "saves", "broken.sav"),
+ ))
+ require.NoError(t, os.Symlink(
+ filepath.Join(env.RootDir, "saves"),
+ filepath.Join(env.RootDir, "saves", "loop"),
+ ))
+ authPath := filepath.Join(env.DataDir, config.AuthFile)
+ writeTestFile(t, authPath, "secret-bearer\n")
+ require.NoError(t, os.Symlink(authPath, filepath.Join(env.RootDir, "saves", "leak.sav")))
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, StatusPartial, info.Status)
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves, Path: "saves/broken.sav", Reason: "broken symlink",
+ })
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves, Path: "saves/loop", Reason: "symlink cycle",
+ })
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves, Path: "saves/leak.sav", Reason: "symlink target outside trusted roots",
+ })
+
+ zr, err := zip.OpenReader(info.Path)
+ require.NoError(t, err)
+ entries := zipEntriesByName(zr.File)
+ external, ok := entries[platformArchive(filepath.Join("saves", "external", "external.sav"))]
+ require.True(t, ok)
+ payload, err := readZipEntryLimited(external, 1024)
+ require.NoError(t, err)
+ assert.Equal(t, "external-save\n", string(payload))
+ _, leaked := entries[platformArchive(filepath.Join("saves", "leak.sav"))]
+ assert.False(t, leaked)
+ require.NoError(t, zr.Close())
+
+ status := env.Manager.Status()
+ assert.Equal(t, StatusPartial, status.Local.LastStatus)
+ assert.Equal(t, len(info.Warnings), status.Local.SkippedFiles)
+}
+
+func TestManagerCreateAllowsApprovedCategoryRootSymlink(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ require.NoError(t, os.RemoveAll(filepath.Join(env.RootDir, "saves")))
+ usbSaves := filepath.Join(env.RootDir, "usb", "saves")
+ writeTestFile(t, filepath.Join(usbSaves, "external.sav"), "external-save\n")
+ require.NoError(t, os.Symlink(usbSaves, filepath.Join(env.RootDir, "saves")))
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ zr, err := zip.OpenReader(info.Path)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, zr.Close()) }()
+ entries := zipEntriesByName(zr.File)
+ assert.Contains(t, entries, platformArchive(filepath.Join("saves", "external.sav")))
+}
+
+func TestManagerCreateRejectsCategoryRootSymlinkToZaparooData(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ require.NoError(t, os.RemoveAll(filepath.Join(env.RootDir, "saves")))
+ authPath := filepath.Join(env.ConfigDir, config.AuthFile)
+ writeTestFile(t, authPath, "secret-bearer\n")
+ require.NoError(t, os.Symlink(env.ConfigDir, filepath.Join(env.RootDir, "saves")))
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves, Path: "saves", Reason: "symlink target outside trusted roots",
+ })
+
+ zr, err := zip.OpenReader(info.Path)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, zr.Close()) }()
+ entries := zipEntriesByName(zr.File)
+ assert.NotContains(t, entries, platformArchive(filepath.Join("saves", config.AuthFile)))
+}
+
+func TestManagerCreateExcludesHardLinkToAuthConfig(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ authPath := filepath.Join(env.ConfigDir, config.AuthFile)
+ writeTestFile(t, authPath, "secret-bearer\n")
+ leakPath := filepath.Join(env.DataDir, config.MappingsDir, "leak.toml")
+ if err := os.Link(authPath, leakPath); err != nil {
+ t.Skipf("hard links unavailable on test filesystem: %v", err)
+ }
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategoryZaparoo,
+ Path: filepath.ToSlash(filepath.Join(config.MappingsDir, "leak.toml")),
+ Reason: "sensitive source excluded",
+ })
+ zr, err := zip.OpenReader(info.Path)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, zr.Close()) }()
+ assert.NotContains(t, zipEntriesByName(zr.File), zaparooArchive(filepath.Join(config.MappingsDir, "leak.toml")))
+}
+
+func TestOpenSourceRejectsSensitiveFileIdentity(t *testing.T) {
+ t.Parallel()
+ root := t.TempDir()
+ authPath := filepath.Join(root, config.AuthFile)
+ writeTestFile(t, authPath, "secret-bearer\n")
+ info, err := os.Stat(authPath)
+ require.NoError(t, err)
+ file := FileRef{
+ sourceIdentity: &sourceIdentity{info: info, excludedIdentities: []os.FileInfo{info}},
+ SourceRoot: root, SourceRel: config.AuthFile, RestorePath: "mappings/leak.toml",
+ }
+
+ opened, err := openSource(&file)
+ require.ErrorIs(t, err, errSensitiveSource)
+ assert.Nil(t, opened)
+}
+
+func TestOpenSourceRejectsCollectedFileReplacedBySensitiveSymlink(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ collection, err := env.Manager.collectFiles(context.Background(), "identity-test", config.BackupScopePlatform)
+ require.NoError(t, err)
+ defer func() { require.NoError(t, collection.Cleanup()) }()
+
+ var collected *FileRef
+ for i := range collection.Files {
+ if collection.Files[i].Category == CategoryZaparoo &&
+ collection.Files[i].RestorePath == config.CfgFile {
+ collected = &collection.Files[i]
+ break
+ }
+ }
+ require.NotNil(t, collected)
+
+ authPath := filepath.Join(env.ConfigDir, config.AuthFile)
+ writeTestFile(t, authPath, "secret-bearer\n")
+ require.NoError(t, os.Remove(filepath.Join(env.ConfigDir, config.CfgFile)))
+ require.NoError(t, os.Symlink(config.AuthFile, filepath.Join(env.ConfigDir, config.CfgFile)))
+
+ opened, err := openSource(collected)
+ require.ErrorIs(t, err, errSourceIdentityChanged)
+ assert.Nil(t, opened)
+ _, warnings, err := prepareSourceFiles(
+ context.Background(), []FileRef{*collected}, openSourceContext, nil,
+ )
+ require.ErrorIs(t, err, errSourceIdentityChanged)
+ assert.Empty(t, warnings, "identity changes must fail rather than become unreadable-file warnings")
+}
+
+func TestInspectUsesManifestOnlyButRestoreVerifiesPayloadHash(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ corruptZipPayload(t, info.Path, platformArchive(filepath.Join("saves", "game.sav")), "bad!-data\n")
+
+ backups, err := env.Manager.List()
+ require.NoError(t, err)
+ require.Len(t, backups, 1)
+ assert.Equal(t, info.Name, backups[0].Name)
+ assert.NotZero(t, backups[0].Size)
+
+ inspected, err := env.Manager.Inspect(context.Background(), info.Name)
+ require.NoError(t, err)
+ assert.Equal(t, IntegrityUnchecked, inspected.Integrity)
+ assert.Contains(t, inspected.Categories, CategorySavestates)
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "hash mismatch")
+}
+
+func TestManagerRestoreRejectsWrongPlatformBeforePayloadVerification(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+
+ zr, err := zip.OpenReader(info.Path)
+ require.NoError(t, err)
+ entries := zipEntriesByName(zr.File)
+ manifestBody, err := readZipEntryLimited(entries[manifestName], maxManifestBytes)
+ require.NoError(t, err)
+ require.NoError(t, zr.Close())
+ var manifest Manifest
+ require.NoError(t, json.Unmarshal(manifestBody, &manifest))
+ manifest.Platform = "other-platform"
+ manifestBody, err = json.Marshal(&manifest)
+ require.NoError(t, err)
+ corruptZipPayload(t, info.Path, manifestName, string(manifestBody))
+ inspected, err := env.Manager.Inspect(context.Background(), info.Name)
+ require.Error(t, err)
+ assert.Empty(t, inspected, "invalid manifest must not return partially trusted metadata")
+ corruptZipPayload(
+ t, info.Path, platformArchive(filepath.Join("saves", "game.sav")), "bad!-data\n",
+ )
+
+ _, err = env.Manager.Restore(context.Background(), info.Name)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "does not match")
+ assert.NotContains(t, err.Error(), "hash mismatch")
+}
+
+func TestManagerCreateEnforcesConfiguredLogicalLimit(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ env.Manager.cfg.SetBackupMaxSizeBytes(1)
+
+ _, err := env.Manager.Create(context.Background())
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "logical size limit")
+}
+
+func TestValidateZipHeadersRejectsArchiveLimits(t *testing.T) {
+ t.Parallel()
+
+ tooMany := make([]*zip.File, maxArchiveEntries+1)
+ _, err := validateZipHeaders(tooMany, config.DefaultBackupMaxSizeBytes)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "too many entries")
+
+ longPath := &zip.File{FileHeader: zip.FileHeader{Name: strings.Repeat("a", maxArchivePathLen+1)}}
+ _, err = validateZipHeaders([]*zip.File{longPath}, config.DefaultBackupMaxSizeBytes)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "path exceeds")
+
+ first := &zip.File{FileHeader: zip.FileHeader{Name: "files/zaparoo/user.db"}}
+ second := &zip.File{FileHeader: zip.FileHeader{Name: "files/zaparoo/user.db"}}
+ _, err = validateZipHeaders([]*zip.File{first, second}, config.DefaultBackupMaxSizeBytes)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "duplicate ZIP entry")
+}
+
+func TestValidateLogicalSizeRejectsOverflowAndLimit(t *testing.T) {
+ t.Parallel()
+ _, err := validateLogicalSize([]FileRef{{Size: 7}, {Size: 4}}, 10)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "logical size limit")
+
+ _, err = validateLogicalSize([]FileRef{{Size: -1}}, 10)
+ require.Error(t, err)
+}
+
+func TestReadAndVerifyZipRejectsUnsafeEntry(t *testing.T) {
+ t.Parallel()
+ zipPath := filepath.Join(t.TempDir(), "backup-20260624-150405-000000000-manual.zip")
+ writeRawZip(t, zipPath, map[string]string{
+ manifestName: "{\"version\":1,\"files\":[]}",
+ path.Join("..", "evil.txt"): "bad",
+ })
+
+ _, err := readAndVerifyZipLimit(zipPath, config.DefaultBackupMaxSizeBytes)
+ require.Error(t, err)
+}
+
+func TestValidateFilesRejectsUnsafeAndDuplicatePaths(t *testing.T) {
+ t.Parallel()
+
+ require.Error(t, validateFiles([]FileRef{{ArchivePath: path.Join("..", "evil"), RestorePath: "safe"}}))
+ require.Error(t, validateFiles([]FileRef{{ArchivePath: "safe", RestorePath: path.Join("..", "evil")}}))
+ require.Error(t, validateFiles([]FileRef{
+ {ArchivePath: path.Join(filesRoot, zaparooRoot, "one"), RestorePath: "one"},
+ {ArchivePath: path.Join(filesRoot, zaparooRoot, "one"), RestorePath: "two"},
+ }))
+ require.Error(t, validateFiles([]FileRef{
+ {ArchivePath: path.Join(filesRoot, zaparooRoot, "one"), RestorePath: "same"},
+ {ArchivePath: path.Join(filesRoot, zaparooRoot, "two"), RestorePath: "same"},
+ }))
+}
+
+func TestRemoteSourceProcessingHonorsCancellation(t *testing.T) {
+ t.Parallel()
+ root := t.TempDir()
+ filePath := filepath.Join(root, "save.dat")
+ writeTestFile(t, filePath, "save")
+ info, err := os.Stat(filePath)
+ require.NoError(t, err)
+ file := FileRef{
+ sourceIdentity: &sourceIdentity{info: info},
+ SourceRoot: root, SourceRel: "save.dat", Size: info.Size(), SHA256: strings.Repeat("0", 64),
+ }
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ _, warnings, err := prepareSourceFiles(ctx, []FileRef{file}, openSourceContext, nil)
+ require.ErrorIs(t, err, context.Canceled)
+ assert.Empty(t, warnings)
+ _, err = hashRemoteFiles(ctx, []FileRef{file}, nil)
+ require.ErrorIs(t, err, context.Canceled)
+ _, _, err = buildRemotePack(ctx, []FileRef{file}, nil)
+ require.ErrorIs(t, err, context.Canceled)
+}
+
+func TestSourceCollectorEnforcesFileBudgetDuringTraversal(t *testing.T) {
+ t.Parallel()
+ root := t.TempDir()
+ writeTestFile(t, filepath.Join(root, "one.sav"), "one")
+ writeTestFile(t, filepath.Join(root, "two.sav"), "two")
+ collector := newSourceCollector(context.Background(), 1<<20, nil)
+ collector.maxFiles = 1
+ spec := collectorDefinition{
+ definition: platforms.BackupDefinition{
+ SourceRoot: root, Category: CategorySaves,
+ Include: []platforms.BackupPattern{{All: true}},
+ },
+ trustedRoots: canonicalCategoryRoots([]string{root}, ""),
+ archive: platformArchive,
+ }
+
+ collector.collect(&spec)
+ require.Error(t, collector.err)
+ assert.Contains(t, collector.err.Error(), "too many files")
+ assert.Len(t, collector.files, 1)
+}
+
+func TestSourceCollectorStopsWhenContextCanceled(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ collector := newSourceCollector(ctx, 1<<20, nil)
+ collector.collect(&collectorDefinition{})
+ require.ErrorIs(t, collector.err, context.Canceled)
+ assert.Empty(t, collector.files)
+}
+
+func TestCancelableSourceClosesBlockedReader(t *testing.T) {
+ t.Parallel()
+ ctx, cancel := context.WithCancel(context.Background())
+ blocked := &blockingReadCloser{closed: make(chan struct{})}
+ source := newCancelableSource(ctx, blocked)
+ readDone := make(chan error, 1)
+ go func() {
+ _, err := source.Read(make([]byte, 1))
+ readDone <- err
+ }()
+
+ cancel()
+ select {
+ case err := <-readDone:
+ require.Error(t, err)
+ case <-time.After(time.Second):
+ t.Fatal("cancel did not close blocked backup source")
+ }
+ require.NoError(t, source.Close())
+}
+
+func TestCollectPlatformFilesIncludesDefinitionsAndSavestates(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ writeTestFile(t, filepath.Join(env.RootDir, "config", "nested", "video.cfg"), "nested cfg\n")
+ writeTestFile(t, filepath.Join(env.RootDir, "config", "inputs", "nested", "arcade.map"), "nested map\n")
+ writeTestFile(t, filepath.Join(env.RootDir, "config", "inputs", "renamed", "old.map"), "excluded\n")
+
+ files := collectPlatformFiles(nil, testPlatformDefinitions(env.RootDir))
+ byArchive := make(map[string]FileRef, len(files))
+ for _, file := range files {
+ byArchive[file.ArchivePath] = file
+ }
+
+ assert.Equal(t, CategorySettings, byArchive[platformArchive("MiSTer.ini")].Category)
+ assert.Equal(t, CategorySettings, byArchive[platformArchive(filepath.Join("config", "core.cfg"))].Category)
+ assert.Equal(t, CategoryInputs, byArchive[platformArchive(filepath.Join("config", "inputs", "pad.map"))].Category)
+ assert.Equal(t, CategorySettings,
+ byArchive[platformArchive(filepath.Join("config", "nested", "video.cfg"))].Category)
+ assert.Equal(t, CategoryInputs,
+ byArchive[platformArchive(filepath.Join("config", "inputs", "nested", "arcade.map"))].Category)
+ assert.Equal(t, CategorySaves, byArchive[platformArchive(filepath.Join("saves", "game.sav"))].Category)
+ assert.Equal(t, CategorySavestates, byArchive[platformArchive(filepath.Join("savestates", "game.ss"))].Category)
+ assert.NotContains(t, byArchive, platformArchive(filepath.Join("config", "core_recent.cfg")))
+ assert.NotContains(t, byArchive, platformArchive(filepath.Join("config", "inputs", "ignored.txt")))
+ assert.NotContains(t, byArchive, platformArchive(filepath.Join("config", "inputs", "renamed", "old.map")))
+}
+
+func TestSourceCollectorMatchesBasenameGlobThroughTrustedDirectorySymlink(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink creation may require elevated Windows privileges")
+ }
+ t.Parallel()
+ root := t.TempDir()
+ target := filepath.Join(root, "approved-inputs")
+ writeTestFile(t, filepath.Join(target, "nested", "pad.map"), "nested map\n")
+ link := filepath.Join(root, "input-link")
+ require.NoError(t, os.Symlink(target, link))
+ collector := newSourceCollector(context.Background(), 1<<20, nil)
+ collector.collect(&collectorDefinition{
+ definition: platforms.BackupDefinition{
+ Category: CategoryInputs, SourceRoot: link, RestoreRoot: filepath.Join("config", "inputs"),
+ Include: []platforms.BackupPattern{{Glob: "*.map"}},
+ },
+ trustedRoots: canonicalCategoryRoots([]string{root}, "approved-inputs"),
+ archive: platformArchive,
+ })
+
+ require.NoError(t, collector.err)
+ require.Len(t, collector.files, 1)
+ assert.Equal(t, platformArchive(filepath.Join("config", "inputs", "nested", "pad.map")),
+ collector.files[0].ArchivePath)
+}
+
+func TestSourceCollectorRejectsDirectorySymlinkIntoExcludedPath(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink creation may require elevated Windows privileges")
+ }
+ t.Parallel()
+ root := t.TempDir()
+ writeTestFile(t, filepath.Join(root, "renamed", "pad.map"), "excluded map\n")
+ require.NoError(t, os.Symlink(filepath.Join(root, "renamed"), filepath.Join(root, "alias")))
+ collector := newSourceCollector(context.Background(), 1<<20, nil)
+ collector.collect(&collectorDefinition{
+ definition: platforms.BackupDefinition{
+ Category: CategoryInputs, SourceRoot: root, RestoreRoot: filepath.Join("config", "inputs"),
+ Include: []platforms.BackupPattern{{Glob: "*.map"}},
+ Exclude: []platforms.BackupPattern{{Glob: filepath.Join("renamed", "*")}},
+ },
+ trustedRoots: canonicalCategoryRoots([]string{root}, ""),
+ archive: platformArchive,
+ })
+
+ require.NoError(t, collector.err)
+ assert.Empty(t, collector.files)
+ assert.Contains(t, collector.warnings, models.BackupWarning{
+ Category: CategoryInputs,
+ Path: filepath.ToSlash(filepath.Join("config", "inputs", "alias", "pad.map")),
+ Reason: "source outside category policy",
+ })
+}
+
+func TestSourceCollectorEnforcesNonRecursivePhysicalSymlinkPolicy(t *testing.T) {
+ if runtime.GOOS == "windows" {
+ t.Skip("symlink creation may require elevated Windows privileges")
+ }
+ t.Parallel()
+ root := t.TempDir()
+ target := filepath.Join(root, "nested", "MiSTer.ini")
+ writeTestFile(t, target, "nested ini\n")
+ require.NoError(t, os.Symlink(target, filepath.Join(root, "MiSTer.ini")))
+ collector := newSourceCollector(context.Background(), 1<<20, nil)
+ collector.collect(&collectorDefinition{
+ definition: platforms.BackupDefinition{
+ Category: CategorySettings, SourceRoot: root, NonRecursive: true,
+ Include: []platforms.BackupPattern{{Glob: "MiSTer.ini"}},
+ },
+ trustedRoots: canonicalCategoryRoots([]string{root}, ""),
+ archive: platformArchive,
+ })
+
+ require.NoError(t, collector.err)
+ assert.Empty(t, collector.files)
+ assert.Contains(t, collector.warnings, models.BackupWarning{
+ Category: CategorySettings, Path: "MiSTer.ini", Reason: "symlink target outside category policy",
+ })
+}
+
+func TestNonRecursiveBackupDefinitionDoesNotDescend(t *testing.T) {
+ t.Parallel()
+ rootDir := t.TempDir()
+ writeTestFile(t, filepath.Join(rootDir, "MiSTer.ini"), "root ini\n")
+ writeTestFile(t, filepath.Join(rootDir, "nested", "MiSTer.ini"), "nested ini\n")
+ writeTestFile(t, filepath.Join(rootDir, "games", "huge.rom"), "rom\n")
+
+ files := collectPlatformFiles(nil, []platforms.BackupDefinition{{
+ Category: CategorySettings,
+ SourceRoot: rootDir,
+ NonRecursive: true,
+ Include: []platforms.BackupPattern{{Glob: "MiSTer.ini"}},
+ }})
+
+ require.Len(t, files, 1)
+ assert.Equal(t, platformArchive("MiSTer.ini"), files[0].ArchivePath)
+ assert.Equal(t, "MiSTer.ini", files[0].RestorePath)
+}
+
+func TestManagerUsesConfiguredLocalDir(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ localDir := filepath.Join(env.RootDir, "usb-backups")
+ env.Manager.cfg.SetBackupLocalDir(localDir)
+
+ info, err := env.Manager.Create(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, localDir, filepath.Dir(info.Path))
+ assert.FileExists(t, filepath.Join(localDir, info.Name))
+}
+
+func TestApplyRestoreUsesPlatformRestoreRootProvider(t *testing.T) {
+ t.Parallel()
+ rootDir := t.TempDir()
+ dataDir := filepath.Join(rootDir, "data", "zaparoo")
+ configDir := filepath.Join(rootDir, "config")
+ platformRoot := filepath.Join(rootDir, "platform-root")
+ restorePath := filepath.Join("saves", "game.sav")
+ require.NoError(t, os.MkdirAll(dataDir, 0o750))
+ require.NoError(t, os.MkdirAll(configDir, 0o750))
+
+ cfg, err := config.NewConfig(configDir, config.BaseDefaults)
+ require.NoError(t, err)
+ mockPlatform := mocks.NewMockPlatform()
+ mockPlatform.On("ID").Return(platformids.Mister)
+ mockPlatform.On("RootDirs", testifymock.Anything).Return([]string{platformRoot})
+ mockPlatform.On("Settings").Return(platforms.Settings{
+ DataDir: dataDir, ConfigDir: configDir, TempDir: filepath.Join(rootDir, "tmp"), LogDir: rootDir,
+ })
+ pl := backupRestoreRootPlatform{
+ backupPlatform: backupPlatform{
+ MockPlatform: mockPlatform,
+ definitions: []platforms.BackupDefinition{{
+ Category: CategorySaves, SourceRoot: filepath.Join(platformRoot, "saves"),
+ RestoreRoot: "saves", Include: []platforms.BackupPattern{{All: true}},
+ }},
+ },
+ restoreRoot: platformRoot,
+ }
+ mgr := NewManager(cfg, pl, nil)
+ payload := []byte("save-data\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(restorePath),
+ RestorePath: filepath.ToSlash(restorePath),
+ Category: CategorySaves,
+ SHA256: sha256Hex(payload),
+ Size: int64(len(payload)),
+ }}}
+
+ err = mgr.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(payload)), nil
+ })
+ require.NoError(t, err)
+ assert.FileExists(t, filepath.Join(platformRoot, restorePath))
+ assert.NoFileExists(t, filepath.Join(filepath.Dir(dataDir), restorePath))
+}
+
+func TestConservativeRestoreSpaceRequirement(t *testing.T) {
+ t.Parallel()
+
+ required, err := conservativeRestoreSpaceRequirement(100, 80, 20)
+ require.NoError(t, err)
+ assert.Equal(t, int64(220), required)
+
+ _, err = conservativeRestoreSpaceRequirement(math.MaxInt64, 1, 0)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "overflow")
+}
+
+func TestPreflightConservativeRestoreSpaceChecksEveryRoot(t *testing.T) {
+ t.Parallel()
+
+ first := filepath.Join(t.TempDir(), "first")
+ second := filepath.Join(t.TempDir(), "second")
+ paths := map[string]struct{}{first: {}, second: {}}
+ const required = int64(1_000)
+ checked := make(map[string]bool)
+ err := preflightConservativeRestoreSpace(paths, required, func(path string) (uint64, error) {
+ checked[path] = true
+ if path == second {
+ return uint64(required - 1), nil
+ }
+ return uint64(required), nil
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), second)
+ assert.Contains(t, err.Error(), "conservative restore preflight")
+ assert.True(t, checked[first])
+ assert.True(t, checked[second])
+}
+
+func TestApplyRestoreStopsBeforeMutationWhenDurabilitySyncFails(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ failPath func(*backupTestEnv) string
+ name string
+ }{
+ {
+ name: "transaction parent",
+ failPath: func(env *backupTestEnv) string {
+ return filepath.Dir(env.Manager.restoreTransactionPath())
+ },
+ },
+ {
+ name: "rollback directory",
+ failPath: func(env *backupTestEnv) string {
+ return filepath.Join(env.Manager.restoreTransactionPath(), restoreRollbackDir)
+ },
+ },
+ {
+ name: "journal directory",
+ failPath: func(env *backupTestEnv) string {
+ return env.Manager.restoreTransactionPath()
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ targetPath := filepath.Join(env.RootDir, "saves", "game.sav")
+ payload := []byte("new-save\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "game.sav")),
+ RestorePath: "saves/game.sav", Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ failPath := filepath.Clean(tt.failPath(&env))
+ env.Manager.directorySync = func(path string) error {
+ if filepath.Clean(path) == failPath {
+ return errors.New("injected directory sync failure")
+ }
+ return syncDirectory(path)
+ }
+
+ err := env.Manager.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(payload)), nil
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "sync")
+ // #nosec G304 -- targetPath is under a test-owned temporary root.
+ stored, readErr := os.ReadFile(targetPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "save-data\n", string(stored))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+ })
+ }
+}
+
+func TestApplyRestoreRollsBackWhenTargetDirectorySyncFails(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ targetPath := filepath.Join(env.RootDir, "saves", "game.sav")
+ payload := []byte("new-save\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "game.sav")),
+ RestorePath: "saves/game.sav", Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+
+ failPath := filepath.Join(env.RootDir, "saves")
+ failed := false
+ env.Manager.directorySync = func(path string) error {
+ if filepath.Clean(path) == filepath.Clean(failPath) && !failed {
+ failed = true
+ return errors.New("injected target directory sync failure")
+ }
+ return syncDirectory(path)
+ }
+
+ err := env.Manager.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(payload)), nil
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "syncing restored payload directory")
+ // #nosec G304 -- targetPath is under a test-owned temporary root.
+ stored, readErr := os.ReadFile(targetPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "save-data\n", string(stored))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestRestoreJournalPersistsImmutablePlanAndCompactStateLog(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ manifest := &Manifest{}
+ for i := range 3 {
+ restorePath := filepath.ToSlash(filepath.Join("saves", fmt.Sprintf("journal-%d.sav", i)))
+ writeTestFile(t, filepath.Join(env.RootDir, filepath.FromSlash(restorePath)), "old-save\n")
+ payload := []byte(fmt.Sprintf("new-save-%d\n", i))
+ manifest.Files = append(manifest.Files, FileRef{
+ ArchivePath: platformArchive(filepath.FromSlash(restorePath)),
+ RestorePath: restorePath,
+ Category: CategorySaves,
+ SHA256: sha256Hex(payload),
+ Size: int64(len(payload)),
+ })
+ }
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, env.Manager.removeRestoreTransaction(env.Manager.restoreTransactionPath()))
+ }()
+ planPath := filepath.Join(env.Manager.restoreTransactionPath(), restoreJournalPlanName)
+ // #nosec G304 -- planPath is fixed under a test-owned temporary root.
+ originalPlan, err := os.ReadFile(planPath)
+ require.NoError(t, err)
+
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseApplying))
+ for i := range journal.Entries {
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, i, restoreEntryStarted))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, i, restoreEntryApplied))
+ }
+ persisted, err := env.Manager.readRestoreJournal()
+ require.NoError(t, err)
+ assert.Equal(t, restorePhaseApplying, persisted.Phase)
+ for i := range persisted.Entries {
+ assert.Equal(t, restoreEntryApplied, persisted.Entries[i].State)
+ }
+ // #nosec G304 -- planPath is fixed under a test-owned temporary root.
+ currentPlan, err := os.ReadFile(planPath)
+ require.NoError(t, err)
+ assert.Equal(t, originalPlan, currentPlan, "entry transitions must not rewrite immutable plan")
+ stateInfo, err := os.Stat(filepath.Join(env.Manager.restoreTransactionPath(), restoreJournalStateName))
+ require.NoError(t, err)
+ assert.LessOrEqual(t, stateInfo.Size(), int64((2*len(journal.Entries)+1)*maxRestoreJournalEventBytes))
+}
+
+func TestRestoreJournalIgnoresIncompleteFinalEvent(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ payload := []byte("new-save\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "game.sav")),
+ RestorePath: "saves/game.sav", Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, env.Manager.removeRestoreTransaction(env.Manager.restoreTransactionPath()))
+ }()
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseApplying))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryStarted))
+ statePath := filepath.Join(env.Manager.restoreTransactionPath(), restoreJournalStateName)
+ // #nosec G304 -- statePath is fixed under a test-owned temporary root.
+ state, err := os.OpenFile(statePath, os.O_APPEND|os.O_WRONLY, 0o600)
+ require.NoError(t, err)
+ _, err = state.WriteString(`{"kind":"entry","sequence":`)
+ require.NoError(t, err)
+ require.NoError(t, state.Close())
+
+ persisted, err := env.Manager.readRestoreJournal()
+ require.NoError(t, err)
+ assert.Equal(t, restoreEntryStarted, persisted.Entries[0].State)
+ assert.Equal(t, journal.Sequence, persisted.Sequence)
+}
+
+func TestRestoreJournalStorageRequirementScalesLinearly(t *testing.T) {
+ t.Parallel()
+ makeJournal := func(count int) restoreJournal {
+ journal := restoreJournal{Version: restoreJournalVersion, Phase: restorePhasePrepared}
+ journal.Entries = make([]restoreJournalEntry, count)
+ for i := range journal.Entries {
+ rel := filepath.ToSlash(filepath.Join("saves", fmt.Sprintf("entry-%06d.sav", i)))
+ journal.Entries[i] = restoreJournalEntry{
+ File: FileRef{
+ ArchivePath: platformArchive(filepath.FromSlash(rel)), RestorePath: rel,
+ Category: CategorySaves, SHA256: strings.Repeat("a", sha256.Size*2), Size: 1,
+ },
+ Root: filepath.Join(string(filepath.Separator), "restore-root"),
+ Rel: filepath.FromSlash(rel), Existed: true,
+ RollbackPath: filepath.Join(restoreRollbackDir, fmt.Sprintf("%06d", i)), RollbackSize: 1,
+ }
+ }
+ return journal
+ }
+
+ smallJournal := makeJournal(1_000)
+ small, err := restoreJournalStorageRequirement(&smallJournal)
+ require.NoError(t, err)
+ largeJournal := makeJournal(2_000)
+ large, err := restoreJournalStorageRequirement(&largeJournal)
+ require.NoError(t, err)
+ assert.Greater(t, large, small)
+ assert.Less(t, large, small*3, "doubling entries must keep journal storage linear")
+}
+
+func TestValidateRestoreJournalRejectsUnsafeOperationID(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ err := env.Manager.validateRestoreJournal(&restoreJournal{
+ OperationID: "../../outside",
+ MaxLogicalSize: env.Manager.cfg.BackupMaxSizeBytes(),
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "operation ID")
+}
+
+func TestRollbackRestoreRemovesCrashLeftStagedTemps(t *testing.T) {
+ t.Parallel()
+
+ for _, existed := range []bool{true, false} {
+ name := "new target"
+ restorePath := "saves/new.sav"
+ if existed {
+ name = "existing target"
+ restorePath = "saves/game.sav"
+ }
+ t.Run(name, func(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ payload := []byte("new-save\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.FromSlash(restorePath)),
+ RestorePath: restorePath, Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ journal.Entries[0].State = restoreEntryStarted
+ defer func() {
+ require.NoError(t, env.Manager.removeRestoreTransaction(env.Manager.restoreTransactionPath()))
+ }()
+
+ target, err := env.Manager.resolveRestoreTarget(&manifest.Files[0])
+ require.NoError(t, err)
+ tmpPath := filepath.Join(target.root, restoreTempRel(target, journal.OperationID, 0))
+ writeTestFile(t, tmpPath, "partial-rollback-payload")
+ finalPath := filepath.Join(target.root, target.rel)
+ if !existed {
+ writeTestFile(t, finalPath, string(payload))
+ }
+
+ require.NoError(t, env.Manager.rollbackRestore(&journal))
+ assert.NoFileExists(t, tmpPath)
+ if existed {
+ // #nosec G304 -- finalPath is under a test-owned temporary root.
+ stored, readErr := os.ReadFile(finalPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "save-data\n", string(stored))
+ } else {
+ assert.NoFileExists(t, finalPath)
+ }
+ })
+ }
+}
+
+func TestRecoverPreparedRestorePreservesExternalFile(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ targetPath := filepath.Join(env.RootDir, "saves", "external.sav")
+ payload := []byte("restore-payload\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "external.sav")),
+ RestorePath: "saves/external.sav", Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ _, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ writeTestFile(t, targetPath, "created-after-preflight\n")
+
+ require.NoError(t, env.Manager.recoverRestoreLocked(context.Background()))
+ // #nosec G304 -- targetPath is under a test-owned temporary root.
+ stored, err := os.ReadFile(targetPath)
+ require.NoError(t, err)
+ assert.Equal(t, "created-after-preflight\n", string(stored))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestRecoverRestoreRetainsUnexpectedThirdPartyContent(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ targetPath := filepath.Join(env.RootDir, "saves", "external.sav")
+ payload := []byte("restore-payload\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "external.sav")),
+ RestorePath: "saves/external.sav", Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseApplying))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryStarted))
+ writeTestFile(t, targetPath, "created-by-another-process\n")
+
+ err = env.Manager.recoverRestoreLocked(context.Background())
+ require.ErrorIs(t, err, ErrRestoreRecoveryNeeded)
+ // #nosec G304 -- targetPath is under a test-owned temporary root.
+ stored, readErr := os.ReadFile(targetPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "created-by-another-process\n", string(stored))
+ assert.DirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestApplyRestorePreservesUntouchedExternalFileOnLaterEntryFailure(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ firstPath := filepath.Join(env.RootDir, "saves", "first.sav")
+ secondPath := filepath.Join(env.RootDir, "saves", "external.sav")
+ writeTestFile(t, firstPath, "first-old\n")
+ firstNew := []byte("first-new\n")
+ secondNew := []byte("second-new\n")
+ manifest := &Manifest{Files: []FileRef{
+ {
+ ArchivePath: platformArchive(filepath.Join("saves", "first.sav")),
+ RestorePath: "saves/first.sav", Category: CategorySaves,
+ SHA256: sha256Hex(firstNew), Size: int64(len(firstNew)),
+ },
+ {
+ ArchivePath: platformArchive(filepath.Join("saves", "external.sav")),
+ RestorePath: "saves/external.sav", Category: CategorySaves,
+ SHA256: sha256Hex(secondNew), Size: int64(len(secondNew)),
+ },
+ }}
+
+ err := env.Manager.applyRestore(context.Background(), manifest, func(file FileRef) (io.ReadCloser, error) {
+ if file.RestorePath == "saves/external.sav" {
+ writeTestFile(t, secondPath, "created-by-another-process\n")
+ return nil, errors.New("injected payload failure")
+ }
+ return io.NopCloser(bytes.NewReader(firstNew)), nil
+ })
+ require.Error(t, err)
+ // #nosec G304 -- firstPath is under a test-owned temporary root.
+ first, readErr := os.ReadFile(firstPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "first-old\n", string(first))
+ // #nosec G304 -- secondPath is under a test-owned temporary root.
+ second, readErr := os.ReadFile(secondPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "created-by-another-process\n", string(second))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestApplyRestoreRollsBackEarlierFilesOnFailure(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ firstPath := filepath.Join(env.RootDir, "saves", "first.sav")
+ secondPath := filepath.Join(env.RootDir, "saves", "second.sav")
+ writeTestFile(t, firstPath, "first-old\n")
+ writeTestFile(t, secondPath, "second-old\n")
+ firstNew := []byte("first-new\n")
+ secondNew := []byte("second-new\n")
+ manifest := &Manifest{Files: []FileRef{
+ {
+ ArchivePath: platformArchive(filepath.Join("saves", "first.sav")),
+ RestorePath: "saves/first.sav", Category: CategorySaves,
+ SHA256: sha256Hex(firstNew), Size: int64(len(firstNew)),
+ },
+ {
+ ArchivePath: platformArchive(filepath.Join("saves", "second.sav")),
+ RestorePath: "saves/second.sav", Category: CategorySaves,
+ SHA256: sha256Hex(secondNew), Size: int64(len(secondNew)),
+ },
+ }}
+
+ err := env.Manager.applyRestore(context.Background(), manifest, func(file FileRef) (io.ReadCloser, error) {
+ if file.RestorePath == "saves/second.sav" {
+ return nil, errors.New("injected payload failure")
+ }
+ return io.NopCloser(bytes.NewReader(firstNew)), nil
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "injected payload failure")
+ // #nosec G304 -- firstPath is created under test-owned temporary roots.
+ first, readErr := os.ReadFile(firstPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "first-old\n", string(first))
+ // #nosec G304 -- secondPath is created under test-owned temporary roots.
+ second, readErr := os.ReadFile(secondPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, "second-old\n", string(second))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestApplyRestoreRollsBackUserDBAfterRestoreFailure(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ env.UserDB.ExpectedCalls = nil
+ env.UserDB.On("ListClients").Return([]database.Client{}, nil)
+ rollbackName := "backup-20260717-120000-000000001-auto.db"
+ env.UserDB.On("Backup", "restore-rollback", false).Return(database.BackupInfo{
+ Name: rollbackName, Path: env.UserSnapshot, Valid: true,
+ }, nil)
+ env.UserDB.On("GetDBPath").Return(filepath.Join(env.DataDir, config.UserDbFile))
+ manualBackupName := testifymock.MatchedBy(func(name string) bool {
+ return strings.HasSuffix(name, "-manual.db")
+ })
+ env.UserDB.On("RestoreBackup", manualBackupName).
+ Return(database.RestoreInfo{}, errors.New("injected database restore failure")).Once()
+ env.UserDB.On("RestoreBackup", manualBackupName).Return(database.RestoreInfo{
+ RestoredFrom: database.BackupInfo{Valid: true},
+ }, nil).Once()
+
+ payload := []byte("portable-user-db")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: zaparooArchive("user.db"), RestorePath: "user.db", Category: CategoryZaparoo,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ err := env.Manager.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(payload)), nil
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "injected database restore failure")
+ env.UserDB.AssertNumberOfCalls(t, "RestoreBackup", 2)
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestRecoverRestoreRetainsTransactionOwnedUserDBRollbackAcrossRetries(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ payload := []byte("portable-user-db")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: zaparooArchive("user.db"), RestorePath: "user.db", Category: CategoryZaparoo,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseApplying))
+ require.NoError(t, env.Manager.persistRestoreUserDBStarted(&journal))
+ require.NoError(t, os.Remove(env.UserSnapshot), "simulate retention pruning original auto backup")
+
+ env.UserDB.ExpectedCalls = nil
+ env.UserDB.On("GetDBPath").Return(filepath.Join(env.DataDir, config.UserDbFile))
+ manualBackupName := testifymock.MatchedBy(func(name string) bool {
+ return strings.HasSuffix(name, "-manual.db")
+ })
+ assertStagedRollback := func(args testifymock.Arguments) {
+ name := args.String(0)
+ staged := filepath.Join(env.DataDir, "backups", name)
+ // #nosec G304 -- staged is generated under a test-owned temporary root.
+ contents, readErr := os.ReadFile(staged)
+ require.NoError(t, readErr)
+ assert.Equal(t, []byte("user-db-snapshot\n"), contents)
+ }
+ env.UserDB.On("RestoreBackup", manualBackupName).Run(assertStagedRollback).
+ Return(database.RestoreInfo{}, errors.New("injected rollback failure")).Once()
+ env.UserDB.On("RestoreBackup", manualBackupName).Run(assertStagedRollback).
+ Return(database.RestoreInfo{RestoredFrom: database.BackupInfo{Valid: true}}, nil).Once()
+
+ err = env.Manager.recoverRestoreLocked(context.Background())
+ require.ErrorIs(t, err, ErrRestoreRecoveryNeeded)
+ require.NotNil(t, journal.UserDBRollback)
+ artifactPath := filepath.Join(env.Manager.restoreTransactionPath(), journal.UserDBRollback.Path)
+ assert.FileExists(t, artifactPath)
+
+ require.NoError(t, env.Manager.recoverRestoreLocked(context.Background()))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+ env.UserDB.AssertNumberOfCalls(t, "RestoreBackup", 2)
+}
+
+func TestApplyRestoreRetainsJournalWhenRollbackCannotReachTarget(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ savesRoot := filepath.Join(env.RootDir, "saves")
+ movedRoot := filepath.Join(env.RootDir, "saves-offline")
+ firstNew := []byte("first-new\n")
+ secondNew := []byte("second-new\n")
+ manifest := &Manifest{Files: []FileRef{
+ {
+ ArchivePath: platformArchive(filepath.Join("saves", "first.sav")),
+ RestorePath: "saves/first.sav", Category: CategorySaves,
+ SHA256: sha256Hex(firstNew), Size: int64(len(firstNew)),
+ },
+ {
+ ArchivePath: platformArchive(filepath.Join("saves", "second.sav")),
+ RestorePath: "saves/second.sav", Category: CategorySaves,
+ SHA256: sha256Hex(secondNew), Size: int64(len(secondNew)),
+ },
+ }}
+ writeTestFile(t, filepath.Join(savesRoot, "first.sav"), "first-old\n")
+ writeTestFile(t, filepath.Join(savesRoot, "second.sav"), "second-old\n")
+
+ err := env.Manager.applyRestore(context.Background(), manifest, func(file FileRef) (io.ReadCloser, error) {
+ if file.RestorePath == "saves/second.sav" {
+ require.NoError(t, os.Rename(savesRoot, movedRoot))
+ return nil, errors.New("injected payload failure")
+ }
+ return io.NopCloser(bytes.NewReader(firstNew)), nil
+ })
+ require.Error(t, err)
+ require.ErrorIs(t, err, ErrRestoreRecoveryNeeded)
+ assert.DirExists(t, env.Manager.restoreTransactionPath())
+
+ require.NoError(t, os.Rename(movedRoot, savesRoot))
+ require.NoError(t, env.Manager.RecoverRestore(context.Background()))
+ // #nosec G304 -- savesRoot is created under test-owned temporary roots.
+ first, readErr := os.ReadFile(filepath.Join(savesRoot, "first.sav"))
+ require.NoError(t, readErr)
+ assert.Equal(t, "first-old\n", string(first))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestApplyRestoreFollowsTrustedExistingSymlink(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ usbTarget := filepath.Join(env.RootDir, "usb", "saves", "linked.sav")
+ writeTestFile(t, usbTarget, "old-usb-save\n")
+ logicalPath := filepath.Join(env.RootDir, "saves", "linked.sav")
+ require.NoError(t, os.Symlink(usbTarget, logicalPath))
+ newPayload := []byte("new-usb-save\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "linked.sav")),
+ RestorePath: "saves/linked.sav", Category: CategorySaves,
+ SHA256: sha256Hex(newPayload), Size: int64(len(newPayload)),
+ }}}
+
+ err := env.Manager.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(newPayload)), nil
+ })
+ require.NoError(t, err)
+ linkTarget, err := os.Readlink(logicalPath)
+ require.NoError(t, err)
+ assert.Equal(t, usbTarget, linkTarget)
+ // #nosec G304 -- usbTarget is created under test-owned temporary roots.
+ payload, err := os.ReadFile(usbTarget)
+ require.NoError(t, err)
+ assert.Equal(t, string(newPayload), string(payload))
+}
+
+func TestApplyRestoreAllowsApprovedCategoryRootSymlink(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ require.NoError(t, os.RemoveAll(filepath.Join(env.RootDir, "saves")))
+ usbSaves := filepath.Join(env.RootDir, "usb", "saves")
+ require.NoError(t, os.MkdirAll(usbSaves, 0o750))
+ require.NoError(t, os.Symlink(usbSaves, filepath.Join(env.RootDir, "saves")))
+
+ payload := []byte("restored-save\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "linked.sav")),
+ RestorePath: "saves/linked.sav", Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ err := env.Manager.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(payload)), nil
+ })
+ require.NoError(t, err)
+
+ // #nosec G304 -- target is under a test-owned temporary root.
+ restored, err := os.ReadFile(filepath.Join(usbSaves, "linked.sav"))
+ require.NoError(t, err)
+ assert.Equal(t, payload, restored)
+}
+
+func TestApplyRestoreRejectsCategoryRootSymlinkToAuthConfig(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ require.NoError(t, os.RemoveAll(filepath.Join(env.RootDir, "saves")))
+ authPath := filepath.Join(env.ConfigDir, config.AuthFile)
+ writeTestFile(t, authPath, "secret-bearer\n")
+ require.NoError(t, os.Symlink(env.ConfigDir, filepath.Join(env.RootDir, "saves")))
+
+ payload := []byte("attacker-controlled\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", config.AuthFile)),
+ RestorePath: filepath.ToSlash(filepath.Join("saves", config.AuthFile)), Category: CategorySaves,
+ SHA256: sha256Hex(payload), Size: int64(len(payload)),
+ }}}
+ err := env.Manager.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(payload)), nil
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "sensitive")
+
+ // #nosec G304 -- authPath is under a test-owned temporary root.
+ stored, err := os.ReadFile(authPath)
+ require.NoError(t, err)
+ assert.Equal(t, "secret-bearer\n", string(stored))
+}
+
+func TestApplyRestoreRejectsOutsideRootSymlink(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ outside := filepath.Join(t.TempDir(), "secret.sav")
+ writeTestFile(t, outside, "must-not-change\n")
+ logicalPath := filepath.Join(env.RootDir, "saves", "leak.sav")
+ require.NoError(t, os.Symlink(outside, logicalPath))
+ newPayload := []byte("attacker-controlled\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "leak.sav")),
+ RestorePath: "saves/leak.sav", Category: CategorySaves,
+ SHA256: sha256Hex(newPayload), Size: int64(len(newPayload)),
+ }}}
+
+ err := env.Manager.applyRestore(context.Background(), manifest, func(FileRef) (io.ReadCloser, error) {
+ return io.NopCloser(bytes.NewReader(newPayload)), nil
+ })
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "escapes trusted roots")
+ // #nosec G304 -- outside is created under a test-owned temporary root.
+ payload, readErr := os.ReadFile(outside)
+ require.NoError(t, readErr)
+ assert.Equal(t, "must-not-change\n", string(payload))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestRecoverRestoreRollsBackApplyingJournal(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ targetPath := filepath.Join(env.RootDir, "saves", "game.sav")
+ writeTestFile(t, targetPath, "before-crash\n")
+ newPayload := []byte("after-crash\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "game.sav")),
+ RestorePath: "saves/game.sav", Category: CategorySaves,
+ SHA256: sha256Hex(newPayload), Size: int64(len(newPayload)),
+ }}}
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseApplying))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryStarted))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryApplied))
+ writeTestFile(t, targetPath, string(newPayload))
+
+ require.NoError(t, env.Manager.recoverRestoreLocked(context.Background()))
+ // #nosec G304 -- targetPath is created under test-owned temporary roots.
+ payload, err := os.ReadFile(targetPath)
+ require.NoError(t, err)
+ assert.Equal(t, "before-crash\n", string(payload))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestRecoverRestoreUsesPersistedPolicyAfterConfiguredRootsChange(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ usbTarget := filepath.Join(env.RootDir, "usb", "saves", "custom.sav")
+ writeTestFile(t, usbTarget, "before-crash\n")
+ logicalPath := filepath.Join(env.RootDir, "saves", "custom.sav")
+ require.NoError(t, os.Symlink(usbTarget, logicalPath))
+ newPayload := []byte("after-crash\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "custom.sav")),
+ RestorePath: "saves/custom.sav", Category: CategorySaves,
+ SHA256: sha256Hex(newPayload), Size: int64(len(newPayload)),
+ }}}
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseApplying))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryStarted))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryApplied))
+ writeTestFile(t, usbTarget, string(newPayload))
+
+ changedPlatform := mocks.NewMockPlatform()
+ changedPlatform.On("Settings").Return(platforms.Settings{
+ DataDir: env.DataDir, ConfigDir: env.ConfigDir,
+ TempDir: filepath.Join(env.RootDir, "tmp"), LogDir: env.RootDir,
+ })
+ changedPlatform.On("RootDirs", testifymock.Anything).Return([]string{env.RootDir}).Maybe()
+ env.Manager.pl = backupPlatform{
+ MockPlatform: changedPlatform,
+ definitions: testPlatformDefinitions(env.RootDir),
+ }
+
+ require.NoError(t, env.Manager.recoverRestoreLocked(context.Background()))
+ // #nosec G304 -- usbTarget is under a test-owned temporary root.
+ payload, err := os.ReadFile(usbTarget)
+ require.NoError(t, err)
+ assert.Equal(t, "before-crash\n", string(payload))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+ changedPlatform.AssertNotCalled(t, "RootDirs", testifymock.Anything)
+}
+
+func TestRecoverRestoreKeepsCommittedFiles(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ targetPath := filepath.Join(env.RootDir, "saves", "game.sav")
+ writeTestFile(t, targetPath, "before-commit\n")
+ newPayload := []byte("after-commit\n")
+ manifest := &Manifest{Files: []FileRef{{
+ ArchivePath: platformArchive(filepath.Join("saves", "game.sav")),
+ RestorePath: "saves/game.sav", Category: CategorySaves,
+ SHA256: sha256Hex(newPayload), Size: int64(len(newPayload)),
+ }}}
+ journal, err := env.Manager.prepareRestoreJournal(context.Background(), manifest)
+ require.NoError(t, err)
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseApplying))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryStarted))
+ require.NoError(t, env.Manager.persistRestoreEntryState(&journal, 0, restoreEntryApplied))
+ require.NoError(t, env.Manager.persistRestorePhase(&journal, restorePhaseCommitted))
+ writeTestFile(t, targetPath, string(newPayload))
+
+ require.NoError(t, env.Manager.recoverRestoreLocked(context.Background()))
+ // #nosec G304 -- targetPath is created under test-owned temporary roots.
+ payload, err := os.ReadFile(targetPath)
+ require.NoError(t, err)
+ assert.Equal(t, string(newPayload), string(payload))
+ assert.NoDirExists(t, env.Manager.restoreTransactionPath())
+}
+
+func TestManagerCorruptStatusFailsRemoteClosed(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ require.NoError(t, os.MkdirAll(filepath.Dir(env.Manager.statusPath()), 0o750))
+ require.NoError(t, os.WriteFile(env.Manager.statusPath(), []byte(`{"remote":`), 0o600))
+
+ _, err := env.Manager.newRemoteClient()
+ require.ErrorIs(t, err, errRemoteUnlinked)
+ assert.True(t, env.Manager.coordinator.RemoteUnlinked())
+ assert.False(t, env.Manager.Status().Remote.Linked)
+}
+
+func TestManagerWritesStatusAtomically(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+
+ require.NoError(t, env.Manager.writeRemoteStatus(&statusEntry{
+ LastStatus: StatusFailed,
+ LastError: "device not linked",
+ Unlinked: true,
+ }))
+ // #nosec G304 -- status path is generated under a test-owned temporary root.
+ data, err := os.ReadFile(env.Manager.statusPath())
+ require.NoError(t, err)
+ var stored statusFile
+ require.NoError(t, json.Unmarshal(data, &stored))
+ assert.True(t, stored.Remote.Unlinked)
+ info, err := os.Stat(env.Manager.statusPath())
+ require.NoError(t, err)
+ assert.Equal(t, os.FileMode(0o600), info.Mode().Perm())
+ entries, err := os.ReadDir(filepath.Dir(env.Manager.statusPath()))
+ require.NoError(t, err)
+ for _, entry := range entries {
+ assert.False(t, strings.HasPrefix(entry.Name(), ".status-"))
+ }
+}
+
+func TestManagerStatusDoesNotRequireDatabase(t *testing.T) {
+ t.Parallel()
+ env := newBackupTestEnv(t, platformids.Mister)
+ mgr := NewManager(env.Manager.cfg, env.Manager.pl, nil)
+
+ status := mgr.Status()
+ assert.True(t, status.Local.Enabled)
+ assert.Equal(t, StatusNever, status.Local.LastStatus)
+ assert.Equal(t, config.DefaultBackupRemoteSchedule, status.Remote.Schedule)
+}
+
+func TestStatusEntrySanitizesStoredError(t *testing.T) {
+ t.Parallel()
+ entry := toStatusEntry(&statusEntry{
+ LastStatus: StatusFailed,
+ LastError: "creating backup directory: mkdir /media/fat/zaparoo/backups/files: permission denied",
+ }, true, "")
+
+ assert.Equal(t, "backup failed", entry.LastError)
+ assert.NotContains(t, entry.LastError, "/media/fat")
+}
+
+func TestManagerRunRemoteBackupUploadsPackedSnapshot(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ uploaded := make(map[string][]byte)
+ var committed remoteSnapshotRequest
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", Name: "test", BackupActive: true})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backup-objects/check":
+ var req remoteCheckRequest
+ decodeErr := json.NewDecoder(r.Body).Decode(&req)
+ if !assert.NoError(t, decodeErr) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, remoteCheckResponse{Missing: req.Hashes})
+ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/v1/device/backup-packs/"):
+ body, err := io.ReadAll(r.Body)
+ if !assert.NoError(t, err) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ assert.Equal(t, sha256Hex(body), strings.TrimPrefix(r.URL.Path, "/v1/device/backup-packs/"))
+ for hash, payload := range parseTestPack(t, body) {
+ uploaded[hash] = payload
+ }
+ writeJSON(t, w, remotePackResponse{
+ PackHash: sha256Hex(body), ObjectCount: len(uploaded), CreatedAt: time.Now().UTC(),
+ })
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups":
+ decodeErr := json.NewDecoder(r.Body).Decode(&committed)
+ if !assert.NoError(t, decodeErr) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ for _, entries := range committed.Categories {
+ for _, entry := range entries {
+ assert.Contains(t, uploaded, entry.SHA256)
+ }
+ }
+ writeJSON(t, w, testCommittedRemoteResponse(
+ t, "backup-1", platformids.Mister, &committed,
+ ))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups":
+ writeJSON(t, w, remoteListResponse{StorageUsedBytes: 99, StorageQuotaBytes: 1000})
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+ env.Manager.cfg.SetBackupRemoteEnabled(false)
+ defaultOpener := env.Manager.sourceOpener
+ env.Manager.sourceOpener = func(ctx context.Context, file *FileRef) (io.ReadCloser, error) {
+ if file.RestorePath == filepath.ToSlash(filepath.Join("saves", "game.sav")) {
+ return io.NopCloser(&errorReader{err: &os.PathError{
+ Op: "read", Path: file.RestorePath, Err: errors.New("network I/O failure"),
+ }}), nil
+ }
+ return defaultOpener(ctx, file)
+ }
+
+ info, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ require.NoError(t, err)
+ assert.Equal(t, "backup-1", info.Backup.ID)
+ assert.Positive(t, info.UploadedPacks)
+ assert.Contains(t, committed.Categories, CategorySavestates)
+ assert.NotEmpty(t, committed.Categories[CategorySavestates])
+ assert.NotContains(t, committed.Categories, CategorySaves)
+ assert.Contains(t, info.Warnings, models.BackupWarning{
+ Category: CategorySaves,
+ Path: filepath.ToSlash(filepath.Join("saves", "game.sav")),
+ Reason: "source unreadable during backup",
+ })
+ assert.Equal(t, int64(99), info.StorageUsedBytes)
+ status := env.Manager.Status()
+ assert.Equal(t, StatusPartial, status.Remote.LastStatus)
+ assert.Equal(t, 1, status.Remote.SkippedFiles)
+ assert.Positive(t, status.Remote.Categories[CategorySavestates].Files)
+}
+
+func TestRemoteUploadMissingReportsEveryPathForDuplicateOversizedContent(t *testing.T) {
+ t.Parallel()
+ payload := []byte("oversized save payload")
+ hash := sha256Hex(payload)
+ sourcePath := filepath.Join(t.TempDir(), "large.sav")
+ require.NoError(t, os.WriteFile(sourcePath, payload, 0o600))
+
+ // The server has no way to store a file that cannot fit inside a single
+ // pack (there is no per-object upload endpoint), so nothing may be sent.
+ server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ t.Errorf("oversized file must not be uploaded, got %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+
+ client := &remoteClient{httpClient: server.Client(), baseURL: server.URL, bearer: "test-token", platform: "test"}
+ files := []FileRef{
+ {
+ Category: CategorySaves,
+ RestorePath: "saves/large.sav",
+ SHA256: hash,
+ Size: remoteMaxPackBytes,
+ },
+ {
+ Category: CategorySavestates,
+ RestorePath: "savestates/duplicate-large.ss",
+ SHA256: hash,
+ Size: remoteMaxPackBytes,
+ },
+ }
+ missing := map[string]struct{}{hash: {}}
+
+ result, err := client.uploadMissing(context.Background(), files, missing, nil)
+ require.NoError(t, err)
+ assert.Zero(t, result.packs)
+ assert.Zero(t, result.bytesUploaded)
+ require.Len(t, result.skipped, 2)
+ assert.Equal(t, []string{"saves/large.sav", "savestates/duplicate-large.ss"}, []string{
+ result.skipped[0].RestorePath,
+ result.skipped[1].RestorePath,
+ })
+
+ // And the manifest must drop the skipped file so the commit never
+ // references bytes the server does not have.
+ assert.Empty(t, withoutSkippedFiles(files, result.skipped))
+}
+
+func TestRemoteUploadMissingNeverPacksEmptyFiles(t *testing.T) {
+ t.Parallel()
+ sourcePath := filepath.Join(t.TempDir(), "empty.sav")
+ require.NoError(t, os.WriteFile(sourcePath, nil, 0o600))
+
+ // Even if a server (wrongly) reports the empty hash missing, it must
+ // never be packed: a zero-length range cannot live inside a pack.
+ server := httptest.NewServer(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ t.Errorf("empty file must not be uploaded, got %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+
+ client := &remoteClient{httpClient: server.Client(), baseURL: server.URL, bearer: "test-token", platform: "test"}
+ files := []FileRef{{
+ Category: CategorySaves,
+ RestorePath: "saves/empty.sav",
+ SHA256: remoteEmptyContentSHA256,
+ Size: 0,
+ }}
+ missing := map[string]struct{}{remoteEmptyContentSHA256: {}}
+
+ result, err := client.uploadMissing(context.Background(), files, missing, nil)
+ require.NoError(t, err)
+ assert.Zero(t, result.packs)
+ assert.Zero(t, result.bytesUploaded)
+ assert.Empty(t, result.skipped, "empty files stay in the manifest; they are not skipped")
+}
+
+func TestManagerRunRemoteBackupReportsQuotaExceeded(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", Name: "test", BackupActive: true})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backup-objects/check":
+ var req remoteCheckRequest
+ decodeErr := json.NewDecoder(r.Body).Decode(&req)
+ if !assert.NoError(t, decodeErr) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, remoteCheckResponse{Missing: req.Hashes})
+ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/v1/device/backup-packs/"):
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{
+ "error":{"code":"quota_exceeded","message":"Backup storage quota exceeded"}
+ }`))
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+ ns := make(chan models.Notification, 1)
+ env.UserDB.On("AddInboxMessage", testifymock.MatchedBy(func(msg *database.InboxMessage) bool {
+ return msg.Title == "Remote backup storage full" &&
+ msg.Category == inboxservice.CategoryBackupRemoteQuotaExceeded &&
+ msg.Severity == inboxservice.SeverityError
+ })).Return(&database.InboxMessage{
+ DBID: 1, Title: "Remote backup storage full", Category: inboxservice.CategoryBackupRemoteQuotaExceeded,
+ }, nil).Once()
+ env.Manager.WithInbox(inboxservice.NewService(env.UserDB, ns))
+
+ _, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "quota exceeded")
+ status := env.Manager.Status()
+ assert.Equal(t, StatusFailed, status.Remote.LastStatus)
+ assert.Equal(t, "storage quota exceeded", status.Remote.LastError)
+ env.UserDB.AssertExpectations(t)
+ select {
+ case notification := <-ns:
+ assert.Equal(t, models.NotificationInboxAdded, notification.Method)
+ default:
+ t.Fatal("expected inbox notification")
+ }
+}
+
+func TestRemotePackFilesSortsByCategoryThenPath(t *testing.T) {
+ t.Parallel()
+ files := []FileRef{
+ {Category: CategorySavestates, RestorePath: "savestates/z.ss", SHA256: "5"},
+ {Category: CategorySettings, RestorePath: "MiSTer.ini", SHA256: "2"},
+ {Category: CategoryZaparoo, RestorePath: "user.db", SHA256: "1"},
+ {Category: CategorySaves, RestorePath: "saves/b.sav", SHA256: "4"},
+ {Category: CategoryInputs, RestorePath: "config/inputs/a.map", SHA256: "3"},
+ {Category: CategorySaves, RestorePath: "saves/a.sav", SHA256: "6"},
+ }
+
+ sortRemotePackFiles(files)
+
+ assert.Equal(t, []string{
+ CategoryZaparoo + ":user.db",
+ CategorySettings + ":MiSTer.ini",
+ CategoryInputs + ":config/inputs/a.map",
+ CategorySaves + ":saves/a.sav",
+ CategorySaves + ":saves/b.sav",
+ CategorySavestates + ":savestates/z.ss",
+ }, remotePackOrder(files))
+}
+
+func TestManagerListRemoteMapsBackupSources(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodGet, r.Method)
+ assert.Equal(t, "/v1/device/backups", r.URL.Path)
+ assert.Empty(t, r.URL.RawQuery)
+ writeJSON(t, w, remoteListResponse{Items: []remoteBackupResponse{{
+ ID: "backup-1", BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion,
+ Platform: testStringPointer(platformids.Mister), CreatedAt: time.Now().UTC(),
+ SourceDevice: &remoteBackupSourceDevice{
+ ID: "source-1", Name: "Old MiSTer", Platform: testStringPointer(platformids.Mister),
+ Linked: false, Current: false,
+ },
+ }}})
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ list, err := env.Manager.ListRemote(context.Background())
+ require.NoError(t, err)
+ require.Len(t, list.Items, 1)
+ require.NotNil(t, list.Items[0].SourceDevice)
+ assert.Equal(t, "source-1", list.Items[0].SourceDevice.ID)
+ assert.Equal(t, "Old MiSTer", list.Items[0].SourceDevice.Name)
+ assert.False(t, list.Items[0].SourceDevice.Linked)
+}
+
+func TestManagerRevokeRemoteLink(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, http.MethodDelete, r.Method)
+ assert.Equal(t, "/v1/device/me", r.URL.Path)
+ assert.Equal(t, "Bearer test-token", r.Header.Get("Authorization"))
+ w.WriteHeader(http.StatusNoContent)
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ require.NoError(t, env.Manager.RevokeRemoteLink(context.Background()))
+}
+
+func TestManagerRestoreRemoteRejectsMissingUserDBBeforePreRestoreBackup(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ manifestData, manifestHash, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{})
+ require.NoError(t, err)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/missing-db" {
+ writeJSON(t, w, remoteBackupResponse{
+ ID: "missing-db", BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion,
+ Platform: testStringPointer(platformids.Mister), ManifestHash: manifestHash,
+ Categories: map[string]remoteCategorySummary{}, Manifest: manifestData, CreatedAt: time.Now().UTC(),
+ })
+ return
+ }
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err = env.Manager.RestoreRemote(context.Background(), "missing-db")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "exactly one files/zaparoo/user.db payload")
+ env.UserDB.AssertNotCalled(t, "BackupForTransfer", testifymock.Anything, testifymock.Anything)
+ env.UserDB.AssertNotCalled(t, "RestoreBackup", testifymock.Anything)
+}
+
+func TestManagerRestoreRemoteDownloadsObjects(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ newSave := []byte("remote-save\n")
+ hash := sha256Hex(newSave)
+ remoteConfig := []byte("[service]\ndevice_id = \"remote-source-device\"\n")
+ configHash := sha256Hex(remoteConfig)
+ manifestData, manifestHash, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{
+ CategoryZaparoo: {
+ {Path: config.UserDbFile, SHA256: remoteEmptyContentSHA256, Size: 0},
+ {Path: config.CfgFile, SHA256: configHash, Size: int64(len(remoteConfig))},
+ },
+ CategorySaves: {{Path: "saves/game.sav", SHA256: hash, Size: int64(len(newSave))}},
+ })
+ require.NoError(t, err)
+ restoreComplete := false
+ var restoreCompleteID string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/7":
+ writeJSON(t, w, remoteBackupResponse{
+ ID: "7", BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion,
+ Platform: testStringPointer(platformids.Mister),
+ ManifestHash: manifestHash, SizeBytes: int64(len(newSave) + len(remoteConfig)),
+ Categories: map[string]remoteCategorySummary{
+ CategoryZaparoo: {Files: 2, Bytes: int64(len(remoteConfig))},
+ CategorySaves: {Files: 1, Bytes: int64(len(newSave))},
+ },
+ Manifest: manifestData, CreatedAt: time.Now().UTC(),
+ })
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backup-objects/"+hash:
+ _, _ = w.Write(newSave)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backup-objects/"+configHash:
+ _, _ = w.Write(remoteConfig)
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups/7/restore-complete":
+ var complete remoteRestoreCompleteRequest
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&complete)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ _, parseErr := uuid.Parse(complete.RestoreID)
+ if !assert.NoError(t, parseErr) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ restoreCompleteID = complete.RestoreID
+ restoreComplete = true
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+ env.Manager.cfg.SetBackupRemoteEnabled(false)
+
+ writeTestFile(t, filepath.Join(env.RootDir, "saves", "game.sav"), "old-save\n")
+ restore, err := env.Manager.RestoreRemote(context.Background(), "7")
+ require.NoError(t, err)
+ assert.Equal(t, "7", restore.RestoredFrom.ID)
+ assert.True(t, restoreComplete)
+ assert.NotEmpty(t, restoreCompleteID)
+ restored, err := os.ReadFile(filepath.Join(env.RootDir, "saves", "game.sav"))
+ require.NoError(t, err)
+ assert.Equal(t, newSave, restored)
+ restoredConfigPath := filepath.Join(env.ConfigDir, config.CfgFile)
+ // #nosec G304 -- restoredConfigPath is under a test-owned temporary root.
+ restoredConfigData, err := os.ReadFile(restoredConfigPath)
+ require.NoError(t, err)
+ restoredConfig := &config.Instance{}
+ require.NoError(t, restoredConfig.LoadTOML(string(restoredConfigData)))
+ assert.Equal(t, env.Manager.cfg.DeviceID(), restoredConfig.DeviceID())
+ assert.NotContains(t, string(restoredConfigData), "remote-source-device")
+}
+
+func remotePackOrder(files []FileRef) []string {
+ out := make([]string, 0, len(files))
+ for _, file := range files {
+ out = append(out, file.Category+":"+file.RestorePath)
+ }
+ return out
+}
+
+func configureRemoteTestAuth(t *testing.T, mgr *Manager, baseURL string) {
+ t.Helper()
+ require.NoError(t, mgr.cfg.SetBackupRemoteBaseURL(baseURL))
+ mgr.cfg.SetBackupRemoteEnabled(true)
+ config.SetAuthCfgForTesting(map[string]config.CredentialEntry{
+ config.BackupAuthLookupURL(baseURL): {Bearer: "test-token"},
+ })
+ t.Cleanup(func() { config.ClearAuthCfgForTesting() })
+}
+
+func parseTestPack(t *testing.T, body []byte) map[string][]byte {
+ t.Helper()
+ require.GreaterOrEqual(t, len(body), 4)
+ footerLen := int(binary.BigEndian.Uint32(body[len(body)-4:]))
+ footerStart := len(body) - 4 - footerLen
+ require.GreaterOrEqual(t, footerStart, 0)
+ var footer []packFooterEntry
+ require.NoError(t, json.Unmarshal(body[footerStart:len(body)-4], &footer))
+ out := make(map[string][]byte, len(footer))
+ for _, entry := range footer {
+ payload := body[entry.Offset : entry.Offset+entry.Length]
+ assert.Equal(t, entry.Hash, sha256Hex(payload))
+ out[entry.Hash] = payload
+ }
+ return out
+}
+
+func testStringPointer(value string) *string { return &value }
+
+func testCommittedRemoteResponse(
+ t *testing.T, id, platform string, request *remoteSnapshotRequest,
+) remoteBackupResponse {
+ t.Helper()
+ // Canonicalize exactly like the server's commit handler so the mock's
+ // manifest_hash matches what a real server would record.
+ manifestData, manifestHash, err := canonicalRemoteManifest(request.Categories)
+ require.NoError(t, err)
+ manifest := &remoteManifest{Format: remoteManifestFormat, Categories: request.Categories}
+ files := remoteManifestFiles(manifest)
+ var size int64
+ for _, file := range files {
+ size += file.Size
+ }
+ return remoteBackupResponse{
+ ID: id, BackupType: request.BackupType, SchemaVersion: request.SchemaVersion,
+ CoreVersion: request.CoreVersion, Platform: testStringPointer(platform),
+ ManifestHash: manifestHash, SizeBytes: size,
+ Categories: remoteCategorySummaries(files), Manifest: manifestData, CreatedAt: time.Now().UTC(),
+ }
+}
+
+func writeJSON(t *testing.T, w http.ResponseWriter, value any) {
+ t.Helper()
+ w.Header().Set("Content-Type", "application/json")
+ encodeErr := json.NewEncoder(w).Encode(value)
+ require.NoError(t, encodeErr)
+}
+
+func sha256Hex(data []byte) string {
+ sum := sha256.Sum256(data)
+ return hex.EncodeToString(sum[:])
+}
+
+func writeTestFile(t *testing.T, filePath, contents string) {
+ t.Helper()
+ require.NoError(t, os.MkdirAll(filepath.Dir(filePath), 0o750))
+ require.NoError(t, os.WriteFile(filePath, []byte(contents), 0o600))
+}
+
+func corruptZipPayload(t *testing.T, zipPath, target, contents string) {
+ t.Helper()
+ zr, err := zip.OpenReader(zipPath)
+ require.NoError(t, err)
+ entries := make(map[string][]byte, len(zr.File))
+ for _, file := range zr.File {
+ r, openErr := file.Open()
+ require.NoError(t, openErr)
+ body, readErr := io.ReadAll(r)
+ require.NoError(t, readErr)
+ require.NoError(t, r.Close())
+ if file.Name == target {
+ body = []byte(contents)
+ }
+ entries[file.Name] = body
+ }
+ require.NoError(t, zr.Close())
+
+ // #nosec G304 -- test helper writes only paths created by individual tests.
+ out, err := os.Create(zipPath)
+ require.NoError(t, err)
+ zw := zip.NewWriter(out)
+ for name, body := range entries {
+ w, createErr := zw.Create(name)
+ require.NoError(t, createErr)
+ _, writeErr := w.Write(body)
+ require.NoError(t, writeErr)
+ }
+ require.NoError(t, zw.Close())
+ require.NoError(t, out.Close())
+}
+
+func writeRawZip(t *testing.T, zipPath string, entries map[string]string) {
+ t.Helper()
+ // #nosec G304 -- test helper writes only paths created by individual tests.
+ out, err := os.Create(zipPath)
+ require.NoError(t, err)
+ zw := zip.NewWriter(out)
+ for name, contents := range entries {
+ w, createErr := zw.Create(name)
+ require.NoError(t, createErr)
+ _, writeErr := w.Write([]byte(contents))
+ require.NoError(t, writeErr)
+ }
+ require.NoError(t, zw.Close())
+ require.NoError(t, out.Close())
+}
+
+func TestManagerRestoreRemoteUsesOpaqueIDAsSinglePathSegment(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ const backupID = "save/a%b ?\u96ea"
+ escapedBackupPath := "/v1/device/backups/" + url.PathEscape(backupID)
+ decodedBackupPath := "/v1/device/backups/" + backupID
+ manifestData, manifestHash, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{
+ CategoryZaparoo: {{Path: config.UserDbFile, SHA256: remoteEmptyContentSHA256, Size: 0}},
+ })
+ require.NoError(t, err)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.Method {
+ case http.MethodGet:
+ assert.Equal(t, decodedBackupPath, r.URL.Path)
+ assert.Equal(t, escapedBackupPath, r.RequestURI)
+ assert.Empty(t, r.URL.RawQuery)
+ writeJSON(t, w, remoteBackupResponse{
+ ID: backupID, BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion,
+ Platform: testStringPointer(platformids.Mister), ManifestHash: manifestHash,
+ Categories: map[string]remoteCategorySummary{CategoryZaparoo: {Files: 1}},
+ Manifest: manifestData, CreatedAt: time.Now().UTC(),
+ })
+ case http.MethodPost:
+ assert.Equal(t, decodedBackupPath+"/restore-complete", r.URL.Path)
+ assert.Equal(t, escapedBackupPath+"/restore-complete", r.RequestURI)
+ assert.Empty(t, r.URL.RawQuery)
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.RequestURI)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err = env.Manager.RestoreRemote(context.Background(), backupID)
+ require.NoError(t, err)
+}
+
+func TestManagerRestoreRemoteRejectsMismatchedResponseIDBeforeChanges(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ localPath := filepath.Join(env.RootDir, "saves", "game.sav")
+ // #nosec G304 -- localPath is under a test-owned temporary root.
+ original, err := os.ReadFile(localPath)
+ require.NoError(t, err)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/backup-a" {
+ writeJSON(t, w, remoteBackupResponse{ID: "backup-b"})
+ return
+ }
+ t.Fatalf("ID mismatch must precede further requests, got %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err = env.Manager.RestoreRemote(context.Background(), "backup-a")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "does not match requested ID")
+ // #nosec G304 -- localPath is under a test-owned temporary root.
+ after, readErr := os.ReadFile(localPath)
+ require.NoError(t, readErr)
+ assert.Equal(t, original, after)
+ env.UserDB.AssertNotCalled(
+ t, "BackupForTransfer", testifymock.Anything, testifymock.AnythingOfType("string"),
+ )
+}
+
+func TestManagerRestoreRemoteRejectsManifestHashBeforeDownload(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ payload := []byte("remote-save\n")
+ hash := sha256Hex(payload)
+ manifestData, _, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{
+ CategorySaves: {{Path: "saves/game.sav", SHA256: hash, Size: int64(len(payload))}},
+ })
+ require.NoError(t, err)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/hash-test" {
+ writeJSON(t, w, remoteBackupResponse{
+ ID: "hash-test", BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion,
+ Platform: testStringPointer(platformids.Mister), ManifestHash: strings.Repeat("0", 64),
+ SizeBytes: int64(len(payload)), Categories: map[string]remoteCategorySummary{
+ CategorySaves: {Files: 1, Bytes: int64(len(payload))},
+ },
+ Manifest: manifestData, CreatedAt: time.Now().UTC(),
+ })
+ return
+ }
+ t.Fatalf("manifest rejection must precede object download, got %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err = env.Manager.RestoreRemote(context.Background(), "hash-test")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "manifest hash mismatch")
+}
+
+func TestManagerRestoreRemoteRefusesNewerSchema(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/9":
+ writeJSON(t, w, remoteBackupResponse{
+ ID: "9", BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion + 1,
+ ManifestHash: "x", CreatedAt: time.Now().UTC(),
+ Manifest: json.RawMessage(`{"format":"cas-v1","categories":{}}`),
+ })
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err := env.Manager.RestoreRemote(context.Background(), "9")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "newer Core")
+}
+
+func TestRemoteBackupToInfoMarksNewerSchemaIncompatible(t *testing.T) {
+ t.Parallel()
+ newer := remoteBackupResponse{ID: "1", SchemaVersion: remoteSchemaVersion + 1}
+ assert.True(t, remoteBackupToInfo(&newer).Incompatible)
+ current := remoteBackupResponse{ID: "2", SchemaVersion: remoteSchemaVersion}
+ assert.False(t, remoteBackupToInfo(¤t).Incompatible)
+}
+
+func TestManagerRestoreRemoteSynthesizesEmptyFiles(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ newSave := []byte("remote-save\n")
+ hash := sha256Hex(newSave)
+ manifestData, manifestHash, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{
+ CategoryZaparoo: {{Path: config.UserDbFile, SHA256: remoteEmptyContentSHA256, Size: 0}},
+ CategorySaves: {
+ {Path: "saves/game.sav", SHA256: hash, Size: int64(len(newSave))},
+ {Path: "saves/empty.sav", SHA256: remoteEmptyContentSHA256, Size: 0},
+ },
+ })
+ require.NoError(t, err)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/8":
+ writeJSON(t, w, remoteBackupResponse{
+ ID: "8", BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion,
+ Platform: testStringPointer(platformids.Mister),
+ ManifestHash: manifestHash, SizeBytes: int64(len(newSave)),
+ Categories: map[string]remoteCategorySummary{
+ CategoryZaparoo: {Files: 1, Bytes: 0},
+ CategorySaves: {Files: 2, Bytes: int64(len(newSave))},
+ },
+ Manifest: manifestData, CreatedAt: time.Now().UTC(),
+ })
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backup-objects/"+hash:
+ _, _ = w.Write(newSave)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backup-objects/"+remoteEmptyContentSHA256:
+ t.Error("the empty object must never be downloaded")
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups/8/restore-complete":
+ w.WriteHeader(http.StatusNoContent)
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err = env.Manager.RestoreRemote(context.Background(), "8")
+ require.NoError(t, err)
+ empty, err := os.ReadFile(filepath.Join(env.RootDir, "saves", "empty.sav"))
+ require.NoError(t, err)
+ assert.Empty(t, empty, "empty manifest entries restore as zero-byte files")
+ restored, err := os.ReadFile(filepath.Join(env.RootDir, "saves", "game.sav"))
+ require.NoError(t, err)
+ assert.Equal(t, newSave, restored)
+}
+
+func TestManagerRestoreRemoteLeavesDeviceUntouchedOnDownloadFailure(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ first := []byte("first-save\n")
+ second := []byte("second-save\n")
+ manifestData, manifestHash, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{
+ CategorySaves: {
+ {Path: "saves/a.sav", SHA256: sha256Hex(first), Size: int64(len(first))},
+ {Path: "saves/b.sav", SHA256: sha256Hex(second), Size: int64(len(second))},
+ },
+ })
+ require.NoError(t, err)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/6":
+ writeJSON(t, w, remoteBackupResponse{
+ ID: "6", BackupType: RemoteBackupTypeManual, SchemaVersion: remoteSchemaVersion,
+ Platform: testStringPointer(platformids.Mister),
+ ManifestHash: manifestHash, SizeBytes: int64(len(first) + len(second)),
+ Categories: map[string]remoteCategorySummary{
+ CategorySaves: {Files: 2, Bytes: int64(len(first) + len(second))},
+ },
+ Manifest: manifestData, CreatedAt: time.Now().UTC(),
+ })
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backup-objects/"+sha256Hex(first):
+ _, _ = w.Write(first)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backup-objects/"+sha256Hex(second):
+ w.WriteHeader(http.StatusNotFound)
+ _, _ = w.Write([]byte(`{"error":{"code":"not_found","message":"Object not found"}}`))
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ writeTestFile(t, filepath.Join(env.RootDir, "saves", "a.sav"), "old-a\n")
+ _, err = env.Manager.RestoreRemote(context.Background(), "6")
+ require.Error(t, err)
+
+ // Everything is downloaded and verified before anything is applied, so
+ // a failed download must leave the device exactly as it was.
+ current, err := os.ReadFile(filepath.Join(env.RootDir, "saves", "a.sav"))
+ require.NoError(t, err)
+ assert.Equal(t, []byte("old-a\n"), current)
+ _, err = os.Stat(filepath.Join(env.RootDir, "saves", "b.sav"))
+ require.ErrorIs(t, err, os.ErrNotExist)
+}
+
+func TestManagerRunRemoteRechecksDeduplicatedSourcesBeforeCommit(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ checkCalls := 0
+ commitCalls := 0
+ var committed remoteSnapshotRequest
+ savePath := filepath.Join(env.RootDir, "saves", "game.sav")
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", BackupActive: true})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backup-objects/check":
+ checkCalls++
+ if checkCalls == 1 && !assert.NoError(
+ t, os.WriteFile(savePath, []byte("changed-save\n"), 0o600),
+ ) {
+ w.WriteHeader(http.StatusInternalServerError)
+ return
+ }
+ writeJSON(t, w, remoteCheckResponse{})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups":
+ commitCalls++
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&committed)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, testCommittedRemoteResponse(
+ t, "backup-dedup", platformids.Mister, &committed,
+ ))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups":
+ writeJSON(t, w, remoteListResponse{})
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ info, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ require.NoError(t, err)
+ assert.Equal(t, "backup-dedup", info.Backup.ID)
+ assert.Equal(t, 2, checkCalls, "source change retries the entire snapshot once")
+ assert.Equal(t, 1, commitCalls, "known-stale snapshot must never be committed")
+}
+
+func TestManagerRunRemoteRetriesOnceOnIntegrityMismatch(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ heartbeats := 0
+ packAttempts := 0
+ var committed remoteSnapshotRequest
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ heartbeats++
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", Name: "test", BackupActive: true})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backup-objects/check":
+ var req remoteCheckRequest
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&req)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, remoteCheckResponse{Missing: req.Hashes})
+ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/v1/device/backup-packs/"):
+ packAttempts++
+ if packAttempts == 1 {
+ w.WriteHeader(http.StatusBadRequest)
+ _, _ = w.Write([]byte(`{"error":{"code":"integrity_mismatch","message":"hash mismatch"}}`))
+ return
+ }
+ body, readErr := io.ReadAll(r.Body)
+ if !assert.NoError(t, readErr) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ w.WriteHeader(http.StatusCreated)
+ writeJSON(t, w, remotePackResponse{PackHash: sha256Hex(body), CreatedAt: time.Now().UTC()})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups":
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&committed)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, testCommittedRemoteResponse(
+ t, "backup-2", platformids.Mister, &committed,
+ ))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups":
+ writeJSON(t, w, remoteListResponse{})
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ info, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeScheduled)
+ require.NoError(t, err)
+ assert.Equal(t, "backup-2", info.Backup.ID)
+ assert.Equal(t, 2, heartbeats, "integrity mismatch re-runs the whole snapshot once")
+ assert.Equal(t, RemoteBackupTypeScheduled, committed.BackupType,
+ "scheduler-initiated runs commit as scheduled")
+}
+
+func TestManagerRunRemoteRepairsMissingObjectsOnce(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ checkCalls := 0
+ commitCalls := 0
+ packCalls := 0
+ var committed remoteSnapshotRequest
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", BackupActive: true})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backup-objects/check":
+ checkCalls++
+ var request remoteCheckRequest
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&request)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ if checkCalls == 1 {
+ writeJSON(t, w, remoteCheckResponse{})
+ } else {
+ writeJSON(t, w, remoteCheckResponse{Missing: request.Hashes})
+ }
+ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/v1/device/backup-packs/"):
+ packCalls++
+ body, readErr := io.ReadAll(r.Body)
+ if !assert.NoError(t, readErr) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, remotePackResponse{PackHash: sha256Hex(body), CreatedAt: time.Now().UTC()})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups":
+ commitCalls++
+ if !assert.NoError(t, json.NewDecoder(r.Body).Decode(&committed)) {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ if commitCalls == 1 {
+ w.WriteHeader(http.StatusConflict)
+ _, _ = w.Write([]byte(`{"error":{"code":"missing_objects","message":"missing"}}`))
+ return
+ }
+ writeJSON(t, w, testCommittedRemoteResponse(
+ t, "backup-repaired", platformids.Mister, &committed,
+ ))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups":
+ writeJSON(t, w, remoteListResponse{})
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ info, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ require.NoError(t, err)
+ assert.Equal(t, "backup-repaired", info.Backup.ID)
+ assert.Equal(t, 2, checkCalls)
+ assert.Equal(t, 2, commitCalls)
+ assert.Positive(t, packCalls)
+}
+
+func TestManagerRunRemoteStopsAfterSecondMissingObjectsResponse(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ commitCalls := 0
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", BackupActive: true})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backup-objects/check":
+ writeJSON(t, w, remoteCheckResponse{})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups":
+ commitCalls++
+ w.WriteHeader(http.StatusConflict)
+ _, _ = w.Write([]byte(`{"error":{"code":"missing_objects","message":"missing"}}`))
+ default:
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ require.ErrorIs(t, err, errRemoteMissingObjects)
+ assert.Equal(t, 2, commitCalls, "missing-object repair has one bounded recommit")
+}
+
+func TestManagerRunRemoteRejectsInvalidType(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ _, err := env.Manager.RunRemote(context.Background(), "pre_restore")
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "invalid remote backup type")
+}
+
+func TestManagerRunRemoteRefusesConcurrentRuns(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ lease, err := env.Manager.coordinator.Begin(
+ context.Background(), OperationLocalCreate, OperationWrite,
+ )
+ require.NoError(t, err)
+ defer lease.Release()
+
+ _, err = env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ var busy *BusyError
+ require.ErrorAs(t, err, &busy)
+ assert.Equal(t, OperationLocalCreate, busy.Kind)
+}
+
+func TestManagerRunRemoteMarksUnlinkedOn401(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // Device-token middleware failures return bare status codes.
+ if r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat" {
+ w.WriteHeader(http.StatusUnauthorized)
+ return
+ }
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ require.ErrorIs(t, err, errRemoteUnlinked)
+
+ status := env.Manager.Status()
+ assert.False(t, status.Remote.Linked,
+ "a 401 records revocation: linked reports false even though the credential file remains")
+ assert.Equal(t, "device not linked", status.Remote.LastError)
+ assert.Equal(t, RemoteAvailabilityUnknown, status.Remote.Availability)
+ entry := config.LookupAuth(
+ config.GetAuthCfg(), config.BackupAuthLookupURL(env.Manager.cfg.BackupRemoteBaseURL()),
+ )
+ require.NotNil(t, entry)
+ assert.Equal(t, "test-token", entry.Bearer, "revocation retains the credential for explicit relinking")
+ _, err = env.Manager.ListRemote(context.Background())
+ require.ErrorIs(t, err, errRemoteUnlinked, "retained bearer must not be reused after a 401")
+
+ env.Manager.MarkRemoteLinked()
+ status = env.Manager.Status()
+ assert.True(t, status.Remote.Linked, "a fresh claim clears the unlinked marker")
+}
+
+func TestUnauthorizedOldBearerDoesNotRevokeFreshCredential(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ oldClient, err := env.Manager.newRemoteClient()
+ require.NoError(t, err)
+ env.Manager.coordinator.SetRemoteUnlinked(true)
+ config.SetAuthCfgForTesting(map[string]config.CredentialEntry{
+ config.BackupAuthLookupURL(server.URL): {Bearer: "fresh-token"},
+ })
+ env.Manager.MarkRemoteLinked()
+
+ oldClient.onUnauthorized()
+ assert.False(t, env.Manager.coordinator.RemoteUnlinked())
+ status := env.Manager.Status()
+ assert.True(t, status.Remote.Linked)
+ entry := config.LookupAuth(config.GetAuthCfg(), config.BackupAuthLookupURL(server.URL))
+ require.NotNil(t, entry)
+ assert.Equal(t, "fresh-token", entry.Bearer)
+}
+
+func TestManagerRefreshRemoteAvailabilityCachesTriState(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ var backupActive atomic.Bool
+ backupActive.Store(true)
+ var requests atomic.Int32
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/v1/device/me" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ requests.Add(1)
+ writeJSON(t, w, remoteDeviceMeResponse{
+ ID: "device-1", Name: "test", BackupActive: backupActive.Load(),
+ })
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ availability, err := env.Manager.RefreshRemoteAvailability(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, RemoteAvailabilityAvailable, availability)
+ status := env.Manager.Status()
+ assert.Equal(t, RemoteAvailabilityAvailable, status.Remote.Availability)
+ require.NotNil(t, status.Remote.AvailabilityCheckedAt)
+
+ availability, err = env.Manager.RefreshRemoteAvailabilityIfStale(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, RemoteAvailabilityAvailable, availability)
+ assert.Equal(t, int32(1), requests.Load(), "fresh eligibility must use the cache")
+
+ backupActive.Store(false)
+ availability, err = env.Manager.RefreshRemoteAvailability(context.Background())
+ require.NoError(t, err)
+ assert.Equal(t, RemoteAvailabilityUnavailable, availability)
+ assert.Equal(t, RemoteAvailabilityUnavailable, env.Manager.Status().Remote.Availability)
+}
+
+func TestManagerRefreshRemoteAvailabilityPersistsDeviceIdentity(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ linkedAt := time.Date(2026, 5, 1, 10, 0, 0, 0, time.UTC)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/v1/device/me" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ writeJSON(t, w, remoteDeviceMeResponse{
+ ID: "device-1", Name: "Living Room", LinkedAt: linkedAt, BackupActive: true,
+ })
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ _, err := env.Manager.RefreshRemoteAvailability(context.Background())
+ require.NoError(t, err)
+
+ status := env.Manager.Status()
+ require.NotNil(t, status.Remote.DeviceName)
+ assert.Equal(t, "Living Room", *status.Remote.DeviceName)
+ require.NotNil(t, status.Remote.LinkedAt)
+ parsed, err := time.Parse(time.RFC3339Nano, *status.Remote.LinkedAt)
+ require.NoError(t, err)
+ assert.True(t, parsed.Equal(linkedAt))
+
+ env.Manager.MarkRemoteUnlinked()
+ status = env.Manager.Status()
+ assert.Nil(t, status.Remote.DeviceName, "unlinking clears the stored device identity")
+ assert.Nil(t, status.Remote.LinkedAt, "unlinking clears the stored link time")
+}
+
+func TestManagerRefreshRemoteAvailabilityIfStaleAsync(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ var requests atomic.Int32
+ refreshed := make(chan struct{}, 4)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet || r.URL.Path != "/v1/device/me" {
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }
+ requests.Add(1)
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", Name: "test", BackupActive: true})
+ refreshed <- struct{}{}
+ }))
+ defer server.Close()
+
+ // Not linked: no background refresh is spawned.
+ env.Manager.RefreshRemoteAvailabilityIfStaleAsync()
+
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+ env.Manager.RefreshRemoteAvailabilityIfStaleAsync()
+ select {
+ case <-refreshed:
+ case <-time.After(5 * time.Second):
+ t.Fatal("expected a background availability refresh")
+ }
+ require.Eventually(t, func() bool {
+ return env.Manager.Status().Remote.Availability == RemoteAvailabilityAvailable
+ }, 2*time.Second, 10*time.Millisecond, "background refresh persists availability")
+ assert.Equal(t, int32(1), requests.Load(), "the unlinked call must not reach the server")
+
+ // A fresh cache short-circuits without spawning another refresh.
+ env.Manager.RefreshRemoteAvailabilityIfStaleAsync()
+ assert.Never(t, func() bool { return requests.Load() > 1 },
+ 300*time.Millisecond, 50*time.Millisecond, "fresh availability must use the cache")
+}
+
+func TestManagerRefreshRemoteAvailabilityMarksTransientFailureUnknown(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }))
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ availability, err := env.Manager.RefreshRemoteAvailability(context.Background())
+ require.Error(t, err)
+ assert.Equal(t, RemoteAvailabilityUnknown, availability)
+ status := env.Manager.Status()
+ assert.Equal(t, RemoteAvailabilityUnknown, status.Remote.Availability)
+ require.NotNil(t, status.Remote.AvailabilityCheckedAt)
+ assert.True(t, status.Remote.Linked)
+}
+
+func TestRemoteAvailabilityNeedsRefreshUsesStateSpecificTTL(t *testing.T) {
+ t.Parallel()
+ now := time.Date(2026, 7, 17, 12, 0, 0, 0, time.UTC)
+ checkedRecently := formatTime(now.Add(-4 * time.Minute))
+ checkedEarlier := formatTime(now.Add(-30 * time.Minute))
+ checkedStale := formatTime(now.Add(-2 * time.Hour))
+
+ assert.False(t, RemoteAvailabilityNeedsRefresh(now, &models.BackupStatusEntry{
+ Availability: RemoteAvailabilityUnknown, AvailabilityCheckedAt: &checkedRecently,
+ }))
+ assert.True(t, RemoteAvailabilityNeedsRefresh(now, &models.BackupStatusEntry{
+ Availability: RemoteAvailabilityUnknown, AvailabilityCheckedAt: &checkedEarlier,
+ }))
+ // Unavailable uses the short retry TTL too, so a subscription activated
+ // after the check is picked up quickly.
+ assert.False(t, RemoteAvailabilityNeedsRefresh(now, &models.BackupStatusEntry{
+ Availability: RemoteAvailabilityUnavailable, AvailabilityCheckedAt: &checkedRecently,
+ }))
+ assert.True(t, RemoteAvailabilityNeedsRefresh(now, &models.BackupStatusEntry{
+ Availability: RemoteAvailabilityUnavailable, AvailabilityCheckedAt: &checkedEarlier,
+ }))
+ // Only a confirmed-available result is cached for the long TTL.
+ assert.False(t, RemoteAvailabilityNeedsRefresh(now, &models.BackupStatusEntry{
+ Availability: RemoteAvailabilityAvailable, AvailabilityCheckedAt: &checkedEarlier,
+ }))
+ assert.True(t, RemoteAvailabilityNeedsRefresh(now, &models.BackupStatusEntry{
+ Availability: RemoteAvailabilityAvailable, AvailabilityCheckedAt: &checkedStale,
+ }))
+}
+
+func TestRemoteClientManifestResponsesExceedDefaultLimit(t *testing.T) {
+ t.Parallel()
+ // A manifest-bearing response larger than the general response cap must
+ // still decode: commit and snapshot GET use the dedicated larger limit.
+ bigManifest := bytes.Repeat([]byte(`{"pad":"xxxxxxxxxxxxxxxx"},`), 80_000) // ~1.8 MiB
+ manifest := append([]byte(`[`), bigManifest...)
+ manifest = append(manifest, []byte(`{"pad":"end"}]`)...)
+ require.Greater(t, len(manifest), int(helpers.MaxResponseBodySize))
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups/3" {
+ writeJSON(t, w, remoteBackupResponse{
+ ID: "backup-3", SchemaVersion: remoteSchemaVersion, Manifest: json.RawMessage(manifest),
+ CreatedAt: time.Now().UTC(),
+ })
+ return
+ }
+ t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
+ }))
+ defer server.Close()
+
+ client := &remoteClient{httpClient: server.Client(), baseURL: server.URL, bearer: "t", platform: "test"}
+
+ var small remoteBackupResponse
+ err := client.doJSON(context.Background(), http.MethodGet, "/v1/device/backups/3", nil, &small)
+ require.Error(t, err, "the default limit truncates manifest-bearing responses")
+
+ var full remoteBackupResponse
+ err = client.doJSONLimit(
+ context.Background(), http.MethodGet, "/v1/device/backups/3", nil, &full, remoteManifestResponseLimit,
+ )
+ require.NoError(t, err)
+ assert.Equal(t, "backup-3", full.ID)
+ assert.Len(t, full.Manifest, len(manifest))
+}
+
+func TestValidateRemoteManifestResponse_AcceptsJSONBReserializedManifest(t *testing.T) {
+ t.Parallel()
+ saveHash := strings.Repeat("a", 64)
+ dbHash := strings.Repeat("b", 64)
+ _, manifestHash, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{
+ CategoryZaparoo: {
+ {Path: "config.toml", SHA256: saveHash, Size: 3},
+ {Path: "user.db", SHA256: dbHash, Size: 5},
+ },
+ })
+ require.NoError(t, err)
+
+ // Postgres JSONB does not preserve the committed bytes: keys are
+ // reordered (shortest first) and whitespace is inserted. The client
+ // must still accept this by recomputing the canonical hash.
+ jsonbManifest := `{"categories": {"zaparoo": [` +
+ `{"path": "config.toml", "size": 3, "sha256": "` + saveHash + `"}, ` +
+ `{"path": "user.db", "size": 5, "sha256": "` + dbHash + `"}]}, "format": "cas-v1"}`
+ resp := remoteBackupResponse{
+ Platform: testStringPointer(platformids.Mister),
+ Manifest: json.RawMessage(jsonbManifest),
+ ManifestHash: manifestHash,
+ SizeBytes: 8,
+ Categories: map[string]remoteCategorySummary{
+ CategoryZaparoo: {Files: 2, Bytes: 8},
+ },
+ }
+
+ manifest, err := validateRemoteManifestResponse(&resp, platformids.Mister)
+ require.NoError(t, err)
+ assert.Len(t, manifest.Categories[CategoryZaparoo], 2)
+}
+
+func TestValidateRemoteManifestResponse_RejectsTamperedManifest(t *testing.T) {
+ t.Parallel()
+ _, manifestHash, err := canonicalRemoteManifest(map[string][]remoteManifestEntry{
+ CategoryZaparoo: {{Path: "user.db", SHA256: strings.Repeat("a", 64), Size: 5}},
+ })
+ require.NoError(t, err)
+
+ tampered, tamperErr := json.Marshal(remoteManifest{
+ Format: remoteManifestFormat,
+ Categories: map[string][]remoteManifestEntry{
+ CategoryZaparoo: {{Path: "user.db", SHA256: strings.Repeat("c", 64), Size: 5}},
+ },
+ })
+ require.NoError(t, tamperErr)
+ resp := remoteBackupResponse{
+ Platform: testStringPointer(platformids.Mister),
+ Manifest: tampered,
+ ManifestHash: manifestHash,
+ }
+
+ _, err = validateRemoteManifestResponse(&resp, platformids.Mister)
+ require.Error(t, err)
+ assert.Contains(t, err.Error(), "manifest hash mismatch")
+}
+
+func TestManagerRecoverInterruptedRuns(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ started := formatTime(time.Now().UTC())
+ require.NoError(t, env.Manager.writeLocalStatus(&statusEntry{LastRunAt: started, LastStatus: StatusRunning}))
+ require.NoError(t, env.Manager.writeRemoteStatus(&statusEntry{LastRunAt: started, LastStatus: StatusRunning}))
+
+ env.Manager.RecoverInterruptedRuns()
+
+ status := env.Manager.Status()
+ assert.Equal(t, StatusFailed, status.Local.LastStatus)
+ assert.Equal(t, "backup interrupted before completion", status.Local.LastError)
+ assert.Equal(t, StatusFailed, status.Remote.LastStatus)
+ assert.Equal(t, "backup interrupted before completion", status.Remote.LastError)
+
+ // Completed statuses are left untouched.
+ require.NoError(t, env.Manager.writeRemoteStatus(&statusEntry{
+ LastRunAt: started, LastSuccessAt: started, LastStatus: StatusSuccess,
+ }))
+ env.Manager.RecoverInterruptedRuns()
+ status = env.Manager.Status()
+ assert.Equal(t, StatusSuccess, status.Remote.LastStatus)
+ assert.Empty(t, status.Remote.LastError)
+}
+
+// newRemoteBackupSuccessServer returns a server accepting a full remote
+// backup run, counting every request it receives.
+func newRemoteBackupSuccessServer(t *testing.T, requests *atomic.Int32) *httptest.Server {
+ t.Helper()
+ uploadedMu := syncutil.Mutex{}
+ uploaded := make(map[string][]byte)
+ return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ requests.Add(1)
+ switch {
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/heartbeat":
+ w.WriteHeader(http.StatusNoContent)
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/me":
+ writeJSON(t, w, remoteDeviceMeResponse{ID: "device-1", Name: "test", BackupActive: true})
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backup-objects/check":
+ var req remoteCheckRequest
+ if decodeErr := json.NewDecoder(r.Body).Decode(&req); decodeErr != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, remoteCheckResponse{Missing: req.Hashes})
+ case r.Method == http.MethodPut && strings.HasPrefix(r.URL.Path, "/v1/device/backup-packs/"):
+ body, readErr := io.ReadAll(r.Body)
+ if readErr != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ uploadedMu.Lock()
+ for hash, payload := range parseTestPack(t, body) {
+ uploaded[hash] = payload
+ }
+ uploadedMu.Unlock()
+ writeJSON(t, w, remotePackResponse{
+ PackHash: sha256Hex(body), ObjectCount: len(uploaded), CreatedAt: time.Now().UTC(),
+ })
+ case r.Method == http.MethodPost && r.URL.Path == "/v1/device/backups":
+ var committed remoteSnapshotRequest
+ if decodeErr := json.NewDecoder(r.Body).Decode(&committed); decodeErr != nil {
+ w.WriteHeader(http.StatusBadRequest)
+ return
+ }
+ writeJSON(t, w, testCommittedRemoteResponse(t, "backup-1", platformids.Mister, &committed))
+ case r.Method == http.MethodGet && r.URL.Path == "/v1/device/backups":
+ writeJSON(t, w, remoteListResponse{StorageUsedBytes: 1, StorageQuotaBytes: 1000})
+ default:
+ t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path)
+ w.WriteHeader(http.StatusNotFound)
+ }
+ }))
+}
+
+func TestManagerRunRemotePausedBlocksUntilResumed(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ var requests atomic.Int32
+ server := newRemoteBackupSuccessServer(t, &requests)
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ pauser := syncutil.NewPauser()
+ pauser.Pause()
+ env.Manager.WithPauser(pauser)
+
+ done := make(chan error, 1)
+ go func() {
+ _, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ done <- err
+ }()
+
+ // While paused, the run must not reach the server at all.
+ assert.Never(t, func() bool { return requests.Load() > 0 },
+ 300*time.Millisecond, 25*time.Millisecond, "paused backup must not contact the server")
+
+ pauser.Resume()
+ select {
+ case err := <-done:
+ require.NoError(t, err)
+ case <-time.After(10 * time.Second):
+ t.Fatal("resumed backup did not complete")
+ }
+ assert.Positive(t, requests.Load())
+ assert.Equal(t, StatusSuccess, env.Manager.Status().Remote.LastStatus)
+}
+
+func TestManagerRunRemoteThrottledStillCompletes(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ var requests atomic.Int32
+ server := newRemoteBackupSuccessServer(t, &requests)
+ defer server.Close()
+ configureRemoteTestAuth(t, env.Manager, server.URL)
+
+ pauser := syncutil.NewPauser()
+ pauser.Throttle(syncutil.ThrottleLight)
+ env.Manager.WithPauser(pauser)
+
+ info, err := env.Manager.RunRemote(context.Background(), RemoteBackupTypeManual)
+ require.NoError(t, err)
+ assert.Equal(t, "backup-1", info.Backup.ID)
+ assert.True(t, pauser.IsThrottled())
+}
+
+func TestManagerRecoverInterruptedRunsSkipsActiveOperation(t *testing.T) {
+ env := newBackupTestEnv(t, platformids.Mister)
+ started := formatTime(time.Now().UTC())
+ require.NoError(t, env.Manager.writeRemoteStatus(&statusEntry{LastRunAt: started, LastStatus: StatusRunning}))
+
+ // A run that began before the recovery pass owns the "running" status.
+ lease, err := env.Manager.coordinator.Begin(context.Background(), OperationRemoteUpload, OperationWrite)
+ require.NoError(t, err)
+ env.Manager.RecoverInterruptedRuns()
+ assert.Equal(t, StatusRunning, env.Manager.Status().Remote.LastStatus)
+
+ lease.Release()
+ env.Manager.RecoverInterruptedRuns()
+ status := env.Manager.Status()
+ assert.Equal(t, StatusFailed, status.Remote.LastStatus)
+ assert.Equal(t, statusErrorInterrupted, status.Remote.LastError)
+}
diff --git a/pkg/service/backup/collector.go b/pkg/service/backup/collector.go
new file mode 100644
index 00000000..c6e9e0bf
--- /dev/null
+++ b/pkg/service/backup/collector.go
@@ -0,0 +1,695 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package backup
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "sync"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+)
+
+type collectorDefinition struct {
+ archive func(string) string
+ trustedRoots []string
+ definition platforms.BackupDefinition
+}
+
+type sourceCollector struct {
+ ctx context.Context
+ err error
+ excludedSources map[string]struct{}
+ pauser *syncutil.Pauser
+ excludedIdentities []os.FileInfo
+ files []FileRef
+ warnings []models.BackupWarning
+ logicalSize int64
+ maxLogicalSize int64
+ maxFiles int
+ maxWarnings int
+}
+
+const collectorReadDirBatchSize = 256
+
+var (
+ errSensitiveSource = errors.New("sensitive backup source excluded")
+ errSourceIdentityChanged = errors.New("backup source identity changed")
+)
+
+type sourceReadCloser struct {
+ file *os.File
+ root *os.Root
+}
+
+type cancelableSource struct {
+ source io.ReadCloser
+ err error
+ stop func() bool
+ once sync.Once
+}
+
+func (r *sourceReadCloser) Read(p []byte) (int, error) {
+ n, err := r.file.Read(p)
+ if errors.Is(err, io.EOF) {
+ return n, io.EOF
+ }
+ if err != nil {
+ return n, fmt.Errorf("reading backup source: %w", err)
+ }
+ return n, nil
+}
+
+func (r *sourceReadCloser) Close() error {
+ return errors.Join(r.file.Close(), r.root.Close())
+}
+
+func (r *cancelableSource) closeSource() {
+ r.once.Do(func() { r.err = r.source.Close() })
+}
+
+func (r *cancelableSource) Read(p []byte) (int, error) {
+ n, err := r.source.Read(p)
+ if errors.Is(err, io.EOF) {
+ return n, io.EOF
+ }
+ if err != nil {
+ return n, fmt.Errorf("reading cancelable backup source: %w", err)
+ }
+ return n, nil
+}
+
+func (r *cancelableSource) Close() error {
+ r.stop()
+ r.closeSource()
+ return r.err
+}
+
+func newSourceCollector(
+ ctx context.Context, maxLogicalSize int64, excludedSources map[string]struct{},
+) *sourceCollector {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+ return &sourceCollector{
+ ctx: ctx, maxLogicalSize: maxLogicalSize, excludedSources: excludedSources,
+ maxFiles: maxArchiveEntries - 1, maxWarnings: maxArchiveEntries,
+ }
+}
+
+func (c *sourceCollector) continueCollection() bool {
+ if c.err != nil {
+ return false
+ }
+ // Checkpoint on the shared media pauser so a filesystem walk yields to a
+ // running game; every collect/walk step passes through here.
+ if err := c.pauser.Wait(c.ctx); err != nil {
+ c.err = fmt.Errorf("collecting backup sources: %w", err)
+ return false
+ }
+ if err := c.ctx.Err(); err != nil {
+ c.err = fmt.Errorf("collecting backup sources: %w", err)
+ return false
+ }
+ return true
+}
+
+func (m *Manager) collectPlatformDefinitions(collector *sourceCollector, definitions []platforms.BackupDefinition) {
+ for i := range definitions {
+ def := &definitions[i]
+ spec := collectorDefinition{
+ definition: *def,
+ trustedRoots: m.definitionTrustedRoots(def),
+ archive: platformArchive,
+ }
+ collector.collect(&spec)
+ }
+}
+
+func (m *Manager) definitionTrustedRoots(def *platforms.BackupDefinition) []string {
+ if len(def.SourceTrustedRoots) > 0 {
+ return canonicalCategoryRoots(def.SourceTrustedRoots, "")
+ }
+ return definitionCategoryRoots(def, m.pl.RootDirs(m.cfg))
+}
+
+func definitionCategoryRoots(def *platforms.BackupDefinition, platformRoots []string) []string {
+ storageRoots := make([]string, 0, len(platformRoots)+1)
+ if primary, ok := definitionStorageRoot(def); ok {
+ storageRoots = append(storageRoots, primary)
+ }
+ for _, candidate := range platformRoots {
+ storageRoot := filepath.Clean(candidate)
+ if strings.EqualFold(filepath.Base(storageRoot), "games") {
+ storageRoot = filepath.Dir(storageRoot)
+ }
+ storageRoots = append(storageRoots, storageRoot)
+ }
+ return canonicalCategoryRoots(storageRoots, def.RestoreRoot)
+}
+
+func definitionStorageRoot(def *platforms.BackupDefinition) (string, bool) {
+ sourceRoot := filepath.Clean(def.SourceRoot)
+ restoreRoot := filepath.Clean(def.RestoreRoot)
+ if restoreRoot == "." {
+ return sourceRoot, true
+ }
+ storageRoot := sourceRoot
+ for range strings.Split(filepath.ToSlash(restoreRoot), "/") {
+ storageRoot = filepath.Dir(storageRoot)
+ }
+ if filepath.Clean(filepath.Join(storageRoot, restoreRoot)) != sourceRoot {
+ return "", false
+ }
+ return storageRoot, true
+}
+
+// canonicalCategoryRoots resolves storage roots but deliberately does not
+// resolve the category path appended beneath them. A category-root symlink is
+// trusted only when its target is also an independently approved category root.
+func canonicalCategoryRoots(storageRoots []string, categoryRoot string) []string {
+ seen := make(map[string]struct{}, len(storageRoots))
+ canonical := make([]string, 0, len(storageRoots))
+ for _, storageRoot := range storageRoots {
+ resolved, ok := canonicalPathFromExistingAncestor(storageRoot)
+ if !ok {
+ continue
+ }
+ root := filepath.Clean(filepath.Join(resolved, categoryRoot))
+ if _, ok := seen[root]; ok {
+ continue
+ }
+ seen[root] = struct{}{}
+ canonical = append(canonical, root)
+ }
+ sort.Slice(canonical, func(i, j int) bool { return len(canonical[i]) > len(canonical[j]) })
+ return canonical
+}
+
+func canonicalPathFromExistingAncestor(candidate string) (string, bool) {
+ absolute, err := filepath.Abs(candidate)
+ if err != nil {
+ return "", false
+ }
+ current := filepath.Clean(absolute)
+ var suffix []string
+ for {
+ _, statErr := os.Lstat(current)
+ if statErr == nil {
+ resolved, resolveErr := filepath.EvalSymlinks(current)
+ if resolveErr != nil {
+ return "", false
+ }
+ info, targetErr := os.Stat(resolved)
+ if targetErr != nil || !info.IsDir() {
+ return "", false
+ }
+ return filepath.Clean(filepath.Join(append([]string{resolved}, suffix...)...)), true
+ }
+ if !errors.Is(statErr, os.ErrNotExist) {
+ return "", false
+ }
+ parent := filepath.Dir(current)
+ if parent == current {
+ return "", false
+ }
+ suffix = append([]string{filepath.Base(current)}, suffix...)
+ current = parent
+ }
+}
+
+func sourceIdentities(paths map[string]struct{}) ([]os.FileInfo, error) {
+ identities := make([]os.FileInfo, 0, len(paths))
+ for sourcePath := range paths {
+ info, err := os.Stat(sourcePath)
+ if errors.Is(err, os.ErrNotExist) {
+ continue
+ }
+ if err != nil {
+ return nil, fmt.Errorf("stating sensitive backup source: %w", err)
+ }
+ if info.Mode().IsRegular() {
+ identities = append(identities, info)
+ }
+ }
+ return identities, nil
+}
+
+func (c *sourceCollector) collect(spec *collectorDefinition) {
+ if !c.continueCollection() {
+ return
+ }
+ stack := make(map[string]struct{})
+ seen := make(map[string]string)
+ c.walk(spec, spec.definition.SourceRoot, "", stack, seen, true)
+}
+
+func (c *sourceCollector) walk(
+ spec *collectorDefinition,
+ physicalPath, logicalRel string,
+ stack map[string]struct{},
+ seen map[string]string,
+ root bool,
+) {
+ if !c.continueCollection() {
+ return
+ }
+ if len(filepath.ToSlash(logicalRel)) > maxArchivePathLen {
+ c.warn(spec.definition.Category, "", "source path exceeds portable limit")
+ return
+ }
+ info, err := os.Lstat(physicalPath)
+ if err != nil {
+ if root && errors.Is(err, os.ErrNotExist) {
+ return
+ }
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source unreadable")
+ return
+ }
+
+ if info.Mode()&os.ModeSymlink != 0 {
+ resolved, resolveErr := filepath.EvalSymlinks(physicalPath)
+ if resolveErr != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "broken symlink")
+ return
+ }
+ resolved, resolveErr = filepath.Abs(resolved)
+ if resolveErr != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "invalid symlink target")
+ return
+ }
+ resolved = filepath.Clean(resolved)
+ targetInfo, statErr := os.Stat(resolved)
+ if statErr != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "broken symlink")
+ return
+ }
+ rootPath, sourceRel, trusted := trustedSource(resolved, spec.trustedRoots)
+ if !trusted {
+ c.warn(
+ spec.definition.Category,
+ warningPath(&spec.definition, logicalRel),
+ "symlink target outside trusted roots",
+ )
+ return
+ }
+ if targetInfo.IsDir() {
+ if spec.definition.NonRecursive {
+ return
+ }
+ c.walkDirectory(spec, resolved, logicalRel, stack, seen, rootPath)
+ return
+ }
+ if !targetInfo.Mode().IsRegular() {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "non-regular symlink target")
+ return
+ }
+ if !definitionIncludes(&spec.definition, logicalRel) || !definitionIncludes(&spec.definition, sourceRel) {
+ c.warn(
+ spec.definition.Category,
+ warningPath(&spec.definition, logicalRel),
+ "symlink target outside category policy",
+ )
+ return
+ }
+ c.add(spec, resolved, rootPath, sourceRel, logicalRel, targetInfo)
+ return
+ }
+
+ if info.IsDir() {
+ resolved, resolveErr := filepath.EvalSymlinks(physicalPath)
+ if resolveErr != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source unreadable")
+ return
+ }
+ resolved, resolveErr = filepath.Abs(resolved)
+ if resolveErr != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source unreadable")
+ return
+ }
+ rootPath, _, trusted := trustedSource(resolved, spec.trustedRoots)
+ if !trusted {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source outside trusted roots")
+ return
+ }
+ c.walkDirectory(spec, filepath.Clean(resolved), logicalRel, stack, seen, rootPath)
+ return
+ }
+ if !info.Mode().IsRegular() {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "non-regular source")
+ return
+ }
+ if !definitionIncludes(&spec.definition, logicalRel) {
+ return
+ }
+ resolved, resolveErr := filepath.Abs(physicalPath)
+ if resolveErr != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source unreadable")
+ return
+ }
+ resolved = filepath.Clean(resolved)
+ rootPath, sourceRel, trusted := trustedSource(resolved, spec.trustedRoots)
+ if !trusted {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source outside trusted roots")
+ return
+ }
+ if !definitionIncludes(&spec.definition, sourceRel) {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source outside category policy")
+ return
+ }
+ c.add(spec, resolved, rootPath, sourceRel, logicalRel, info)
+}
+
+func (c *sourceCollector) walkDirectory(
+ spec *collectorDefinition,
+ physicalPath, logicalRel string,
+ stack map[string]struct{},
+ seen map[string]string,
+ _ string,
+) {
+ if _, ok := stack[physicalPath]; ok {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "symlink cycle")
+ return
+ }
+ if first, ok := seen[physicalPath]; ok && first != logicalRel {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "duplicate directory target")
+ return
+ }
+ seen[physicalPath] = logicalRel
+ stack[physicalPath] = struct{}{}
+ defer delete(stack, physicalPath)
+
+ directory, err := os.Open(physicalPath) // #nosec G304 -- path is validated against trusted roots.
+ if err != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source unreadable")
+ return
+ }
+ defer func() { _ = directory.Close() }()
+ for c.continueCollection() {
+ entries, readErr := directory.ReadDir(collectorReadDirBatchSize)
+ for _, entry := range entries {
+ childRel := entry.Name()
+ if logicalRel != "" {
+ childRel = filepath.Join(logicalRel, entry.Name())
+ }
+ if spec.definition.NonRecursive && logicalRel == "" && entry.IsDir() {
+ continue
+ }
+ c.walk(spec, filepath.Join(physicalPath, entry.Name()), childRel, stack, seen, false)
+ if !c.continueCollection() {
+ return
+ }
+ }
+ if errors.Is(readErr, io.EOF) {
+ return
+ }
+ if readErr != nil {
+ c.warn(spec.definition.Category, warningPath(&spec.definition, logicalRel), "source unreadable")
+ return
+ }
+ }
+}
+
+func definitionIncludes(def *platforms.BackupDefinition, rel string) bool {
+ if rel == "" || (def.NonRecursive && strings.Contains(filepath.ToSlash(rel), "/")) {
+ return false
+ }
+ return backupPatternsMatch(rel, def.Include) && !backupPatternsMatch(rel, def.Exclude)
+}
+
+func trustedSource(target string, roots []string) (root, rel string, ok bool) {
+ for _, trustedRoot := range roots {
+ candidate, err := filepath.Rel(trustedRoot, target)
+ if err != nil || candidate == ".." || strings.HasPrefix(candidate, ".."+string(os.PathSeparator)) {
+ continue
+ }
+ return trustedRoot, candidate, true
+ }
+ return "", "", false
+}
+
+func matchesSourceIdentity(info os.FileInfo, identities []os.FileInfo) bool {
+ for _, identity := range identities {
+ if os.SameFile(info, identity) {
+ return true
+ }
+ }
+ return false
+}
+
+func (c *sourceCollector) appendFile(file *FileRef) {
+ if !c.continueCollection() {
+ return
+ }
+ if len(file.ArchivePath) > maxArchivePathLen || len(file.RestorePath) > maxArchivePathLen ||
+ validateArchivePath(file.ArchivePath) != nil || validateRestorePath(file.RestorePath) != nil {
+ c.warn(file.Category, file.RestorePath, "source path is not portable")
+ return
+ }
+ if len(c.files) >= c.maxFiles {
+ c.err = fmt.Errorf("backup has too many files: exceeds %d", c.maxFiles)
+ return
+ }
+ if file.Size < 0 || c.maxLogicalSize <= 0 || file.Size > c.maxLogicalSize-c.logicalSize {
+ c.err = fmt.Errorf("backup exceeds logical size limit of %d bytes", c.maxLogicalSize)
+ return
+ }
+ c.logicalSize += file.Size
+ c.files = append(c.files, *file)
+}
+
+func (c *sourceCollector) add(
+ spec *collectorDefinition,
+ sourcePath, sourceRoot, sourceRel, logicalRel string,
+ info os.FileInfo,
+) {
+ restorePath := filepath.Join(spec.definition.RestoreRoot, logicalRel)
+ if _, excluded := c.excludedSources[filepath.Clean(sourcePath)]; excluded ||
+ matchesSourceIdentity(info, c.excludedIdentities) {
+ c.warn(spec.definition.Category, restorePath, "sensitive source excluded")
+ return
+ }
+ c.appendFile(&FileRef{
+ sourceIdentity: &sourceIdentity{info: info, excludedIdentities: c.excludedIdentities},
+ SourceRoot: sourceRoot,
+ SourceRel: sourceRel,
+ ArchivePath: filepath.ToSlash(spec.archive(restorePath)),
+ RestorePath: filepath.ToSlash(restorePath),
+ Category: spec.definition.Category,
+ Size: info.Size(),
+ })
+}
+
+func (c *sourceCollector) addTrustedFile(
+ sourcePath, category, archivePath, restorePath string,
+) error {
+ resolved, err := filepath.EvalSymlinks(sourcePath)
+ if err != nil {
+ return fmt.Errorf("resolving trusted backup source: %w", err)
+ }
+ resolved, err = filepath.Abs(resolved)
+ if err != nil {
+ return fmt.Errorf("resolving trusted backup source path: %w", err)
+ }
+ info, err := os.Stat(resolved)
+ if err != nil {
+ return fmt.Errorf("stating trusted backup source: %w", err)
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("trusted backup source is not a regular file: %s", sourcePath)
+ }
+ root := filepath.Dir(resolved)
+ c.appendFile(&FileRef{
+ sourceIdentity: &sourceIdentity{info: info},
+ SourceRoot: root,
+ SourceRel: filepath.Base(resolved),
+ ArchivePath: filepath.ToSlash(archivePath),
+ RestorePath: filepath.ToSlash(restorePath),
+ Category: category,
+ Size: info.Size(),
+ })
+ return c.err
+}
+
+func (c *sourceCollector) warn(category, logicalPath, reason string) {
+ if !c.continueCollection() {
+ return
+ }
+ if len(c.warnings) >= c.maxWarnings {
+ c.err = fmt.Errorf("backup has too many warnings: exceeds %d", c.maxWarnings)
+ return
+ }
+ c.warnings = append(c.warnings, models.BackupWarning{
+ Category: category,
+ Path: portableWarningPath(logicalPath),
+ Reason: reason,
+ })
+}
+
+func portableWarningPath(logicalPath string) string {
+ warningPath := strings.ToValidUTF8(filepath.ToSlash(logicalPath), "\uFFFD")
+ warningPath = strings.ReplaceAll(warningPath, "\\", "%5C")
+ warningPath = strings.ReplaceAll(warningPath, "\x00", "%00")
+ if len(warningPath) > maxArchivePathLen {
+ return ""
+ }
+ if validateRestorePath(warningPath) != nil {
+ return ""
+ }
+ return warningPath
+}
+
+func warningPath(def *platforms.BackupDefinition, logicalRel string) string {
+ if logicalRel == "" && def.RestoreRoot == "" {
+ return ""
+ }
+ if logicalRel == "" {
+ return def.RestoreRoot
+ }
+ return filepath.Join(def.RestoreRoot, logicalRel)
+}
+
+func (m *Manager) validateManifestPolicy(manifest *Manifest) error {
+ if manifest.Platform != m.pl.ID() {
+ return fmt.Errorf("backup platform %q does not match %q", manifest.Platform, m.pl.ID())
+ }
+ userDBFiles := 0
+ for _, file := range manifest.Files {
+ if file.Category == CategoryZaparoo && file.RestorePath == config.UserDbFile &&
+ file.ArchivePath == zaparooArchive(config.UserDbFile) {
+ userDBFiles++
+ }
+ }
+ if userDBFiles != 1 {
+ return fmt.Errorf("full-device backup must contain exactly one %s payload", zaparooArchive(config.UserDbFile))
+ }
+ definitions := []platforms.BackupDefinition(nil)
+ if provider, ok := m.pl.(platforms.BackupProvider); ok {
+ definitions = provider.BackupDefinitions()
+ }
+ // Zaparoo files are validated against the same definitions collection
+ // uses, so the two policies cannot drift. user.db is the one
+ // constructed entry, allowed by its exact name.
+ zaparooDefinitions := m.zaparooRestoreDefinitions()
+ for _, file := range manifest.Files {
+ if file.Category == CategoryZaparoo {
+ if file.RestorePath != config.UserDbFile && !allowedRestorePath(&file, zaparooDefinitions) {
+ return fmt.Errorf("backup path is not collected by Zaparoo policy: %s", file.RestorePath)
+ }
+ if file.ArchivePath != zaparooArchive(file.RestorePath) {
+ return fmt.Errorf("backup archive path does not match restore path: %s", file.ArchivePath)
+ }
+ continue
+ }
+ if file.ArchivePath != platformArchive(file.RestorePath) {
+ return fmt.Errorf("backup archive path does not match restore path: %s", file.ArchivePath)
+ }
+ if !allowedRestorePath(&file, definitions) {
+ return fmt.Errorf("backup path is not collected by platform policy: %s", file.RestorePath)
+ }
+ }
+ return nil
+}
+
+// zaparooRestoreDefinitions returns the collection definitions used to
+// validate zaparoo-category restore paths.
+func (m *Manager) zaparooRestoreDefinitions() []platforms.BackupDefinition {
+ return zaparooBackupDefinitions(helpers.ConfigDir(m.pl), helpers.DataDir(m.pl))
+}
+
+func allowedRestorePath(file *FileRef, definitions []platforms.BackupDefinition) bool {
+ for i := range definitions {
+ def := &definitions[i]
+ if def.Category != file.Category {
+ continue
+ }
+ restoreRoot := filepath.ToSlash(def.RestoreRoot)
+ rel := file.RestorePath
+ if restoreRoot != "" {
+ prefix := restoreRoot + "/"
+ if !strings.HasPrefix(file.RestorePath, prefix) {
+ continue
+ }
+ rel = strings.TrimPrefix(file.RestorePath, prefix)
+ }
+ if definitionIncludes(def, filepath.FromSlash(rel)) {
+ return true
+ }
+ }
+ return false
+}
+
+func openSource(file *FileRef) (io.ReadCloser, error) {
+ if file.SourceRoot == "" || file.SourceRel == "" {
+ return nil, fmt.Errorf("backup source is not root-scoped: %s", file.RestorePath)
+ }
+ root, err := os.OpenRoot(file.SourceRoot)
+ if err != nil {
+ return nil, fmt.Errorf("opening source root %s: %w", file.SourceRoot, err)
+ }
+ opened, err := root.Open(file.SourceRel)
+ if err != nil {
+ _ = root.Close()
+ return nil, fmt.Errorf("opening source %s: %w", file.RestorePath, err)
+ }
+ if file.sourceIdentity != nil {
+ openedInfo, statErr := opened.Stat()
+ if statErr != nil {
+ _ = opened.Close()
+ _ = root.Close()
+ return nil, fmt.Errorf("stating opened source %s: %w", file.RestorePath, statErr)
+ }
+ if !openedInfo.Mode().IsRegular() || !os.SameFile(file.sourceIdentity.info, openedInfo) {
+ _ = opened.Close()
+ _ = root.Close()
+ return nil, fmt.Errorf("%w: %s", errSourceIdentityChanged, file.RestorePath)
+ }
+ if matchesSourceIdentity(openedInfo, file.sourceIdentity.excludedIdentities) {
+ _ = opened.Close()
+ _ = root.Close()
+ return nil, fmt.Errorf("%w: %s", errSensitiveSource, file.RestorePath)
+ }
+ }
+ return &sourceReadCloser{file: opened, root: root}, nil
+}
+
+func newCancelableSource(ctx context.Context, source io.ReadCloser) *cancelableSource {
+ cancelable := &cancelableSource{source: source}
+ cancelable.stop = context.AfterFunc(ctx, cancelable.closeSource)
+ return cancelable
+}
+
+func openSourceContext(ctx context.Context, file *FileRef) (io.ReadCloser, error) {
+ source, err := openSource(file)
+ if err != nil {
+ return nil, err
+ }
+ return newCancelableSource(ctx, source), nil
+}
diff --git a/pkg/service/backup/coordinator.go b/pkg/service/backup/coordinator.go
new file mode 100644
index 00000000..b562efb6
--- /dev/null
+++ b/pkg/service/backup/coordinator.go
@@ -0,0 +1,50 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package backup
+
+import backupcoordinator "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup/coordinator"
+
+type OperationKind = backupcoordinator.OperationKind
+
+type OperationMode = backupcoordinator.OperationMode
+
+type BusyError = backupcoordinator.BusyError
+
+type Coordinator = backupcoordinator.Coordinator
+
+type Lease = backupcoordinator.Lease
+
+const (
+ OperationLocalCreate = backupcoordinator.OperationLocalCreate
+ OperationLocalInspect = backupcoordinator.OperationLocalInspect
+ OperationLocalDelete = backupcoordinator.OperationLocalDelete
+ OperationLocalRestore = backupcoordinator.OperationLocalRestore
+ OperationRemoteUpload = backupcoordinator.OperationRemoteUpload
+ OperationRemoteRestore = backupcoordinator.OperationRemoteRestore
+ OperationRecovery = backupcoordinator.OperationRecovery
+ OperationRead = backupcoordinator.OperationRead
+ OperationWrite = backupcoordinator.OperationWrite
+)
+
+var ErrCoordinatorStopped = backupcoordinator.ErrStopped
+
+func NewCoordinator() *Coordinator {
+ return backupcoordinator.New()
+}
diff --git a/pkg/service/backup/coordinator/coordinator.go b/pkg/service/backup/coordinator/coordinator.go
new file mode 100644
index 00000000..c6d4811b
--- /dev/null
+++ b/pkg/service/backup/coordinator/coordinator.go
@@ -0,0 +1,225 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package coordinator
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+)
+
+type OperationKind string
+
+type OperationMode uint8
+
+const (
+ OperationLocalCreate OperationKind = "local-create"
+ OperationLocalInspect OperationKind = "local-inspect"
+ OperationLocalDelete OperationKind = "local-delete"
+ OperationLocalRestore OperationKind = "local-restore"
+ OperationRemoteUpload OperationKind = "remote-upload"
+ OperationRemoteRestore OperationKind = "remote-restore"
+ OperationRecovery OperationKind = "restore-recovery"
+)
+
+const (
+ OperationRead OperationMode = iota
+ OperationWrite
+)
+
+var ErrStopped = errors.New("backup coordinator is shutting down")
+
+type BusyError struct {
+ StartedAt time.Time
+ Kind OperationKind
+}
+
+func (e *BusyError) Error() string {
+ return fmt.Sprintf("backup operation %s has been running since %s", e.Kind, e.StartedAt.Format(time.RFC3339))
+}
+
+type activeOperation struct {
+ cancel context.CancelFunc
+ startedAt time.Time
+ kind OperationKind
+ mode OperationMode
+}
+
+type Coordinator struct {
+ operations map[uint64]activeOperation
+ done chan struct{}
+ onWriteFinished func(OperationKind)
+ nextID uint64
+ mu syncutil.Mutex
+ shuttingDown bool
+ remoteUnlinked bool
+}
+
+type Lease struct {
+ coordinator *Coordinator
+ ctx context.Context
+ id uint64
+}
+
+func New() *Coordinator {
+ return &Coordinator{operations: make(map[uint64]activeOperation)}
+}
+
+func (c *Coordinator) Begin(ctx context.Context, kind OperationKind, mode OperationMode) (*Lease, error) {
+ if ctx == nil {
+ ctx = context.Background()
+ }
+
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ if c.shuttingDown {
+ return nil, ErrStopped
+ }
+ if conflict := c.conflictingOperation(mode); conflict != nil {
+ return nil, &BusyError{Kind: conflict.kind, StartedAt: conflict.startedAt}
+ }
+
+ //nolint:gosec // Lease.Release or Coordinator.Shutdown owns cancellation.
+ opCtx, cancel := context.WithCancel(ctx)
+ c.nextID++
+ id := c.nextID
+ if len(c.operations) == 0 {
+ c.done = make(chan struct{})
+ }
+ c.operations[id] = activeOperation{
+ cancel: cancel,
+ startedAt: time.Now().UTC(),
+ kind: kind,
+ mode: mode,
+ }
+ return &Lease{coordinator: c, ctx: opCtx, id: id}, nil
+}
+
+func (c *Coordinator) conflictingOperation(mode OperationMode) *activeOperation {
+ for _, operation := range c.operations {
+ if mode == OperationWrite || operation.mode == OperationWrite {
+ operationCopy := operation
+ return &operationCopy
+ }
+ }
+ return nil
+}
+
+func (l *Lease) Context() context.Context {
+ if l == nil || l.ctx == nil {
+ return context.Background()
+ }
+ return l.ctx
+}
+
+func (l *Lease) Release() {
+ if l == nil || l.coordinator == nil {
+ return
+ }
+ l.coordinator.release(l.id)
+}
+
+// SetOnWriteFinished registers a callback invoked, outside the coordinator
+// lock, whenever a write operation's lease is released. It reports that a
+// backup, upload, restore, or recovery operation ended, whatever its outcome.
+func (c *Coordinator) SetOnWriteFinished(fn func(OperationKind)) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.onWriteFinished = fn
+}
+
+func (c *Coordinator) release(id uint64) {
+ c.mu.Lock()
+ operation, ok := c.operations[id]
+ if !ok {
+ c.mu.Unlock()
+ return
+ }
+ delete(c.operations, id)
+ operation.cancel()
+ if len(c.operations) == 0 && c.done != nil {
+ close(c.done)
+ c.done = nil
+ }
+ onWriteFinished := c.onWriteFinished
+ c.mu.Unlock()
+ if operation.mode == OperationWrite && onWriteFinished != nil {
+ onWriteFinished(operation.kind)
+ }
+}
+
+func (c *Coordinator) SetRemoteUnlinked(unlinked bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ c.remoteUnlinked = unlinked
+}
+
+func (c *Coordinator) RemoteUnlinked() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ return c.remoteUnlinked
+}
+
+func (c *Coordinator) Active() (OperationKind, time.Time, bool) {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ for _, operation := range c.operations {
+ return operation.kind, operation.startedAt, true
+ }
+ return "", time.Time{}, false
+}
+
+func (c *Coordinator) CancelRestore() bool {
+ c.mu.Lock()
+ defer c.mu.Unlock()
+ cancelled := false
+ for _, operation := range c.operations {
+ if operation.kind != OperationLocalRestore && operation.kind != OperationRemoteRestore &&
+ operation.kind != OperationRecovery {
+ continue
+ }
+ operation.cancel()
+ cancelled = true
+ }
+ return cancelled
+}
+
+func (c *Coordinator) Shutdown(ctx context.Context) error {
+ c.mu.Lock()
+ c.shuttingDown = true
+ for _, operation := range c.operations {
+ operation.cancel()
+ }
+ done := c.done
+ c.mu.Unlock()
+
+ if done == nil {
+ return nil
+ }
+ select {
+ case <-done:
+ return nil
+ case <-ctx.Done():
+ return fmt.Errorf("waiting for backup operation shutdown: %w", ctx.Err())
+ }
+}
diff --git a/pkg/service/backup/coordinator/coordinator_test.go b/pkg/service/backup/coordinator/coordinator_test.go
new file mode 100644
index 00000000..d3868b20
--- /dev/null
+++ b/pkg/service/backup/coordinator/coordinator_test.go
@@ -0,0 +1,174 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package coordinator
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestCoordinatorAllowsReadersAndRejectsConflictingWriter(t *testing.T) {
+ t.Parallel()
+ coordinator := New()
+ first, err := coordinator.Begin(context.Background(), OperationLocalInspect, OperationRead)
+ require.NoError(t, err)
+ defer first.Release()
+ second, err := coordinator.Begin(context.Background(), OperationLocalInspect, OperationRead)
+ require.NoError(t, err)
+ defer second.Release()
+
+ _, err = coordinator.Begin(context.Background(), OperationLocalDelete, OperationWrite)
+ var busy *BusyError
+ require.ErrorAs(t, err, &busy)
+ assert.Equal(t, OperationLocalInspect, busy.Kind)
+
+ first.Release()
+ second.Release()
+ writer, err := coordinator.Begin(context.Background(), OperationLocalDelete, OperationWrite)
+ require.NoError(t, err)
+ writer.Release()
+}
+
+func TestCoordinatorShutdownCancelsAndWaitsForOperation(t *testing.T) {
+ t.Parallel()
+ coordinator := New()
+ lease, err := coordinator.Begin(context.Background(), OperationRemoteUpload, OperationWrite)
+ require.NoError(t, err)
+
+ released := make(chan struct{})
+ go func() {
+ <-lease.Context().Done()
+ lease.Release()
+ close(released)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
+ defer cancel()
+ require.NoError(t, coordinator.Shutdown(ctx))
+ <-released
+ require.ErrorIs(t, lease.Context().Err(), context.Canceled)
+
+ _, err = coordinator.Begin(context.Background(), OperationLocalCreate, OperationWrite)
+ assert.ErrorIs(t, err, ErrStopped)
+}
+
+func TestCoordinatorReportsWriteFinished(t *testing.T) {
+ t.Parallel()
+ coordinator := New()
+ finished := make(chan OperationKind, 4)
+ coordinator.SetOnWriteFinished(func(kind OperationKind) { finished <- kind })
+
+ // Read operations end silently.
+ readLease, err := coordinator.Begin(context.Background(), OperationLocalInspect, OperationRead)
+ require.NoError(t, err)
+ readLease.Release()
+ select {
+ case kind := <-finished:
+ t.Fatalf("read operation must not report finished, got %s", kind)
+ default:
+ }
+
+ // A write operation reports its kind exactly once on release.
+ writeLease, err := coordinator.Begin(context.Background(), OperationRemoteUpload, OperationWrite)
+ require.NoError(t, err)
+ writeLease.Release()
+ select {
+ case kind := <-finished:
+ assert.Equal(t, OperationRemoteUpload, kind)
+ default:
+ t.Fatal("write operation release must report finished")
+ }
+ writeLease.Release()
+ select {
+ case kind := <-finished:
+ t.Fatalf("double release must not report finished again, got %s", kind)
+ default:
+ }
+}
+
+func TestCoordinatorCancelRestoreLeavesUnrelatedLeaseRunning(t *testing.T) {
+ t.Parallel()
+ coordinator := New()
+ restoreKinds := []OperationKind{OperationLocalRestore, OperationRemoteRestore, OperationRecovery}
+ restoreLeases := make([]*Lease, 0, len(restoreKinds))
+ for _, kind := range restoreKinds {
+ lease, err := coordinator.Begin(context.Background(), kind, OperationRead)
+ require.NoError(t, err)
+ restoreLeases = append(restoreLeases, lease)
+ }
+ unrelated, err := coordinator.Begin(context.Background(), OperationLocalInspect, OperationRead)
+ require.NoError(t, err)
+ defer unrelated.Release()
+
+ assert.True(t, coordinator.CancelRestore())
+ for _, lease := range restoreLeases {
+ require.ErrorIs(t, lease.Context().Err(), context.Canceled)
+ lease.Release()
+ }
+ require.NoError(t, unrelated.Context().Err())
+ kind, _, active := coordinator.Active()
+ assert.True(t, active)
+ assert.Equal(t, OperationLocalInspect, kind)
+}
+
+func TestCoordinatorRemoteUnlinkedState(t *testing.T) {
+ t.Parallel()
+ coordinator := New()
+
+ assert.False(t, coordinator.RemoteUnlinked())
+ coordinator.SetRemoteUnlinked(true)
+ assert.True(t, coordinator.RemoteUnlinked())
+ coordinator.SetRemoteUnlinked(false)
+ assert.False(t, coordinator.RemoteUnlinked())
+}
+
+func TestCoordinatorActiveTracksReadAndWriteLeases(t *testing.T) {
+ t.Parallel()
+ coordinator := New()
+
+ kind, startedAt, active := coordinator.Active()
+ assert.False(t, active)
+ assert.Empty(t, kind)
+ assert.True(t, startedAt.IsZero())
+
+ readLease, err := coordinator.Begin(context.Background(), OperationLocalInspect, OperationRead)
+ require.NoError(t, err)
+ kind, startedAt, active = coordinator.Active()
+ assert.True(t, active)
+ assert.Equal(t, OperationLocalInspect, kind)
+ assert.False(t, startedAt.IsZero())
+ readLease.Release()
+ _, _, active = coordinator.Active()
+ assert.False(t, active)
+
+ writeLease, err := coordinator.Begin(context.Background(), OperationRemoteUpload, OperationWrite)
+ require.NoError(t, err)
+ kind, startedAt, active = coordinator.Active()
+ assert.True(t, active)
+ assert.Equal(t, OperationRemoteUpload, kind)
+ assert.False(t, startedAt.IsZero())
+ writeLease.Release()
+ _, _, active = coordinator.Active()
+ assert.False(t, active)
+}
diff --git a/pkg/service/backup/mister_integration_test.go b/pkg/service/backup/mister_integration_test.go
new file mode 100644
index 00000000..facf035c
--- /dev/null
+++ b/pkg/service/backup/mister_integration_test.go
@@ -0,0 +1,68 @@
+//go:build linux
+
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package backup
+
+import (
+ "path/filepath"
+ "testing"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mister"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestMiSTerBackupDefinitionsCollectorExcludesPrivateAndGeneratedFiles(t *testing.T) {
+ t.Parallel()
+ rootDir := t.TempDir()
+ writeTestFile(t, filepath.Join(rootDir, "MiSTer.ini"), "user ini\n")
+ writeTestFile(t, filepath.Join(rootDir, "MiSTer_example.ini"), "example ini\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "core.cfg"), "core settings\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "core_recent.cfg"), "recent\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "nested", "video.cfg"), "nested settings\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "inputs", "nested", "arcade.map"), "nested input\n")
+ writeTestFile(t, filepath.Join(rootDir, "config", "inputs", "renamed", "old.map"), "renamed input\n")
+ writeTestFile(t, filepath.Join(rootDir, "games", "MiSTer.ini"), "nested ini\n")
+ writeTestFile(t, filepath.Join(rootDir, "games", "game.rom"), "rom\n")
+ writeTestFile(t, filepath.Join(rootDir, "gamecontrollerdb_user.txt"), "root controller db\n")
+ writeTestFile(t, filepath.Join(rootDir, "linux", "gamecontrollerdb_user.txt"), "linux controller db\n")
+ profileID := "11111111-aaaa-bbbb-cccc-000000000001"
+ writeTestFile(t, filepath.Join(rootDir, "zaparoo", "profiles", profileID, "saves", "nested", "game.sav"),
+ "profile save\n")
+ writeTestFile(t, filepath.Join(rootDir, "zaparoo", "profiles", profileID, "savestates", "game.ss"),
+ "profile state\n")
+ nasProfileID := "22222222-aaaa-bbbb-cccc-000000000002"
+ writeTestFile(t, filepath.Join(rootDir, "saves", ".zaparoo-profiles", nasProfileID, "saves", "nas.sav"),
+ "nas save\n")
+
+ definitions := mister.BackupDefinitions(platforms.Settings{DataDir: filepath.Join(rootDir, "zaparoo")})
+ files := collectPlatformFiles(nil, definitions)
+ byArchive := make(map[string]FileRef, len(files))
+ for _, file := range files {
+ byArchive[file.ArchivePath] = file
+ }
+
+ assert.Contains(t, byArchive, platformArchive("MiSTer.ini"))
+ assert.Contains(t, byArchive, platformArchive(filepath.Join("config", "core.cfg")))
+ assert.Contains(t, byArchive, platformArchive(filepath.Join("config", "nested", "video.cfg")))
+ assert.Contains(t, byArchive, platformArchive(filepath.Join("config", "inputs", "nested", "arcade.map")))
+ assert.Contains(t, byArchive, platformArchive("gamecontrollerdb_user.txt"))
+ assert.Contains(t, byArchive, platformArchive(filepath.Join("linux", "gamecontrollerdb_user.txt")))
+ assert.Contains(t, byArchive, platformArchive(filepath.Join(
+ "zaparoo", "profiles", profileID, "saves", "nested", "game.sav",
+ )))
+ assert.Contains(t, byArchive, platformArchive(filepath.Join(
+ "zaparoo", "profiles", profileID, "savestates", "game.ss",
+ )))
+ assert.Contains(t, byArchive, platformArchive(filepath.Join(
+ "saves", ".zaparoo-profiles", nasProfileID, "saves", "nas.sav",
+ )))
+ assert.NotContains(t, byArchive, platformArchive("MiSTer_example.ini"))
+ assert.NotContains(t, byArchive, platformArchive(filepath.Join("config", "core_recent.cfg")))
+ assert.NotContains(t, byArchive, platformArchive(filepath.Join("config", "inputs", "renamed", "old.map")))
+ assert.NotContains(t, byArchive, platformArchive(filepath.Join("games", "MiSTer.ini")))
+ assert.NotContains(t, byArchive, platformArchive(filepath.Join("games", "game.rom")))
+}
diff --git a/pkg/service/backup/remote.go b/pkg/service/backup/remote.go
new file mode 100644
index 00000000..ac92b423
--- /dev/null
+++ b/pkg/service/backup/remote.go
@@ -0,0 +1,1921 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package backup
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/binary"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "runtime"
+ "sort"
+ "strings"
+ "sync/atomic"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ inboxservice "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/inbox"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/zapscript"
+ "github.com/google/uuid"
+ "github.com/rs/zerolog/log"
+)
+
+// Remote snapshot types accepted by the server for Core-initiated commits.
+const (
+ RemoteBackupTypeManual = "manual"
+ RemoteBackupTypeScheduled = "scheduled"
+)
+
+const (
+ // remoteSchemaVersion is the manifest compatibility version Core commits
+ // with and the newest version it can restore. Bump it whenever a change
+ // makes older Cores unable to apply a restored backup (e.g. a breaking
+ // user.db schema change): restore refuses snapshots with a newer version.
+ remoteSchemaVersion = 1
+ remoteCheckBatchSize = 10000
+ remotePackTargetBytes = 8 << 20
+ remoteMaxPackBytes = 64 << 20
+ remotePackFooterTrailerSize = 4
+
+ // remoteEmptyContentSHA256 is the sha256 of zero bytes. Empty files stay
+ // in the manifest but are never packed or downloaded: the server treats
+ // the empty object as always-present and Core synthesizes it on restore.
+ remoteEmptyContentSHA256 = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+
+ // remoteRequestTimeout bounds JSON API calls. Byte transfers (pack
+ // uploads, object downloads) scale on top of it via remoteTransferTimeout
+ // so a 64 MiB pack on a slow uplink is not killed at the base timeout.
+ remoteRequestTimeout = 60 * time.Second
+ remoteTransferBytesPerSec = 128 << 10
+ remoteManifestResponseLimit = 64 << 20
+ // remoteAvailabilityTTL caches a confirmed-available subscription check.
+ // Any other result (unavailable, unknown) is rechecked on the shorter
+ // retry TTL so a user who just subscribed or fixed connectivity is not
+ // stuck behind a stale negative result.
+ remoteAvailabilityTTL = 1 * time.Hour
+ remoteAvailabilityRetryTTL = 5 * time.Minute
+)
+
+var (
+ errRemoteNotAvailable = errors.New("remote backup is not available for this account")
+ errRemoteQuotaExceeded = errors.New("remote backup quota exceeded")
+ errRemoteUnlinked = errors.New("remote backup is unlinked")
+ errRemoteRateLimited = errors.New("remote backup rate limited")
+ errRemoteIntegrityRetry = errors.New("remote backup integrity mismatch")
+ errRemoteMissingObjects = errors.New("remote backup snapshot references missing objects")
+ errRemoteNewerSchema = errors.New("backup requires a newer Core version")
+)
+
+// RemoteRunInfo describes one completed remote backup run.
+//
+//nolint:govet // JSON response shape is grouped for API readability.
+type RemoteRunInfo struct {
+ Backup RemoteBackupInfo `json:"backup"`
+ Categories map[string]remoteCategorySummary `json:"categories"`
+ Warnings []models.BackupWarning `json:"warnings,omitempty"`
+ UploadedFiles int `json:"uploadedFiles"`
+ DedupedFiles int `json:"dedupedFiles"`
+ SkippedFiles int `json:"skippedFiles,omitempty"`
+ UploadedPacks int `json:"uploadedPacks"`
+ UploadedBytes int64 `json:"uploadedBytes"`
+ StorageUsedBytes int64 `json:"storageUsedBytes,omitempty"`
+ StorageQuotaBytes int64 `json:"storageQuotaBytes,omitempty"`
+}
+
+// RemoteRestoreInfo describes one completed remote restore.
+//
+//nolint:govet // JSON response shape is grouped for API readability.
+type RemoteRestoreInfo struct {
+ PreRestoreBackup *Info `json:"preRestoreBackup,omitempty"`
+ RestoredFrom RemoteBackupInfo `json:"restoredFrom"`
+}
+
+// RemoteListInfo contains remote backups and quota usage.
+//
+//nolint:govet // JSON response shape is grouped for API readability.
+type RemoteListInfo struct {
+ Items []RemoteBackupInfo `json:"items"`
+ StorageUsedBytes int64 `json:"storageUsedBytes"`
+ StorageQuotaBytes int64 `json:"storageQuotaBytes"`
+}
+
+// RemoteBackupInfo is remote snapshot metadata returned to Core clients.
+//
+//nolint:govet // JSON response shape is grouped for API readability.
+type RemoteBackupInfo struct {
+ CoreVersion *string `json:"coreVersion,omitempty"`
+ Platform *string `json:"platform,omitempty"`
+ RestoredAt *time.Time `json:"restoredAt,omitempty"`
+ SourceDevice *RemoteBackupSourceDevice `json:"sourceDevice,omitempty"`
+ Categories map[string]remoteCategorySummary `json:"categories"`
+ ManifestHash string `json:"manifestHash"`
+ BackupType string `json:"backupType"`
+ CreatedAt time.Time `json:"createdAt"`
+ Manifest json.RawMessage `json:"manifest,omitempty"`
+ ID string `json:"id"`
+ SchemaVersion int `json:"schemaVersion"`
+ SizeBytes int64 `json:"sizeBytes"`
+ // Incompatible marks snapshots committed with a newer schema version
+ // than this Core supports: they list fine but refuse to restore.
+ Incompatible bool `json:"incompatible,omitempty"`
+}
+
+// RemoteBackupSourceDevice identifies the account device that created a
+// snapshot. Current is relative to the device requesting the catalog.
+type RemoteBackupSourceDevice struct {
+ Platform *string `json:"platform,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Linked bool `json:"linked"`
+ Current bool `json:"current"`
+}
+
+type remoteCategorySummary struct {
+ Files int64 `json:"files"`
+ Bytes int64 `json:"bytes"`
+}
+
+// remoteManifestFormat is the canonical manifest format identifier shared
+// with the server.
+const remoteManifestFormat = "cas-v1"
+
+type remoteManifestEntry struct {
+ Path string `json:"path"`
+ SHA256 string `json:"sha256"`
+ Size int64 `json:"size"`
+}
+
+//nolint:govet // JSON wire shape is grouped for readability.
+type remoteManifest struct {
+ Format string `json:"format"`
+ Categories map[string][]remoteManifestEntry `json:"categories"`
+}
+
+//nolint:tagliatelle,govet // Remote API contract uses snake_case JSON fields.
+type remoteSnapshotRequest struct {
+ CoreVersion *string `json:"core_version,omitempty"`
+ Categories map[string][]remoteManifestEntry `json:"categories"`
+ BackupType string `json:"backup_type"`
+ SchemaVersion int `json:"schema_version"`
+}
+
+type remoteCheckRequest struct {
+ Hashes []string `json:"hashes"`
+}
+
+type remoteCheckResponse struct {
+ Missing []string `json:"missing"`
+}
+
+//nolint:tagliatelle,govet // Remote API contract uses snake_case JSON fields.
+type remoteListResponse struct {
+ Items []remoteBackupResponse `json:"items"`
+ StorageUsedBytes int64 `json:"storage_used_bytes"`
+ StorageQuotaBytes int64 `json:"storage_quota_bytes"`
+}
+
+//nolint:tagliatelle,govet // Remote API contract uses snake_case JSON fields.
+type remoteBackupResponse struct {
+ CoreVersion *string `json:"core_version,omitempty"`
+ Platform *string `json:"platform,omitempty"`
+ RestoredAt *time.Time `json:"restored_at,omitempty"`
+ SourceDevice *remoteBackupSourceDevice `json:"source_device,omitempty"`
+ Categories map[string]remoteCategorySummary `json:"categories"`
+ Manifest json.RawMessage `json:"manifest,omitempty"`
+ ManifestHash string `json:"manifest_hash"`
+ BackupType string `json:"backup_type"`
+ CreatedAt time.Time `json:"created_at"`
+ ID string `json:"id"`
+ SchemaVersion int `json:"schema_version"`
+ SizeBytes int64 `json:"size_bytes"`
+}
+
+//nolint:tagliatelle,govet // Remote API contract uses snake_case JSON fields.
+type remoteBackupSourceDevice struct {
+ Platform *string `json:"platform,omitempty"`
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Linked bool `json:"linked"`
+ Current bool `json:"current"`
+}
+
+//nolint:tagliatelle // Remote API contract uses snake_case JSON fields.
+type remoteRestoreCompleteRequest struct {
+ RestoreID string `json:"restore_id"`
+}
+
+//nolint:tagliatelle // Remote API contract uses snake_case JSON fields.
+type remotePackResponse struct {
+ CreatedAt time.Time `json:"created_at"`
+ PackHash string `json:"pack_hash"`
+ SizeBytes int64 `json:"size_bytes"`
+ ObjectCount int `json:"object_count"`
+}
+
+//nolint:tagliatelle,govet // Remote API contract uses snake_case JSON fields.
+type remoteDeviceMeResponse struct {
+ Name string `json:"name"`
+ LinkedAt time.Time `json:"linked_at"`
+ ID string `json:"id"`
+ BackupActive bool `json:"backup_active"`
+}
+
+type remoteAPIErrorBody struct {
+ Code string `json:"code"`
+ Message string `json:"message"`
+}
+
+type remoteAPIError struct {
+ Error remoteAPIErrorBody `json:"error"`
+}
+
+type packFooterEntry struct {
+ Hash string `json:"hash"`
+ Offset int64 `json:"offset"`
+ Length int64 `json:"length"`
+}
+
+type remoteClient struct {
+ httpClient *http.Client
+ onUnauthorized func()
+ baseURL string
+ bearer string
+ platform string
+}
+
+func (m *Manager) RunRemote(ctx context.Context, backupType string) (RemoteRunInfo, error) {
+ if backupType != RemoteBackupTypeManual && backupType != RemoteBackupTypeScheduled {
+ return RemoteRunInfo{}, fmt.Errorf("invalid remote backup type: %s", backupType)
+ }
+ lease, err := m.begin(ctx, OperationRemoteUpload, OperationWrite)
+ if err != nil {
+ return RemoteRunInfo{}, err
+ }
+ defer lease.Release()
+ ctx = lease.Context()
+
+ started := time.Now().UTC()
+ _ = m.writeRemoteStatus(&statusEntry{LastRunAt: formatTime(started), LastStatus: StatusRunning})
+
+ info, err := m.createRemoteSnapshot(ctx, backupType)
+ if errors.Is(err, errRemoteIntegrityRetry) {
+ // A file changed between hashing and packing (e.g. a save written
+ // mid-run). Re-collect and re-hash everything once; the second pass
+ // sees the settled bytes.
+ log.Warn().Msg("remote backup integrity mismatch, re-reading files and retrying once")
+ info, err = m.createRemoteSnapshot(ctx, backupType)
+ }
+ if err != nil {
+ failed := &statusEntry{
+ LastRunAt: formatTime(started),
+ LastStatus: StatusFailed,
+ LastError: safeStatusError(err),
+ }
+ if errors.Is(err, errRemoteUnlinked) {
+ failed.Unlinked = true
+ }
+ _ = m.writeRemoteStatus(failed)
+ m.notifyRemoteFailure(err)
+ return RemoteRunInfo{}, err
+ }
+ lastStatus := StatusSuccess
+ if len(info.Warnings) > 0 || info.SkippedFiles > 0 {
+ lastStatus = StatusPartial
+ }
+ _ = m.writeRemoteStatus(&statusEntry{
+ LastRunAt: formatTime(started),
+ LastSuccessAt: formatTime(info.Backup.CreatedAt),
+ LastStatus: lastStatus,
+ LastBackupSize: info.Backup.SizeBytes,
+ Categories: remoteCategoriesToStatus(info.Categories),
+ Warnings: info.Warnings,
+ SkippedFiles: len(info.Warnings) + info.SkippedFiles,
+ })
+ return info, nil
+}
+
+func (m *Manager) ListRemote(ctx context.Context) (RemoteListInfo, error) {
+ client, err := m.newRemoteClient()
+ if err != nil {
+ return RemoteListInfo{}, err
+ }
+ var resp remoteListResponse
+ if err := client.doJSON(ctx, http.MethodGet, "/v1/device/backups", nil, &resp); err != nil {
+ return RemoteListInfo{}, err
+ }
+ items := make([]RemoteBackupInfo, 0, len(resp.Items))
+ for i := range resp.Items {
+ items = append(items, remoteBackupToInfo(&resp.Items[i]))
+ }
+ return RemoteListInfo{
+ Items: items, StorageUsedBytes: resp.StorageUsedBytes, StorageQuotaBytes: resp.StorageQuotaBytes,
+ }, nil
+}
+
+// RevokeRemoteLink invalidates the current device on the backup server before
+// Core removes its local bearer. An already-missing or revoked credential is
+// treated as success so local cleanup can finish.
+func (m *Manager) RevokeRemoteLink(ctx context.Context) error {
+ client, err := m.newRemoteClient()
+ if errors.Is(err, errRemoteUnlinked) {
+ return nil
+ }
+ if err != nil {
+ return err
+ }
+ if err := client.doJSON(ctx, http.MethodDelete, "/v1/device/me", nil, nil); err != nil &&
+ !errors.Is(err, errRemoteUnlinked) {
+ return err
+ }
+ return nil
+}
+
+func (m *Manager) RestoreRemote(ctx context.Context, id string) (RemoteRestoreInfo, error) {
+ if id == "" {
+ return RemoteRestoreInfo{}, errors.New("invalid remote backup id")
+ }
+ restoreID := uuid.NewString()
+ lease, err := m.begin(ctx, OperationRemoteRestore, OperationWrite)
+ if err != nil {
+ return RemoteRestoreInfo{}, err
+ }
+ defer lease.Release()
+ ctx = lease.Context()
+ finishRestore, err := m.beginRestoreGate()
+ if err != nil {
+ return RemoteRestoreInfo{}, err
+ }
+ restoreSucceeded := false
+ defer func() { finishRestore(restoreSucceeded) }()
+ if idleErr := m.requireRestoreIdle(); idleErr != nil {
+ return RemoteRestoreInfo{}, idleErr
+ }
+ if recoveryErr := m.recoverRestoreLocked(ctx); recoveryErr != nil {
+ return RemoteRestoreInfo{}, recoveryErr
+ }
+ client, err := m.newRemoteClient()
+ if err != nil {
+ return RemoteRestoreInfo{}, err
+ }
+ var resp remoteBackupResponse
+ backupPath := remoteBackupPath(id)
+ getErr := client.doJSONLimit(ctx, http.MethodGet, backupPath, nil, &resp, remoteManifestResponseLimit)
+ if getErr != nil {
+ return RemoteRestoreInfo{}, getErr
+ }
+ if resp.ID == "" || resp.ID != id {
+ return RemoteRestoreInfo{}, fmt.Errorf(
+ "remote backup response ID %q does not match requested ID %q", resp.ID, id,
+ )
+ }
+ if resp.SchemaVersion > remoteSchemaVersion {
+ return RemoteRestoreInfo{}, fmt.Errorf(
+ "%w: backup schema version %d is newer than supported version %d",
+ errRemoteNewerSchema, resp.SchemaVersion, remoteSchemaVersion,
+ )
+ }
+ manifest, err := validateRemoteManifestResponse(&resp, m.pl.ID())
+ if err != nil {
+ return RemoteRestoreInfo{}, err
+ }
+ files := remoteManifestFiles(manifest)
+ if validateErr := validateFiles(files); validateErr != nil {
+ return RemoteRestoreInfo{}, validateErr
+ }
+ if len(files) > maxArchiveEntries-1 {
+ return RemoteRestoreInfo{}, fmt.Errorf("remote backup has too many files: %d", len(files))
+ }
+ if _, validateErr := validateLogicalSize(files, m.cfg.BackupMaxSizeBytes()); validateErr != nil {
+ return RemoteRestoreInfo{}, validateErr
+ }
+ for _, file := range files {
+ hash, decodeErr := hex.DecodeString(file.SHA256)
+ if decodeErr != nil || len(hash) != sha256.Size {
+ return RemoteRestoreInfo{}, fmt.Errorf("invalid remote backup SHA-256 for %s", file.RestorePath)
+ }
+ }
+ if policyErr := m.validateManifestPolicy(&Manifest{Platform: *resp.Platform, Files: files}); policyErr != nil {
+ return RemoteRestoreInfo{}, policyErr
+ }
+
+ // Download and verify every payload before touching the device: a
+ // mid-restore network failure or hash mismatch must leave it unchanged.
+ staged, cleanup, err := m.stageRemotePayloads(ctx, client, files)
+ if err != nil {
+ return RemoteRestoreInfo{}, err
+ }
+ defer cleanup()
+
+ pre, err := m.createBackup(ctx, true)
+ if err != nil {
+ return RemoteRestoreInfo{}, fmt.Errorf("creating pre-restore backup: %w", err)
+ }
+ if idleErr := m.requireRestoreIdle(); idleErr != nil {
+ return RemoteRestoreInfo{}, idleErr
+ }
+ finishPlatformRestore, err := m.preparePlatformRestore()
+ if err != nil {
+ return RemoteRestoreInfo{}, err
+ }
+ if err = m.applyRestore(ctx, &Manifest{Files: files}, func(file FileRef) (io.ReadCloser, error) {
+ return staged.open(file.SHA256, file.Size)
+ }); err != nil {
+ return RemoteRestoreInfo{}, errors.Join(err, finishPlatformRestore(false))
+ }
+ if finishErr := finishPlatformRestore(true); finishErr != nil {
+ log.Warn().Err(finishErr).Msg("committed remote restore profile cleanup deferred until restart")
+ }
+ restoreCompletePath := remoteBackupPath(id) + "/restore-complete"
+ complete := remoteRestoreCompleteRequest{RestoreID: restoreID}
+ if err := client.doJSON(ctx, http.MethodPost, restoreCompletePath, &complete, nil); err != nil {
+ log.Warn().Err(err).Str("backup_id", id).Str("restore_id", restoreID).
+ Msg("failed to mark remote backup restored")
+ } else {
+ completed := log.Info().Str("backup_id", id).Str("restore_id", restoreID).
+ Str("target_device_hint", m.cfg.DeviceID())
+ if resp.SourceDevice != nil {
+ completed = completed.Str("source_device_id", resp.SourceDevice.ID)
+ }
+ completed.Msg("remote backup restore recorded")
+ }
+ preInfo := pre
+ restoreSucceeded = true
+ return RemoteRestoreInfo{PreRestoreBackup: &preInfo, RestoredFrom: remoteBackupToInfo(&resp)}, nil
+}
+
+// remoteStaging holds downloaded, hash-verified payloads on disk, keyed by
+// content hash, so the apply phase never depends on the network.
+type remoteStaging struct {
+ dir string
+}
+
+func (s *remoteStaging) path(hash string) string { return filepath.Join(s.dir, hash) }
+
+func (s *remoteStaging) open(hash string, wantSize int64) (io.ReadCloser, error) {
+ if hash == remoteEmptyContentSHA256 {
+ return io.NopCloser(strings.NewReader("")), nil
+ }
+ file, err := os.Open(s.path(hash))
+ if err != nil {
+ return nil, fmt.Errorf("opening staged restore payload %s: %w", hash, err)
+ }
+ info, err := file.Stat()
+ if err != nil {
+ _ = file.Close()
+ return nil, fmt.Errorf("stating staged restore payload %s: %w", hash, err)
+ }
+ if info.Size() != wantSize {
+ _ = file.Close()
+ return nil, fmt.Errorf("staged restore payload size mismatch: %s", hash)
+ }
+ return file, nil
+}
+
+// stageRemotePayloads downloads and verifies every unique non-empty object
+// referenced by files into a temporary directory under the backup dir. The
+// returned cleanup removes the staging directory; it is safe to call after a
+// partial failure.
+func (m *Manager) stageRemotePayloads(
+ ctx context.Context,
+ client *remoteClient,
+ files []FileRef,
+) (*remoteStaging, func(), error) {
+ if err := os.MkdirAll(m.backupDir(), 0o750); err != nil {
+ return nil, nil, fmt.Errorf("creating backup directory: %w", err)
+ }
+ var required int64
+ sizes := make(map[string]int64, len(files))
+ for _, file := range files {
+ if file.SHA256 == remoteEmptyContentSHA256 {
+ continue
+ }
+ if size, ok := sizes[file.SHA256]; ok {
+ if size != file.Size {
+ return nil, nil, fmt.Errorf("remote manifest has conflicting sizes for %s", file.SHA256)
+ }
+ continue
+ }
+ if file.Size < 0 || file.Size > m.cfg.BackupMaxSizeBytes()-required {
+ return nil, nil, errors.New("remote restore exceeds backup size limit")
+ }
+ sizes[file.SHA256] = file.Size
+ required += file.Size
+ }
+ free, err := helpers.FreeDiskSpace(m.backupDir())
+ if err != nil {
+ return nil, nil, fmt.Errorf("checking remote restore staging space: %w", err)
+ }
+ if uint64(required) > free {
+ return nil, nil, fmt.Errorf("insufficient disk space to stage remote restore: need %d bytes", required)
+ }
+ dir, err := os.MkdirTemp(m.backupDir(), "remote-restore-")
+ if err != nil {
+ return nil, nil, fmt.Errorf("creating restore staging directory: %w", err)
+ }
+ staging := &remoteStaging{dir: dir}
+ cleanup := func() {
+ if removeErr := os.RemoveAll(dir); removeErr != nil {
+ log.Debug().Err(removeErr).Str("dir", dir).Msg("failed to remove restore staging directory")
+ }
+ }
+
+ seen := make(map[string]struct{}, len(files))
+ for _, file := range files {
+ if file.SHA256 == remoteEmptyContentSHA256 {
+ continue
+ }
+ if _, ok := seen[file.SHA256]; ok {
+ continue
+ }
+ seen[file.SHA256] = struct{}{}
+ downloadErr := client.downloadObject(
+ ctx, file.SHA256, file.Size, staging.path(file.SHA256),
+ )
+ if downloadErr != nil {
+ cleanup()
+ return nil, nil, downloadErr
+ }
+ }
+ return staging, cleanup, nil
+}
+
+func (m *Manager) createRemoteSnapshot(ctx context.Context, backupType string) (result RemoteRunInfo, err error) {
+ client, err := m.newRemoteClient()
+ if err != nil {
+ return RemoteRunInfo{}, err
+ }
+ if waitErr := m.pauser.Wait(ctx); waitErr != nil {
+ return RemoteRunInfo{}, fmt.Errorf("creating remote backup snapshot: %w", waitErr)
+ }
+ heartbeatErr := client.heartbeat(ctx)
+ if heartbeatErr != nil {
+ return RemoteRunInfo{}, heartbeatErr
+ }
+ availability, err := m.updateRemoteAvailability(ctx, client)
+ if err != nil {
+ return RemoteRunInfo{}, err
+ }
+ if availability != RemoteAvailabilityAvailable {
+ return RemoteRunInfo{}, errRemoteNotAvailable
+ }
+ collection, err := m.collectFiles(ctx, "remote-"+backupType, m.cfg.BackupScope())
+ if err != nil {
+ return RemoteRunInfo{}, err
+ }
+ defer func() {
+ if cleanupErr := collection.Cleanup(); cleanupErr != nil {
+ err = errors.Join(err, cleanupErr)
+ }
+ }()
+ files := collection.Files
+ validateErr := validateFiles(files)
+ if validateErr != nil {
+ return RemoteRunInfo{}, validateErr
+ }
+ if len(files) > maxArchiveEntries-1 {
+ return RemoteRunInfo{}, fmt.Errorf(
+ "backup has too many entries: %d exceeds %d", len(files), maxArchiveEntries-1,
+ )
+ }
+ if _, sizeErr := validateLogicalSize(files, m.cfg.BackupMaxSizeBytes()); sizeErr != nil {
+ return RemoteRunInfo{}, sizeErr
+ }
+ files, unreadableWarnings, err := prepareSourceFiles(ctx, files, m.sourceOpener, m.pauser)
+ if err != nil {
+ if errors.Is(err, errSourceIdentityChanged) {
+ return RemoteRunInfo{}, fmt.Errorf("%w: %w", errRemoteIntegrityRetry, err)
+ }
+ return RemoteRunInfo{}, err
+ }
+ collection.Warnings, err = appendBackupWarnings(collection.Warnings, unreadableWarnings)
+ if err != nil {
+ return RemoteRunInfo{}, err
+ }
+ missing, err := client.checkMissing(ctx, uniqueHashes(files))
+ if err != nil {
+ return RemoteRunInfo{}, err
+ }
+ missingSet := stringSet(missing)
+ uploaded, err := client.uploadMissing(ctx, files, missingSet, m.pauser)
+ if err != nil {
+ return RemoteRunInfo{}, err
+ }
+ if len(uploaded.skipped) > 0 {
+ // Files too large to fit in a single pack cannot be stored under
+ // this protocol: surface them and back up everything else.
+ files = withoutSkippedFiles(files, uploaded.skipped)
+ for _, skipped := range uploaded.skipped {
+ delete(missingSet, skipped.SHA256)
+ }
+ m.notifyRemoteSkipped(uploaded.skipped)
+ }
+ request := remoteSnapshotRequest{
+ BackupType: backupType,
+ SchemaVersion: remoteSchemaVersion,
+ CoreVersion: &config.AppVersion,
+ Categories: remoteCategories(files),
+ }
+ if verifyErr := verifyRemoteSources(ctx, files, m.pauser); verifyErr != nil {
+ return RemoteRunInfo{}, verifyErr
+ }
+ var backup remoteBackupResponse
+ commit := func() error {
+ backup = remoteBackupResponse{}
+ return client.doJSONLimit(
+ ctx, http.MethodPost, "/v1/device/backups", &request, &backup, remoteManifestResponseLimit,
+ )
+ }
+ commitErr := commit()
+ if errors.Is(commitErr, errRemoteMissingObjects) {
+ if verifyErr := verifyRemoteSources(ctx, files, m.pauser); verifyErr != nil {
+ return RemoteRunInfo{}, verifyErr
+ }
+ repairMissing, checkErr := client.checkMissing(ctx, uniqueHashes(files))
+ if checkErr != nil {
+ return RemoteRunInfo{}, checkErr
+ }
+ repairSet := stringSet(repairMissing)
+ repair, repairErr := client.uploadMissing(ctx, files, repairSet, m.pauser)
+ if repairErr != nil {
+ return RemoteRunInfo{}, repairErr
+ }
+ if len(repair.skipped) > 0 {
+ return RemoteRunInfo{}, errors.New("remote backup could not repair missing oversized objects")
+ }
+ uploaded.packs += repair.packs
+ uploaded.bytesUploaded += repair.bytesUploaded
+ for hash := range repairSet {
+ missingSet[hash] = struct{}{}
+ }
+ if verifyErr := verifyRemoteSources(ctx, files, m.pauser); verifyErr != nil {
+ return RemoteRunInfo{}, verifyErr
+ }
+ commitErr = commit()
+ }
+ if commitErr != nil {
+ return RemoteRunInfo{}, commitErr
+ }
+ if validateErr := validateCommittedRemoteBackup(
+ &backup, files, m.pl.ID(), backupType,
+ ); validateErr != nil {
+ return RemoteRunInfo{}, validateErr
+ }
+ list, listErr := m.ListRemote(ctx)
+ if listErr != nil {
+ log.Debug().Err(listErr).Msg("failed to refresh remote backup quota after upload")
+ }
+ m.notifyRemoteWarnings(collection.Warnings)
+ uploadedFiles := 0
+ for _, hash := range uniqueHashes(files) {
+ if hash == remoteEmptyContentSHA256 {
+ continue
+ }
+ if _, ok := missingSet[hash]; ok {
+ uploadedFiles++
+ }
+ }
+ return RemoteRunInfo{
+ Backup: remoteBackupToInfo(&backup),
+ Categories: remoteCategorySummaries(files),
+ Warnings: collection.Warnings,
+ UploadedFiles: uploadedFiles,
+ DedupedFiles: len(uniqueHashes(files)) - uploadedFiles,
+ SkippedFiles: len(uploaded.skipped),
+ UploadedPacks: uploaded.packs,
+ UploadedBytes: uploaded.bytesUploaded,
+ StorageUsedBytes: list.StorageUsedBytes,
+ StorageQuotaBytes: list.StorageQuotaBytes,
+ }, nil
+}
+
+// expandSkippedFiles widens a skipped set to every file sharing a skipped
+// content hash, so warnings and manifest filtering cover deduplicated
+// duplicates of an unstorable file too.
+func expandSkippedFiles(files, skipped []FileRef) []FileRef {
+ skippedHashes := make(map[string]struct{}, len(skipped))
+ for i := range skipped {
+ skippedHashes[skipped[i].SHA256] = struct{}{}
+ }
+ expanded := make([]FileRef, 0, len(skipped))
+ for _, file := range files {
+ if _, ok := skippedHashes[file.SHA256]; ok {
+ expanded = append(expanded, file)
+ }
+ }
+ return expanded
+}
+
+// withoutSkippedFiles drops every file whose content hash was skipped at
+// upload time, so the committed manifest never references bytes the server
+// does not have.
+func withoutSkippedFiles(files, skipped []FileRef) []FileRef {
+ skippedHashes := make(map[string]struct{}, len(skipped))
+ for i := range skipped {
+ skippedHashes[skipped[i].SHA256] = struct{}{}
+ }
+ kept := make([]FileRef, 0, len(files))
+ for _, file := range files {
+ if _, ok := skippedHashes[file.SHA256]; ok {
+ continue
+ }
+ kept = append(kept, file)
+ }
+ return kept
+}
+
+func (m *Manager) newRemoteClient() (*remoteClient, error) {
+ if m.coordinator != nil && m.coordinator.RemoteUnlinked() {
+ return nil, errRemoteUnlinked
+ }
+ if m.readStatus().Remote.Unlinked {
+ if m.coordinator != nil {
+ m.coordinator.SetRemoteUnlinked(true)
+ }
+ return nil, errRemoteUnlinked
+ }
+ baseURL := strings.TrimRight(m.cfg.BackupRemoteBaseURL(), "/")
+ lookupURL := config.BackupAuthLookupURL(baseURL)
+ entry := config.LookupAuth(config.GetAuthCfg(), lookupURL)
+ if entry == nil || entry.Bearer == "" {
+ return nil, errRemoteUnlinked
+ }
+ bearer := entry.Bearer
+ return &remoteClient{
+ // Timeouts are applied per request (scaled to transfer size for
+ // uploads/downloads), not on the client, so a large pack on a slow
+ // uplink is not killed at the base timeout.
+ httpClient: &http.Client{},
+ onUnauthorized: func() {
+ m.markRemoteUnlinkedIfCurrent(bearer)
+ },
+ baseURL: baseURL,
+ bearer: bearer,
+ platform: m.pl.ID(),
+ }, nil
+}
+
+// remoteTransferTimeout returns the request timeout for a transfer of the
+// given size: the base request timeout plus time for the bytes at a
+// deliberately pessimistic throughput floor.
+func remoteTransferTimeout(sizeBytes int64) time.Duration {
+ timeout := remoteRequestTimeout
+ if sizeBytes > 0 {
+ timeout += time.Duration(sizeBytes/remoteTransferBytesPerSec) * time.Second
+ }
+ return timeout
+}
+
+func (m *Manager) notifyRemoteFailure(err error) {
+ if m.inbox == nil {
+ return
+ }
+
+ var busy *BusyError
+ var title, body, category string
+ switch {
+ case errors.Is(err, errRemoteNotAvailable):
+ title = "Remote backup not available"
+ body = "Remote backup is not available for this account. Local backups still work."
+ category = inboxservice.CategoryBackupRemoteNotAvailable
+ case errors.Is(err, errRemoteQuotaExceeded):
+ title = "Remote backup storage full"
+ body = "Remote backup could not run because storage quota was reached. " +
+ "Delete remote backups or reduce backup size."
+ category = inboxservice.CategoryBackupRemoteQuotaExceeded
+ case errors.Is(err, errRemoteUnlinked):
+ title = "Remote backup needs relinking"
+ body = "Remote backup could not run because this device is not linked. " +
+ "Relink Zaparoo Online to resume remote backups."
+ category = inboxservice.CategoryBackupRemoteUnlinked
+ case errors.As(err, &busy):
+ // Not a failure worth an inbox message: another run is in flight.
+ return
+ default:
+ title = "Remote backup failed"
+ body = "Remote backup did not complete. It will be retried automatically; " +
+ "check the backup status screen for details."
+ category = inboxservice.CategoryBackupRemoteFailed
+ }
+
+ if addErr := m.inbox.Add(
+ title,
+ inboxservice.WithBody(body),
+ inboxservice.WithSeverity(inboxservice.SeverityError),
+ inboxservice.WithCategory(category),
+ ); addErr != nil {
+ log.Warn().Err(addErr).Str("category", category).Msg("failed to add remote backup inbox message")
+ }
+}
+
+// notifyRemoteSkipped surfaces files dropped from a remote backup because
+// they cannot fit inside a single pack.
+func (m *Manager) notifyRemoteSkipped(skipped []FileRef) {
+ if m.inbox == nil || len(skipped) == 0 {
+ return
+ }
+ names := make([]string, 0, len(skipped))
+ for i := range skipped {
+ names = append(names, skipped[i].RestorePath)
+ }
+ body := fmt.Sprintf(
+ "%d file(s) were too large for remote backup and were not uploaded: %s",
+ len(skipped), strings.Join(names, ", "),
+ )
+ if len(body) > 500 {
+ body = body[:500] + "…"
+ }
+ if addErr := m.inbox.Add(
+ "Some files were not backed up remotely",
+ inboxservice.WithBody(body),
+ inboxservice.WithSeverity(inboxservice.SeverityWarning),
+ inboxservice.WithCategory(inboxservice.CategoryBackupRemoteFilesSkipped),
+ ); addErr != nil {
+ log.Warn().Err(addErr).Msg("failed to add remote backup skipped-files inbox message")
+ }
+}
+
+func (m *Manager) notifyRemoteWarnings(warnings []models.BackupWarning) {
+ if m.inbox == nil || len(warnings) == 0 {
+ return
+ }
+ details := make([]string, 0, len(warnings))
+ for _, warning := range warnings {
+ details = append(details, warning.Path+" ("+warning.Reason+")")
+ }
+ body := fmt.Sprintf(
+ "%d path(s) could not be backed up: %s", len(warnings), strings.Join(details, ", "),
+ )
+ if len(body) > 500 {
+ body = body[:500] + "…"
+ }
+ if addErr := m.inbox.Add(
+ "Remote backup completed with warnings",
+ inboxservice.WithBody(body),
+ inboxservice.WithSeverity(inboxservice.SeverityWarning),
+ inboxservice.WithCategory(inboxservice.CategoryBackupRemoteFilesSkipped),
+ ); addErr != nil {
+ log.Warn().Err(addErr).Msg("failed to add remote backup warning inbox message")
+ }
+}
+
+// SendHeartbeat reports liveness (Core version + capabilities) when the
+// device is linked. Callers use it independently of backup runs so "last
+// seen" stays fresh even with remote backup disabled.
+func (m *Manager) SendHeartbeat(ctx context.Context) error {
+ client, err := m.newRemoteClient()
+ if err != nil {
+ return err
+ }
+ if heartbeatErr := client.heartbeat(ctx); heartbeatErr != nil {
+ return heartbeatErr
+ }
+ _, err = m.updateRemoteAvailability(ctx, client)
+ return err
+}
+
+func (m *Manager) RefreshRemoteAvailability(ctx context.Context) (string, error) {
+ client, err := m.newRemoteClient()
+ if err != nil {
+ return RemoteAvailabilityUnknown, err
+ }
+ return m.updateRemoteAvailability(ctx, client)
+}
+
+func RemoteAvailabilityNeedsRefresh(now time.Time, status *models.BackupStatusEntry) bool {
+ if status == nil || status.AvailabilityCheckedAt == nil {
+ return true
+ }
+ checkedAt, err := time.Parse(time.RFC3339Nano, *status.AvailabilityCheckedAt)
+ if err != nil || now.Before(checkedAt) {
+ return true
+ }
+ ttl := remoteAvailabilityTTL
+ if status.Availability != RemoteAvailabilityAvailable {
+ ttl = remoteAvailabilityRetryTTL
+ }
+ return now.Sub(checkedAt) >= ttl
+}
+
+// cachedRemoteAvailability returns the stored availability and whether it is
+// still within its TTL.
+func cachedRemoteAvailability(remote *statusEntry) (string, bool) {
+ availability := remote.Availability
+ if availability == "" {
+ availability = RemoteAvailabilityUnknown
+ }
+ checkedAt, err := time.Parse(time.RFC3339Nano, remote.AvailabilityCheckedAt)
+ if err != nil {
+ return availability, false
+ }
+ ttl := remoteAvailabilityTTL
+ if availability != RemoteAvailabilityAvailable {
+ ttl = remoteAvailabilityRetryTTL
+ }
+ age := time.Since(checkedAt)
+ return availability, age >= 0 && age < ttl
+}
+
+func (m *Manager) RefreshRemoteAvailabilityIfStale(ctx context.Context) (string, error) {
+ remote := m.readStatus().Remote
+ if availability, fresh := cachedRemoteAvailability(&remote); fresh {
+ return availability, nil
+ }
+ return m.RefreshRemoteAvailability(ctx)
+}
+
+// availabilityRefreshInFlight dedupes background availability refreshes.
+// Managers are constructed per request, so the flag is package-level like
+// the status file lock.
+var availabilityRefreshInFlight atomic.Bool
+
+// RefreshRemoteAvailabilityIfStaleAsync refreshes remote availability in the
+// background when the cached value is past its TTL, so status requests
+// return immediately instead of blocking on a network round trip.
+func (m *Manager) RefreshRemoteAvailabilityIfStaleAsync() {
+ if !m.Status().Remote.Linked {
+ return
+ }
+ remote := m.readStatus().Remote
+ if _, fresh := cachedRemoteAvailability(&remote); fresh {
+ return
+ }
+ if !availabilityRefreshInFlight.CompareAndSwap(false, true) {
+ return
+ }
+ go func() {
+ defer availabilityRefreshInFlight.Store(false)
+ ctx, cancel := context.WithTimeout(context.Background(), remoteRequestTimeout)
+ defer cancel()
+ if _, err := m.RefreshRemoteAvailability(ctx); err != nil {
+ log.Debug().Err(err).Msg("background remote availability refresh failed")
+ }
+ }()
+}
+
+func (m *Manager) updateRemoteAvailability(
+ ctx context.Context, client *remoteClient,
+) (string, error) {
+ me, err := client.deviceMe(ctx)
+ if err != nil {
+ if !errors.Is(err, errRemoteUnlinked) {
+ m.setRemoteAvailability(RemoteAvailabilityUnknown, time.Now().UTC(), nil)
+ }
+ return RemoteAvailabilityUnknown, err
+ }
+ availability := RemoteAvailabilityUnavailable
+ if me.BackupActive {
+ availability = RemoteAvailabilityAvailable
+ }
+ m.setRemoteAvailability(availability, time.Now().UTC(), me)
+ return availability, nil
+}
+
+// setRemoteAvailability persists the availability check result. When the
+// check reached the server, the device identity it reported (name, link
+// time) is recorded alongside so the UI can show what this device is
+// linked as.
+func (m *Manager) setRemoteAvailability(
+ availability string, checkedAt time.Time, me *remoteDeviceMeResponse,
+) {
+ statusMu.Lock()
+ defer statusMu.Unlock()
+ st := m.readStatusLocked()
+ st.Remote.Availability = availability
+ st.Remote.AvailabilityCheckedAt = formatTime(checkedAt)
+ if me != nil {
+ st.Remote.DeviceName = me.Name
+ st.Remote.LinkedAt = ""
+ if !me.LinkedAt.IsZero() {
+ st.Remote.LinkedAt = formatTime(me.LinkedAt)
+ }
+ }
+ if err := m.writeStatusLocked(&st); err != nil {
+ log.Warn().Err(err).Msg("failed to persist remote backup availability")
+ }
+}
+
+func (m *Manager) markRemoteUnlinkedIfCurrent(rejectedBearer string) {
+ baseURL := strings.TrimRight(m.cfg.BackupRemoteBaseURL(), "/")
+ lookupURL := config.BackupAuthLookupURL(baseURL)
+ current := config.LookupAuth(config.GetAuthCfg(), lookupURL)
+ if current == nil || current.Bearer == "" || current.Bearer != rejectedBearer {
+ log.Debug().Msg("ignoring unauthorized response for superseded remote backup credential")
+ return
+ }
+ m.MarkRemoteUnlinked()
+}
+
+// MarkRemoteUnlinked records that no valid remote credential exists (the
+// token was revoked server-side or removed by logout), so the status UI
+// prompts a re-link and the scheduler stops attempting remote backups.
+func (m *Manager) MarkRemoteUnlinked() {
+ if m.coordinator != nil {
+ m.coordinator.SetRemoteUnlinked(true)
+ }
+ statusMu.Lock()
+ defer statusMu.Unlock()
+
+ st := m.readStatusLocked()
+ st.Remote.Unlinked = true
+ st.Remote.Availability = RemoteAvailabilityUnknown
+ st.Remote.AvailabilityCheckedAt = ""
+ st.Remote.DeviceName = ""
+ st.Remote.LinkedAt = ""
+ if err := m.writeStatusLocked(&st); err != nil {
+ log.Warn().Err(err).Msg("failed to persist remote backup revocation")
+ }
+}
+
+// MarkRemoteLinked clears a persisted unlinked marker after a successful
+// claim/link, so the status UI reflects the fresh credential immediately.
+func (m *Manager) MarkRemoteLinked() {
+ if m.coordinator != nil {
+ m.coordinator.SetRemoteUnlinked(false)
+ }
+ statusMu.Lock()
+ defer statusMu.Unlock()
+
+ st := m.readStatusLocked()
+ st.Remote.Unlinked = false
+ st.Remote.Availability = RemoteAvailabilityUnknown
+ st.Remote.AvailabilityCheckedAt = ""
+ st.Remote.DeviceName = ""
+ st.Remote.LinkedAt = ""
+ if err := m.writeStatusLocked(&st); err != nil {
+ log.Warn().Err(err).Msg("failed to clear remote backup unlinked marker")
+ }
+}
+
+func (m *Manager) writeRemoteStatus(remote *statusEntry) error {
+ statusMu.Lock()
+ defer statusMu.Unlock()
+
+ st := m.readStatusLocked()
+ if remote.Categories == nil && st.Remote.Categories != nil {
+ remote.Categories = st.Remote.Categories
+ }
+ if remote.LastSuccessAt == "" {
+ remote.LastSuccessAt = st.Remote.LastSuccessAt
+ }
+ if remote.Availability == "" {
+ remote.Availability = st.Remote.Availability
+ }
+ if remote.AvailabilityCheckedAt == "" {
+ remote.AvailabilityCheckedAt = st.Remote.AvailabilityCheckedAt
+ }
+ if remote.ScheduleEnabledSince == "" {
+ remote.ScheduleEnabledSince = st.Remote.ScheduleEnabledSince
+ }
+ if remote.DeviceName == "" {
+ remote.DeviceName = st.Remote.DeviceName
+ }
+ if remote.LinkedAt == "" {
+ remote.LinkedAt = st.Remote.LinkedAt
+ }
+ remote.Unlinked = remote.Unlinked || st.Remote.Unlinked
+ st.Remote = *remote
+ return m.writeStatusLocked(&st)
+}
+
+// parseReliableTime parses a persisted RFC 3339 timestamp, rejecting
+// values recorded against an unreliable (pre-2024) clock.
+func parseReliableTime(raw string) (time.Time, bool) {
+ if raw == "" {
+ return time.Time{}, false
+ }
+ parsed, err := time.Parse(time.RFC3339Nano, raw)
+ if err != nil || !helpers.IsClockReliable(parsed) {
+ return time.Time{}, false
+ }
+ return parsed, true
+}
+
+// TrackScheduleStale maintains the persisted record of when remote backup
+// scheduling became active and reports whether scheduled backups are
+// stale: scheduling active for at least staleAfter with no successful run
+// inside that window. Staleness is only judged against a reliable clock.
+func (m *Manager) TrackScheduleStale(now time.Time, active bool, staleAfter time.Duration) bool {
+ if !helpers.IsClockReliable(now) {
+ return false
+ }
+ statusMu.Lock()
+ st := m.readStatusLocked()
+ if !active {
+ if st.Remote.ScheduleEnabledSince != "" {
+ st.Remote.ScheduleEnabledSince = ""
+ if err := m.writeStatusLocked(&st); err != nil {
+ log.Warn().Err(err).Msg("failed to clear backup schedule activity marker")
+ }
+ }
+ statusMu.Unlock()
+ return false
+ }
+ if _, ok := parseReliableTime(st.Remote.ScheduleEnabledSince); !ok {
+ st.Remote.ScheduleEnabledSince = formatTime(now)
+ if err := m.writeStatusLocked(&st); err != nil {
+ log.Warn().Err(err).Msg("failed to persist backup schedule activity marker")
+ }
+ }
+ remote := st.Remote
+ statusMu.Unlock()
+
+ anchor, ok := parseReliableTime(remote.ScheduleEnabledSince)
+ if !ok {
+ return false
+ }
+ if lastSuccess, ok := parseReliableTime(remote.LastSuccessAt); ok && lastSuccess.After(anchor) {
+ anchor = lastSuccess
+ }
+ return now.Sub(anchor) >= staleAfter
+}
+
+// NotifyScheduleStale posts the deduplicated overdue-backup inbox notice.
+func (m *Manager) NotifyScheduleStale() {
+ if m.inbox == nil {
+ return
+ }
+ if addErr := m.inbox.Add(
+ "Remote backup is overdue",
+ inboxservice.WithBody(
+ "Scheduled remote backup has not completed in over a week. "+
+ "Check the backup status screen for connectivity or linking problems.",
+ ),
+ inboxservice.WithSeverity(inboxservice.SeverityWarning),
+ inboxservice.WithCategory(inboxservice.CategoryBackupRemoteStale),
+ ); addErr != nil {
+ log.Warn().Err(addErr).Msg("failed to add stale remote backup inbox message")
+ }
+}
+
+func (c *remoteClient) heartbeat(ctx context.Context) error {
+ body := map[string]any{
+ "core_version": config.AppVersion,
+ "capabilities": map[string]any{"backup": 1},
+ }
+ return c.doJSON(ctx, http.MethodPost, "/v1/device/heartbeat", body, nil)
+}
+
+func (c *remoteClient) deviceMe(ctx context.Context) (*remoteDeviceMeResponse, error) {
+ var resp remoteDeviceMeResponse
+ if err := c.doJSON(ctx, http.MethodGet, "/v1/device/me", nil, &resp); err != nil {
+ return nil, err
+ }
+ return &resp, nil
+}
+
+func (c *remoteClient) checkMissing(ctx context.Context, hashes []string) ([]string, error) {
+ missing := make([]string, 0, len(hashes))
+ for start := 0; start < len(hashes); start += remoteCheckBatchSize {
+ end := start + remoteCheckBatchSize
+ if end > len(hashes) {
+ end = len(hashes)
+ }
+ var resp remoteCheckResponse
+ req := remoteCheckRequest{Hashes: hashes[start:end]}
+ if err := c.doJSON(
+ ctx, http.MethodPost, "/v1/device/backup-objects/check", &req, &resp,
+ ); err != nil {
+ return nil, err
+ }
+ missing = append(missing, resp.Missing...)
+ }
+ return missing, nil
+}
+
+// uploadResult summarizes one uploadMissing pass: how many packs and bytes
+// went over the wire, and which files were skipped as unstorable.
+type uploadResult struct {
+ skipped []FileRef
+ packs int
+ bytesUploaded int64
+}
+
+func (c *remoteClient) uploadMissing(
+ ctx context.Context,
+ files []FileRef,
+ missing map[string]struct{},
+ pauser *syncutil.Pauser,
+) (uploadResult, error) {
+ var result uploadResult
+ if len(missing) == 0 {
+ return result, nil
+ }
+ candidates := make([]FileRef, 0, len(files))
+ for _, file := range files {
+ if _, ok := missing[file.SHA256]; !ok {
+ continue
+ }
+ // Empty files are never packed: a zero-length range cannot live in
+ // a pack, and the server treats the empty object as always-present.
+ // (A current server never reports it missing; this also covers one
+ // that predates that rule.)
+ if file.Size == 0 || file.SHA256 == remoteEmptyContentSHA256 {
+ continue
+ }
+ candidates = append(candidates, file)
+ }
+ sortRemotePackFiles(candidates)
+
+ unique := make([]FileRef, 0, len(missing))
+ seenHashes := make(map[string]struct{}, len(missing))
+ for _, file := range candidates {
+ if _, seen := seenHashes[file.SHA256]; seen {
+ continue
+ }
+ seenHashes[file.SHA256] = struct{}{}
+ unique = append(unique, file)
+ }
+
+ var current []FileRef
+ var currentBytes int64
+ flush := func() error {
+ if len(current) == 0 {
+ return nil
+ }
+ if waitErr := pauser.Wait(ctx); waitErr != nil {
+ return fmt.Errorf("uploading remote backup pack: %w", waitErr)
+ }
+ body, packHash, buildErr := buildRemotePack(ctx, current, pauser)
+ if buildErr != nil {
+ return buildErr
+ }
+ if len(body) > remoteMaxPackBytes {
+ return fmt.Errorf("remote backup pack exceeds maximum size: %d bytes", len(body))
+ }
+ var resp remotePackResponse
+ uploadPath := "/v1/device/backup-packs/" + packHash
+ if uploadErr := c.doBytes(ctx, http.MethodPut, uploadPath, body, &resp); uploadErr != nil {
+ return uploadErr
+ }
+ result.packs++
+ result.bytesUploaded += int64(len(body))
+ current = nil
+ currentBytes = 0
+ return nil
+ }
+
+ for i := range unique {
+ file := &unique[i]
+ if remoteSingleFilePackExceedsMax(file) {
+ // There is no way to store a file that cannot fit inside one
+ // pack: skip it and let the caller drop it from the manifest.
+ log.Warn().Str("path", file.RestorePath).Int64("size", file.Size).
+ Msg("skipping file too large for remote backup")
+ result.skipped = append(result.skipped, *file)
+ continue
+ }
+ categoryChanged := len(current) > 0 && current[0].Category != file.Category
+ packFull := len(current) > 0 && currentBytes+file.Size > remotePackTargetBytes
+ if categoryChanged || packFull {
+ if err := flush(); err != nil {
+ return result, err
+ }
+ }
+ current = append(current, *file)
+ currentBytes += file.Size
+ }
+ if err := flush(); err != nil {
+ return result, err
+ }
+ result.skipped = expandSkippedFiles(files, result.skipped)
+ return result, nil
+}
+
+func (c *remoteClient) downloadObject(
+ ctx context.Context,
+ hash string,
+ wantSize int64,
+ destination string,
+) error {
+ ctx, cancel := context.WithTimeout(ctx, remoteTransferTimeout(wantSize))
+ defer cancel()
+ downloadPath := "/v1/device/backup-objects/" + hash
+ return c.doRaw(ctx, http.MethodGet, downloadPath, nil, "", func(resp *http.Response) (err error) {
+ // #nosec G304 -- destination is a validated hash inside a private staging directory.
+ out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("creating remote restore staging file: %w", err)
+ }
+ defer func() {
+ if closeErr := out.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("closing remote restore staging file: %w", closeErr))
+ }
+ }()
+
+ hasher := sha256.New()
+ limited := &io.LimitedReader{R: resp.Body, N: wantSize + 1}
+ written, copyErr := io.Copy(io.MultiWriter(out, hasher), limited)
+ if copyErr != nil {
+ return fmt.Errorf("reading remote backup object: %w", copyErr)
+ }
+ if written != wantSize {
+ return fmt.Errorf("remote backup object size mismatch: %s", hash)
+ }
+ if hex.EncodeToString(hasher.Sum(nil)) != hash {
+ return fmt.Errorf("remote backup object hash mismatch: %s", hash)
+ }
+ if syncErr := out.Sync(); syncErr != nil {
+ return fmt.Errorf("syncing remote restore staging file: %w", syncErr)
+ }
+ return nil
+ })
+}
+
+func (c *remoteClient) doJSON(ctx context.Context, method, path string, body, out any) error {
+ return c.doJSONLimit(ctx, method, path, body, out, helpers.MaxResponseBodySize)
+}
+
+// doJSONLimit is doJSON with an explicit response-size limit, for the calls
+// whose responses carry a full snapshot manifest (commit and snapshot GET) —
+// those can far exceed the default cap on file-heavy devices.
+func (c *remoteClient) doJSONLimit(
+ ctx context.Context,
+ method, path string,
+ body, out any,
+ responseLimit int64,
+) error {
+ var reader io.Reader
+ var requestBytes int64
+ if body != nil {
+ data, err := json.Marshal(body)
+ if err != nil {
+ return fmt.Errorf("encoding remote backup request: %w", err)
+ }
+ requestBytes = int64(len(data))
+ reader = bytes.NewReader(data)
+ }
+ ctx, cancel := context.WithTimeout(ctx, remoteTransferTimeout(requestBytes))
+ defer cancel()
+ return c.doRaw(ctx, method, path, reader, "application/json", func(resp *http.Response) error {
+ if out == nil {
+ return nil
+ }
+ limitedBody := io.LimitReader(resp.Body, responseLimit)
+ if err := json.NewDecoder(limitedBody).Decode(out); err != nil {
+ return fmt.Errorf("decoding remote backup response: %w", err)
+ }
+ return nil
+ })
+}
+
+func (c *remoteClient) doBytes(
+ ctx context.Context,
+ method, path string,
+ body []byte,
+ out any,
+) error {
+ ctx, cancel := context.WithTimeout(ctx, remoteTransferTimeout(int64(len(body))))
+ defer cancel()
+ reader := bytes.NewReader(body)
+ contentType := "application/octet-stream"
+ return c.doRaw(ctx, method, path, reader, contentType, func(resp *http.Response) error {
+ if out == nil {
+ return nil
+ }
+ limitedBody := io.LimitReader(resp.Body, helpers.MaxResponseBodySize)
+ if err := json.NewDecoder(limitedBody).Decode(out); err != nil {
+ return fmt.Errorf("decoding remote backup response: %w", err)
+ }
+ return nil
+ })
+}
+
+func (c *remoteClient) doRaw(
+ ctx context.Context,
+ method, requestPath string,
+ body io.Reader,
+ contentType string,
+ onOK func(*http.Response) error,
+) error {
+ endpoint, err := remoteEndpoint(c.baseURL, requestPath)
+ if err != nil {
+ return err
+ }
+ req, err := http.NewRequestWithContext(ctx, method, endpoint, body)
+ if err != nil {
+ return fmt.Errorf("creating remote backup request: %w", err)
+ }
+ req.Header.Set("Authorization", "Bearer "+c.bearer)
+ req.Header.Set(zapscript.HeaderZaparooOS, runtime.GOOS)
+ req.Header.Set(zapscript.HeaderZaparooArch, runtime.GOARCH)
+ req.Header.Set(zapscript.HeaderZaparooPlatform, c.platform)
+ if contentType != "" {
+ req.Header.Set("Content-Type", contentType)
+ }
+
+ //nolint:gosec // URL is validated backup.remote.base_url or HTTPS default.
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("contacting remote backup server: %w", err)
+ }
+ defer func() {
+ if closeErr := resp.Body.Close(); closeErr != nil {
+ log.Debug().Err(closeErr).Msg("failed to close remote backup response")
+ }
+ }()
+ if resp.StatusCode < 200 || resp.StatusCode >= 300 {
+ if resp.StatusCode == http.StatusUnauthorized && c.onUnauthorized != nil {
+ c.onUnauthorized()
+ }
+ return remoteStatusError(resp)
+ }
+ return onOK(resp)
+}
+
+func remoteBackupPath(id string) string {
+ escapedID := url.PathEscape(id)
+ if id == "." || id == ".." {
+ escapedID = strings.ReplaceAll(escapedID, ".", "%2E")
+ }
+ return "/v1/device/backups/" + escapedID
+}
+
+func remoteEndpoint(baseURL, requestPath string) (string, error) {
+ base, err := url.Parse(baseURL)
+ if err != nil {
+ return "", fmt.Errorf("invalid remote backup base URL: %w", err)
+ }
+ decodedRequestPath, err := url.PathUnescape(requestPath)
+ if err != nil {
+ return "", fmt.Errorf("invalid remote backup request path: %w", err)
+ }
+ basePath := strings.TrimRight(base.Path, "/")
+ baseEscapedPath := strings.TrimRight(base.EscapedPath(), "/")
+ base.Path = basePath + decodedRequestPath
+ base.RawPath = baseEscapedPath + requestPath
+ return base.String(), nil
+}
+
+func remoteStatusError(resp *http.Response) error {
+ body, _ := io.ReadAll(io.LimitReader(resp.Body, helpers.MaxResponseBodySize))
+ var apiErr remoteAPIError
+ _ = json.Unmarshal(body, &apiErr)
+ switch apiErr.Error.Code {
+ case "not_available":
+ return errRemoteNotAvailable
+ case "quota_exceeded":
+ return errRemoteQuotaExceeded
+ case "payload_too_large":
+ return errors.New("remote backup payload too large")
+ case "rate_limited":
+ return errRemoteRateLimited
+ case "missing_objects":
+ return errRemoteMissingObjects
+ case "backup_too_large":
+ return errors.New("remote backup has too many files")
+ case "integrity_mismatch":
+ return errRemoteIntegrityRetry
+ }
+ if resp.StatusCode == http.StatusUnauthorized {
+ return errRemoteUnlinked
+ }
+ if apiErr.Error.Message != "" {
+ return fmt.Errorf(
+ "remote backup server returned status %d: %s",
+ resp.StatusCode,
+ apiErr.Error.Message,
+ )
+ }
+ msg := strings.TrimSpace(string(body))
+ if msg == "" {
+ msg = resp.Status
+ }
+ return fmt.Errorf("remote backup server returned status %d: %s", resp.StatusCode, msg)
+}
+
+func hashRemoteFiles(ctx context.Context, files []FileRef, pauser *syncutil.Pauser) ([]FileRef, error) {
+ if err := ctx.Err(); err != nil {
+ return nil, fmt.Errorf("hashing remote backup sources: %w", err)
+ }
+ out := make([]FileRef, len(files))
+ copy(out, files)
+ for i := range out {
+ if err := pauser.Wait(ctx); err != nil {
+ return nil, fmt.Errorf("hashing remote backup sources: %w", err)
+ }
+ f, err := openSourceContext(ctx, &out[i])
+ if err != nil {
+ if errors.Is(err, errSourceIdentityChanged) {
+ return nil, fmt.Errorf("%w: %w", errRemoteIntegrityRetry, err)
+ }
+ return nil, err
+ }
+ hash := sha256.New()
+ limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: f}, N: out[i].Size + 1}
+ size, copyErr := io.Copy(hash, limited)
+ closeErr := f.Close()
+ if copyErr != nil {
+ return nil, fmt.Errorf("hashing %s: %w", out[i].RestorePath, copyErr)
+ }
+ if closeErr != nil {
+ return nil, fmt.Errorf("closing %s: %w", out[i].RestorePath, closeErr)
+ }
+ if size != out[i].Size {
+ return nil, fmt.Errorf("%w: source size changed for %s", errRemoteIntegrityRetry, out[i].RestorePath)
+ }
+ out[i].SHA256 = hex.EncodeToString(hash.Sum(nil))
+ }
+ return out, nil
+}
+
+func verifyRemoteSources(ctx context.Context, files []FileRef, pauser *syncutil.Pauser) error {
+ verified, err := hashRemoteFiles(ctx, files, pauser)
+ if err != nil {
+ return err
+ }
+ for i := range files {
+ if verified[i].Size != files[i].Size || verified[i].SHA256 != files[i].SHA256 {
+ return fmt.Errorf("%w: source changed for %s", errRemoteIntegrityRetry, files[i].RestorePath)
+ }
+ }
+ return nil
+}
+
+func sortRemotePackFiles(files []FileRef) {
+ sort.Slice(files, func(i, j int) bool {
+ leftRank := remoteCategoryRank(files[i].Category)
+ rightRank := remoteCategoryRank(files[j].Category)
+ if leftRank != rightRank {
+ return leftRank < rightRank
+ }
+ if files[i].RestorePath != files[j].RestorePath {
+ return files[i].RestorePath < files[j].RestorePath
+ }
+ return files[i].SHA256 < files[j].SHA256
+ })
+}
+
+func remoteCategoryRank(category string) int {
+ switch category {
+ case CategoryZaparoo:
+ return 0
+ case CategorySettings:
+ return 1
+ case CategoryInputs:
+ return 2
+ case CategorySaves:
+ return 3
+ case CategorySavestates:
+ return 4
+ default:
+ return 100
+ }
+}
+
+func remoteSingleFilePackExceedsMax(file *FileRef) bool {
+ footer := []packFooterEntry{{Hash: file.SHA256, Offset: 0, Length: file.Size}}
+ footerData, err := json.Marshal(footer)
+ if err != nil {
+ return true
+ }
+ return file.Size+int64(len(footerData))+remotePackFooterTrailerSize > remoteMaxPackBytes
+}
+
+func buildRemotePack(
+ ctx context.Context, files []FileRef, pauser *syncutil.Pauser,
+) (body []byte, packHash string, err error) {
+ if ctxErr := ctx.Err(); ctxErr != nil {
+ return nil, "", fmt.Errorf("building remote backup pack: %w", ctxErr)
+ }
+ var buf bytes.Buffer
+ footer := make([]packFooterEntry, 0, len(files))
+ seen := make(map[string]struct{}, len(files))
+ for _, file := range files {
+ if waitErr := pauser.Wait(ctx); waitErr != nil {
+ return nil, "", fmt.Errorf("building remote backup pack: %w", waitErr)
+ }
+ if _, ok := seen[file.SHA256]; ok {
+ continue
+ }
+ seen[file.SHA256] = struct{}{}
+ opened, openErr := openSourceContext(ctx, &file)
+ if openErr != nil {
+ if errors.Is(openErr, errSourceIdentityChanged) {
+ return nil, "", fmt.Errorf("%w: %w", errRemoteIntegrityRetry, openErr)
+ }
+ return nil, "", openErr
+ }
+ payload, readErr := io.ReadAll(io.LimitReader(
+ &contextReader{ctx: ctx, reader: opened}, file.Size+1,
+ ))
+ closeErr := opened.Close()
+ if readErr != nil {
+ return nil, "", fmt.Errorf("reading %s: %w", file.RestorePath, readErr)
+ }
+ if closeErr != nil {
+ return nil, "", fmt.Errorf("closing %s: %w", file.RestorePath, closeErr)
+ }
+ payloadHash := sha256.Sum256(payload)
+ if int64(len(payload)) != file.Size || hex.EncodeToString(payloadHash[:]) != file.SHA256 {
+ return nil, "", fmt.Errorf("%w: source changed for %s", errRemoteIntegrityRetry, file.RestorePath)
+ }
+ footer = append(footer, packFooterEntry{
+ Hash: file.SHA256, Offset: int64(buf.Len()), Length: int64(len(payload)),
+ })
+ _, _ = buf.Write(payload)
+ }
+ footerData, err := json.Marshal(footer)
+ if err != nil {
+ return nil, "", fmt.Errorf("encoding remote backup pack footer: %w", err)
+ }
+ _, _ = buf.Write(footerData)
+ if len(footerData) > remoteMaxPackBytes {
+ return nil, "", errors.New("remote backup pack footer exceeds maximum size")
+ }
+ var trailer [4]byte
+ //nolint:gosec // len(footerData) is capped by remoteMaxPackBytes above.
+ binary.BigEndian.PutUint32(trailer[:], uint32(len(footerData)))
+ _, _ = buf.Write(trailer[:])
+ body = buf.Bytes()
+ sum := sha256.Sum256(body)
+ return body, hex.EncodeToString(sum[:]), nil
+}
+
+func remoteCategories(files []FileRef) map[string][]remoteManifestEntry {
+ categories := make(map[string][]remoteManifestEntry)
+ for _, file := range files {
+ categories[file.Category] = append(categories[file.Category], remoteManifestEntry{
+ Path: file.RestorePath,
+ SHA256: file.SHA256,
+ Size: file.Size,
+ })
+ }
+ for category := range categories {
+ entries := categories[category]
+ sort.Slice(entries, func(i, j int) bool { return entries[i].Path < entries[j].Path })
+ categories[category] = entries
+ }
+ return categories
+}
+
+func remoteCategorySummaries(files []FileRef) map[string]remoteCategorySummary {
+ out := make(map[string]remoteCategorySummary)
+ for _, file := range files {
+ entry := out[file.Category]
+ entry.Files++
+ entry.Bytes += file.Size
+ out[file.Category] = entry
+ }
+ return out
+}
+
+func remoteCategoriesToStatus(
+ in map[string]remoteCategorySummary,
+) map[string]models.BackupCategoryStatus {
+ out := map[string]models.BackupCategoryStatus{
+ CategoryZaparoo: {Enabled: true},
+ CategorySettings: {Enabled: true},
+ CategoryInputs: {Enabled: true},
+ CategorySaves: {Enabled: true},
+ CategorySavestates: {Enabled: true},
+ }
+ for category, summary := range in {
+ out[category] = models.BackupCategoryStatus{
+ Files: summary.Files, Bytes: summary.Bytes, Enabled: true,
+ }
+ }
+ return out
+}
+
+func uniqueHashes(files []FileRef) []string {
+ seen := make(map[string]struct{}, len(files))
+ out := make([]string, 0, len(files))
+ for _, file := range files {
+ if _, ok := seen[file.SHA256]; ok {
+ continue
+ }
+ seen[file.SHA256] = struct{}{}
+ out = append(out, file.SHA256)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func stringSet(items []string) map[string]struct{} {
+ out := make(map[string]struct{}, len(items))
+ for _, item := range items {
+ out[item] = struct{}{}
+ }
+ return out
+}
+
+func remoteBackupToInfo(resp *remoteBackupResponse) RemoteBackupInfo {
+ info := RemoteBackupInfo{
+ ID: resp.ID,
+ BackupType: resp.BackupType,
+ SchemaVersion: resp.SchemaVersion,
+ Incompatible: resp.SchemaVersion > remoteSchemaVersion,
+ CoreVersion: resp.CoreVersion,
+ Platform: resp.Platform,
+ ManifestHash: resp.ManifestHash,
+ SizeBytes: resp.SizeBytes,
+ Categories: resp.Categories,
+ Manifest: resp.Manifest,
+ CreatedAt: resp.CreatedAt,
+ RestoredAt: resp.RestoredAt,
+ }
+ if resp.SourceDevice != nil {
+ info.SourceDevice = &RemoteBackupSourceDevice{
+ ID: resp.SourceDevice.ID,
+ Name: resp.SourceDevice.Name,
+ Platform: resp.SourceDevice.Platform,
+ Linked: resp.SourceDevice.Linked,
+ Current: resp.SourceDevice.Current,
+ }
+ }
+ return info
+}
+
+func validateCommittedRemoteBackup(
+ resp *remoteBackupResponse,
+ expectedFiles []FileRef,
+ expectedPlatform, expectedType string,
+) error {
+ manifest, err := validateRemoteManifestResponse(resp, expectedPlatform)
+ if err != nil {
+ return err
+ }
+ if resp.ID == "" || resp.BackupType != expectedType || resp.SchemaVersion != remoteSchemaVersion {
+ return errors.New("remote backup commit response metadata mismatch")
+ }
+ actualFiles := remoteManifestFiles(manifest)
+ if !remoteFilesEqual(actualFiles, expectedFiles) {
+ return errors.New("remote backup commit manifest does not match verified snapshot")
+ }
+ return nil
+}
+
+// canonicalRemoteManifest rebuilds the canonical manifest bytes and their
+// sha256 hex exactly as the server does when computing manifest_hash: empty
+// categories dropped, entries sorted by path, and the format/categories
+// shape marshaled compactly (json.Marshal sorts map keys, so the result is
+// deterministic).
+func canonicalRemoteManifest(
+ categories map[string][]remoteManifestEntry,
+) (manifest []byte, manifestHash string, err error) {
+ sorted := make(map[string][]remoteManifestEntry, len(categories))
+ for category, entries := range categories {
+ if len(entries) == 0 {
+ continue
+ }
+ copied := make([]remoteManifestEntry, len(entries))
+ copy(copied, entries)
+ sort.Slice(copied, func(i, j int) bool { return copied[i].Path < copied[j].Path })
+ sorted[category] = copied
+ }
+ data, err := json.Marshal(remoteManifest{Format: remoteManifestFormat, Categories: sorted})
+ if err != nil {
+ return nil, "", fmt.Errorf("marshaling canonical remote manifest: %w", err)
+ }
+ sum := sha256.Sum256(data)
+ return data, hex.EncodeToString(sum[:]), nil
+}
+
+func validateRemoteManifestResponse(
+ resp *remoteBackupResponse, expectedPlatform string,
+) (*remoteManifest, error) {
+ if resp.Platform == nil || *resp.Platform != expectedPlatform {
+ return nil, fmt.Errorf("remote backup platform does not match %q", expectedPlatform)
+ }
+ manifest, err := remoteManifestFromResponse(resp)
+ if err != nil {
+ return nil, err
+ }
+ // The server stores manifests in a JSONB column, which does not
+ // preserve the committed bytes (key order and whitespace change on the
+ // way back out), so the hash must be recomputed over the canonical
+ // form rather than the wire bytes.
+ _, canonicalHash, err := canonicalRemoteManifest(manifest.Categories)
+ if err != nil {
+ return nil, err
+ }
+ if !strings.EqualFold(canonicalHash, resp.ManifestHash) {
+ return nil, errors.New("remote backup manifest hash mismatch")
+ }
+ files := remoteManifestFiles(manifest)
+ if len(files) > maxArchiveEntries-1 {
+ return nil, fmt.Errorf("remote backup has too many files: %d", len(files))
+ }
+ var total int64
+ seen := make(map[string]struct{}, len(files))
+ for _, file := range files {
+ if !knownCategory(file.Category) {
+ return nil, fmt.Errorf("unknown remote backup category: %s", file.Category)
+ }
+ key := file.Category + "\x00" + file.RestorePath
+ if _, ok := seen[key]; ok {
+ return nil, fmt.Errorf("duplicate remote backup path: %s", file.RestorePath)
+ }
+ seen[key] = struct{}{}
+ if file.Size < 0 || file.Size > math.MaxInt64-total {
+ return nil, errors.New("remote backup manifest size overflow")
+ }
+ if file.Size > 0 && remoteSingleFilePackExceedsMax(&file) {
+ return nil, fmt.Errorf("remote backup object exceeds supported pack size: %s", file.RestorePath)
+ }
+ total += file.Size
+ }
+ if total != resp.SizeBytes || !remoteSummariesEqual(resp.Categories, remoteCategorySummaries(files)) {
+ return nil, errors.New("remote backup manifest summary mismatch")
+ }
+ return manifest, nil
+}
+
+func remoteFilesEqual(actual, expected []FileRef) bool {
+ if len(actual) != len(expected) {
+ return false
+ }
+ type identity struct {
+ hash string
+ size int64
+ }
+ expectedByPath := make(map[string]identity, len(expected))
+ for _, file := range expected {
+ expectedByPath[file.Category+"\x00"+file.RestorePath] = identity{hash: file.SHA256, size: file.Size}
+ }
+ for _, file := range actual {
+ want, ok := expectedByPath[file.Category+"\x00"+file.RestorePath]
+ if !ok || want.hash != file.SHA256 || want.size != file.Size {
+ return false
+ }
+ }
+ return true
+}
+
+func remoteSummariesEqual(
+ actual, expected map[string]remoteCategorySummary,
+) bool {
+ if len(actual) != len(expected) {
+ return false
+ }
+ for category, want := range expected {
+ if got, ok := actual[category]; !ok || got != want {
+ return false
+ }
+ }
+ return true
+}
+
+func remoteManifestFromResponse(resp *remoteBackupResponse) (*remoteManifest, error) {
+ if len(resp.Manifest) == 0 {
+ return nil, errors.New("remote backup response missing manifest")
+ }
+ var manifest remoteManifest
+ if err := json.Unmarshal(resp.Manifest, &manifest); err != nil {
+ return nil, fmt.Errorf("decoding remote backup manifest: %w", err)
+ }
+ if manifest.Format != remoteManifestFormat {
+ return nil, fmt.Errorf("unsupported remote backup manifest format: %s", manifest.Format)
+ }
+ return &manifest, nil
+}
+
+func remoteManifestFiles(manifest *remoteManifest) []FileRef {
+ var files []FileRef
+ for category, entries := range manifest.Categories {
+ for _, entry := range entries {
+ archivePath := platformArchive(entry.Path)
+ if category == CategoryZaparoo {
+ archivePath = zaparooArchive(entry.Path)
+ }
+ files = append(files, FileRef{
+ Category: category,
+ ArchivePath: archivePath,
+ RestorePath: entry.Path,
+ SHA256: entry.SHA256,
+ Size: entry.Size,
+ })
+ }
+ }
+ return files
+}
diff --git a/pkg/service/backup/restore.go b/pkg/service/backup/restore.go
new file mode 100644
index 00000000..d4718515
--- /dev/null
+++ b/pkg/service/backup/restore.go
@@ -0,0 +1,1743 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package backup
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math"
+ "os"
+ "path/filepath"
+ "slices"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ "github.com/rs/zerolog/log"
+)
+
+const (
+ restoreJournalVersion = 5
+ restoreTransactionDir = ".restore-transaction"
+ restoreJournalPlanName = "journal-plan.json"
+ restoreJournalStateName = "journal-state.log"
+ restoreRollbackDir = "rollback"
+ restoreUserDBRollbackName = "user.db"
+
+ restorePhasePrepared = "prepared"
+ restorePhaseApplying = "applying"
+ restorePhaseCommitted = "committed"
+
+ restoreEntryPending = ""
+ restoreEntryStarted = "started"
+ restoreEntryApplied = "applied"
+
+ restoreEventPhase = "phase"
+ restoreEventEntry = "entry"
+ restoreEventUserDBStarted = "userDbStarted"
+
+ maxRestoreJournalEventBytes = 256
+ maxRestoreJournalEvents = 2*(maxArchiveEntries-1) + 3
+ maxRestoreJournalStateBytes = maxRestoreJournalEventBytes * maxRestoreJournalEvents
+)
+
+var (
+ ErrRestoreMediaActive = errors.New("cannot restore backup while media is active")
+ ErrRestoreLaunchInProgress = errors.New("cannot restore backup while media is launching")
+ ErrRestoreRecoveryNeeded = errors.New("backup restore rollback requires recovery")
+ ErrRestoreJournalConflict = errors.New("a pending backup restore transaction exists")
+)
+
+type restoreJournalEvent struct {
+ Kind string `json:"kind"`
+ State string `json:"state,omitempty"`
+ Sequence int `json:"sequence"`
+ Index int `json:"index,omitempty"`
+}
+
+type restoreJournal struct {
+ OperationID string `json:"operationId"`
+ Phase string `json:"phase"`
+ UserDBRollback *restoreRollbackArtifact `json:"userDbRollback,omitempty"`
+ UserDBFile *FileRef `json:"userDbFile,omitempty"`
+ Entries []restoreJournalEntry `json:"entries"`
+ CreatedDirs []restoreJournalDir `json:"createdDirs,omitempty"`
+ Version int `json:"version"`
+ Sequence int `json:"sequence"`
+ MaxLogicalSize int64 `json:"maxLogicalSize"`
+ UserDBRestoreUsed bool `json:"userDbRestoreUsed,omitempty"`
+ UserDBStarted bool `json:"userDbStarted,omitempty"`
+}
+
+type restoreJournalEntry struct {
+ Root string `json:"root"`
+ Rel string `json:"rel"`
+ PolicyPrefix string `json:"policyPrefix,omitempty"`
+ RollbackPath string `json:"rollbackPath,omitempty"`
+ RollbackSHA256 string `json:"rollbackSha256,omitempty"`
+ State string `json:"state,omitempty"`
+ File FileRef `json:"file"`
+ RollbackSize int64 `json:"rollbackSize,omitempty"`
+ Mode os.FileMode `json:"mode,omitempty"`
+ Existed bool `json:"existed"`
+}
+
+type restoreJournalDir struct {
+ Root string `json:"root"`
+ Rel string `json:"rel"`
+}
+
+type restoreRollbackArtifact struct {
+ Path string `json:"path"`
+ SHA256 string `json:"sha256"`
+ Size int64 `json:"size"`
+}
+
+type restoreTarget struct {
+ root string
+ rel string
+ policyPrefix string
+}
+
+type restoreRootPolicy struct {
+ root string
+ prefix string
+}
+
+type restorePolicy struct {
+ definition *platforms.BackupDefinition
+ logical string
+ roots []restoreRootPolicy
+}
+
+type restorePolicyMatch struct {
+ policy restoreRootPolicy
+ targetRel string
+ categoryRel string
+}
+
+func (m *Manager) beginRestoreGate() (func(bool), error) {
+ if m.restoreGate == nil {
+ return func(bool) {}, nil
+ }
+ finish, err := m.restoreGate()
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", ErrRestoreLaunchInProgress, err)
+ }
+ return finish, nil
+}
+
+func (m *Manager) requireRestoreIdle() error {
+ if m.activeMedia != nil && m.activeMedia() != nil {
+ return ErrRestoreMediaActive
+ }
+ return nil
+}
+
+func (m *Manager) preparePlatformRestore() (func(bool) error, error) {
+ preparer, ok := m.pl.(platforms.BackupRestorePreparer)
+ if !ok {
+ return func(bool) error { return nil }, nil
+ }
+ finish, err := preparer.PrepareBackupRestore()
+ if err != nil {
+ return nil, fmt.Errorf("preparing platform profile data for restore: %w", err)
+ }
+ return finish, nil
+}
+
+func (m *Manager) RecoverRestore(ctx context.Context) error {
+ lease, err := m.begin(ctx, OperationRecovery, OperationWrite)
+ if err != nil {
+ return err
+ }
+ defer lease.Release()
+ return m.recoverRestoreLocked(lease.Context())
+}
+
+func (m *Manager) recoverRestoreLocked(ctx context.Context) error {
+ dir := m.restoreTransactionPath()
+ info, err := os.Lstat(dir)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("checking restore transaction: %w", err)
+ }
+ if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
+ return fmt.Errorf("invalid restore transaction path: %s", dir)
+ }
+ journal, err := m.readRestoreJournal()
+ if errors.Is(err, os.ErrNotExist) {
+ // Mutation starts only after a complete prepared journal is durable.
+ return m.removeRestoreTransaction(dir)
+ }
+ if err != nil {
+ return err
+ }
+ if journal.Phase == restorePhaseCommitted || journal.Phase == restorePhasePrepared {
+ return m.removeRestoreTransaction(dir)
+ }
+ if err = ctx.Err(); err != nil {
+ return fmt.Errorf("recovering backup restore: %w", err)
+ }
+ if err = m.rollbackRestore(&journal); err != nil {
+ return fmt.Errorf("%w: %w", ErrRestoreRecoveryNeeded, err)
+ }
+ return m.removeRestoreTransaction(dir)
+}
+
+func (m *Manager) applyRestoreTransaction(
+ ctx context.Context,
+ manifest *Manifest,
+ openPayload func(FileRef) (io.ReadCloser, error),
+) error {
+ if err := m.recoverRestoreLocked(ctx); err != nil {
+ return err
+ }
+ journal, err := m.prepareRestoreJournal(ctx, manifest)
+ if err != nil {
+ return err
+ }
+ if err = m.persistRestorePhase(&journal, restorePhaseApplying); err != nil {
+ _ = m.removeRestoreTransaction(m.restoreTransactionPath())
+ return err
+ }
+
+ applyErr := m.applyRestoreJournal(ctx, &journal, openPayload)
+ if applyErr != nil {
+ rollbackErr := m.rollbackRestore(&journal)
+ if rollbackErr != nil {
+ return errors.Join(
+ applyErr,
+ fmt.Errorf("%w: %w", ErrRestoreRecoveryNeeded, rollbackErr),
+ )
+ }
+ if cleanupErr := m.removeRestoreTransaction(m.restoreTransactionPath()); cleanupErr != nil {
+ return errors.Join(applyErr, cleanupErr)
+ }
+ return applyErr
+ }
+
+ if err = m.persistRestorePhase(&journal, restorePhaseCommitted); err != nil {
+ rollbackErr := m.rollbackRestore(&journal)
+ if rollbackErr != nil {
+ return errors.Join(err, fmt.Errorf("%w: %w", ErrRestoreRecoveryNeeded, rollbackErr))
+ }
+ return errors.Join(err, m.removeRestoreTransaction(m.restoreTransactionPath()))
+ }
+ if err = m.removeRestoreTransaction(m.restoreTransactionPath()); err != nil {
+ log.Warn().Err(err).Msg("committed restore cleanup deferred until restart")
+ }
+ return nil
+}
+
+func (m *Manager) prepareRestoreJournal(
+ ctx context.Context, manifest *Manifest,
+) (journal restoreJournal, err error) {
+ dir := m.restoreTransactionPath()
+ if _, statErr := os.Lstat(dir); statErr == nil {
+ return restoreJournal{}, ErrRestoreJournalConflict
+ } else if !errors.Is(statErr, os.ErrNotExist) {
+ return restoreJournal{}, fmt.Errorf("checking restore transaction directory: %w", statErr)
+ }
+ if err = os.MkdirAll(filepath.Dir(dir), 0o750); err != nil {
+ return restoreJournal{}, fmt.Errorf("creating private restore directory: %w", err)
+ }
+ if err = os.Mkdir(dir, 0o700); err != nil {
+ return restoreJournal{}, fmt.Errorf("creating restore transaction directory: %w", err)
+ }
+ if err = m.syncRestoreDirectory(filepath.Dir(dir)); err != nil {
+ _ = os.RemoveAll(dir)
+ return restoreJournal{}, fmt.Errorf("syncing restore transaction parent: %w", err)
+ }
+ defer func() {
+ if err != nil {
+ err = errors.Join(err, m.removeRestoreTransaction(dir))
+ }
+ }()
+ if err = os.Mkdir(filepath.Join(dir, restoreRollbackDir), 0o700); err != nil {
+ return restoreJournal{}, fmt.Errorf("creating restore rollback directory: %w", err)
+ }
+
+ operationID, err := newRestoreOperationID()
+ if err != nil {
+ return restoreJournal{}, err
+ }
+ journal = restoreJournal{
+ Version: restoreJournalVersion,
+ OperationID: operationID,
+ Phase: restorePhasePrepared,
+ MaxLogicalSize: m.cfg.BackupMaxSizeBytes(),
+ }
+
+ if hasUserDBRestore(manifest.Files) {
+ if m.database == nil || m.database.UserDB == nil {
+ return restoreJournal{}, errors.New("database is not available for restore")
+ }
+ backup, backupErr := m.database.UserDB.Backup("restore-rollback", false)
+ if backupErr != nil {
+ return restoreJournal{}, fmt.Errorf("creating private user database rollback: %w", backupErr)
+ }
+ if spaceErr := preflightRollbackSpace(dir, backup.Size); spaceErr != nil {
+ return restoreJournal{}, spaceErr
+ }
+ artifact, artifactErr := writeRestoreRollbackArtifact(
+ ctx,
+ backup.Path,
+ filepath.Join(dir, restoreRollbackDir, restoreUserDBRollbackName),
+ )
+ if artifactErr != nil {
+ return restoreJournal{}, fmt.Errorf("copying private user database rollback: %w", artifactErr)
+ }
+ journal.UserDBRollback = artifact
+ journal.UserDBRestoreUsed = true
+ }
+
+ seenTargets := make(map[string]struct{}, len(manifest.Files))
+ seenDirs := make(map[string]struct{})
+ targetBytes := make(map[string]int64)
+ spacePaths := map[string]struct{}{dir: {}}
+ var incomingBytes, rollbackBytes, userDBBytes int64
+ for i := range manifest.Files {
+ if err = ctx.Err(); err != nil {
+ return restoreJournal{}, fmt.Errorf("preparing restore rollback: %w", err)
+ }
+ file := &manifest.Files[i]
+ if file.Size < 0 || incomingBytes > math.MaxInt64-file.Size {
+ return restoreJournal{}, errors.New("restore space requirement overflow")
+ }
+ incomingBytes += file.Size
+ if file.Category == CategoryZaparoo && file.RestorePath == "user.db" {
+ fileCopy := *file
+ journal.UserDBFile = &fileCopy
+ userDBBytes = file.Size
+ continue
+ }
+ target, targetErr := m.resolveRestoreTarget(file)
+ if targetErr != nil {
+ return restoreJournal{}, targetErr
+ }
+ targetKey := target.root + "\x00" + target.rel
+ if _, exists := seenTargets[targetKey]; exists {
+ return restoreJournal{}, fmt.Errorf("duplicate resolved restore target: %s", file.RestorePath)
+ }
+ seenTargets[targetKey] = struct{}{}
+ spacePaths[target.root] = struct{}{}
+ entry := restoreJournalEntry{
+ File: *file, Root: target.root, Rel: target.rel, PolicyPrefix: target.policyPrefix,
+ }
+ if entryErr := inspectRollbackEntry(&entry, len(journal.Entries)); entryErr != nil {
+ return restoreJournal{}, entryErr
+ }
+ if entry.RollbackSize > m.cfg.BackupMaxSizeBytes()-rollbackBytes {
+ return restoreJournal{}, errors.New("restore rollback exceeds backup size limit")
+ }
+ rollbackBytes += entry.RollbackSize
+ journal.Entries = append(journal.Entries, entry)
+ if file.Size > m.cfg.BackupMaxSizeBytes()-targetBytes[target.root] {
+ return restoreJournal{}, errors.New("restore target data exceeds backup size limit")
+ }
+ targetBytes[target.root] += file.Size
+
+ for _, created := range missingTargetDirs(target) {
+ key := created.Root + "\x00" + created.Rel
+ if _, exists := seenDirs[key]; exists {
+ continue
+ }
+ seenDirs[key] = struct{}{}
+ journal.CreatedDirs = append(journal.CreatedDirs, created)
+ }
+ }
+ requiredSpace, spaceErr := conservativeRestoreSpaceRequirement(
+ incomingBytes, rollbackBytes, userDBBytes,
+ )
+ if spaceErr != nil {
+ return restoreJournal{}, spaceErr
+ }
+ journalSpace, journalSpaceErr := restoreJournalStorageRequirement(&journal)
+ if journalSpaceErr != nil {
+ return restoreJournal{}, journalSpaceErr
+ }
+ if journalSpace > math.MaxInt64-requiredSpace {
+ return restoreJournal{}, errors.New("restore space requirement overflow")
+ }
+ requiredSpace += journalSpace
+ if preflightErr := preflightConservativeRestoreSpace(
+ spacePaths, requiredSpace, helpers.FreeDiskSpace,
+ ); preflightErr != nil {
+ return restoreJournal{}, preflightErr
+ }
+ for i := range journal.Entries {
+ if writeErr := m.writeRollbackEntry(ctx, &journal.Entries[i]); writeErr != nil {
+ return restoreJournal{}, writeErr
+ }
+ }
+ if syncErr := m.syncRestoreDirectory(filepath.Join(dir, restoreRollbackDir)); syncErr != nil {
+ return restoreJournal{}, fmt.Errorf("syncing restore rollback directory: %w", syncErr)
+ }
+ if writeErr := m.writeRestoreJournal(&journal); writeErr != nil {
+ return restoreJournal{}, writeErr
+ }
+ return journal, nil
+}
+
+func inspectRollbackEntry(entry *restoreJournalEntry, index int) error {
+ root, err := os.OpenRoot(entry.Root)
+ if err != nil {
+ return fmt.Errorf("opening restore target root: %w", err)
+ }
+ defer func() { _ = root.Close() }()
+ info, err := root.Stat(entry.Rel)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("stating restore target %s: %w", entry.File.RestorePath, err)
+ }
+ if !info.Mode().IsRegular() {
+ return fmt.Errorf("restore target is not a regular file: %s", entry.File.RestorePath)
+ }
+ entry.Existed = true
+ entry.Mode = info.Mode().Perm()
+ entry.RollbackPath = filepath.Join(restoreRollbackDir, fmt.Sprintf("%06d", index))
+ entry.RollbackSize = info.Size()
+ return nil
+}
+
+func (m *Manager) writeRollbackEntry(ctx context.Context, entry *restoreJournalEntry) error {
+ if !entry.Existed {
+ return nil
+ }
+ root, err := os.OpenRoot(entry.Root)
+ if err != nil {
+ return fmt.Errorf("opening restore target root: %w", err)
+ }
+ defer func() { _ = root.Close() }()
+ source, err := root.Open(entry.Rel)
+ if err != nil {
+ return fmt.Errorf("opening restore rollback source %s: %w", entry.File.RestorePath, err)
+ }
+ defer func() { _ = source.Close() }()
+ rollbackPath := filepath.Join(m.restoreTransactionPath(), entry.RollbackPath)
+ // #nosec G304 -- path is generated inside the private restore transaction directory.
+ rollback, err := os.OpenFile(rollbackPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("creating restore rollback artifact: %w", err)
+ }
+ hash := sha256.New()
+ written, copyErr := io.Copy(
+ io.MultiWriter(rollback, hash), &contextReader{ctx: ctx, reader: source},
+ )
+ syncErr := rollback.Sync()
+ closeErr := rollback.Close()
+ if copyErr != nil {
+ return fmt.Errorf("copying restore rollback artifact: %w", copyErr)
+ }
+ if syncErr != nil {
+ return fmt.Errorf("syncing restore rollback artifact: %w", syncErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing restore rollback artifact: %w", closeErr)
+ }
+ if written != entry.RollbackSize {
+ return fmt.Errorf("restore target changed while preparing rollback: %s", entry.File.RestorePath)
+ }
+ entry.RollbackSHA256 = hex.EncodeToString(hash.Sum(nil))
+ return nil
+}
+
+func writeRestoreRollbackArtifact(
+ ctx context.Context, sourcePath, destinationPath string,
+) (*restoreRollbackArtifact, error) {
+ // #nosec G304 -- sourcePath is returned by private UserDB backup creation.
+ source, err := os.Open(sourcePath)
+ if err != nil {
+ return nil, fmt.Errorf("opening rollback source: %w", err)
+ }
+ defer func() { _ = source.Close() }()
+ info, err := source.Stat()
+ if err != nil {
+ return nil, fmt.Errorf("stating rollback source: %w", err)
+ }
+ if !info.Mode().IsRegular() {
+ return nil, errors.New("rollback source is not a regular file")
+ }
+ // #nosec G304 -- destinationPath is fixed inside the private transaction directory.
+ destination, err := os.OpenFile(destinationPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return nil, fmt.Errorf("creating rollback artifact: %w", err)
+ }
+ hash := sha256.New()
+ written, copyErr := io.Copy(
+ io.MultiWriter(destination, hash),
+ &contextReader{ctx: ctx, reader: source},
+ )
+ syncErr := destination.Sync()
+ closeErr := destination.Close()
+ if copyErr != nil {
+ return nil, fmt.Errorf("copying rollback artifact: %w", copyErr)
+ }
+ if syncErr != nil {
+ return nil, fmt.Errorf("syncing rollback artifact: %w", syncErr)
+ }
+ if closeErr != nil {
+ return nil, fmt.Errorf("closing rollback artifact: %w", closeErr)
+ }
+ if written != info.Size() {
+ return nil, errors.New("user database rollback changed while copying")
+ }
+ return &restoreRollbackArtifact{
+ Path: filepath.Join(restoreRollbackDir, restoreUserDBRollbackName),
+ SHA256: hex.EncodeToString(hash.Sum(nil)),
+ Size: written,
+ }, nil
+}
+
+func (m *Manager) applyRestoreJournal(
+ ctx context.Context,
+ journal *restoreJournal,
+ openPayload func(FileRef) (io.ReadCloser, error),
+) error {
+ for i := range journal.Entries {
+ entry := &journal.Entries[i]
+ target, err := m.validateJournalTarget(entry)
+ if err != nil {
+ return err
+ }
+ payload, err := openPayload(entry.File)
+ if err != nil {
+ return err
+ }
+ if writeErr := m.persistRestoreEntryState(journal, i, restoreEntryStarted); writeErr != nil {
+ _ = payload.Close()
+ return writeErr
+ }
+ mode := os.FileMode(0o600)
+ if entry.Existed {
+ mode = entry.Mode
+ }
+ if installErr := installPayloadAtTarget(
+ ctx, target, &entry.File, payload, mode, m.syncRestoreDirectory, journal.OperationID, i,
+ ); installErr != nil {
+ return installErr
+ }
+ if writeErr := m.persistRestoreEntryState(journal, i, restoreEntryApplied); writeErr != nil {
+ return writeErr
+ }
+ }
+
+ if !journal.UserDBRestoreUsed {
+ return nil
+ }
+ if err := m.persistRestoreUserDBStarted(journal); err != nil {
+ return err
+ }
+ if journal.UserDBFile == nil {
+ return errors.New("restore journal is missing user database payload metadata")
+ }
+ return m.restoreUserDBPayload(ctx, journal.UserDBFile, openPayload)
+}
+
+func (m *Manager) restoreUserDBPayload(
+ ctx context.Context,
+ file *FileRef,
+ openPayload func(FileRef) (io.ReadCloser, error),
+) error {
+ // Portable snapshots are created without paired-client rows, so a plain
+ // swap would unpair every client. Paired clients are destination device
+ // state, not backup content: carry the live rows across the restore.
+ clients, err := m.database.UserDB.ListClients()
+ if err != nil {
+ return fmt.Errorf("snapshotting paired clients before restore: %w", err)
+ }
+ payload, err := openPayload(*file)
+ if err != nil {
+ return err
+ }
+ name := databaseBackupName(time.Now().UTC())
+ backupDir := filepath.Join(filepath.Dir(m.database.UserDB.GetDBPath()), "backups")
+ if err = os.MkdirAll(backupDir, 0o750); err != nil {
+ _ = payload.Close()
+ return fmt.Errorf("creating user database restore staging directory: %w", err)
+ }
+ staged := filepath.Join(backupDir, name)
+ if err = installVerifiedPayload(ctx, staged, file, payload); err != nil {
+ return fmt.Errorf("staging user database restore: %w", err)
+ }
+ defer func() { _ = os.Remove(staged) }()
+ if _, err = m.database.UserDB.RestoreBackup(name); err != nil {
+ return fmt.Errorf("restoring user database: %w", err)
+ }
+ // Runs after MigrateUp inside RestoreBackup, so the insert always
+ // targets the current schema even when the backup is from an older
+ // Core. Failure fails the restore, which rolls back to the full
+ // pre-restore copy (clients included).
+ if err = m.database.UserDB.ReplaceAllClients(clients); err != nil {
+ return fmt.Errorf("preserving paired clients after restore: %w", err)
+ }
+ return nil
+}
+
+func (m *Manager) restoreUserDBRollback(artifact *restoreRollbackArtifact) (err error) {
+ if m.database == nil || m.database.UserDB == nil {
+ return errors.New("database is not available for user database rollback")
+ }
+ artifactPath := filepath.Join(m.restoreTransactionPath(), artifact.Path)
+ // #nosec G304 -- artifact path is fixed and validated inside the private transaction directory.
+ payload, err := os.Open(artifactPath)
+ if err != nil {
+ return fmt.Errorf("opening user database rollback artifact: %w", err)
+ }
+ file := &FileRef{
+ RestorePath: "user.db",
+ SHA256: artifact.SHA256,
+ Size: artifact.Size,
+ }
+ backupDir := filepath.Join(filepath.Dir(m.database.UserDB.GetDBPath()), "backups")
+ if err = os.MkdirAll(backupDir, 0o750); err != nil {
+ _ = payload.Close()
+ return fmt.Errorf("creating user database rollback staging directory: %w", err)
+ }
+ name := databaseBackupName(time.Now().UTC())
+ staged := filepath.Join(backupDir, name)
+ defer func() {
+ removeErr := os.Remove(staged)
+ if removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
+ err = errors.Join(err, fmt.Errorf("removing staged user database rollback: %w", removeErr))
+ return
+ }
+ if syncErr := m.syncRestoreDirectory(backupDir); syncErr != nil {
+ err = errors.Join(err, fmt.Errorf("syncing user database rollback cleanup: %w", syncErr))
+ }
+ }()
+ if err = installVerifiedPayload(context.Background(), staged, file, payload); err != nil {
+ return fmt.Errorf("staging user database rollback: %w", err)
+ }
+ if err = m.syncRestoreDirectory(backupDir); err != nil {
+ return fmt.Errorf("syncing staged user database rollback: %w", err)
+ }
+ if _, err = m.database.UserDB.RestoreBackup(name); err != nil {
+ return fmt.Errorf("restoring user database rollback: %w", err)
+ }
+ return nil
+}
+
+func (m *Manager) rollbackRestore(journal *restoreJournal) error {
+ var rollbackErr error
+ if journal.UserDBStarted && journal.UserDBRollback != nil {
+ if err := m.restoreUserDBRollback(journal.UserDBRollback); err != nil {
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf("rolling back user database: %w", err))
+ }
+ }
+ for i := len(journal.Entries) - 1; i >= 0; i-- {
+ entry := &journal.Entries[i]
+ if entry.State == restoreEntryPending {
+ continue
+ }
+ target, err := m.validateJournalTarget(entry)
+ if err != nil {
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf("validating rollback target: %w", err))
+ continue
+ }
+ if err = m.removeRestoreStagedTemp(target, journal.OperationID, i); err != nil {
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf("removing staged restore remnant: %w", err))
+ continue
+ }
+ digest, exists, digestErr := readRestoreTargetDigest(target)
+ if digestErr != nil {
+ rollbackErr = errors.Join(rollbackErr, digestErr)
+ continue
+ }
+ if !entry.Existed {
+ switch {
+ case !exists:
+ continue
+ case !restoreDigestMatches(digest, &entry.File):
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf(
+ "restore rollback found unexpected content at %s", entry.File.RestorePath,
+ ))
+ continue
+ }
+ if removeErr := m.removeRestoredTarget(target); removeErr != nil {
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf(
+ "removing restored file %s: %w", entry.File.RestorePath, removeErr,
+ ))
+ }
+ continue
+ }
+
+ rollbackFile := FileRef{
+ RestorePath: entry.File.RestorePath,
+ SHA256: entry.RollbackSHA256,
+ Size: entry.RollbackSize,
+ }
+ switch {
+ case !exists:
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf(
+ "restore rollback target disappeared: %s", entry.File.RestorePath,
+ ))
+ continue
+ case restoreDigestMatches(digest, &rollbackFile):
+ continue
+ case !restoreDigestMatches(digest, &entry.File):
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf(
+ "restore rollback found unexpected content at %s", entry.File.RestorePath,
+ ))
+ continue
+ }
+ rollbackPath := filepath.Join(m.restoreTransactionPath(), entry.RollbackPath)
+ // #nosec G304 -- rollback path comes from a validated private journal.
+ payload, openErr := os.Open(rollbackPath)
+ if openErr != nil {
+ rollbackErr = errors.Join(rollbackErr, fmt.Errorf("opening rollback artifact: %w", openErr))
+ continue
+ }
+ installErr := installPayloadAtTarget(
+ context.Background(), target, &rollbackFile, payload, entry.Mode,
+ m.syncRestoreDirectory, journal.OperationID, i,
+ )
+ if installErr != nil {
+ rollbackErr = errors.Join(rollbackErr, installErr)
+ }
+ }
+ return rollbackErr
+}
+
+type restoreTargetDigest struct {
+ sha256 string
+ size int64
+}
+
+func readRestoreTargetDigest(target restoreTarget) (digest restoreTargetDigest, exists bool, err error) {
+ root, err := os.OpenRoot(target.root)
+ if err != nil {
+ return digest, false, fmt.Errorf("opening rollback target root: %w", err)
+ }
+ defer func() {
+ if closeErr := root.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("closing rollback target root: %w", closeErr))
+ }
+ }()
+ file, err := root.Open(target.rel)
+ if errors.Is(err, os.ErrNotExist) {
+ return digest, false, nil
+ }
+ if err != nil {
+ return digest, false, fmt.Errorf("opening rollback target: %w", err)
+ }
+ defer func() {
+ if closeErr := file.Close(); closeErr != nil {
+ err = errors.Join(err, fmt.Errorf("closing rollback target: %w", closeErr))
+ }
+ }()
+ info, err := file.Stat()
+ if err != nil {
+ return digest, false, fmt.Errorf("stating rollback target: %w", err)
+ }
+ if !info.Mode().IsRegular() {
+ return digest, false, errors.New("restore rollback target is not a regular file")
+ }
+ hash := sha256.New()
+ if _, err = io.Copy(hash, file); err != nil {
+ return digest, false, fmt.Errorf("hashing rollback target: %w", err)
+ }
+ return restoreTargetDigest{sha256: hex.EncodeToString(hash.Sum(nil)), size: info.Size()}, true, nil
+}
+
+func restoreDigestMatches(digest restoreTargetDigest, file *FileRef) bool {
+ return digest.size == file.Size && digest.sha256 == file.SHA256
+}
+
+func (m *Manager) removeRestoredTarget(target restoreTarget) error {
+ root, err := os.OpenRoot(target.root)
+ if err != nil {
+ return fmt.Errorf("opening rollback root: %w", err)
+ }
+ removeErr := root.Remove(target.rel)
+ closeErr := root.Close()
+ if removeErr != nil {
+ return fmt.Errorf("removing restored target: %w", removeErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing rollback root: %w", closeErr)
+ }
+ parentPath := target.root
+ if parent := filepath.Dir(target.rel); parent != "." {
+ parentPath = filepath.Join(target.root, parent)
+ }
+ if err = m.syncRestoreDirectory(parentPath); err != nil {
+ return fmt.Errorf("syncing restored target removal: %w", err)
+ }
+ return nil
+}
+
+func installPayloadAtTarget(
+ ctx context.Context,
+ target restoreTarget,
+ file *FileRef,
+ payload io.ReadCloser,
+ mode os.FileMode,
+ syncDirectory func(string) error,
+ operationID string,
+ index int,
+) (err error) {
+ defer func() {
+ if closeErr := payload.Close(); closeErr != nil {
+ err = errors.Join(err, closeErr)
+ }
+ }()
+ root, err := os.OpenRoot(target.root)
+ if err != nil {
+ return fmt.Errorf("opening restore root: %w", err)
+ }
+ defer func() { _ = root.Close() }()
+ parent := filepath.Dir(target.rel)
+ if parent != "." {
+ if err = root.MkdirAll(parent, 0o750); err != nil {
+ return fmt.Errorf("creating restore directory for %s: %w", file.RestorePath, err)
+ }
+ if err = syncRestoreTargetDirectoryChain(syncDirectory, target.root, parent); err != nil {
+ return fmt.Errorf("syncing restore directory for %s: %w", file.RestorePath, err)
+ }
+ }
+ tmpRel := restoreTempRel(target, operationID, index)
+ tmp, err := root.OpenFile(tmpRel, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("creating staged restore file for %s: %w", file.RestorePath, err)
+ }
+ defer func() { _ = root.Remove(tmpRel) }()
+ hash := sha256.New()
+ limited := &io.LimitedReader{R: &contextReader{ctx: ctx, reader: payload}, N: file.Size + 1}
+ written, copyErr := io.Copy(io.MultiWriter(tmp, hash), limited)
+ syncErr := tmp.Sync()
+ closeErr := tmp.Close()
+ if copyErr != nil {
+ return fmt.Errorf("staging restore payload %s: %w", file.RestorePath, copyErr)
+ }
+ if syncErr != nil {
+ return fmt.Errorf("syncing restore payload %s: %w", file.RestorePath, syncErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing restore payload %s: %w", file.RestorePath, closeErr)
+ }
+ if written != file.Size {
+ return fmt.Errorf("restore payload size mismatch: %s", file.RestorePath)
+ }
+ if hex.EncodeToString(hash.Sum(nil)) != file.SHA256 {
+ return fmt.Errorf("restore payload hash mismatch: %s", file.RestorePath)
+ }
+ if err = root.Chmod(tmpRel, mode.Perm()); err != nil {
+ return fmt.Errorf("setting restore payload mode %s: %w", file.RestorePath, err)
+ }
+ if err = root.Rename(tmpRel, target.rel); err != nil {
+ return fmt.Errorf("installing restore payload %s: %w", file.RestorePath, err)
+ }
+ parentPath := target.root
+ if parent != "." {
+ parentPath = filepath.Join(target.root, parent)
+ }
+ if err = syncDirectory(parentPath); err != nil {
+ return fmt.Errorf("syncing restored payload directory %s: %w", file.RestorePath, err)
+ }
+ return nil
+}
+
+func restoreTempRel(target restoreTarget, operationID string, index int) string {
+ return filepath.Join(
+ filepath.Dir(target.rel),
+ fmt.Sprintf(".restore-%s-%06d", operationID, index),
+ )
+}
+
+func (m *Manager) removeRestoreStagedTemp(target restoreTarget, operationID string, index int) error {
+ root, err := os.OpenRoot(target.root)
+ if err != nil {
+ return fmt.Errorf("opening restore root: %w", err)
+ }
+ tmpRel := restoreTempRel(target, operationID, index)
+ removeErr := root.Remove(tmpRel)
+ removed := removeErr == nil
+ closeErr := root.Close()
+ if errors.Is(removeErr, os.ErrNotExist) {
+ removeErr = nil
+ }
+ if removeErr != nil {
+ return fmt.Errorf("removing restore temp file: %w", removeErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing restore root: %w", closeErr)
+ }
+ if !removed {
+ return nil
+ }
+ parentPath := target.root
+ if parent := filepath.Dir(target.rel); parent != "." {
+ parentPath = filepath.Join(target.root, parent)
+ }
+ if err = m.syncRestoreDirectory(parentPath); err != nil {
+ return fmt.Errorf("syncing restore temp removal: %w", err)
+ }
+ return nil
+}
+
+func syncRestoreTargetDirectoryChain(syncDirectory func(string) error, root, parent string) error {
+ current := root
+ if err := syncDirectory(current); err != nil {
+ return err
+ }
+ for _, part := range strings.Split(filepath.ToSlash(parent), "/") {
+ current = filepath.Join(current, part)
+ if err := syncDirectory(current); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func (m *Manager) resolveRestoreTarget(file *FileRef) (restoreTarget, error) {
+ policy, err := m.restoreTargetPolicy(file)
+ if err != nil {
+ return restoreTarget{}, err
+ }
+ physicalPath, err := resolvePhysicalRestorePath(policy.logical)
+ if err != nil {
+ return restoreTarget{}, fmt.Errorf("resolving restore target %s: %w", file.RestorePath, err)
+ }
+ if m.isSensitiveRestoreTarget(physicalPath) {
+ return restoreTarget{}, fmt.Errorf("restore target is sensitive: %s", file.RestorePath)
+ }
+ match, ok := matchRestorePolicy(physicalPath, policy.roots)
+ if !ok {
+ return restoreTarget{}, fmt.Errorf("restore target escapes trusted roots: %s", file.RestorePath)
+ }
+ if policy.definition == nil {
+ if filepath.Clean(match.categoryRel) != filepath.Clean(filepath.FromSlash(file.RestorePath)) {
+ return restoreTarget{}, fmt.Errorf("zaparoo restore symlink target is not allowed: %s", file.RestorePath)
+ }
+ } else if !definitionIncludes(policy.definition, match.categoryRel) {
+ return restoreTarget{}, fmt.Errorf("restore symlink target violates category policy: %s", file.RestorePath)
+ }
+ return restoreTarget{
+ root: match.policy.root, rel: match.targetRel, policyPrefix: match.policy.prefix,
+ }, nil
+}
+
+func (m *Manager) restoreTargetPolicy(file *FileRef) (restorePolicy, error) {
+ if file.Category == CategoryZaparoo {
+ root := helpers.DataDir(m.pl)
+ if file.RestorePath == config.CfgFile {
+ root = helpers.ConfigDir(m.pl)
+ }
+ policy := restorePolicy{
+ logical: filepath.Join(root, filepath.FromSlash(file.RestorePath)),
+ roots: buildRestoreRootPolicies([]string{root}),
+ }
+ if len(policy.roots) == 0 {
+ return restorePolicy{}, fmt.Errorf("zaparoo restore root is unavailable: %s", root)
+ }
+ return policy, nil
+ }
+ provider, ok := m.pl.(platforms.BackupProvider)
+ if !ok {
+ return restorePolicy{}, errors.New("platform does not support backup restore")
+ }
+ definitions := provider.BackupDefinitions()
+ for i := range definitions {
+ def := &definitions[i]
+ if def.Category != file.Category || !allowedRestorePath(file, []platforms.BackupDefinition{*def}) {
+ continue
+ }
+ policy := restorePolicy{
+ definition: def,
+ logical: filepath.Join(
+ m.platformRestoreRoot(helpers.DataDir(m.pl)), filepath.FromSlash(file.RestorePath),
+ ),
+ roots: buildRestoreRootPolicies(m.definitionRestoreRootCandidates(def)),
+ }
+ if len(policy.roots) == 0 {
+ return restorePolicy{}, fmt.Errorf("restore roots are unavailable for %s", file.RestorePath)
+ }
+ return policy, nil
+ }
+ return restorePolicy{}, fmt.Errorf("restore path is outside collector policy: %s", file.RestorePath)
+}
+
+func (m *Manager) definitionRestoreRootCandidates(def *platforms.BackupDefinition) []string {
+ return definitionCategoryRoots(def, m.pl.RootDirs(m.cfg))
+}
+
+func (m *Manager) isSensitiveRestoreTarget(target string) bool {
+ for _, root := range canonicalCategoryRoots([]string{helpers.ConfigDir(m.pl)}, "") {
+ if filepath.Clean(target) == filepath.Join(root, config.AuthFile) {
+ return true
+ }
+ }
+ return false
+}
+
+func buildRestoreRootPolicies(candidates []string) []restoreRootPolicy {
+ seen := make(map[string]struct{}, len(candidates))
+ var policies []restoreRootPolicy
+ for _, candidate := range candidates {
+ for _, policy := range restoreRootPolicyChain(candidate) {
+ key := policy.root + "\x00" + policy.prefix
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ policies = append(policies, policy)
+ }
+ }
+ sort.Slice(policies, func(i, j int) bool {
+ left := len(filepath.Join(policies[i].root, policies[i].prefix))
+ right := len(filepath.Join(policies[j].root, policies[j].prefix))
+ if left != right {
+ return left > right
+ }
+ return len(policies[i].root) > len(policies[j].root)
+ })
+ return policies
+}
+
+func restoreRootPolicyChain(candidate string) []restoreRootPolicy {
+ candidate, err := filepath.Abs(candidate)
+ if err != nil {
+ return nil
+ }
+ current := filepath.Clean(candidate)
+ categoryRoot := true
+ var prefixParts []string
+ var policies []restoreRootPolicy
+ for {
+ info, statErr := os.Lstat(current)
+ if statErr == nil {
+ resolved := current
+ if info.Mode()&os.ModeSymlink != 0 {
+ if categoryRoot {
+ // The category target must match another independently approved
+ // policy; resolving it here would make any target trusted.
+ statErr = os.ErrInvalid
+ } else {
+ resolved, statErr = filepath.EvalSymlinks(current)
+ if statErr == nil {
+ info, statErr = os.Stat(resolved)
+ }
+ }
+ }
+ if statErr == nil && info.IsDir() {
+ if absolute, absErr := filepath.Abs(resolved); absErr == nil {
+ policies = append(policies, restoreRootPolicy{
+ root: filepath.Clean(absolute), prefix: filepath.Join(prefixParts...),
+ })
+ }
+ }
+ } else if !errors.Is(statErr, os.ErrNotExist) && !errors.Is(statErr, os.ErrInvalid) {
+ return policies
+ }
+ parent := filepath.Dir(current)
+ if parent == current {
+ return policies
+ }
+ prefixParts = append([]string{filepath.Base(current)}, prefixParts...)
+ current = parent
+ categoryRoot = false
+ }
+}
+
+func matchRestorePolicy(
+ target string, policies []restoreRootPolicy,
+) (restorePolicyMatch, bool) {
+ return matchRestorePolicyForRoot(target, policies, "")
+}
+
+func matchRestorePolicyForRoot(
+ target string, policies []restoreRootPolicy, requiredRoot string,
+) (restorePolicyMatch, bool) {
+ for _, policy := range policies {
+ if requiredRoot != "" && policy.root != requiredRoot {
+ continue
+ }
+ categoryRoot := filepath.Join(policy.root, policy.prefix)
+ categoryRel, err := filepath.Rel(categoryRoot, target)
+ if err != nil || categoryRel == ".." || strings.HasPrefix(categoryRel, ".."+string(os.PathSeparator)) {
+ continue
+ }
+ targetRel, err := filepath.Rel(policy.root, target)
+ if err != nil {
+ continue
+ }
+ return restorePolicyMatch{
+ policy: policy, targetRel: targetRel, categoryRel: categoryRel,
+ }, true
+ }
+ return restorePolicyMatch{}, false
+}
+
+func resolvePhysicalRestorePath(logicalPath string) (string, error) {
+ logicalPath, err := filepath.Abs(logicalPath)
+ if err != nil {
+ return "", fmt.Errorf("resolving absolute restore path: %w", err)
+ }
+ logicalPath = filepath.Clean(logicalPath)
+ resolved, err := filepath.EvalSymlinks(logicalPath)
+ if err == nil {
+ absolute, absErr := filepath.Abs(resolved)
+ if absErr != nil {
+ return "", fmt.Errorf("resolving symlink target path: %w", absErr)
+ }
+ return absolute, nil
+ }
+ if info, lstatErr := os.Lstat(logicalPath); lstatErr == nil && info.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("restore target is a broken symlink")
+ } else if lstatErr != nil && !errors.Is(lstatErr, os.ErrNotExist) {
+ return "", fmt.Errorf("checking restore target: %w", lstatErr)
+ }
+ parent, err := resolveRestoreParent(filepath.Dir(logicalPath))
+ if err != nil {
+ return "", fmt.Errorf("resolving restore target parent: %w", err)
+ }
+ return filepath.Join(parent, filepath.Base(logicalPath)), nil
+}
+
+func resolveRestoreParent(parent string) (string, error) {
+ resolved, err := filepath.EvalSymlinks(parent)
+ if err == nil {
+ absolute, absErr := filepath.Abs(resolved)
+ if absErr != nil {
+ return "", fmt.Errorf("resolving restore parent path: %w", absErr)
+ }
+ return absolute, nil
+ }
+ info, lstatErr := os.Lstat(parent)
+ if lstatErr == nil && info.Mode()&os.ModeSymlink != 0 {
+ return "", errors.New("restore parent is a broken symlink")
+ }
+ if lstatErr != nil && !errors.Is(lstatErr, os.ErrNotExist) {
+ return "", fmt.Errorf("checking restore parent: %w", lstatErr)
+ }
+ if filepath.Dir(parent) == parent {
+ return "", errors.New("no existing restore path ancestor")
+ }
+ grandparent, err := resolveRestoreParent(filepath.Dir(parent))
+ if err != nil {
+ return "", fmt.Errorf("resolving ancestor restore parent: %w", err)
+ }
+ return filepath.Join(grandparent, filepath.Base(parent)), nil
+}
+
+func (m *Manager) validateJournalTarget(entry *restoreJournalEntry) (restoreTarget, error) {
+ if err := validateRestorePath(filepath.ToSlash(entry.Rel)); err != nil {
+ return restoreTarget{}, fmt.Errorf("invalid restore journal target: %w", err)
+ }
+ if entry.PolicyPrefix != "" {
+ if err := validateRestorePath(filepath.ToSlash(entry.PolicyPrefix)); err != nil {
+ return restoreTarget{}, fmt.Errorf("invalid restore journal policy prefix: %w", err)
+ }
+ }
+ if !filepath.IsAbs(entry.Root) || filepath.Clean(entry.Root) != entry.Root {
+ return restoreTarget{}, errors.New("restore journal root must be an absolute clean path")
+ }
+ rootInfo, err := os.Lstat(entry.Root)
+ if err != nil {
+ return restoreTarget{}, fmt.Errorf("checking restore journal root: %w", err)
+ }
+ if !rootInfo.IsDir() || rootInfo.Mode()&os.ModeSymlink != 0 {
+ return restoreTarget{}, errors.New("restore journal root is not a physical directory")
+ }
+
+ targetPath := filepath.Join(entry.Root, entry.Rel)
+ physicalPath, err := resolvePhysicalRestorePath(targetPath)
+ if err != nil {
+ return restoreTarget{}, fmt.Errorf("resolving restore journal target: %w", err)
+ }
+ policy := restoreRootPolicy{root: entry.Root, prefix: entry.PolicyPrefix}
+ match, ok := matchRestorePolicyForRoot(physicalPath, []restoreRootPolicy{policy}, entry.Root)
+ if !ok || filepath.Clean(match.targetRel) != filepath.Clean(entry.Rel) {
+ return restoreTarget{}, fmt.Errorf(
+ "restore journal target escapes persisted policy: %s", entry.File.RestorePath,
+ )
+ }
+ if m.isSensitiveRestoreTarget(physicalPath) {
+ return restoreTarget{}, fmt.Errorf("restore journal target is sensitive: %s", entry.File.RestorePath)
+ }
+ if entry.File.Category == CategoryZaparoo {
+ if (entry.File.RestorePath != config.UserDbFile &&
+ !allowedRestorePath(&entry.File, m.zaparooRestoreDefinitions())) ||
+ filepath.Clean(match.categoryRel) != filepath.Clean(filepath.FromSlash(entry.File.RestorePath)) {
+ return restoreTarget{}, fmt.Errorf("invalid zaparoo restore journal target: %s", entry.File.RestorePath)
+ }
+ } else if !m.journalDefinitionIncludes(&entry.File, match.categoryRel) {
+ return restoreTarget{}, fmt.Errorf(
+ "restore journal target violates category policy: %s", entry.File.RestorePath,
+ )
+ }
+ return restoreTarget{
+ root: entry.Root, rel: entry.Rel, policyPrefix: entry.PolicyPrefix,
+ }, nil
+}
+
+func (m *Manager) journalDefinitionIncludes(file *FileRef, categoryRel string) bool {
+ provider, ok := m.pl.(platforms.BackupProvider)
+ if !ok {
+ return false
+ }
+ definitions := provider.BackupDefinitions()
+ for i := range definitions {
+ definition := &definitions[i]
+ if definition.Category == file.Category &&
+ allowedRestorePath(file, []platforms.BackupDefinition{*definition}) &&
+ definitionIncludes(definition, categoryRel) {
+ return true
+ }
+ }
+ return false
+}
+
+func missingTargetDirs(target restoreTarget) []restoreJournalDir {
+ root, err := os.OpenRoot(target.root)
+ if err != nil {
+ return nil
+ }
+ defer func() { _ = root.Close() }()
+ var missing []restoreJournalDir
+ parent := filepath.Dir(target.rel)
+ for parent != "." && parent != "" {
+ info, statErr := root.Stat(parent)
+ if statErr == nil {
+ if !info.IsDir() {
+ return nil
+ }
+ break
+ }
+ if !errors.Is(statErr, os.ErrNotExist) {
+ return nil
+ }
+ missing = append(missing, restoreJournalDir{Root: target.root, Rel: parent})
+ parent = filepath.Dir(parent)
+ }
+ slices.Reverse(missing)
+ return missing
+}
+
+func conservativeRestoreSpaceRequirement(
+ incomingBytes, rollbackBytes, userDBBytes int64,
+) (int64, error) {
+ if incomingBytes < 0 || rollbackBytes < 0 || userDBBytes < 0 {
+ return 0, errors.New("restore space requirement contains a negative size")
+ }
+ if incomingBytes > math.MaxInt64-rollbackBytes {
+ return 0, errors.New("restore space requirement overflow")
+ }
+ required := incomingBytes + rollbackBytes
+ // UserDB restore later needs a manual staging copy and an internal
+ // pre-restore/atomic replacement reserve in addition to its incoming copy.
+ if userDBBytes > (math.MaxInt64-required)/2 {
+ return 0, errors.New("restore space requirement overflow")
+ }
+ return required + (2 * userDBBytes), nil
+}
+
+func preflightConservativeRestoreSpace(
+ paths map[string]struct{},
+ required int64,
+ freeSpace func(string) (uint64, error),
+) error {
+ if required < 0 {
+ return errors.New("restore space requirement contains a negative size")
+ }
+ ordered := make([]string, 0, len(paths))
+ for path := range paths {
+ ordered = append(ordered, path)
+ }
+ sort.Strings(ordered)
+ for _, path := range ordered {
+ free, err := freeSpace(path)
+ if err != nil {
+ return fmt.Errorf("checking conservative restore disk space under %s: %w", path, err)
+ }
+ if uint64(required) > free {
+ return fmt.Errorf(
+ "insufficient disk space for conservative restore preflight under %s: have %d bytes, need %d",
+ path, free, required,
+ )
+ }
+ }
+ return nil
+}
+
+func restoreJournalStorageRequirement(journal *restoreJournal) (int64, error) {
+ plan := *journal
+ plan.Sequence = 1
+ plan.Entries = slices.Clone(journal.Entries)
+ for i := range plan.Entries {
+ if plan.Entries[i].Existed && plan.Entries[i].RollbackSHA256 == "" {
+ plan.Entries[i].RollbackSHA256 = strings.Repeat("0", sha256.Size*2)
+ }
+ }
+ data, err := json.MarshalIndent(&plan, "", " ")
+ if err != nil {
+ return 0, fmt.Errorf("encoding restore journal space estimate: %w", err)
+ }
+ events := 2*len(plan.Entries) + 2
+ if plan.UserDBRestoreUsed {
+ events++
+ }
+ if events > maxRestoreJournalEvents {
+ return 0, errors.New("restore journal has too many events")
+ }
+ stateBytes := int64(events * maxRestoreJournalEventBytes)
+ if int64(len(data)) > math.MaxInt64-stateBytes {
+ return 0, errors.New("restore journal space requirement overflow")
+ }
+ return int64(len(data)) + stateBytes, nil
+}
+
+func preflightRollbackSpace(transactionDir string, size int64) error {
+ free, err := helpers.FreeDiskSpace(transactionDir)
+ if err != nil {
+ return fmt.Errorf("checking restore rollback disk space: %w", err)
+ }
+ if size < 0 || uint64(size) > free {
+ return fmt.Errorf("insufficient disk space for %d bytes of restore rollback", size)
+ }
+ return nil
+}
+
+func (m *Manager) writeRestoreJournal(journal *restoreJournal) error {
+ if journal.Sequence != 0 {
+ return errors.New("restore journal plan is already persisted")
+ }
+ plan := *journal
+ plan.Sequence = 1
+ data, err := json.MarshalIndent(&plan, "", " ")
+ if err != nil {
+ return fmt.Errorf("encoding restore journal plan: %w", err)
+ }
+ dir := m.restoreTransactionPath()
+ planPath := filepath.Join(dir, restoreJournalPlanName)
+ if _, err = os.Lstat(planPath); err == nil {
+ return errors.New("restore journal plan already exists")
+ } else if !errors.Is(err, os.ErrNotExist) {
+ return fmt.Errorf("checking restore journal plan: %w", err)
+ }
+ tmp, err := os.CreateTemp(dir, ".journal-plan-*")
+ if err != nil {
+ return fmt.Errorf("creating restore journal plan: %w", err)
+ }
+ tmpPath := tmp.Name()
+ defer func() { _ = os.Remove(tmpPath) }()
+ if err = tmp.Chmod(0o600); err == nil {
+ _, err = tmp.Write(data)
+ }
+ if err == nil {
+ err = tmp.Sync()
+ }
+ closeErr := tmp.Close()
+ if err != nil {
+ return fmt.Errorf("writing restore journal plan: %w", err)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing restore journal plan: %w", closeErr)
+ }
+ if err = os.Rename(tmpPath, planPath); err != nil {
+ return fmt.Errorf("installing restore journal plan: %w", err)
+ }
+ if err = m.syncRestoreDirectory(dir); err != nil {
+ return fmt.Errorf("syncing restore journal directory: %w", err)
+ }
+ journal.Sequence = plan.Sequence
+ return nil
+}
+
+func (m *Manager) appendRestoreJournalEvent(journal *restoreJournal, event *restoreJournalEvent) error {
+ event.Sequence = journal.Sequence + 1
+ data, err := json.Marshal(event)
+ if err != nil {
+ return fmt.Errorf("encoding restore journal event: %w", err)
+ }
+ data = append(data, '\n')
+ if len(data) > maxRestoreJournalEventBytes {
+ return errors.New("restore journal event exceeds size limit")
+ }
+ dir := m.restoreTransactionPath()
+ statePath := filepath.Join(dir, restoreJournalStateName)
+ _, statErr := os.Lstat(statePath)
+ created := errors.Is(statErr, os.ErrNotExist)
+ if statErr != nil && !created {
+ return fmt.Errorf("checking restore journal state: %w", statErr)
+ }
+ // #nosec G304 -- statePath is fixed inside the private restore transaction directory.
+ state, err := os.OpenFile(statePath, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600)
+ if err != nil {
+ return fmt.Errorf("opening restore journal state: %w", err)
+ }
+ written, writeErr := state.Write(data)
+ if writeErr == nil && written != len(data) {
+ writeErr = io.ErrShortWrite
+ }
+ if writeErr == nil {
+ writeErr = state.Sync()
+ }
+ closeErr := state.Close()
+ if writeErr != nil {
+ return fmt.Errorf("writing restore journal state: %w", writeErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing restore journal state: %w", closeErr)
+ }
+ if created {
+ if err = m.syncRestoreDirectory(dir); err != nil {
+ return fmt.Errorf("syncing restore journal directory: %w", err)
+ }
+ }
+ journal.Sequence = event.Sequence
+ return nil
+}
+
+func restoreJournalReadyToCommit(journal *restoreJournal) bool {
+ for i := range journal.Entries {
+ if journal.Entries[i].State != restoreEntryApplied {
+ return false
+ }
+ }
+ return !journal.UserDBRestoreUsed || journal.UserDBStarted
+}
+
+func (m *Manager) persistRestorePhase(journal *restoreJournal, phase string) error {
+ valid := (journal.Phase == restorePhasePrepared && phase == restorePhaseApplying) ||
+ (journal.Phase == restorePhaseApplying && phase == restorePhaseCommitted &&
+ restoreJournalReadyToCommit(journal))
+ if !valid {
+ return fmt.Errorf("invalid restore phase transition: %s to %s", journal.Phase, phase)
+ }
+ if err := m.appendRestoreJournalEvent(journal, &restoreJournalEvent{
+ Kind: restoreEventPhase, State: phase,
+ }); err != nil {
+ return err
+ }
+ journal.Phase = phase
+ return nil
+}
+
+func (m *Manager) persistRestoreEntryState(journal *restoreJournal, index int, state string) error {
+ if index < 0 || index >= len(journal.Entries) {
+ return errors.New("restore journal entry index is out of range")
+ }
+ entry := &journal.Entries[index]
+ valid := (entry.State == restoreEntryPending && state == restoreEntryStarted) ||
+ (entry.State == restoreEntryStarted && state == restoreEntryApplied)
+ if !valid {
+ return fmt.Errorf("invalid restore entry transition: %s to %s", entry.State, state)
+ }
+ if err := m.appendRestoreJournalEvent(journal, &restoreJournalEvent{
+ Kind: restoreEventEntry, State: state, Index: index,
+ }); err != nil {
+ return err
+ }
+ entry.State = state
+ return nil
+}
+
+func (m *Manager) persistRestoreUserDBStarted(journal *restoreJournal) error {
+ if !journal.UserDBRestoreUsed || journal.UserDBStarted {
+ return errors.New("invalid user database restore transition")
+ }
+ if err := m.appendRestoreJournalEvent(journal, &restoreJournalEvent{
+ Kind: restoreEventUserDBStarted,
+ }); err != nil {
+ return err
+ }
+ journal.UserDBStarted = true
+ return nil
+}
+
+func applyRestoreJournalEvent(journal *restoreJournal, event *restoreJournalEvent) error {
+ if event.Sequence != journal.Sequence+1 {
+ return errors.New("restore journal event sequence is invalid")
+ }
+ switch event.Kind {
+ case restoreEventPhase:
+ valid := (journal.Phase == restorePhasePrepared && event.State == restorePhaseApplying) ||
+ (journal.Phase == restorePhaseApplying && event.State == restorePhaseCommitted &&
+ restoreJournalReadyToCommit(journal))
+ if !valid {
+ return errors.New("restore journal phase event is invalid")
+ }
+ journal.Phase = event.State
+ case restoreEventEntry:
+ if event.Index < 0 || event.Index >= len(journal.Entries) {
+ return errors.New("restore journal entry event index is invalid")
+ }
+ entry := &journal.Entries[event.Index]
+ valid := (entry.State == restoreEntryPending && event.State == restoreEntryStarted) ||
+ (entry.State == restoreEntryStarted && event.State == restoreEntryApplied)
+ if !valid {
+ return errors.New("restore journal entry event is invalid")
+ }
+ entry.State = event.State
+ case restoreEventUserDBStarted:
+ if event.State != "" || !journal.UserDBRestoreUsed || journal.UserDBStarted {
+ return errors.New("restore journal user database event is invalid")
+ }
+ journal.UserDBStarted = true
+ default:
+ return errors.New("restore journal event kind is invalid")
+ }
+ journal.Sequence = event.Sequence
+ return nil
+}
+
+func readRestoreJournalEvents(path string, journal *restoreJournal) error {
+ info, err := os.Stat(path)
+ if errors.Is(err, os.ErrNotExist) {
+ return nil
+ }
+ if err != nil {
+ return fmt.Errorf("stating restore journal state: %w", err)
+ }
+ if !info.Mode().IsRegular() || info.Size() > int64(maxRestoreJournalStateBytes) {
+ return errors.New("restore journal state is invalid")
+ }
+ // #nosec G304 -- path is fixed inside the private restore transaction directory.
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("reading restore journal state: %w", err)
+ }
+ lines := bytes.Split(data, []byte{'\n'})
+ complete := len(lines)
+ if !bytes.HasSuffix(data, []byte{'\n'}) {
+ complete--
+ }
+ for _, line := range lines[:complete] {
+ if len(line) == 0 {
+ continue
+ }
+ if len(line)+1 > maxRestoreJournalEventBytes {
+ return errors.New("restore journal event exceeds size limit")
+ }
+ var event restoreJournalEvent
+ if err = json.Unmarshal(line, &event); err != nil {
+ return fmt.Errorf("decoding restore journal event: %w", err)
+ }
+ if applyErr := applyRestoreJournalEvent(journal, &event); applyErr != nil {
+ return applyErr
+ }
+ }
+ return nil
+}
+
+func (m *Manager) readRestoreJournal() (restoreJournal, error) {
+ var journal restoreJournal
+ planPath := filepath.Join(m.restoreTransactionPath(), restoreJournalPlanName)
+ // #nosec G304 -- planPath is fixed inside the private restore transaction directory.
+ data, err := os.ReadFile(planPath)
+ if errors.Is(err, os.ErrNotExist) {
+ return journal, os.ErrNotExist
+ }
+ if err != nil {
+ return journal, fmt.Errorf("reading restore journal plan: %w", err)
+ }
+ if err = json.Unmarshal(data, &journal); err != nil {
+ return journal, fmt.Errorf("decoding restore journal plan: %w", err)
+ }
+ if journal.Version != restoreJournalVersion || journal.OperationID == "" || journal.Sequence != 1 ||
+ (journal.Phase != restorePhasePrepared && journal.Phase != restorePhaseApplying &&
+ journal.Phase != restorePhaseCommitted) {
+ return journal, errors.New("invalid restore journal")
+ }
+ if stateErr := readRestoreJournalEvents(
+ filepath.Join(m.restoreTransactionPath(), restoreJournalStateName), &journal,
+ ); stateErr != nil {
+ return journal, stateErr
+ }
+ if journal.Phase == restorePhaseCommitted || journal.Phase == restorePhasePrepared {
+ return journal, nil
+ }
+ if validateErr := m.validateRestoreJournal(&journal); validateErr != nil {
+ return journal, validateErr
+ }
+ return journal, nil
+}
+
+func (m *Manager) validateRestoreJournal(journal *restoreJournal) error {
+ operationID, err := hex.DecodeString(journal.OperationID)
+ if err != nil || len(operationID) != 12 {
+ return errors.New("restore journal has invalid operation ID")
+ }
+ if journal.MaxLogicalSize <= 0 || journal.MaxLogicalSize == math.MaxInt64 {
+ return errors.New("restore journal has invalid logical size limit")
+ }
+ if len(journal.Entries) > maxArchiveEntries-1 {
+ return errors.New("restore journal has too many entries")
+ }
+ files := make([]FileRef, 0, len(journal.Entries)+1)
+ entryTargets := make(map[string][]string, len(journal.Entries))
+ var total int64
+ for i := range journal.Entries {
+ entry := &journal.Entries[i]
+ if entry.State != restoreEntryPending && entry.State != restoreEntryStarted &&
+ entry.State != restoreEntryApplied {
+ return errors.New("restore journal has invalid entry state")
+ }
+ if journal.Phase == restorePhasePrepared && entry.State != restoreEntryPending {
+ return errors.New("prepared restore journal contains started entry")
+ }
+ if journal.Phase == restorePhaseCommitted && entry.State != restoreEntryApplied {
+ return errors.New("committed restore journal contains incomplete entry")
+ }
+ files = append(files, entry.File)
+ if journal.MaxLogicalSize <= 0 || entry.File.Size < 0 ||
+ entry.File.Size > journal.MaxLogicalSize-total {
+ return errors.New("restore journal exceeds backup size limit")
+ }
+ total += entry.File.Size
+ hash, err := hex.DecodeString(entry.File.SHA256)
+ if err != nil || len(hash) != sha256.Size {
+ return fmt.Errorf("restore journal has invalid payload hash: %s", entry.File.RestorePath)
+ }
+ if entry.Existed {
+ expectedRollback := filepath.Join(restoreRollbackDir, fmt.Sprintf("%06d", i))
+ rollbackHash, hashErr := hex.DecodeString(entry.RollbackSHA256)
+ if entry.RollbackPath != expectedRollback || entry.RollbackSize < 0 ||
+ hashErr != nil || len(rollbackHash) != sha256.Size {
+ return errors.New("restore journal has invalid rollback metadata")
+ }
+ } else if entry.RollbackPath != "" || entry.RollbackSHA256 != "" || entry.RollbackSize != 0 {
+ return errors.New("restore journal has rollback metadata for a new file")
+ }
+ if _, err = m.validateJournalTarget(entry); err != nil {
+ return err
+ }
+ entryTargets[entry.Root] = append(entryTargets[entry.Root], filepath.Clean(entry.Rel))
+ }
+ if journal.UserDBRestoreUsed {
+ artifact := journal.UserDBRollback
+ if artifact == nil {
+ return errors.New("restore journal is missing user database rollback metadata")
+ }
+ artifactHash, artifactHashErr := hex.DecodeString(artifact.SHA256)
+ if journal.UserDBFile == nil ||
+ artifact.Path != filepath.Join(restoreRollbackDir, restoreUserDBRollbackName) ||
+ artifact.Size < 0 || artifact.Size > journal.MaxLogicalSize ||
+ artifactHashErr != nil || len(artifactHash) != sha256.Size {
+ return errors.New("restore journal has invalid user database rollback metadata")
+ }
+ artifactPath := filepath.Join(m.restoreTransactionPath(), artifact.Path)
+ artifactInfo, artifactErr := os.Lstat(artifactPath)
+ if artifactErr != nil || !artifactInfo.Mode().IsRegular() || artifactInfo.Size() != artifact.Size {
+ return errors.New("restore journal user database rollback artifact is unavailable")
+ }
+ hash, hashErr := hex.DecodeString(journal.UserDBFile.SHA256)
+ if journal.UserDBFile.Category != CategoryZaparoo ||
+ journal.UserDBFile.RestorePath != "user.db" || journal.UserDBFile.Size < 0 ||
+ journal.UserDBFile.Size > journal.MaxLogicalSize-total ||
+ hashErr != nil || len(hash) != sha256.Size {
+ return errors.New("restore journal has invalid user database payload metadata")
+ }
+ files = append(files, *journal.UserDBFile)
+ } else if journal.UserDBFile != nil || journal.UserDBRollback != nil || journal.UserDBStarted {
+ return errors.New("restore journal has unexpected user database metadata")
+ }
+ if err := validateFiles(files); err != nil {
+ return fmt.Errorf("restore journal contains invalid files: %w", err)
+ }
+ for _, dir := range journal.CreatedDirs {
+ if err := validateRestorePath(filepath.ToSlash(dir.Rel)); err != nil {
+ return errors.New("restore journal contains invalid created directory")
+ }
+ valid := false
+ for _, target := range entryTargets[dir.Root] {
+ if strings.HasPrefix(target, filepath.Clean(dir.Rel)+string(os.PathSeparator)) {
+ valid = true
+ break
+ }
+ }
+ if !valid {
+ return errors.New("restore journal created directory is not a target parent")
+ }
+ }
+ return nil
+}
+
+func (m *Manager) restoreTransactionPath() string {
+ return filepath.Join(helpers.DataDir(m.pl), "backups", restoreTransactionDir)
+}
+
+func (m *Manager) syncRestoreDirectory(path string) error {
+ if m.directorySync != nil {
+ return m.directorySync(path)
+ }
+ return syncDirectory(path)
+}
+
+func syncDirectory(path string) error {
+ // #nosec G304 -- callers pass validated restore transaction or target directories.
+ dir, err := os.Open(path)
+ if err != nil {
+ return fmt.Errorf("opening directory for sync: %w", err)
+ }
+ syncErr := dir.Sync()
+ closeErr := dir.Close()
+ if syncErr != nil {
+ return fmt.Errorf("syncing directory: %w", syncErr)
+ }
+ if closeErr != nil {
+ return fmt.Errorf("closing synced directory: %w", closeErr)
+ }
+ return nil
+}
+
+func (m *Manager) removeRestoreTransaction(dir string) error {
+ if err := os.RemoveAll(dir); err != nil {
+ return fmt.Errorf("removing restore transaction: %w", err)
+ }
+ if err := m.syncRestoreDirectory(filepath.Dir(dir)); err != nil {
+ return fmt.Errorf("syncing restore transaction removal: %w", err)
+ }
+ return nil
+}
+
+func newRestoreOperationID() (string, error) {
+ var value [12]byte
+ if _, err := rand.Read(value[:]); err != nil {
+ return "", fmt.Errorf("generating restore operation ID: %w", err)
+ }
+ return hex.EncodeToString(value[:]), nil
+}
+
+func hasUserDBRestore(files []FileRef) bool {
+ for _, file := range files {
+ if file.Category == CategoryZaparoo && file.RestorePath == "user.db" {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/service/backup_scheduler.go b/pkg/service/backup_scheduler.go
new file mode 100644
index 00000000..7b698745
--- /dev/null
+++ b/pkg/service/backup_scheduler.go
@@ -0,0 +1,336 @@
+/*
+Zaparoo Core
+Copyright (c) 2026 The Zaparoo Project Contributors.
+SPDX-License-Identifier: GPL-3.0-or-later
+
+This file is part of Zaparoo Core.
+
+Zaparoo Core is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+Zaparoo Core is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with Zaparoo Core. If not, see .
+*/
+
+package service
+
+import (
+ "context"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/database"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
+ "github.com/rs/zerolog/log"
+)
+
+type remoteHeartbeatState struct {
+ lastSuccess time.Time
+ nextAttempt time.Time
+ backoff time.Duration
+}
+
+const (
+ remoteBackupSchedulerCheckInterval = 1 * time.Minute
+ remoteBackupIdleQuietWindow = 5 * time.Second
+ remoteBackupIdleMaxWait = 300 * time.Second
+ // remoteBackupFailureRetryInterval is how soon a failed scheduled run is
+ // retried, instead of waiting out the full daily/weekly interval.
+ remoteBackupFailureRetryInterval = 1 * time.Hour
+ // remoteHeartbeatInterval paces liveness reports to the remote API. A
+ // heartbeat is also sent when the service starts. Heartbeats run for any
+ // linked device, independent of whether remote backup is enabled.
+ remoteHeartbeatInterval = 24 * time.Hour
+ remoteHeartbeatInitialBackoff = 1 * time.Minute
+ remoteHeartbeatMaxBackoff = 1 * time.Hour
+ // remoteBackupStaleAfter is how long scheduling may go without a
+ // successful run before the user is told via the inbox. The inbox
+ // message wording assumes roughly a week.
+ remoteBackupStaleAfter = 7 * 24 * time.Hour
+ // remoteBackupStaleNoticeInterval paces re-posting the (category-
+ // deduplicated) stale notice while the condition persists.
+ remoteBackupStaleNoticeInterval = 24 * time.Hour
+)
+
+type staleNoticeState struct {
+ notifiedAt time.Time
+}
+
+// shouldNotify reports whether the stale notice should be posted now. It
+// re-arms as soon as staleness clears, so a later stale episode notifies
+// again.
+func (s *staleNoticeState) shouldNotify(now time.Time, stale bool) bool {
+ if !stale {
+ s.notifiedAt = time.Time{}
+ return false
+ }
+ if !s.notifiedAt.IsZero() && now.Sub(s.notifiedAt) < remoteBackupStaleNoticeInterval {
+ return false
+ }
+ s.notifiedAt = now
+ return true
+}
+
+func (s *remoteHeartbeatState) due(now time.Time) bool {
+ if !s.lastSuccess.IsZero() && now.Sub(s.lastSuccess) < remoteHeartbeatInterval {
+ return false
+ }
+ return s.nextAttempt.IsZero() || !now.Before(s.nextAttempt)
+}
+
+func (s *remoteHeartbeatState) recordFailure(now time.Time) {
+ if s.backoff <= 0 {
+ s.backoff = remoteHeartbeatInitialBackoff
+ }
+ s.nextAttempt = now.Add(s.backoff)
+ s.backoff = min(s.backoff*2, remoteHeartbeatMaxBackoff)
+}
+
+func (s *remoteHeartbeatState) recordSuccess(now time.Time) {
+ s.lastSuccess = now
+ s.nextAttempt = time.Time{}
+ s.backoff = remoteHeartbeatInitialBackoff
+}
+
+func startRemoteBackupScheduler(
+ ctx context.Context,
+ cfg *config.Instance,
+ pl platforms.Platform,
+ db *database.Database,
+ st *state.State,
+ idleSched *idle.Scheduler,
+ pauser *syncutil.Pauser,
+ wg *sync.WaitGroup,
+) {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ remoteBackupSchedulerLoop(ctx, cfg, pl, db, st, idleSched, pauser, time.NewTicker)
+ }()
+}
+
+func remoteBackupSchedulerLoop(
+ ctx context.Context,
+ cfg *config.Instance,
+ pl platforms.Platform,
+ db *database.Database,
+ st *state.State,
+ idleSched *idle.Scheduler,
+ pauser *syncutil.Pauser,
+ newTicker func(time.Duration) *time.Ticker,
+) {
+ // A run interrupted by power loss or a hard shutdown left "running" in
+ // the status file; record it as failed so it retries on the short
+ // failure interval instead of waiting out the full schedule cadence.
+ // The shared coordinator lets recovery skip a run that started between
+ // the API coming up and this pass.
+ recoveryMgr := backupsvc.NewManager(cfg, pl, db)
+ if st != nil {
+ recoveryMgr.WithCoordinator(st.BackupCoordinator())
+ }
+ recoveryMgr.RecoverInterruptedRuns()
+
+ var scheduled atomic.Bool
+ trySchedule := func() {
+ // While a pause-tier core (CD-based) is running, don't start at all:
+ // a run would immediately block on the pauser while holding the
+ // backup coordinator lease. The next tick retries; throttled states
+ // still run and make slow progress.
+ if scheduled.Load() || pauser.IsPaused() || !scheduledRemoteBackupDue(time.Now(), cfg, pl, db) {
+ return
+ }
+ scheduled.Store(true)
+ idleSched.Schedule(
+ ctx,
+ "remote-backup",
+ remoteBackupIdleQuietWindow,
+ remoteBackupIdleMaxWait,
+ func(taskCtx context.Context) {
+ defer scheduled.Store(false)
+ runScheduledRemoteBackup(taskCtx, cfg, pl, db, st, pauser)
+ },
+ )
+ }
+
+ heartbeatState := remoteHeartbeatState{backoff: remoteHeartbeatInitialBackoff}
+ tryHeartbeat := func() {
+ now := time.Now()
+ if !heartbeatState.due(now) {
+ return
+ }
+ mgr := backupsvc.NewManager(cfg, pl, db).WithCoordinator(st.BackupCoordinator())
+ if err := mgr.SendHeartbeat(ctx); err != nil {
+ // Not linked or unreachable: fine, heartbeats are best-effort.
+ heartbeatState.recordFailure(now)
+ log.Debug().Err(err).Msg("remote heartbeat not sent")
+ return
+ }
+ heartbeatState.recordSuccess(now)
+ }
+
+ staleState := staleNoticeState{}
+ tryStaleNotice := func() {
+ now := time.Now()
+ active := cfg.BackupRemoteEnabled() && cfg.BackupRemoteSchedule() != "manual"
+ mgr := backupsvc.NewManager(cfg, pl, db).
+ WithCoordinator(st.BackupCoordinator()).WithInbox(st.Inbox())
+ if staleState.shouldNotify(now, mgr.TrackScheduleStale(now, active, remoteBackupStaleAfter)) {
+ mgr.NotifyScheduleStale()
+ }
+ }
+
+ tryHeartbeat()
+ trySchedule()
+ tryStaleNotice()
+ ticker := newTicker(remoteBackupSchedulerCheckInterval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ tryHeartbeat()
+ trySchedule()
+ tryStaleNotice()
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+func scheduledRemoteBackupDue(
+ now time.Time,
+ cfg *config.Instance,
+ pl platforms.Platform,
+ db *database.Database,
+) bool {
+ if !cfg.BackupRemoteEnabled() {
+ return false
+ }
+ schedule := cfg.BackupRemoteSchedule()
+ if schedule == "manual" {
+ return false
+ }
+ status := backupsvc.NewManager(cfg, pl, db).Status()
+ if status.Remote.Availability != backupsvc.RemoteAvailabilityAvailable &&
+ !backupsvc.RemoteAvailabilityNeedsRefresh(now, &status.Remote) {
+ return false
+ }
+ return remoteBackupDue(now, &status.Remote, schedule)
+}
+
+func runScheduledRemoteBackup(
+ ctx context.Context,
+ cfg *config.Instance,
+ pl platforms.Platform,
+ db *database.Database,
+ st *state.State,
+ pauser *syncutil.Pauser,
+) {
+ if pauser.IsPaused() || !scheduledRemoteBackupDue(time.Now(), cfg, pl, db) {
+ return
+ }
+ log.Info().Str("schedule", cfg.BackupRemoteSchedule()).Msg("running scheduled remote backup")
+ mgr := backupsvc.NewManager(cfg, pl, db).WithPauser(pauser)
+ if st != nil {
+ if _, _, active := st.BackupCoordinator().Active(); active {
+ log.Debug().Msg("skipping scheduled remote backup while backup service is busy")
+ return
+ }
+ mgr.WithCoordinator(st.BackupCoordinator()).WithInbox(st.Inbox())
+ }
+ availability, refreshErr := mgr.RefreshRemoteAvailability(ctx)
+ if refreshErr != nil {
+ log.Debug().Err(refreshErr).Msg("scheduled remote backup eligibility refresh failed")
+ return
+ }
+ if availability != backupsvc.RemoteAvailabilityAvailable {
+ log.Info().Str("availability", availability).Msg("skipping scheduled remote backup")
+ return
+ }
+ if _, err := mgr.RunRemote(ctx, backupsvc.RemoteBackupTypeScheduled); err != nil {
+ log.Warn().Err(err).Msg("scheduled remote backup failed")
+ return
+ }
+ log.Info().Msg("scheduled remote backup completed")
+}
+
+func remoteBackupDue(now time.Time, status *models.BackupStatusEntry, schedule string) bool {
+ if !helpers.IsClockReliable(now) {
+ return false
+ }
+ interval, ok := remoteBackupScheduleInterval(schedule)
+ if !ok {
+ return false
+ }
+ // A failed run retries sooner than the normal cadence; success resets
+ // to the full interval.
+ if status != nil && status.LastStatus == backupsvc.StatusFailed &&
+ remoteBackupFailureRetryInterval < interval {
+ interval = remoteBackupFailureRetryInterval
+ }
+ lastRun, lastRunOK := reliableStatusTime(statusLastRunAt(status))
+ lastSuccess, lastSuccessOK := reliableStatusTime(statusLastSuccessAt(status))
+ switch {
+ case lastRunOK && lastSuccessOK:
+ if lastSuccess.After(lastRun) {
+ return !now.Before(lastSuccess.Add(interval))
+ }
+ return !now.Before(lastRun.Add(interval))
+ case lastRunOK:
+ return !now.Before(lastRun.Add(interval))
+ case lastSuccessOK:
+ return !now.Before(lastSuccess.Add(interval))
+ default:
+ return true
+ }
+}
+
+func statusLastRunAt(status *models.BackupStatusEntry) *string {
+ if status == nil {
+ return nil
+ }
+ return status.LastRunAt
+}
+
+func statusLastSuccessAt(status *models.BackupStatusEntry) *string {
+ if status == nil {
+ return nil
+ }
+ return status.LastSuccessAt
+}
+
+func reliableStatusTime(raw *string) (time.Time, bool) {
+ if raw == nil || *raw == "" {
+ return time.Time{}, false
+ }
+ parsed, err := time.Parse(time.RFC3339Nano, *raw)
+ if err != nil || !helpers.IsClockReliable(parsed) {
+ return time.Time{}, false
+ }
+ return parsed, true
+}
+
+func remoteBackupScheduleInterval(schedule string) (time.Duration, bool) {
+ switch schedule {
+ case "daily":
+ return 24 * time.Hour, true
+ case "weekly":
+ return 7 * 24 * time.Hour, true
+ default:
+ return 0, false
+ }
+}
diff --git a/pkg/service/backup_scheduler_test.go b/pkg/service/backup_scheduler_test.go
new file mode 100644
index 00000000..6becb1cf
--- /dev/null
+++ b/pkg/service/backup_scheduler_test.go
@@ -0,0 +1,222 @@
+/*
+Zaparoo Core
+Copyright (c) 2026 The Zaparoo Project Contributors.
+SPDX-License-Identifier: GPL-3.0-or-later
+
+This file is part of Zaparoo Core.
+
+Zaparoo Core is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+Zaparoo Core is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with Zaparoo Core. If not, see .
+*/
+
+package service
+
+import (
+ "context"
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestRemoteBackupDue(t *testing.T) {
+ t.Parallel()
+ now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
+ unreliableNow := time.Date(1970, 1, 1, 12, 0, 0, 0, time.UTC)
+ unreliableStored := time.Date(1970, 1, 1, 1, 0, 0, 0, time.UTC)
+
+ tests := []struct {
+ lastRun *string
+ lastSuccess *string
+ now time.Time
+ schedule string
+ name string
+ want bool
+ }{
+ {name: "unreliable now never due", now: unreliableNow, schedule: "daily", want: false},
+ {name: "no run daily due", now: now, schedule: "daily", want: true},
+ {name: "manual never due", now: now, schedule: "manual", want: false},
+ {name: "invalid never due", now: now, schedule: "hourly", want: false},
+ {
+ name: "daily too recent", now: now, schedule: "daily",
+ lastRun: backupTime(now.Add(-23 * time.Hour)), want: false,
+ },
+ {name: "daily due", now: now, schedule: "daily", lastRun: backupTime(now.Add(-24 * time.Hour)), want: true},
+ {
+ name: "weekly too recent", now: now, schedule: "weekly",
+ lastRun: backupTime(now.AddDate(0, 0, -6)), want: false,
+ },
+ {name: "weekly due", now: now, schedule: "weekly", lastRun: backupTime(now.AddDate(0, 0, -7)), want: true},
+ {name: "invalid timestamp due", now: now, schedule: "daily", lastRun: stringPtr("bad-time"), want: true},
+ {
+ name: "unreliable stored timestamp ignored", now: now, schedule: "daily",
+ lastRun: backupTime(unreliableStored), want: true,
+ },
+ {
+ name: "newer last success controls due check",
+ now: now, schedule: "daily",
+ lastRun: backupTime(now.Add(-48 * time.Hour)), lastSuccess: backupTime(now.Add(-23 * time.Hour)),
+ want: false,
+ },
+ {
+ name: "newer last run controls due check",
+ now: now, schedule: "daily",
+ lastRun: backupTime(now.Add(-23 * time.Hour)), lastSuccess: backupTime(now.Add(-48 * time.Hour)),
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ status := models.BackupStatusEntry{LastRunAt: tt.lastRun, LastSuccessAt: tt.lastSuccess}
+ assert.Equal(t, tt.want, remoteBackupDue(tt.now, &status, tt.schedule))
+ })
+ }
+}
+
+func TestRemoteBackupDueRetriesSoonerAfterFailure(t *testing.T) {
+ t.Parallel()
+ now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
+
+ failedRecently := models.BackupStatusEntry{
+ LastStatus: backupsvc.StatusFailed,
+ LastRunAt: backupTime(now.Add(-30 * time.Minute)),
+ }
+ assert.False(t, remoteBackupDue(now, &failedRecently, "daily"),
+ "a failed run still backs off for the retry interval")
+
+ failedAWhileAgo := models.BackupStatusEntry{
+ LastStatus: backupsvc.StatusFailed,
+ LastRunAt: backupTime(now.Add(-2 * time.Hour)),
+ }
+ assert.True(t, remoteBackupDue(now, &failedAWhileAgo, "daily"),
+ "a failed run retries after the failure interval, not the full schedule")
+
+ succeededRecently := models.BackupStatusEntry{
+ LastStatus: backupsvc.StatusSuccess,
+ LastRunAt: backupTime(now.Add(-2 * time.Hour)),
+ }
+ assert.False(t, remoteBackupDue(now, &succeededRecently, "daily"),
+ "success resets to the normal schedule interval")
+}
+
+func TestRemoteHeartbeatStateBacksOffFailuresAndRecordsOnlySuccess(t *testing.T) {
+ t.Parallel()
+ now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
+ state := remoteHeartbeatState{backoff: remoteHeartbeatInitialBackoff}
+ assert.True(t, state.due(now))
+
+ state.recordFailure(now)
+ assert.True(t, state.lastSuccess.IsZero(), "failure must not advance heartbeat success time")
+ assert.False(t, state.due(now.Add(30*time.Second)))
+ assert.True(t, state.due(now.Add(remoteHeartbeatInitialBackoff)))
+ assert.Equal(t, 2*remoteHeartbeatInitialBackoff, state.backoff)
+
+ succeededAt := now.Add(remoteHeartbeatInitialBackoff)
+ state.recordSuccess(succeededAt)
+ assert.Equal(t, succeededAt, state.lastSuccess)
+ assert.False(t, state.due(succeededAt.Add(time.Hour)))
+ assert.True(t, state.due(succeededAt.Add(remoteHeartbeatInterval)))
+ assert.Equal(t, remoteHeartbeatInitialBackoff, state.backoff)
+}
+
+func TestRemoteBackupScheduleInterval(t *testing.T) {
+ t.Parallel()
+
+ daily, ok := remoteBackupScheduleInterval("daily")
+ require.True(t, ok)
+ assert.Equal(t, 24*time.Hour, daily)
+
+ weekly, ok := remoteBackupScheduleInterval("weekly")
+ require.True(t, ok)
+ assert.Equal(t, 7*24*time.Hour, weekly)
+
+ _, ok = remoteBackupScheduleInterval("manual")
+ assert.False(t, ok)
+}
+
+func TestStaleNoticeStateShouldNotify(t *testing.T) {
+ t.Parallel()
+ now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
+ var state staleNoticeState
+
+ assert.False(t, state.shouldNotify(now, false))
+ assert.True(t, state.shouldNotify(now, true))
+ // Paced: not re-posted within the notice interval.
+ assert.False(t, state.shouldNotify(now.Add(time.Hour), true))
+ assert.True(t, state.shouldNotify(now.Add(remoteBackupStaleNoticeInterval), true))
+ // Clearing staleness re-arms for the next episode.
+ assert.False(t, state.shouldNotify(now.Add(25*time.Hour), false))
+ assert.True(t, state.shouldNotify(now.Add(26*time.Hour), true))
+}
+
+func backupTime(t time.Time) *string {
+ return stringPtr(t.Format(time.RFC3339Nano))
+}
+
+func stringPtr(s string) *string { return &s }
+
+func TestScheduledRemoteBackupDueUsesAvailabilityFreshness(t *testing.T) {
+ now := time.Date(2026, 7, 9, 12, 0, 0, 0, time.UTC)
+ rootDir := t.TempDir()
+ cfg, err := config.NewConfig(rootDir, config.BaseDefaults)
+ require.NoError(t, err)
+ cfg.SetBackupRemoteEnabled(true)
+ cfg.SetBackupRemoteSchedule("daily")
+ pl := mocks.NewMockPlatform()
+ pl.On("Settings").Return(platforms.Settings{DataDir: rootDir})
+
+ statusPath := filepath.Join(rootDir, "backups", "status.json")
+ require.NoError(t, os.MkdirAll(filepath.Dir(statusPath), 0o750))
+ writeUnavailableStatus := func(checkedAt time.Time) {
+ t.Helper()
+ data, marshalErr := json.Marshal(map[string]any{
+ "remote": map[string]any{
+ "lastStatus": backupsvc.StatusNever,
+ "availability": backupsvc.RemoteAvailabilityUnavailable,
+ "availabilityCheckedAt": checkedAt.Format(time.RFC3339Nano),
+ },
+ })
+ require.NoError(t, marshalErr)
+ require.NoError(t, os.WriteFile(statusPath, data, 0o600))
+ }
+
+ writeUnavailableStatus(now.Add(-time.Minute))
+ assert.False(t, scheduledRemoteBackupDue(now, cfg, pl, nil),
+ "fresh unavailable state must suppress scheduled work")
+
+ writeUnavailableStatus(now.Add(-10 * time.Minute))
+ assert.True(t, scheduledRemoteBackupDue(now, cfg, pl, nil),
+ "stale unavailable state must schedule work so eligibility can refresh")
+ pl.AssertExpectations(t)
+}
+
+func TestRunScheduledRemoteBackupSkipsWhilePaused(t *testing.T) {
+ t.Parallel()
+ pauser := syncutil.NewPauser()
+ pauser.Pause()
+ // The pause gate must be checked before anything else: the nil platform
+ // and database would panic if the run proceeded past it.
+ runScheduledRemoteBackup(context.Background(), nil, nil, nil, nil, pauser)
+}
diff --git a/pkg/service/inbox/inbox.go b/pkg/service/inbox/inbox.go
index 18806fa8..3da2d750 100644
--- a/pkg/service/inbox/inbox.go
+++ b/pkg/service/inbox/inbox.go
@@ -50,6 +50,12 @@ const (
CategoryMediaDBCorruptionRecoveryLimit = "mediadb_corruption_recovery_limit"
CategoryMediaIndexResumeLimit = "media_index_resume_limit"
CategoryUpdateAvailable = "update_available"
+ CategoryBackupRemoteNotAvailable = "backup_remote_not_available"
+ CategoryBackupRemoteQuotaExceeded = "backup_remote_quota_exceeded"
+ CategoryBackupRemoteUnlinked = "backup_remote_unlinked"
+ CategoryBackupRemoteFailed = "backup_remote_failed"
+ CategoryBackupRemoteFilesSkipped = "backup_remote_files_skipped"
+ CategoryBackupRemoteStale = "backup_remote_stale"
)
// MessageOptions configures optional fields for inbox messages.
diff --git a/pkg/service/indexpause.go b/pkg/service/indexpause.go
index 41e00267..777fe84e 100644
--- a/pkg/service/indexpause.go
+++ b/pkg/service/indexpause.go
@@ -59,6 +59,39 @@ func watchGameForIndexPause(
)
}
+// watchGameForBackupPause mirrors media indexing pause behavior for backup
+// work (collection, hashing, packing, uploads) so a running remote backup
+// does not compete with gameplay for storage or CPU.
+func watchGameForBackupPause(
+ ctx context.Context,
+ b *broker.Broker,
+ st *state.State,
+ cfg *config.Instance,
+ ns chan<- models.Notification,
+ pauser *syncutil.Pauser,
+) {
+ notifChan, subID := b.Subscribe(32, models.NotificationStarted, models.NotificationStopped)
+ defer b.Unsubscribe(subID)
+
+ primaryActive := func() bool { return activeMediaPausesMediaWork(st.ActiveMedia()) }
+ resolvePolicy := func() config.MediaPausePolicy { return cfg.ResolveMediaPausePolicy(activeSystemID(st)) }
+ isActive := func() bool {
+ _, _, active := st.BackupCoordinator().Active()
+ return active
+ }
+ handleMediaPauseNotifications(
+ ctx, notifChan, pauser, primaryActive(), isActive, "remote backup",
+ func(paused, throttled bool) {
+ operation, _, _ := st.BackupCoordinator().Active()
+ notifications.BackupState(ns, models.BackupStateNotification{
+ Operation: string(operation),
+ Paused: paused,
+ Throttled: throttled,
+ })
+ }, resolvePolicy, primaryActive,
+ )
+}
+
// watchGameForScrapePause mirrors media indexing pause behavior for metadata
// scraping so SQLite and filesystem-heavy scrape work does not compete with gameplay.
func watchGameForScrapePause(
diff --git a/pkg/service/indexpause_test.go b/pkg/service/indexpause_test.go
index f8419337..dcd04b4a 100644
--- a/pkg/service/indexpause_test.go
+++ b/pkg/service/indexpause_test.go
@@ -31,6 +31,8 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/database/systemdefs"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers/syncutil"
+ backupcoordinator "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup/coordinator"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/broker"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/stretchr/testify/assert"
@@ -636,3 +638,90 @@ func TestHandleScrapePauseNotifications_ThrottlesOnStartedInThrottleMode(t *test
assert.False(t, resp.Paused)
assert.True(t, resp.Throttled)
}
+
+func TestWatchGameForBackupPause_AppliesPolicyPerSystem(t *testing.T) {
+ t.Parallel()
+
+ st, ns := state.NewState(mocks.NewMockPlatform(), "test-boot-uuid")
+ drainState(t, st, ns)
+ cfg := &config.Instance{}
+ pauser := syncutil.NewPauser()
+ backupNS := make(chan models.Notification, 32)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ source := make(chan models.Notification, 10)
+ b := broker.NewBroker(ctx, source)
+ b.Start()
+ t.Cleanup(b.Stop)
+
+ done := make(chan struct{})
+ go func() {
+ watchGameForBackupPause(ctx, b, st, cfg, backupNS, pauser)
+ close(done)
+ }()
+
+ // The watcher subscribes asynchronously; re-publish until it reacts.
+ // A non-streaming system gets the throttle policy.
+ st.SetActiveMedia(models.NewActiveMedia(
+ systemdefs.SystemNES, systemdefs.SystemNES, "game.nes", "Game", "NES",
+ ))
+ require.Eventually(t, func() bool {
+ b.Publish(models.Notification{Method: models.NotificationStarted})
+ return pauser.IsThrottled() && !pauser.IsPaused()
+ }, 2*time.Second, 20*time.Millisecond, "non-streaming system must throttle backup work")
+
+ // With no backup operation running, no backup.state notification is sent.
+ select {
+ case notif := <-backupNS:
+ t.Fatalf("unexpected backup.state notification with no active operation: %+v", notif)
+ default:
+ }
+
+ // A pause-tier CD system escalates to a full pause, and with a backup
+ // operation in flight the state change is reported as backup.state.
+ lease, err := st.BackupCoordinator().Begin(
+ context.Background(), backupcoordinator.OperationRemoteUpload, backupcoordinator.OperationWrite,
+ )
+ require.NoError(t, err)
+ defer lease.Release()
+ st.SetActiveMedia(models.NewActiveMedia(
+ systemdefs.SystemSaturn, systemdefs.SystemSaturn, "game.chd", "Game", "Saturn",
+ ))
+ require.Eventually(t, func() bool {
+ b.Publish(models.Notification{Method: models.NotificationStarted})
+ return pauser.IsPaused()
+ }, 2*time.Second, 20*time.Millisecond, "pause-tier system must pause backup work")
+
+ // Queued media events from the throttle phase may emit a throttled
+ // backup.state first; scan until the paused notification arrives.
+ require.Eventually(t, func() bool {
+ select {
+ case notif := <-backupNS:
+ if notif.Method != models.NotificationBackupState {
+ return false
+ }
+ var payload models.BackupStateNotification
+ if json.Unmarshal(notif.Params, &payload) != nil {
+ return false
+ }
+ return payload.Paused && !payload.Throttled &&
+ payload.Operation == string(backupcoordinator.OperationRemoteUpload)
+ default:
+ return false
+ }
+ }, 2*time.Second, 20*time.Millisecond, "paused backup.state notification must be sent")
+
+ // Stopping the game resumes backup work.
+ st.SetActiveMedia(nil)
+ require.Eventually(t, func() bool {
+ b.Publish(models.Notification{Method: models.NotificationStopped})
+ return !pauser.IsPaused() && !pauser.IsThrottled()
+ }, 2*time.Second, 20*time.Millisecond, "stopping the game must resume backup work")
+
+ cancel()
+ select {
+ case <-done:
+ case <-time.After(2 * time.Second):
+ t.Fatal("backup pause watcher did not exit on context cancellation")
+ }
+}
diff --git a/pkg/service/profiles/dataswap.go b/pkg/service/profiles/dataswap.go
index 69e16f41..ec9ef269 100644
--- a/pkg/service/profiles/dataswap.go
+++ b/pkg/service/profiles/dataswap.go
@@ -304,7 +304,11 @@ func (c *DataSwapCoordinator) apply(req *swapRequest) {
}
}
- err := c.swapper.ApplyProfile(req.ref, items)
+ release, err := c.st.AcquireRestoreAccess()
+ if err == nil {
+ err = c.swapper.ApplyProfile(req.ref, items)
+ release()
+ }
c.mu.Lock()
notify := c.notify
@@ -319,6 +323,9 @@ func (c *DataSwapCoordinator) apply(req *swapRequest) {
Status: models.ProfilesDataApplied,
})
}
+ case errors.Is(err, state.ErrRestoreRestartRequired):
+ log.Debug().Err(err).Str("profileId", req.ref.ID).
+ Msg("profiles: data swap skipped while restore restart is pending")
case errors.Is(err, platforms.ErrProfileDataUnavailable):
log.Warn().Err(err).Str("profileId", req.ref.ID).
Msg("profiles: data swap unavailable")
diff --git a/pkg/service/profiles/dataswap_test.go b/pkg/service/profiles/dataswap_test.go
index ac521309..c3c14f37 100644
--- a/pkg/service/profiles/dataswap_test.go
+++ b/pkg/service/profiles/dataswap_test.go
@@ -150,6 +150,50 @@ func TestDataSwap_SwitchApplies(t *testing.T) {
assert.Contains(t, string(n.Params), models.ProfilesDataApplied)
}
+func TestDataSwap_WaitsForRestoreRollback(t *testing.T) {
+ t.Parallel()
+ swapper := &fakeSwapper{}
+ fix := newTestCoordinator(t, swapper)
+ finishRestore, err := fix.st.BeginRestoreGate()
+ require.NoError(t, err)
+ requested := make(chan struct{})
+ go func() {
+ fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1", Name: "Kid A"})
+ close(requested)
+ }()
+
+ time.Sleep(50 * time.Millisecond)
+ assert.Zero(t, swapper.applyCount())
+ finishRestore(false)
+ require.Eventually(t, func() bool { return swapper.applyCount() == 1 },
+ 2*time.Second, 5*time.Millisecond)
+ select {
+ case <-requested:
+ case <-time.After(2 * time.Second):
+ t.Fatal("profile switch request did not complete after restore rollback")
+ }
+}
+
+func TestDataSwap_RestartPendingSkipsFailureNotification(t *testing.T) {
+ t.Parallel()
+ swapper := &fakeSwapper{}
+ fix := newTestCoordinator(t, swapper)
+ finishRestore, err := fix.st.BeginRestoreGate()
+ require.NoError(t, err)
+ finishRestore(true)
+
+ fix.coord.RequestSwitch(platforms.ProfileRef{ID: "profile-1", Name: "Kid A"})
+
+ assert.Zero(t, swapper.applyCount())
+ select {
+ case n := <-fix.notifs:
+ if n.Method == models.NotificationProfilesData {
+ t.Fatalf("unexpected profiles.data notification: %s", n.Params)
+ }
+ case <-time.After(50 * time.Millisecond):
+ }
+}
+
func TestDataSwap_DeferredWhileMediaRunning(t *testing.T) {
t.Parallel()
swapper := &fakeSwapper{}
diff --git a/pkg/service/profiles/profiles.go b/pkg/service/profiles/profiles.go
index a74d33a3..10a32763 100644
--- a/pkg/service/profiles/profiles.go
+++ b/pkg/service/profiles/profiles.go
@@ -353,6 +353,12 @@ func (s *Service) VerifyBySwitchID(switchID string) (*database.Profile, error) {
// (PINs gate entry only); restricting what a profile-less device can do is
// handled by the require-profile launch setting.
func (s *Service) Deactivate() error {
+ release, err := s.st.TryAcquireRestoreAccess()
+ if err != nil {
+ return fmt.Errorf("cannot deactivate profile during backup restore: %w", err)
+ }
+ defer release()
+
s.activateMu.Lock()
defer s.activateMu.Unlock()
@@ -402,6 +408,12 @@ func (s *Service) RestoreOnBoot() error {
}
func (s *Service) activate(p *database.Profile) (*models.ActiveProfile, error) {
+ release, err := s.st.TryAcquireRestoreAccess()
+ if err != nil {
+ return nil, fmt.Errorf("cannot activate profile during backup restore: %w", err)
+ }
+ defer release()
+
// Serialize activations so two concurrent switches cannot interleave
// the persisted device state with the in-memory snapshot.
s.activateMu.Lock()
diff --git a/pkg/service/profiles/profiles_test.go b/pkg/service/profiles/profiles_test.go
index c0c33634..bac3569c 100644
--- a/pkg/service/profiles/profiles_test.go
+++ b/pkg/service/profiles/profiles_test.go
@@ -87,6 +87,24 @@ func TestActivateByID_NoPIN(t *testing.T) {
mockDB.AssertExpectations(t)
}
+func TestActivateByIDRejectedDuringRestore(t *testing.T) {
+ t.Parallel()
+ svc, mockDB, st := newTestService(t)
+ mockDB.On("GetProfile", "profile-1").Return(pinProfile(t, ""), nil)
+ finishRestore, err := st.BeginRestoreGate()
+ require.NoError(t, err)
+ defer finishRestore(false)
+
+ activateErr := make(chan error, 1)
+ go func() {
+ _, activateResultErr := svc.ActivateByID("profile-1", "")
+ activateErr <- activateResultErr
+ }()
+ require.ErrorIs(t, <-activateErr, state.ErrRestoreInProgress)
+ mockDB.AssertNotCalled(t, "ActivateProfile", mock.Anything, mock.Anything)
+ assert.Nil(t, st.ActiveProfile())
+}
+
func TestActivateByID_PINEnforced(t *testing.T) {
t.Parallel()
svc, mockDB, st := newTestService(t)
diff --git a/pkg/service/queues.go b/pkg/service/queues.go
index 6dae19c2..7509018b 100644
--- a/pkg/service/queues.go
+++ b/pkg/service/queues.go
@@ -195,7 +195,8 @@ func runTokenZapScript(
i,
svc.DB,
zapscript.RunCommandOptions{
- LauncherManager: svc.State.LauncherManager(),
+ LauncherManager: svc.State.LauncherManager(),
+ AcquireMediaLaunch: svc.State.AcquireMediaLaunch,
WaitForMediaReady: func(ctx context.Context) error {
return waitForMediaReady(ctx, svc, mediaReadyGen)
},
diff --git a/pkg/service/service.go b/pkg/service/service.go
index a90c8a1a..12da4155 100644
--- a/pkg/service/service.go
+++ b/pkg/service/service.go
@@ -41,6 +41,7 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mediaslot"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/readers"
+ backupsvc "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/broker"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/discovery"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/idle"
@@ -58,6 +59,11 @@ import (
"github.com/rs/zerolog/log"
)
+const (
+ backupShutdownWarningAfter = 30 * time.Second
+ backupShutdownHardDeadline = 2 * time.Minute
+)
+
// StartResult holds the return values from Start.
type StartResult struct {
Stop func() error
@@ -65,6 +71,26 @@ type StartResult struct {
RestartRequested func() bool
}
+func waitForBackupShutdown(
+ coordinator *backupsvc.Coordinator,
+ warningAfter time.Duration,
+ hardDeadline time.Duration,
+) error {
+ hardCtx, cancelHard := context.WithTimeout(context.Background(), hardDeadline)
+ defer cancelHard()
+ warningCtx, cancelWarning := context.WithTimeout(hardCtx, warningAfter)
+ err := coordinator.Shutdown(warningCtx)
+ cancelWarning()
+ if err == nil {
+ return nil
+ }
+ log.Warn().Err(err).Msg("backup operation still stopping; delaying service teardown")
+ if waitErr := coordinator.Shutdown(hardCtx); waitErr != nil {
+ return fmt.Errorf("waiting for backup operation before teardown: %w", waitErr)
+ }
+ return nil
+}
+
func rebuildStartupSlugSearchCache(mediaDB database.MediaDBI, slugCacheLoaded bool) {
if mediaDB == nil || slugCacheLoaded {
return
@@ -188,6 +214,16 @@ func Start(
) (*StartResult, error) {
log.Info().Msgf("version: %s", config.AppVersion)
+ // A config file created outside Core can lack a device ID. The service
+ // daemon owns device identity (TUI/CLI processes only read it), so
+ // mint and persist one before anything reads it. Save generates a
+ // UUID whenever the ID is empty.
+ if cfg.DeviceID() == "" {
+ if err := cfg.Save(); err != nil {
+ log.Error().Err(err).Msg("failed to persist generated device id")
+ }
+ }
+
// Generate boot UUID for this session (for timestamp healing on MiSTer)
bootUUID := uuid.New().String()
log.Info().Msgf("boot session UUID: %s", bootUUID)
@@ -251,6 +287,11 @@ func Start(
return nil, err
}
log.Debug().Dur("duration", time.Since(databaseStarted)).Msg("databases opened")
+ backupManager := backupsvc.NewManager(cfg, pl, db).WithCoordinator(st.BackupCoordinator())
+ if recoveryErr := backupManager.RecoverRestore(st.GetContext()); recoveryErr != nil {
+ closeDatabase(db)
+ return nil, fmt.Errorf("recovering interrupted backup restore: %w", recoveryErr)
+ }
closeHangingMediaHistoryOnStartup(db)
// Initialize inbox service for system notifications
@@ -358,6 +399,7 @@ func Start(
// Create pausers to pause heavy background media work while a game is running.
indexPauser := syncutil.NewPauser()
scrapePauser := syncutil.NewPauser()
+ backupPauser := syncutil.NewPauser()
discoveryService := discovery.New(cfg)
@@ -372,7 +414,7 @@ func Start(
apiDone <- api.StartWithReady(
pl, cfg, st, itq, cfq, db, limitsManager, profilesSvc,
notifBroker, discoveryService.InstanceName(), player, playbackManager, indexPauser, scrapePauser,
- idleSched, apiReady,
+ backupPauser, idleSched, apiReady,
)
}()
@@ -501,8 +543,10 @@ func Start(
pruneExpiredZapLinkHosts(db)
},
)
+ startRemoteBackupScheduler(st.GetContext(), cfg, pl, db, st, idleSched, backupPauser, backgroundWG)
go watchGameForIndexPause(st.GetContext(), notifBroker, st, cfg, st.Notifications, indexPauser)
go watchGameForScrapePause(st.GetContext(), notifBroker, st, cfg, st.Notifications, scrapePauser)
+ go watchGameForBackupPause(st.GetContext(), notifBroker, st, cfg, st.Notifications, backupPauser)
go watchForCorruptMediaDBRecovery(st.GetContext(), notifBroker, pl, cfg, db, st, indexPauser)
log.Info().Msg("starting publishers")
@@ -564,6 +608,11 @@ func Start(
go func() {
<-st.GetContext().Done()
log.Info().Msg("service context cancelled, running cleanup")
+ if backupErr := waitForBackupShutdown(
+ st.BackupCoordinator(), backupShutdownWarningAfter, backupShutdownHardDeadline,
+ ); backupErr != nil {
+ log.Error().Err(backupErr).Msg("backup operation did not stop cleanly")
+ }
uiEvents.Shutdown()
indexPauser.Resume()
scrapePauser.Resume()
diff --git a/pkg/service/service_test.go b/pkg/service/service_test.go
index fa1f2c47..979e67f7 100644
--- a/pkg/service/service_test.go
+++ b/pkg/service/service_test.go
@@ -39,6 +39,7 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mediaslot"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/readers"
+ backupcoordinator "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup/coordinator"
inboxservice "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/inbox"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/state"
@@ -50,6 +51,62 @@ import (
"github.com/stretchr/testify/require"
)
+func TestWaitForBackupShutdownDoesNotTearDownBeforeLeaseRelease(t *testing.T) {
+ t.Parallel()
+ coordinator := backupcoordinator.New()
+ lease, err := coordinator.Begin(
+ context.Background(), backupcoordinator.OperationLocalRestore, backupcoordinator.OperationWrite,
+ )
+ require.NoError(t, err)
+ waitDone := make(chan error, 1)
+ go func() {
+ waitDone <- waitForBackupShutdown(coordinator, 10*time.Millisecond, time.Second)
+ }()
+
+ select {
+ case <-lease.Context().Done():
+ case <-time.After(time.Second):
+ t.Fatal("shutdown did not cancel active restore lease")
+ }
+ select {
+ case <-waitDone:
+ t.Fatal("shutdown returned before restore lease released")
+ case <-time.After(50 * time.Millisecond):
+ }
+ lease.Release()
+ select {
+ case waitErr := <-waitDone:
+ require.NoError(t, waitErr)
+ case <-time.After(time.Second):
+ t.Fatal("shutdown did not finish after restore lease released")
+ }
+}
+
+func TestWaitForBackupShutdownReturnsAtHardDeadline(t *testing.T) {
+ t.Parallel()
+ coordinator := backupcoordinator.New()
+ lease, err := coordinator.Begin(
+ context.Background(), backupcoordinator.OperationRemoteUpload, backupcoordinator.OperationWrite,
+ )
+ require.NoError(t, err)
+ defer lease.Release()
+
+ waitDone := make(chan error, 1)
+ go func() {
+ waitDone <- waitForBackupShutdown(coordinator, 10*time.Millisecond, 50*time.Millisecond)
+ }()
+
+ select {
+ case waitErr := <-waitDone:
+ require.Error(t, waitErr)
+ require.ErrorIs(t, waitErr, context.DeadlineExceeded)
+ case <-time.After(250 * time.Millisecond):
+ t.Fatal("shutdown did not return at hard deadline")
+ }
+ _, _, active := coordinator.Active()
+ assert.True(t, active, "timed-out lease remains active until its owner releases it")
+}
+
func TestStartReturnsErrorWhenAPIPortIsOccupied(t *testing.T) {
listener, err := (&net.ListenConfig{}).Listen(context.Background(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
diff --git a/pkg/service/state/state.go b/pkg/service/state/state.go
index ca696f13..5bcfda22 100644
--- a/pkg/service/state/state.go
+++ b/pkg/service/state/state.go
@@ -31,6 +31,7 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/platforms/mediaslot"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/readers"
+ backupcoordinator "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup/coordinator"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/inbox"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
@@ -79,14 +80,18 @@ type State struct {
onMediaStopHook func()
launcherManager *LauncherManager
uiEvents *uievents.Service
+ backupCoordinator *backupcoordinator.Coordinator
bootUUID string
lastScanned tokens.Token
activeToken tokens.Token
activeMediaReadyGen uint64
+ mediaLaunchAccesses int
mu syncutil.RWMutex
+ mediaRestoreMu syncutil.RWMutex
activeMediaReady bool
stopService bool
restartRequested bool
+ restorePendingRestart bool
runZapScript bool
backgroundAutoPaused bool
}
@@ -96,16 +101,32 @@ func NewState(platform platforms.Platform, bootUUID string) (state *State, notif
// without dropping critical user-facing notifications (tokens, readers, media state)
ns := make(chan models.Notification, 500)
ctx, ctxCancelFunc := context.WithCancel(context.Background())
- return &State{
- runZapScript: true,
- platform: platform,
- readers: make(map[string]readers.Reader),
- Notifications: ns,
- ctx: ctx,
- ctxCancelFunc: ctxCancelFunc,
- launcherManager: NewLauncherManager(),
- bootUUID: bootUUID,
- }, ns
+ state = &State{
+ runZapScript: true,
+ platform: platform,
+ readers: make(map[string]readers.Reader),
+ Notifications: ns,
+ ctx: ctx,
+ ctxCancelFunc: ctxCancelFunc,
+ launcherManager: NewLauncherManager(),
+ backupCoordinator: backupcoordinator.New(),
+ bootUUID: bootUUID,
+ }
+ // Terminal backup.state event: clients showing a paused/throttled backup
+ // banner clear it when the operation's lease is released.
+ state.backupCoordinator.SetOnWriteFinished(func(kind backupcoordinator.OperationKind) {
+ notifications.BackupState(ns, models.BackupStateNotification{
+ Operation: string(kind),
+ Finished: true,
+ })
+ })
+ return state, ns
+}
+
+func (s *State) BackupCoordinator() *backupcoordinator.Coordinator {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.backupCoordinator
}
// SetUIEvents stores the process-wide UI event service.
@@ -448,10 +469,73 @@ func (s *State) SetBackgroundPlaylist(playlist *playlists.Playlist) {
}
var (
- ErrNoActiveMedia = errors.New("no active media")
- ErrActiveMediaChanged = errors.New("active media changed")
+ ErrNoActiveMedia = errors.New("no active media")
+ ErrActiveMediaChanged = errors.New("active media changed")
+ ErrRestoreInProgress = errors.New("backup restore is in progress")
+ ErrMediaLaunchInProgress = errors.New("media launch is in progress")
+ ErrRestoreRestartRequired = errors.New("backup restore restart is pending")
)
+func (s *State) restoreAccessAfterLock() (func(), error) {
+ s.mu.RLock()
+ pendingRestart := s.restorePendingRestart
+ s.mu.RUnlock()
+ if pendingRestart {
+ s.mediaRestoreMu.RUnlock()
+ return nil, ErrRestoreRestartRequired
+ }
+ return s.mediaRestoreMu.RUnlock, nil
+}
+
+func (s *State) TryAcquireRestoreAccess() (func(), error) {
+ if !s.mediaRestoreMu.TryRLock() {
+ return nil, ErrRestoreInProgress
+ }
+ return s.restoreAccessAfterLock()
+}
+
+func (s *State) AcquireRestoreAccess() (func(), error) {
+ s.mediaRestoreMu.RLock()
+ return s.restoreAccessAfterLock()
+}
+
+func (s *State) AcquireMediaLaunch() (func(), error) {
+ release, err := s.TryAcquireRestoreAccess()
+ if err != nil {
+ return nil, err
+ }
+ s.mu.Lock()
+ s.mediaLaunchAccesses++
+ s.mu.Unlock()
+ return func() {
+ s.mu.Lock()
+ s.mediaLaunchAccesses--
+ s.mu.Unlock()
+ release()
+ }, nil
+}
+
+func (s *State) BeginRestoreGate() (func(bool), error) {
+ if !s.mediaRestoreMu.TryLock() {
+ return nil, ErrMediaLaunchInProgress
+ }
+ s.mu.RLock()
+ pendingRestart := s.restorePendingRestart
+ s.mu.RUnlock()
+ if pendingRestart {
+ s.mediaRestoreMu.Unlock()
+ return nil, ErrRestoreRestartRequired
+ }
+ return func(success bool) {
+ if success {
+ s.mu.Lock()
+ s.restorePendingRestart = true
+ s.mu.Unlock()
+ }
+ s.mediaRestoreMu.Unlock()
+ }, nil
+}
+
func (s *State) ActiveMedia() *models.ActiveMedia {
s.mu.RLock()
defer s.mu.RUnlock()
@@ -523,6 +607,30 @@ func (s *State) MarkActiveMediaReady(gen uint64) {
}
func (s *State) SetActiveMedia(media *models.ActiveMedia) {
+ if media != nil {
+ s.mu.RLock()
+ launchAccessHeld := s.mediaLaunchAccesses > 0
+ s.mu.RUnlock()
+ if launchAccessHeld {
+ s.updateActiveMediaState(media)
+ return
+ }
+ release, err := s.TryAcquireRestoreAccess()
+ if errors.Is(err, ErrRestoreInProgress) {
+ s.backupCoordinator.CancelRestore()
+ release, err = s.AcquireRestoreAccess()
+ }
+ if err != nil {
+ log.Warn().Err(err).Msg("active media update rejected during backup restore")
+ return
+ }
+ defer release()
+ }
+
+ s.updateActiveMediaState(media)
+}
+
+func (s *State) updateActiveMediaState(media *models.ActiveMedia) {
s.mu.Lock()
// Read oldMedia inside lock to prevent race condition where another
diff --git a/pkg/service/state/state_media_test.go b/pkg/service/state/state_media_test.go
index 2b38dc00..611f2e94 100644
--- a/pkg/service/state/state_media_test.go
+++ b/pkg/service/state/state_media_test.go
@@ -25,6 +25,7 @@ import (
"time"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ backupcoordinator "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup/coordinator"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/playlists"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -45,6 +46,116 @@ func drainState(t *testing.T, st *State, ns <-chan models.Notification) {
})
}
+func TestMediaRestoreGateMutualExclusion(t *testing.T) {
+ t.Parallel()
+ st, _ := NewState(nil, "test-boot")
+ defer st.StopService()
+
+ releaseLaunch, err := st.AcquireMediaLaunch()
+ require.NoError(t, err)
+ restoreErr := make(chan error, 1)
+ go func() {
+ finish, beginErr := st.BeginRestoreGate()
+ if finish != nil {
+ finish(false)
+ }
+ restoreErr <- beginErr
+ }()
+ require.ErrorIs(t, <-restoreErr, ErrMediaLaunchInProgress)
+ releaseLaunch()
+
+ finishRestore, err := st.BeginRestoreGate()
+ require.NoError(t, err)
+ launchErr := make(chan error, 1)
+ go func() {
+ release, acquireErr := st.AcquireMediaLaunch()
+ if release != nil {
+ release()
+ }
+ launchErr <- acquireErr
+ }()
+ require.ErrorIs(t, <-launchErr, ErrRestoreInProgress)
+ finishRestore(false)
+
+ releaseLaunch, err = st.AcquireMediaLaunch()
+ require.NoError(t, err)
+ releaseLaunch()
+}
+
+func TestExternalActiveMediaCancelsRestoreBeforeUpdatingState(t *testing.T) {
+ t.Parallel()
+ st, _ := NewState(nil, "test-boot")
+ defer st.StopService()
+ lease, err := st.BackupCoordinator().Begin(
+ context.Background(), backupcoordinator.OperationLocalRestore, backupcoordinator.OperationWrite,
+ )
+ require.NoError(t, err)
+ defer lease.Release()
+ finishRestore, err := st.BeginRestoreGate()
+ require.NoError(t, err)
+ updated := make(chan struct{})
+ go func() {
+ st.SetActiveMedia(&models.ActiveMedia{SystemID: "SNES", Path: "game.sfc", Name: "Game"})
+ close(updated)
+ }()
+
+ select {
+ case <-lease.Context().Done():
+ case <-time.After(time.Second):
+ t.Fatal("external active media did not cancel restore")
+ }
+ select {
+ case <-updated:
+ t.Fatal("active media changed before restore gate released")
+ case <-time.After(50 * time.Millisecond):
+ }
+ finishRestore(false)
+ select {
+ case <-updated:
+ case <-time.After(time.Second):
+ t.Fatal("active media update did not resume after rollback gate released")
+ }
+ assert.NotNil(t, st.ActiveMedia())
+}
+
+func TestBlockingRestoreAccessWaitsForRollback(t *testing.T) {
+ t.Parallel()
+ st, _ := NewState(nil, "test-boot")
+ defer st.StopService()
+ finishRestore, err := st.BeginRestoreGate()
+ require.NoError(t, err)
+ acquired := make(chan error, 1)
+ go func() {
+ release, accessErr := st.AcquireRestoreAccess()
+ if release != nil {
+ release()
+ }
+ acquired <- accessErr
+ }()
+ select {
+ case <-acquired:
+ t.Fatal("restore-sensitive operation did not wait for restore")
+ case <-time.After(50 * time.Millisecond):
+ }
+ finishRestore(false)
+ require.NoError(t, <-acquired)
+}
+
+func TestSuccessfulRestoreGateBlocksLaunchUntilRestart(t *testing.T) {
+ t.Parallel()
+ st, _ := NewState(nil, "test-boot")
+ defer st.StopService()
+
+ finishRestore, err := st.BeginRestoreGate()
+ require.NoError(t, err)
+ finishRestore(true)
+
+ _, err = st.AcquireMediaLaunch()
+ require.ErrorIs(t, err, ErrRestoreRestartRequired)
+ _, err = st.BeginRestoreGate()
+ require.ErrorIs(t, err, ErrRestoreRestartRequired)
+}
+
func TestSetRunZapScript(t *testing.T) {
t.Parallel()
st, _ := NewState(nil, "boot")
diff --git a/pkg/service/state/state_test.go b/pkg/service/state/state_test.go
index 68715125..e7abcf88 100644
--- a/pkg/service/state/state_test.go
+++ b/pkg/service/state/state_test.go
@@ -20,11 +20,14 @@
package state
import (
+ "context"
+ "encoding/json"
"sync"
"testing"
"time"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ backupcoordinator "github.com/ZaparooProject/zaparoo-core/v2/pkg/service/backup/coordinator"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/service/tokens"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/stretchr/testify/assert"
@@ -277,3 +280,29 @@ func TestSetOnMediaStartHookNotCalledOnStop(t *testing.T) {
t.Error("OnMediaStart hook should not be called when media stops")
}
}
+
+func TestBackupCoordinatorReleasePublishesFinishedNotification(t *testing.T) {
+ t.Parallel()
+ mockPlatform := mocks.NewMockPlatform()
+ state, ns := NewState(mockPlatform, "test-boot-uuid")
+ t.Cleanup(state.StopService)
+
+ lease, err := state.BackupCoordinator().Begin(
+ context.Background(), backupcoordinator.OperationRemoteUpload, backupcoordinator.OperationWrite,
+ )
+ require.NoError(t, err)
+ lease.Release()
+
+ select {
+ case notif := <-ns:
+ assert.Equal(t, models.NotificationBackupState, notif.Method)
+ var payload models.BackupStateNotification
+ require.NoError(t, json.Unmarshal(notif.Params, &payload))
+ assert.Equal(t, string(backupcoordinator.OperationRemoteUpload), payload.Operation)
+ assert.True(t, payload.Finished)
+ assert.False(t, payload.Paused)
+ assert.False(t, payload.Throttled)
+ case <-time.After(time.Second):
+ t.Fatal("expected backup.state finished notification")
+ }
+}
diff --git a/pkg/testing/helpers/db_mocks.go b/pkg/testing/helpers/db_mocks.go
index 6ae111fc..285f2c34 100644
--- a/pkg/testing/helpers/db_mocks.go
+++ b/pkg/testing/helpers/db_mocks.go
@@ -548,6 +548,14 @@ func (m *MockUserDBI) ListClients() ([]database.Client, error) {
return nil, nil
}
+func (m *MockUserDBI) ReplaceAllClients(clients []database.Client) error {
+ args := m.Called(clients)
+ if err := args.Error(0); err != nil {
+ return fmt.Errorf("mock UserDBI replace all clients failed: %w", err)
+ }
+ return nil
+}
+
func (m *MockUserDBI) DeleteClient(clientID string) error {
args := m.Called(clientID)
if err := args.Error(0); err != nil {
@@ -690,6 +698,24 @@ func (m *MockUserDBI) Backup(reason string, manual bool) (database.BackupInfo, e
return info, nil
}
+func (m *MockUserDBI) BackupForTransfer(
+ ctx context.Context, reason string,
+) (database.BackupInfo, func() error, error) {
+ args := m.Called(ctx, reason)
+ info, ok := args.Get(0).(database.BackupInfo)
+ if !ok {
+ return database.BackupInfo{}, nil, errors.New("mock UserDBI transfer backup returned invalid backup info")
+ }
+ cleanup, ok := args.Get(1).(func() error)
+ if !ok {
+ return info, nil, errors.New("mock UserDBI transfer backup returned invalid cleanup")
+ }
+ if err := args.Error(2); err != nil {
+ return info, cleanup, fmt.Errorf("mock UserDBI transfer backup failed: %w", err)
+ }
+ return info, cleanup, nil
+}
+
func (m *MockUserDBI) EnsureRecentBackup(maxAge time.Duration) (database.BackupInfo, bool, error) {
args := m.Called(maxAge)
info, ok := args.Get(0).(database.BackupInfo)
diff --git a/pkg/ui/events/service.go b/pkg/ui/events/service.go
index 1251b5c1..73613d4f 100644
--- a/pkg/ui/events/service.go
+++ b/pkg/ui/events/service.go
@@ -191,6 +191,8 @@ func (s *Service) Open(ctx context.Context, request *Request) (*Handle, error) {
now := s.clock.Now()
entry := newActiveEvent(request, now)
+ eventID := entry.event.ID
+ timerGeneration := entry.timerGeneration
var replaced *activeEvent
var replacedResolution *models.UIResolution
@@ -220,13 +222,13 @@ func (s *Service) Open(ctx context.Context, request *Request) (*Handle, error) {
}
s.publishState(state)
- s.attachCancellation(ctx, entry.event.ID)
- s.attachTimer(entry.event.ID, entry.timerGeneration, request.Timeout)
+ s.attachCancellation(ctx, eventID)
+ s.attachTimer(eventID, timerGeneration, request.Timeout)
minimumDisplay := time.Duration(0)
if !entry.skipHostRenderer {
event := cloneEvent(&entry.event)
- s.present(ctx, entry.event.ID, &event)
+ s.present(ctx, eventID, &event)
if timedRenderer, ok := s.renderer.(TimedRenderer); ok {
minimumDisplay = timedRenderer.MinimumUIDisplay(entry.event.Kind)
}
@@ -234,7 +236,7 @@ func (s *Service) Open(ctx context.Context, request *Request) (*Handle, error) {
return &Handle{
service: s,
Results: entry.result,
- ID: entry.event.ID,
+ ID: eventID,
MinimumDisplay: minimumDisplay,
}, nil
}
diff --git a/pkg/ui/events/service_test.go b/pkg/ui/events/service_test.go
index 00ff7fee..0504d9a0 100644
--- a/pkg/ui/events/service_test.go
+++ b/pkg/ui/events/service_test.go
@@ -432,6 +432,24 @@ func TestServiceContextCancellationAndShutdown(t *testing.T) {
require.ErrorIs(t, err, ErrClosed)
}
+func TestServiceOpenWithCanceledContext(t *testing.T) {
+ t.Parallel()
+
+ for range 100 {
+ service, _ := newTestService(clockwork.NewRealClock(), nil)
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ handle, err := service.Open(ctx, &Request{
+ Kind: models.UIEventKindNotice,
+ Timeout: time.Minute,
+ })
+ require.NoError(t, err)
+ assert.Equal(t, models.UIOutcomeCancelled, receiveResult(t, handle.Results).Resolution.Outcome)
+ assert.Empty(t, service.State().Events)
+ }
+}
+
func TestServiceUpdateNotifiesRenderer(t *testing.T) {
t.Parallel()
diff --git a/pkg/ui/tui/dialog.go b/pkg/ui/tui/dialog.go
new file mode 100644
index 00000000..ca6b4309
--- /dev/null
+++ b/pkg/ui/tui/dialog.go
@@ -0,0 +1,235 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package tui
+
+import (
+ "strings"
+
+ "github.com/gdamore/tcell/v2"
+ "github.com/rivo/tview"
+)
+
+const (
+ // dialogChromeWidth is the border plus one column of padding per side.
+ dialogChromeWidth = 4
+ // dialogWindowMargin keeps at least one column visible around a dialog
+ // on each side of the parent window.
+ dialogWindowMargin = 2
+)
+
+// Dialog is a modal dialog box sized to fit its content and clamped to the
+// window it is drawn in. tview.Modal is deliberately not used for dialogs:
+// it sizes and centers itself against the physical terminal, so in CRT mode
+// (or any capped window) it draws wider than the TUI window itself.
+type Dialog struct {
+ *tview.Box
+ frame *tview.Box
+ textView *tview.TextView
+ form *tview.Form
+ done func(buttonIndex int)
+ text string
+ buttons []string
+}
+
+// NewDialog returns an empty dialog. Add a message with SetText, buttons
+// with AddButtons, and a close handler with SetDoneFunc. Add it to pages
+// with resize enabled so it can center itself within the parent window.
+func NewDialog() *Dialog {
+ d := &Dialog{
+ Box: tview.NewBox(),
+ frame: tview.NewBox().
+ SetBackgroundColor(tview.Styles.ContrastBackgroundColor),
+ textView: tview.NewTextView().
+ SetDynamicColors(true).
+ SetWrap(true).
+ SetWordWrap(true).
+ SetTextAlign(tview.AlignCenter),
+ form: tview.NewForm().
+ SetButtonsAlign(tview.AlignCenter).
+ SetButtonBackgroundColor(tview.Styles.PrimitiveBackgroundColor).
+ SetButtonTextColor(tview.Styles.PrimaryTextColor),
+ }
+ d.frame.SetBorder(true).SetTitleAlign(tview.AlignCenter)
+ d.textView.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
+ d.form.SetBackgroundColor(tview.Styles.ContrastBackgroundColor)
+ d.form.SetBorderPadding(0, 0, 0, 0)
+ d.form.SetCancelFunc(func() {
+ if d.done != nil {
+ d.done(-1)
+ }
+ })
+ return d
+}
+
+// SetText sets the message text. It may contain line breaks and tview color
+// tags; long lines word-wrap to the available width.
+func (d *Dialog) SetText(text string) *Dialog {
+ d.text = text
+ d.textView.SetText(text)
+ return d
+}
+
+// SetTextAlign sets the horizontal alignment of the message text.
+func (d *Dialog) SetTextAlign(align int) *Dialog {
+ d.textView.SetTextAlign(align)
+ return d
+}
+
+// SetTitle sets the dialog title, padded consistently with SetBoxTitle.
+func (d *Dialog) SetTitle(title string) *Dialog {
+ d.frame.SetTitle(" " + title + " ")
+ return d
+}
+
+// AddButtons adds buttons to the dialog. Left/right and up/down move
+// between buttons.
+func (d *Dialog) AddButtons(labels []string) *Dialog {
+ for index, label := range labels {
+ i := index
+ d.buttons = append(d.buttons, label)
+ d.form.AddButton(label, func() {
+ if d.done != nil {
+ d.done(i)
+ }
+ })
+ button := d.form.GetButton(d.form.GetButtonCount() - 1)
+ button.SetInputCapture(func(event *tcell.EventKey) *tcell.EventKey {
+ switch event.Key() {
+ case tcell.KeyDown, tcell.KeyRight:
+ return tcell.NewEventKey(tcell.KeyTab, 0, tcell.ModNone)
+ case tcell.KeyUp, tcell.KeyLeft:
+ return tcell.NewEventKey(tcell.KeyBacktab, 0, tcell.ModNone)
+ default:
+ return event
+ }
+ })
+ }
+ return d
+}
+
+// SetDoneFunc sets the handler called with the pressed button's index. It
+// is also called with index -1 when the user presses Escape.
+func (d *Dialog) SetDoneFunc(handler func(buttonIndex int)) *Dialog {
+ d.done = handler
+ return d
+}
+
+// contentWidth returns the widest element the dialog wants to display
+// without wrapping: text line, button row, or title.
+func (d *Dialog) contentWidth() int {
+ width := 0
+ for _, label := range d.buttons {
+ width += tview.TaggedStringWidth(label) + 6
+ }
+ if width > 0 {
+ width -= 2
+ }
+ for _, line := range strings.Split(d.text, "\n") {
+ if lineWidth := tview.TaggedStringWidth(line); lineWidth > width {
+ width = lineWidth
+ }
+ }
+ if titleWidth := tview.TaggedStringWidth(d.frame.GetTitle()); titleWidth > width {
+ width = titleWidth
+ }
+ return width
+}
+
+// Draw implements tview.Primitive. The dialog rect covers the whole parent
+// window; only the centered frame is drawn so the page behind stays visible.
+func (d *Dialog) Draw(screen tcell.Screen) {
+ x, y, w, h := d.GetInnerRect()
+ if w < 1 || h < 1 {
+ return
+ }
+
+ width := d.contentWidth() + dialogChromeWidth
+ if maxWidth := w - dialogWindowMargin; width > maxWidth {
+ width = maxWidth
+ }
+ textWidth := width - dialogChromeWidth
+ if textWidth < 1 {
+ textWidth = 1
+ width = textWidth + dialogChromeWidth
+ }
+
+ buttonRows := 0
+ if len(d.buttons) > 0 {
+ buttonRows = 2 // blank spacer + button row
+ }
+ height := len(tview.WordWrap(d.text, textWidth)) + buttonRows + 2
+ if height > h {
+ height = h
+ }
+
+ d.frame.SetRect(x+(w-width)/2, y+(h-height)/2, width, height)
+ d.frame.Draw(screen)
+
+ innerX, innerY, innerWidth, innerHeight := d.frame.GetInnerRect()
+ if textHeight := innerHeight - buttonRows; textHeight > 0 {
+ d.textView.SetRect(innerX+1, innerY, innerWidth-2, textHeight)
+ d.textView.Draw(screen)
+ }
+ if buttonRows > 0 && innerHeight > 0 {
+ d.form.SetRect(innerX, innerY+innerHeight-1, innerWidth, 1)
+ d.form.Draw(screen)
+ }
+}
+
+// Focus implements tview.Primitive.
+func (d *Dialog) Focus(delegate func(p tview.Primitive)) {
+ if len(d.buttons) > 0 {
+ delegate(d.form)
+ return
+ }
+ d.Box.Focus(delegate)
+}
+
+// HasFocus implements tview.Primitive.
+func (d *Dialog) HasFocus() bool {
+ return d.form.HasFocus() || d.Box.HasFocus()
+}
+
+// InputHandler implements tview.Primitive.
+func (d *Dialog) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+ return d.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
+ if d.form.HasFocus() {
+ if handler := d.form.InputHandler(); handler != nil {
+ handler(event, setFocus)
+ }
+ }
+ })
+}
+
+// MouseHandler implements tview.Primitive.
+func (d *Dialog) MouseHandler() func(
+ action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive),
+) (consumed bool, capture tview.Primitive) {
+ return d.WrapMouseHandler(func(
+ action tview.MouseAction, event *tcell.EventMouse, setFocus func(p tview.Primitive),
+ ) (consumed bool, capture tview.Primitive) {
+ consumed, capture = d.form.MouseHandler()(action, event, setFocus)
+ if !consumed && action == tview.MouseLeftDown && d.InRect(event.Position()) {
+ setFocus(d)
+ consumed = true
+ }
+ return consumed, capture
+ })
+}
diff --git a/pkg/ui/tui/dialog_test.go b/pkg/ui/tui/dialog_test.go
new file mode 100644
index 00000000..25ffb989
--- /dev/null
+++ b/pkg/ui/tui/dialog_test.go
@@ -0,0 +1,256 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package tui
+
+import (
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/rivo/tview"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+// screenBounds is the bounding box of all non-blank cells on screen.
+type screenBounds struct {
+ minX, minY, maxX, maxY int
+}
+
+// drawnBounds returns the bounding box of all non-blank cells on screen,
+// synchronized with the app's draw cycle. ok is false when nothing is drawn.
+func drawnBounds(runner *TestAppRunner) (bounds screenBounds, ok bool) {
+ runner.QueueUpdateDraw(func() {
+ cells, width, height := runner.Screen().GetContents()
+ bounds = screenBounds{minX: width, minY: height, maxX: -1, maxY: -1}
+ for y := range height {
+ for x := range width {
+ cell := cells[y*width+x]
+ if len(cell.Runes) == 0 || cell.Runes[0] == ' ' {
+ continue
+ }
+ bounds.minX = min(bounds.minX, x)
+ bounds.minY = min(bounds.minY, y)
+ bounds.maxX = max(bounds.maxX, x)
+ bounds.maxY = max(bounds.maxY, y)
+ }
+ }
+ })
+ return bounds, bounds.maxX >= 0
+}
+
+func TestDialog_SizesToContent(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewBox(), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ runner.QueueUpdateDraw(func() {
+ dialog := NewDialog().
+ SetText("Hi").
+ AddButtons([]string{"OK"}).
+ SetDoneFunc(func(_ int) {})
+ pages.AddPage("dialog", dialog, true, true)
+ runner.App().SetFocus(dialog)
+ })
+ require.True(t, runner.WaitForText("Hi", 500*time.Millisecond))
+
+ bounds, ok := drawnBounds(runner)
+ require.True(t, ok, "dialog should be drawn")
+ width := bounds.maxX - bounds.minX + 1
+ height := bounds.maxY - bounds.minY + 1
+ // Content is tiny, so the dialog must be far narrower than the
+ // tview.Modal minimum of a third of the screen plus chrome.
+ assert.LessOrEqual(t, width, 14, "dialog width should fit content")
+ assert.LessOrEqual(t, height, 6, "dialog height should fit content")
+}
+
+func TestDialog_ClampedToParentWindow(t *testing.T) {
+ t.Parallel()
+
+ // The simulation screen is always 80x25: tview re-inits the screen on
+ // Run, which resets a SimulationScreen to its default size.
+ const (
+ screenWidth = 80
+ screenHeight = 25
+ windowWidth = 30
+ windowHeight = 10
+ )
+
+ runner := NewTestAppRunner(t, screenWidth, screenHeight)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewBox(), true, true)
+ // Same shape as CRT mode: the whole TUI lives in a small centered window.
+ runner.Start(CenterWidget(windowWidth, windowHeight, pages))
+ runner.Draw()
+
+ runner.QueueUpdateDraw(func() {
+ dialog := NewDialog().
+ SetText("This message is far too long to fit in the parent window " +
+ "on a single line so it has to word-wrap instead of overflowing.").
+ SetTitle("Clamp").
+ AddButtons([]string{"OK"}).
+ SetDoneFunc(func(_ int) {})
+ pages.AddPage("dialog", dialog, true, true)
+ runner.App().SetFocus(dialog)
+ })
+ require.True(t, runner.WaitForText("word-wrap", 500*time.Millisecond))
+
+ bounds, ok := drawnBounds(runner)
+ require.True(t, ok, "dialog should be drawn")
+
+ windowX := (screenWidth - windowWidth) / 2
+ windowY := (screenHeight - windowHeight) / 2
+ assert.GreaterOrEqual(t, bounds.minX, windowX, "dialog must not draw left of the window")
+ assert.GreaterOrEqual(t, bounds.minY, windowY, "dialog must not draw above the window")
+ assert.Less(t, bounds.maxX, windowX+windowWidth, "dialog must not draw right of the window")
+ assert.Less(t, bounds.maxY, windowY+windowHeight, "dialog must not draw below the window")
+}
+
+func TestDialog_ButtonSelectionAndEscape(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewBox(), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ show := func() chan int {
+ pressed := make(chan int, 1)
+ runner.QueueUpdateDraw(func() {
+ dialog := NewDialog().
+ SetText("Pick one").
+ AddButtons([]string{"First", "Second"}).
+ SetDoneFunc(func(buttonIndex int) {
+ pages.RemovePage("dialog")
+ pressed <- buttonIndex
+ })
+ pages.AddPage("dialog", dialog, true, true)
+ runner.App().SetFocus(dialog)
+ })
+ require.True(t, runner.WaitForText("Pick one", 500*time.Millisecond))
+ return pressed
+ }
+
+ pressed := show()
+ runner.Screen().InjectEnter()
+ runner.Draw()
+ select {
+ case index := <-pressed:
+ assert.Equal(t, 0, index, "Enter should select the first button")
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("done handler was not called for the first button")
+ }
+
+ pressed = show()
+ runner.Screen().InjectArrowRight()
+ runner.Screen().InjectEnter()
+ runner.Draw()
+ select {
+ case index := <-pressed:
+ assert.Equal(t, 1, index, "right arrow then Enter should select the second button")
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("done handler was not called for the second button")
+ }
+
+ pressed = show()
+ runner.Screen().InjectEscape()
+ runner.Draw()
+ select {
+ case index := <-pressed:
+ assert.Equal(t, -1, index, "Escape should report index -1")
+ case <-time.After(500 * time.Millisecond):
+ t.Fatal("done handler was not called for Escape")
+ }
+}
+
+func TestDialog_LongTextWordWraps(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewBox(), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ longText := strings.Repeat("word ", 30) + "FINAL-MARKER"
+ runner.QueueUpdateDraw(func() {
+ dialog := NewDialog().
+ SetText(longText).
+ AddButtons([]string{"OK"}).
+ SetDoneFunc(func(_ int) {})
+ pages.AddPage("dialog", dialog, true, true)
+ runner.App().SetFocus(dialog)
+ })
+
+ assert.True(t, runner.WaitForText("FINAL-MARKER", 500*time.Millisecond),
+ "wrapped text should remain fully visible")
+}
+
+func TestDialog_SetTextUpdatesContent(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewBox(), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ var dialog *Dialog
+ runner.QueueUpdateDraw(func() {
+ dialog = NewDialog().
+ SetText("Before").
+ AddButtons([]string{"Cancel"}).
+ SetDoneFunc(func(_ int) {})
+ pages.AddPage("dialog", dialog, true, true)
+ runner.App().SetFocus(dialog)
+ })
+ require.True(t, runner.WaitForText("Before", 500*time.Millisecond))
+
+ runner.QueueUpdateDraw(func() {
+ dialog.SetText("After the update")
+ })
+ assert.True(t, runner.WaitForText("After the update", 500*time.Millisecond))
+ assert.False(t, runner.ContainsText("Before"), "old text should be gone")
+}
+
+func TestAuthLinkMessage(t *testing.T) {
+ t.Parallel()
+
+ message := authLinkMessage("https://zaparoo.com/link", "ABCD-1234")
+ assert.Contains(t, message, "zaparoo.com/link")
+ assert.NotContains(t, message, "https://", "URL scheme should be stripped for display")
+ assert.Contains(t, message, "ABCD-1234")
+ assert.Contains(t, message, "Waiting for approval")
+}
diff --git a/pkg/ui/tui/encryption_prompt.go b/pkg/ui/tui/encryption_prompt.go
new file mode 100644
index 00000000..ca294627
--- /dev/null
+++ b/pkg/ui/tui/encryption_prompt.go
@@ -0,0 +1,226 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package tui
+
+// The secure-device prompt drives a transactional first-run setup: require
+// encrypted remote connections, then immediately pair an admin client with
+// the PIN shown on screen. If no client pairs before the PIN expires (or
+// the user cancels), encryption is switched back off so a half-finished
+// setup can never lock remote clients out.
+
+import (
+ "fmt"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/helpers"
+ "github.com/rivo/tview"
+ "github.com/rs/zerolog/log"
+)
+
+const (
+ encryptionPairingModalPage = "encryption_pairing_modal"
+ encryptionPairPollInterval = 2 * time.Second
+ encryptionPairAdminRole = "admin"
+ encryptionSetupModalTitle = "Secure Zaparoo"
+ encryptionSetupNotAppliedMsg = "No changes were made. You can set this up\nanytime from Settings > Clients."
+)
+
+// shouldPromptEncryption reports whether the secure-device prompt applies:
+// the device has no paired clients. This covers both the fresh state
+// (encryption off) and the recovery state where a previous setup required
+// encryption but the TUI exited before a client paired.
+func shouldPromptEncryption(clients *models.ClientsResponse) bool {
+ return clients != nil && len(clients.Clients) == 0
+}
+
+// maybeShowEncryptionPrompt shows the secure-device prompt when the device
+// has no paired clients. markPrompted is called when the prompt should not
+// be shown again (setup succeeded or the user declined permanently).
+func maybeShowEncryptionPrompt(
+ svc SettingsService, pages *tview.Pages, app *tview.Application, markPrompted func(),
+) {
+ ctx, cancel := tuiContext()
+ clients, err := svc.GetClients(ctx)
+ cancel()
+ if err != nil {
+ log.Warn().Err(err).Msg("failed to check paired clients for encryption prompt")
+ return
+ }
+ if !shouldPromptEncryption(clients) {
+ return
+ }
+ ShowEncryptionPrompt(pages, app,
+ func() { startEncryptionSetup(svc, pages, app, markPrompted) },
+ nil,
+ markPrompted,
+ )
+}
+
+// startEncryptionSetup enables required encryption and starts an admin
+// pairing approval, showing the PIN until a client pairs or the PIN expires.
+func startEncryptionSetup(
+ svc SettingsService, pages *tview.Pages, app *tview.Application, markPrompted func(),
+) {
+ enabled := true
+ ctx, cancel := tuiContext()
+ err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{Encryption: &enabled})
+ cancel()
+ if err != nil {
+ log.Warn().Err(err).Msg("failed to require encryption during secure-device setup")
+ ShowErrorModal(pages, app, "Failed to require encryption", nil)
+ return
+ }
+ ctx, cancel = tuiContext()
+ pairing, err := svc.StartClientPairing(ctx, encryptionPairAdminRole)
+ cancel()
+ if err != nil {
+ failOpenEncryptionSetup(svc, pages, app, "Failed to start pairing: "+err.Error())
+ return
+ }
+ showEncryptionPairingModal(svc, pages, app, pairing, encryptionPairPollInterval, markPrompted)
+}
+
+// failOpenEncryptionSetup reverts to the open (plaintext-allowed) state after
+// an incomplete setup so remote clients are not locked out, then reports why.
+func failOpenEncryptionSetup(
+ svc SettingsService, pages *tview.Pages, app *tview.Application, reason string,
+) {
+ disabled := false
+ ctx, cancel := tuiContext()
+ err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{Encryption: &disabled})
+ cancel()
+ if err != nil {
+ log.Error().Err(err).Msg("failed to revert encryption after incomplete secure-device setup")
+ ShowErrorModal(pages, app,
+ reason+"\n\nEncryption may still be required.\nCheck Settings > Clients.", nil)
+ return
+ }
+ ShowInfoModal(pages, app, encryptionSetupModalTitle,
+ reason+"\n\n"+encryptionSetupNotAppliedMsg, nil)
+}
+
+// showEncryptionPairingModal displays the admin pairing PIN with a countdown,
+// polling for a successful pairing. On success encryption stays required and
+// markPrompted fires; on expiry or cancel the setup fails open.
+func showEncryptionPairingModal(
+ svc SettingsService, pages *tview.Pages, app *tview.Application,
+ pairing *models.ClientsPairStartResponse, pollInterval time.Duration, markPrompted func(),
+) {
+ expiresAt := time.Unix(pairing.ExpiresAt, 0)
+ address := ""
+ if ip := helpers.GetLocalIP(); ip != "" {
+ address = "\nDevice address: " + ip
+ }
+ message := func(now time.Time) string {
+ return fmt.Sprintf(
+ "Pairing PIN: %s\nExpires in: %s%s\n\n"+
+ "Open the Zaparoo App and enter this PIN\nwhen it asks to pair with this device.",
+ formatPairingPIN(pairing.PIN), formatPairingCountdown(expiresAt, now), address)
+ }
+
+ resolved := false
+ done := make(chan struct{})
+ dialog := NewDialog().
+ SetText(message(time.Now())).
+ SetTitle("Pair Admin Client").
+ AddButtons([]string{"Cancel"})
+
+ // resolve funcs run only on the UI thread (modal DoneFunc or
+ // QueueUpdateDraw), so the resolved guard needs no locking.
+ beginResolve := func() bool {
+ if resolved {
+ return false
+ }
+ resolved = true
+ close(done)
+ pages.HidePage(encryptionPairingModalPage)
+ pages.RemovePage(encryptionPairingModalPage)
+ return true
+ }
+ cancelPairing := func() {
+ ctx, cancel := tuiContext()
+ if err := svc.CancelClientPairing(ctx); err != nil {
+ log.Warn().Err(err).Msg("failed to cancel pairing approval")
+ }
+ cancel()
+ }
+ resolvePaired := func() {
+ if !beginResolve() {
+ return
+ }
+ cancelPairing()
+ markPrompted()
+ ShowInfoModal(pages, app, encryptionSetupModalTitle,
+ "Zaparoo secured.\n\nOnly approved devices can now connect to\nZaparoo. Your phone has admin access.", nil)
+ }
+ resolveExpired := func() {
+ if !beginResolve() {
+ return
+ }
+ failOpenEncryptionSetup(svc, pages, app, "No phone was approved before the PIN expired.")
+ }
+ resolveCancelled := func() {
+ if !beginResolve() {
+ return
+ }
+ cancelPairing()
+ failOpenEncryptionSetup(svc, pages, app, "Pairing cancelled.")
+ }
+
+ dialog.SetDoneFunc(func(_ int) {
+ resolveCancelled()
+ })
+ pages.AddPage(encryptionPairingModalPage, dialog, true, true)
+ app.SetFocus(dialog)
+
+ go func() {
+ countdown := time.NewTicker(time.Second)
+ defer countdown.Stop()
+ poll := time.NewTicker(pollInterval)
+ defer poll.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case now := <-countdown.C:
+ if !now.Before(expiresAt) {
+ app.QueueUpdateDraw(resolveExpired)
+ return
+ }
+ app.QueueUpdateDraw(func() {
+ dialog.SetText(message(now))
+ })
+ case <-poll.C:
+ if !time.Now().Before(expiresAt) {
+ app.QueueUpdateDraw(resolveExpired)
+ return
+ }
+ ctx, cancel := tuiContext()
+ clients, err := svc.GetClients(ctx)
+ cancel()
+ if err == nil && len(clients.Clients) > 0 {
+ app.QueueUpdateDraw(resolvePaired)
+ return
+ }
+ }
+ }
+ }()
+}
diff --git a/pkg/ui/tui/encryption_prompt_test.go b/pkg/ui/tui/encryption_prompt_test.go
new file mode 100644
index 00000000..8de97e07
--- /dev/null
+++ b/pkg/ui/tui/encryption_prompt_test.go
@@ -0,0 +1,265 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package tui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/rivo/tview"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+func TestShouldPromptEncryption(t *testing.T) {
+ t.Parallel()
+
+ assert.False(t, shouldPromptEncryption(nil))
+ assert.True(t, shouldPromptEncryption(&models.ClientsResponse{}))
+ assert.False(t, shouldPromptEncryption(&models.ClientsResponse{
+ Clients: []models.PairedClient{{ClientID: "a", ClientName: "Phone", Role: "admin"}},
+ }))
+}
+
+func TestShowEncryptionPrompt_Buttons_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ secureCalled := make(chan struct{}, 1)
+ dontAskCalled := make(chan struct{}, 1)
+
+ runner.QueueUpdateDraw(func() {
+ ShowEncryptionPrompt(pages, runner.App(),
+ func() { close(secureCalled) },
+ nil,
+ func() { close(dontAskCalled) },
+ )
+ })
+
+ require.True(t, runner.WaitForText("Secure Zaparoo?", 100*time.Millisecond))
+ assert.True(t, runner.ContainsText("Secure Now"))
+ assert.True(t, runner.ContainsText("Not Now"))
+ assert.True(t, runner.ContainsText("Don't Ask Again"))
+
+ runner.Screen().InjectEnter()
+ runner.Draw()
+
+ assert.True(t, runner.WaitForSignal(secureCalled, 100*time.Millisecond),
+ "Secure Now callback should be called")
+ select {
+ case <-dontAskCalled:
+ t.Fatal("Don't Ask Again callback should not be called")
+ default:
+ }
+}
+
+func TestMaybeShowEncryptionPrompt_SkipsWithPairedClients(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ svc := NewMockSettingsService()
+ svc.On("GetClients", mock.Anything).Return(&models.ClientsResponse{
+ Clients: []models.PairedClient{{ClientID: "a", ClientName: "Phone", Role: "admin"}},
+ }, nil)
+
+ runner.QueueUpdateDraw(func() {
+ maybeShowEncryptionPrompt(svc, pages, runner.App(), func() {
+ t.Error("markPrompted should not be called")
+ })
+ })
+ runner.Draw()
+
+ assert.False(t, runner.ContainsText("Secure Zaparoo?"))
+ svc.AssertExpectations(t)
+}
+
+func TestMaybeShowEncryptionPrompt_ShowsWithNoClients(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ svc := NewMockSettingsService()
+ svc.On("GetClients", mock.Anything).Return(&models.ClientsResponse{}, nil)
+
+ runner.QueueUpdateDraw(func() {
+ maybeShowEncryptionPrompt(svc, pages, runner.App(), nil)
+ })
+
+ assert.True(t, runner.WaitForText("Secure Zaparoo?", 100*time.Millisecond))
+ svc.AssertExpectations(t)
+}
+
+func TestEncryptionPairingModal_SuccessKeepsEncryption(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ svc := NewMockSettingsService()
+ svc.On("GetClients", mock.Anything).Return(&models.ClientsResponse{
+ Clients: []models.PairedClient{{ClientID: "a", ClientName: "Phone", Role: "admin"}},
+ }, nil)
+ svc.On("CancelClientPairing", mock.Anything).Return(nil)
+
+ pairing := &models.ClientsPairStartResponse{
+ PIN: "123456",
+ ExpiresAt: time.Now().Add(time.Minute).Unix(),
+ }
+ markPrompted := make(chan struct{}, 1)
+ runner.QueueUpdateDraw(func() {
+ showEncryptionPairingModal(svc, pages, runner.App(), pairing,
+ 10*time.Millisecond, func() { close(markPrompted) })
+ })
+
+ require.True(t, runner.WaitForText("Zaparoo secured.", time.Second))
+ assert.True(t, runner.WaitForSignal(markPrompted, 100*time.Millisecond),
+ "markPrompted should be called on success")
+ // Encryption must not be reverted on success.
+ assert.Equal(t, 0, svc.UpdateSettingsCallCount())
+ svc.AssertExpectations(t)
+}
+
+func TestEncryptionPairingModal_ExpiryFailsOpen(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ svc := NewMockSettingsService()
+ disabled := false
+ svc.On("UpdateSettings", mock.Anything, &models.UpdateSettingsParams{
+ Encryption: &disabled,
+ }).Return(nil)
+
+ pairing := &models.ClientsPairStartResponse{
+ PIN: "123456",
+ ExpiresAt: time.Now().Add(-time.Second).Unix(),
+ }
+ runner.QueueUpdateDraw(func() {
+ showEncryptionPairingModal(svc, pages, runner.App(), pairing,
+ 10*time.Millisecond, func() { t.Error("markPrompted should not be called on expiry") })
+ })
+
+ require.True(t, runner.WaitForText("No changes were made.", time.Second))
+ assert.True(t, runner.ContainsText("PIN expired"))
+ svc.AssertExpectations(t)
+}
+
+func TestEncryptionPairingModal_CancelFailsOpen(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ svc := NewMockSettingsService()
+ svc.On("GetClients", mock.Anything).Return(&models.ClientsResponse{}, nil).Maybe()
+ svc.On("CancelClientPairing", mock.Anything).Return(nil)
+ disabled := false
+ svc.On("UpdateSettings", mock.Anything, &models.UpdateSettingsParams{
+ Encryption: &disabled,
+ }).Return(nil)
+
+ pairing := &models.ClientsPairStartResponse{
+ PIN: "123456",
+ ExpiresAt: time.Now().Add(time.Minute).Unix(),
+ }
+ runner.QueueUpdateDraw(func() {
+ showEncryptionPairingModal(svc, pages, runner.App(), pairing,
+ time.Minute, func() { t.Error("markPrompted should not be called on cancel") })
+ })
+ require.True(t, runner.WaitForText("Pairing PIN: 123 456", time.Second))
+
+ runner.Screen().InjectEnter()
+ runner.Draw()
+
+ require.True(t, runner.WaitForText("No changes were made.", time.Second))
+ assert.True(t, runner.ContainsText("Pairing cancelled."))
+ svc.AssertExpectations(t)
+}
+
+func TestStartEncryptionSetup_PairStartFailureFailsOpen(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ svc := NewMockSettingsService()
+ enabled := true
+ disabled := false
+ svc.On("UpdateSettings", mock.Anything, &models.UpdateSettingsParams{
+ Encryption: &enabled,
+ }).Return(nil)
+ svc.On("StartClientPairing", mock.Anything, "admin").
+ Return(nil, assert.AnError)
+ svc.On("UpdateSettings", mock.Anything, &models.UpdateSettingsParams{
+ Encryption: &disabled,
+ }).Return(nil)
+
+ runner.QueueUpdateDraw(func() {
+ startEncryptionSetup(svc, pages, runner.App(), func() {
+ t.Error("markPrompted should not be called on failure")
+ })
+ })
+
+ require.True(t, runner.WaitForText("No changes were made.", time.Second))
+ svc.AssertExpectations(t)
+}
diff --git a/pkg/ui/tui/exportlog.go b/pkg/ui/tui/exportlog.go
index b6b16d90..b6dfba0a 100644
--- a/pkg/ui/tui/exportlog.go
+++ b/pkg/ui/tui/exportlog.go
@@ -154,11 +154,11 @@ var (
func uploadLog(pl platforms.Platform, pages *tview.Pages, app *tview.Application) string {
logPath := path.Join(pl.Settings().LogDir, config.LogFile)
- loadingModal := tview.NewModal().SetText("Uploading log file...")
- SetBoxTitle(loadingModal, "Log upload")
- loadingModal.SetBorder(true)
- pages.AddPage("temp_upload", loadingModal, true, true)
- app.SetFocus(loadingModal)
+ loadingDialog := NewDialog().
+ SetText("Uploading log file...").
+ SetTitle("Log upload")
+ pages.AddPage("temp_upload", loadingDialog, true, true)
+ app.SetFocus(loadingDialog)
app.ForceDraw()
//nolint:gosec // logPath is from internal platform settings, not user input
diff --git a/pkg/ui/tui/main.go b/pkg/ui/tui/main.go
index 5a30df45..dec99f03 100644
--- a/pkg/ui/tui/main.go
+++ b/pkg/ui/tui/main.go
@@ -42,6 +42,9 @@ const (
PageSettingsMain = "settings_main"
PageSettingsBasic = "settings_basic"
PageSettingsAdvanced = "settings_advanced"
+ PageSettingsBackup = "settings_backup"
+ PageSettingsBackupList = "settings_backup_list"
+ PageSettingsOnline = "settings_online"
PageSettingsReaderList = "settings_reader_list"
PageSettingsReaderEdit = "settings_reader_edit"
PageSettingsIgnoreSystems = "settings_ignore_systems"
@@ -784,48 +787,73 @@ func BuildMain(
}
app.SetRoot(rootWidget, true)
- if !tuiCfg.ErrorReportingPrompted && isRunning() {
- // Check the service's actual error reporting state via API, not the
- // local config. The service daemon may run as a separate process with
- // different in-memory state than what's on disk.
+ if isRunning() {
apiClient := client.NewLocalAPIClient(cfg)
svc := NewSettingsService(apiClient)
- ctx, cancel := tuiContext()
- settings, err := svc.GetSettings(ctx)
- cancel()
+ configDir := helpers.ConfigDir(pl)
+ markTUIPrompted := func(update func(*config.TUIConfig)) {
+ updated := config.GetTUIConfig()
+ update(&updated)
+ config.SetTUIConfig(updated)
+ go func() {
+ if err := config.SaveTUIConfig(configDir); err != nil {
+ log.Error().Err(err).Msg("failed to save TUI config")
+ }
+ }()
+ }
+ // The secure-device prompt is chained after the error reporting
+ // prompt so the two never stack on the same launch.
+ showSecureDevicePrompt := func() {
+ if config.GetTUIConfig().EncryptionPrompted {
+ return
+ }
+ maybeShowEncryptionPrompt(svc, pages, app, func() {
+ markTUIPrompted(func(c *config.TUIConfig) { c.EncryptionPrompted = true })
+ })
+ }
- if err != nil {
- log.Warn().Err(err).Msg("failed to check error reporting state")
- } else if !settings.ErrorReporting {
- configDir := helpers.ConfigDir(pl)
- markPrompted := func() {
- updated := config.GetTUIConfig()
- updated.ErrorReportingPrompted = true
- config.SetTUIConfig(updated)
- go func() {
- if err := config.SaveTUIConfig(configDir); err != nil {
- log.Error().Err(err).Msg("failed to save TUI config")
- }
- }()
+ if !tuiCfg.ErrorReportingPrompted {
+ // Check the service's actual error reporting state via API, not
+ // the local config. The service daemon may run as a separate
+ // process with different in-memory state than what's on disk.
+ ctx, cancel := tuiContext()
+ settings, err := svc.GetSettings(ctx)
+ cancel()
+
+ switch {
+ case err != nil:
+ log.Warn().Err(err).Msg("failed to check error reporting state")
+ case !settings.ErrorReporting:
+ markPrompted := func() {
+ markTUIPrompted(func(c *config.TUIConfig) { c.ErrorReportingPrompted = true })
+ }
+ ShowErrorReportingPrompt(pages, app,
+ func() {
+ enabled := true
+ ctx, cancel := tuiContext()
+ defer cancel()
+ err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{
+ ErrorReporting: &enabled,
+ })
+ if err != nil {
+ log.Warn().Err(err).Msg("error enabling error reporting")
+ ShowErrorModal(pages, app, "Failed to enable error reporting", showSecureDevicePrompt)
+ return
+ }
+ markPrompted()
+ showSecureDevicePrompt()
+ },
+ showSecureDevicePrompt,
+ func() {
+ markPrompted()
+ showSecureDevicePrompt()
+ },
+ )
+ default:
+ showSecureDevicePrompt()
}
- ShowErrorReportingPrompt(pages, app,
- func() {
- enabled := true
- ctx, cancel := tuiContext()
- defer cancel()
- err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{
- ErrorReporting: &enabled,
- })
- if err != nil {
- log.Warn().Err(err).Msg("error enabling error reporting")
- ShowErrorModal(pages, app, "Failed to enable error reporting", nil)
- return
- }
- markPrompted()
- },
- nil,
- markPrompted,
- )
+ } else {
+ showSecureDevicePrompt()
}
}
diff --git a/pkg/ui/tui/mock_api_client_test.go b/pkg/ui/tui/mock_api_client_test.go
index bc309492..bba762b7 100644
--- a/pkg/ui/tui/mock_api_client_test.go
+++ b/pkg/ui/tui/mock_api_client_test.go
@@ -88,6 +88,111 @@ func (m *MockSettingsService) UpdateSettings(ctx context.Context, params *models
return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors
}
+func (m *MockSettingsService) CreateBackup(ctx context.Context) (string, error) {
+ args := m.Called(ctx)
+ return args.String(0), args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) ListBackups(ctx context.Context) ([]map[string]any, error) {
+ args := m.Called(ctx)
+ if args.Get(0) == nil {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ backups, ok := args.Get(0).([]map[string]any)
+ if !ok {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ return backups, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) InspectBackup(ctx context.Context, name string) (map[string]any, error) {
+ args := m.Called(ctx, name)
+ if args.Get(0) == nil {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ backup, ok := args.Get(0).(map[string]any)
+ if !ok {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ return backup, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) DeleteBackup(ctx context.Context, name string) error {
+ args := m.Called(ctx, name)
+ return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) RestoreBackup(ctx context.Context, name string) error {
+ args := m.Called(ctx, name)
+ return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) GetBackupStatus(ctx context.Context) (*models.BackupStatusResponse, error) {
+ args := m.Called(ctx)
+ if args.Get(0) == nil {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ status, ok := args.Get(0).(*models.BackupStatusResponse)
+ if !ok {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ return status, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) RunRemoteBackup(ctx context.Context) (string, error) {
+ args := m.Called(ctx)
+ id, ok := args.Get(0).(string)
+ if !ok {
+ return "", args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ return id, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) ListRemoteBackups(ctx context.Context) ([]RemoteBackupItem, error) {
+ args := m.Called(ctx)
+ if args.Get(0) == nil {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ backups, ok := args.Get(0).([]RemoteBackupItem)
+ if !ok {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ return backups, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) RestoreRemoteBackup(ctx context.Context, id string) error {
+ args := m.Called(ctx, id)
+ return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) StartAuthLink(ctx context.Context) (*models.AuthLinkStatusResponse, error) {
+ args := m.Called(ctx)
+ link, ok := args.Get(0).(*models.AuthLinkStatusResponse)
+ if !ok {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ return link, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) GetAuthLinkStatus(ctx context.Context) (*models.AuthLinkStatusResponse, error) {
+ args := m.Called(ctx)
+ link, ok := args.Get(0).(*models.AuthLinkStatusResponse)
+ if !ok {
+ return nil, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+ }
+ return link, args.Error(1) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) CancelAuthLink(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors
+}
+
+func (m *MockSettingsService) Unlink(ctx context.Context) error {
+ args := m.Called(ctx)
+ return args.Error(0) //nolint:wrapcheck // mock returns test-provided errors
+}
+
// GetSystems mocks fetching systems.
func (m *MockSettingsService) GetSystems(ctx context.Context) ([]models.System, error) {
args := m.Called(ctx)
@@ -121,6 +226,34 @@ func (m *MockSettingsService) SetupUpdateSettingsError(err error) {
m.On("UpdateSettings", mock.Anything, mock.Anything).Return(err)
}
+func (m *MockSettingsService) SetupGetBackupStatus(status *models.BackupStatusResponse) {
+ m.On("GetBackupStatus", mock.Anything).Return(status, nil)
+}
+
+func (m *MockSettingsService) SetupCreateBackup(name string) {
+ m.On("CreateBackup", mock.Anything).Return(name, nil)
+}
+
+func (m *MockSettingsService) SetupListBackups(backups []map[string]any) {
+ m.On("ListBackups", mock.Anything).Return(backups, nil)
+}
+
+func (m *MockSettingsService) SetupInspectBackup(backup map[string]any) {
+ m.On("InspectBackup", mock.Anything, mock.AnythingOfType("string")).Return(backup, nil)
+}
+
+func (m *MockSettingsService) SetupInspectBackupError(err error) {
+ m.On("InspectBackup", mock.Anything, mock.AnythingOfType("string")).Return(nil, err)
+}
+
+func (m *MockSettingsService) SetupDeleteBackupSuccess() {
+ m.On("DeleteBackup", mock.Anything, mock.AnythingOfType("string")).Return(nil)
+}
+
+func (m *MockSettingsService) SetupRestoreBackupSuccess() {
+ m.On("RestoreBackup", mock.Anything, mock.AnythingOfType("string")).Return(nil)
+}
+
// SetupGetSystems configures the mock to return systems.
func (m *MockSettingsService) SetupGetSystems(systems []models.System) {
m.On("GetSystems", mock.Anything).Return(systems, nil)
diff --git a/pkg/ui/tui/online.go b/pkg/ui/tui/online.go
new file mode 100644
index 00000000..2fa7bdf1
--- /dev/null
+++ b/pkg/ui/tui/online.go
@@ -0,0 +1,228 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package tui
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/rivo/tview"
+ "github.com/rs/zerolog/log"
+)
+
+// onlinePageData bundles the API responses the online settings page renders.
+type onlinePageData struct {
+ status *models.BackupStatusResponse
+ settings *models.SettingsResponse
+}
+
+// onlineServerHost returns the backup server host to display when a custom
+// server is configured, or "" when using the official default.
+func onlineServerHost(settings *models.SettingsResponse) string {
+ if settings == nil || settings.BackupRemoteBaseURL == nil {
+ return ""
+ }
+ base := *settings.BackupRemoteBaseURL
+ if base == "" || strings.EqualFold(base, config.DefaultBackupRemoteBaseURL) {
+ return ""
+ }
+ parsed, err := url.Parse(base)
+ if err != nil || parsed.Host == "" {
+ return base
+ }
+ return parsed.Host
+}
+
+// buildOnlineSettingsMenu loads account status in the background, then shows
+// the Zaparoo Online settings page. goBack runs when the page is dismissed.
+func buildOnlineSettingsMenu(svc SettingsService, pages *tview.Pages, app *tview.Application, goBack func()) {
+ loadSettingsPage(pages, app, PageSettingsOnline,
+ []string{"Settings", "Online"},
+ "Loading online status...",
+ "Failed to load online status",
+ tuiContext, goBack,
+ func(ctx context.Context) (*onlinePageData, error) {
+ status, err := svc.GetBackupStatus(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get backup status: %w", err)
+ }
+ settings, err := svc.GetSettings(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get settings: %w", err)
+ }
+ return &onlinePageData{status: status, settings: settings}, nil
+ },
+ func(data *onlinePageData) {
+ renderOnlineSettingsMenu(svc, pages, app, data, goBack)
+ },
+ )
+}
+
+// renderOnlineSettingsMenu shows the Zaparoo Online page: an Account section
+// with link status shown directly on the menu lines, and a Features section
+// pointing at the cloud features an account unlocks.
+func renderOnlineSettingsMenu(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ data *onlinePageData,
+ goBack func(),
+) {
+ frame := NewPageFrame(app).SetTitle("Settings", "Online")
+ frame.SetOnEscape(goBack)
+ buttonBar := NewButtonBar(app).AddButton("Back", goBack).SetupNavigation(goBack)
+ frame.SetButtonBar(buttonBar)
+
+ menu := NewSettingsList(pages, PageSettingsMain).SetRebuildPrevious(goBack)
+ menu.SetDynamicHelpMode(true).SetHelpCallback(func(desc string) { frame.SetHelpText(desc) })
+ menu.SetOnNavigateOut(frame.FocusButtonBar)
+
+ rebuild := func() { buildOnlineSettingsMenu(svc, pages, app, goBack) }
+ status := data.status
+ serverHost := onlineServerHost(data.settings)
+
+ menu.AddHeader("Account")
+ if status.Remote.Linked {
+ addOnlineAccountItems(svc, pages, app, menu, status, serverHost, rebuild)
+ } else {
+ menu.AddValueAction("Account",
+ "Link a Zaparoo Online account to unlock cloud features",
+ func() string { return "Not linked" }, nil)
+ linkDesc := "Connect this device to your Zaparoo Online account"
+ if serverHost != "" {
+ linkDesc = "Connect this device to " + serverHost
+ }
+ menu.AddNavAction("Link account", linkDesc, func() {
+ startAuthLinkFlow(svc, pages, app, rebuild)
+ })
+ }
+
+ menu.AddHeader("Features")
+ cloudDesc := "Create, restore, and schedule cloud backups of this device"
+ if !status.Remote.Linked {
+ cloudDesc = "Keep this device backed up to the cloud — included with Zaparoo Warp"
+ }
+ menu.AddNavAction("Cloud backup", cloudDesc, func() {
+ buildBackupSettingsMenu(svc, pages, app, rebuild)
+ })
+
+ frame.SetContent(menu.List)
+ menu.TriggerInitialHelp()
+ frame.SetupContentToButtonNavigation()
+ pages.AddAndSwitchToPage(PageSettingsOnline, frame, true)
+}
+
+// addOnlineAccountItems adds the Account section items available while an
+// account is linked: link status, Warp subscription state, and unlink.
+func addOnlineAccountItems(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ menu *SettingsList,
+ status *models.BackupStatusResponse,
+ serverHost string,
+ rebuild func(),
+) {
+ deviceName := ""
+ if status.Remote.DeviceName != nil {
+ deviceName = *status.Remote.DeviceName
+ }
+ accountValue := "Linked"
+ if deviceName != "" {
+ accountValue = "Linked as " + deviceName
+ }
+ accountDesc := "This device is linked to Zaparoo Online"
+ if serverHost != "" {
+ accountDesc = "This device is linked to " + serverHost
+ }
+ accountDetail := accountDesc + "."
+ if deviceName != "" {
+ accountDetail += "\n\nDevice name: " + deviceName
+ }
+ if since := formatLinkedSince(status.Remote.LinkedAt); since != "" {
+ accountDetail += "\nLinked since: " + since
+ }
+ menu.AddValueAction("Account", accountDesc, func() string { return accountValue }, func() {
+ ShowInfoModal(pages, app, "Zaparoo Online", accountDetail, func() {
+ app.SetFocus(menu.List)
+ })
+ })
+
+ var warpValue, warpDetail string
+ switch status.Remote.Availability {
+ case "available":
+ warpValue = "Active"
+ warpDetail = "Your Zaparoo Warp subscription is active.\n\n" +
+ "Cloud backup and other premium features are enabled."
+ case "unavailable":
+ warpValue = "Not active"
+ warpDetail = "Cloud backup uploads require an active\nZaparoo Warp subscription.\n\n" +
+ "Existing cloud backups can still be restored."
+ default:
+ warpValue = "Checking..."
+ warpDetail = "Warp subscription status is being checked.\n\n" +
+ "It refreshes automatically in the background."
+ }
+ menu.AddValueAction("Warp", "Premium subscription powering cloud features",
+ func() string { return warpValue }, func() {
+ ShowInfoModal(pages, app, "Zaparoo Warp", warpDetail, func() {
+ app.SetFocus(menu.List)
+ })
+ })
+
+ menu.AddNavAction("Unlink account", "Remove this device's Zaparoo Online credentials", func() {
+ ShowConfirmModal(pages, app,
+ "Unlink from Zaparoo Online?\n\nAutomatic cloud backups will stop\nuntil you link this device again.",
+ func() {
+ ctx, cancel := tuiContext()
+ err := svc.Unlink(ctx)
+ cancel()
+ if err != nil {
+ log.Warn().Err(err).Msg("error unlinking from Zaparoo Online")
+ ShowErrorModal(pages, app, "Failed to unlink", func() {
+ app.SetFocus(menu.List)
+ })
+ return
+ }
+ ShowInfoModal(pages, app, "Unlinked",
+ "This device's Zaparoo Online\ncredentials were removed.", rebuild)
+ },
+ func() { app.SetFocus(menu.List) },
+ )
+ })
+}
+
+// formatLinkedSince renders a stored link timestamp as a short date, or ""
+// when missing or unparseable.
+func formatLinkedSince(linkedAt *string) string {
+ if linkedAt == nil || *linkedAt == "" {
+ return ""
+ }
+ parsed, err := time.Parse(time.RFC3339Nano, *linkedAt)
+ if err != nil {
+ return ""
+ }
+ return parsed.UTC().Format("2 Jan 2006")
+}
diff --git a/pkg/ui/tui/online_test.go b/pkg/ui/tui/online_test.go
new file mode 100644
index 00000000..28e35ff0
--- /dev/null
+++ b/pkg/ui/tui/online_test.go
@@ -0,0 +1,196 @@
+// Zaparoo Core
+// Copyright (c) 2026 The Zaparoo Project Contributors.
+// SPDX-License-Identifier: GPL-3.0-or-later
+//
+// This file is part of Zaparoo Core.
+//
+// Zaparoo Core is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Zaparoo Core is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Zaparoo Core. If not, see .
+
+package tui
+
+import (
+ "testing"
+ "time"
+
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
+ "github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
+ "github.com/rivo/tview"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
+ "github.com/stretchr/testify/require"
+)
+
+func onlineTestSettings(baseURL string) *models.SettingsResponse {
+ return &models.SettingsResponse{BackupRemoteBaseURL: &baseURL}
+}
+
+func TestOnlineServerHost(t *testing.T) {
+ t.Parallel()
+
+ assert.Empty(t, onlineServerHost(nil))
+ assert.Empty(t, onlineServerHost(&models.SettingsResponse{}))
+ assert.Empty(t, onlineServerHost(onlineTestSettings(config.DefaultBackupRemoteBaseURL)))
+ assert.Equal(t, "backup.example.com:8787",
+ onlineServerHost(onlineTestSettings("https://backup.example.com:8787")))
+}
+
+func TestBuildOnlineSettingsMenu_NotLinkedShowsLinkAction_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+ mockSvc.SetupGetSettings(onlineTestSettings(config.DefaultBackupRemoteBaseURL))
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildOnlineSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Link account", 100*time.Millisecond))
+ assert.True(t, runner.ContainsText("Not linked"), "link status shows on the menu line")
+ assert.True(t, runner.ContainsText("Cloud backup"), "features are discoverable while unlinked")
+ assert.False(t, runner.ContainsText("Unlink account"))
+ assert.False(t, runner.ContainsText("Warp:"), "Warp status is hidden until an account is linked")
+}
+
+func TestBuildOnlineSettingsMenu_LinkedShowsAccountControls_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
+ mockSvc.SetupGetSettings(onlineTestSettings(config.DefaultBackupRemoteBaseURL))
+ mockSvc.SetupUpdateSettingsSuccess()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildOnlineSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Account", 100*time.Millisecond))
+ assert.True(t, runner.ContainsText("Linked"), "link status shows on the menu line")
+ assert.True(t, runner.ContainsText("Warp"), "Warp subscription status shows on the menu line")
+ assert.True(t, runner.ContainsText("Cloud backup"))
+ assert.True(t, runner.ContainsText("Unlink account"))
+ assert.False(t, runner.ContainsText("Link account"))
+}
+
+func TestBuildOnlineSettingsMenu_LinkedShowsDeviceName_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ status := backupTestStatus(true)
+ deviceName := "Living Room MiSTer"
+ status.Remote.DeviceName = &deviceName
+ mockSvc.SetupGetBackupStatus(status)
+ mockSvc.SetupGetSettings(onlineTestSettings(config.DefaultBackupRemoteBaseURL))
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildOnlineSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Linked as Living Room MiSTer", 100*time.Millisecond))
+}
+
+func TestBuildOnlineSettingsMenu_CustomServerShownInStatus_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
+ mockSvc.SetupGetSettings(onlineTestSettings("https://backup.example.com"))
+ mockSvc.SetupUpdateSettingsSuccess()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildOnlineSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ // The custom server host shows in the help text of the selected
+ // Account row.
+ require.True(t, runner.WaitForText("This device is linked to backup.example.com", 100*time.Millisecond))
+}
+
+func TestBuildOnlineSettingsMenu_UnlinkConfirmFlow_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ // First build: linked. After unlinking the page rebuilds: not linked.
+ mockSvc.On("GetBackupStatus", mock.Anything).Return(backupTestStatus(true), nil).Once()
+ mockSvc.On("GetBackupStatus", mock.Anything).Return(backupTestStatus(false), nil)
+ mockSvc.SetupGetSettings(onlineTestSettings(config.DefaultBackupRemoteBaseURL))
+ mockSvc.On("Unlink", mock.Anything).Return(nil).Once()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildOnlineSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+ require.True(t, runner.WaitForText("Unlink account", 100*time.Millisecond))
+
+ // Account section: Account row, Warp row, Unlink account.
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Unlink from Zaparoo Online?", 100*time.Millisecond))
+
+ // Confirm ("Yes" is focused first).
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("credentials were removed", 100*time.Millisecond))
+
+ // Dismiss the confirmation: the page rebuilds in the unlinked state.
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Link account", 100*time.Millisecond))
+ mockSvc.AssertCalled(t, "Unlink", mock.Anything)
+}
+
+func TestBuildOnlineSettingsMenu_CloudBackupNavigatesToBackupPage_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
+ mockSvc.SetupGetSettings(onlineTestSettings(config.DefaultBackupRemoteBaseURL))
+ mockSvc.SetupUpdateSettingsSuccess()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildOnlineSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+ require.True(t, runner.WaitForText("Cloud backup", 100*time.Millisecond))
+
+ // Account row, Warp row, Unlink account, then Cloud backup in Features.
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateEnter()
+
+ require.True(t, runner.WaitForText("Automatic backup", 500*time.Millisecond),
+ "selecting Cloud backup should open the backup settings page")
+}
diff --git a/pkg/ui/tui/profiles.go b/pkg/ui/tui/profiles.go
index 080227e8..90c68f97 100644
--- a/pkg/ui/tui/profiles.go
+++ b/pkg/ui/tui/profiles.go
@@ -271,10 +271,11 @@ func showProfileSwitchIDModal(
onReset func(),
onCancel func(),
) {
- modal := tview.NewModal().
+ dialog := NewDialog().
SetText("Switch ID:\n" + switchID + "\n\nResetting invalidates existing switch cards.").
+ SetTitle("Switch ID").
AddButtons([]string{"Reset", "Cancel"}).
- SetDoneFunc(func(buttonIndex int, _ string) {
+ SetDoneFunc(func(buttonIndex int) {
pages.HidePage(profileSwitchIDModalPage)
pages.RemovePage(profileSwitchIDModalPage)
if buttonIndex == 0 {
@@ -283,9 +284,8 @@ func showProfileSwitchIDModal(
onCancel()
}
})
- SetBoxTitle(modal.Box, "Switch ID")
- pages.AddPage(profileSwitchIDModalPage, modal, false, true)
- app.SetFocus(modal)
+ pages.AddPage(profileSwitchIDModalPage, dialog, true, true)
+ app.SetFocus(dialog)
}
func hasAdminProfile(profiles []models.ProfileResponse) bool {
diff --git a/pkg/ui/tui/settings.go b/pkg/ui/tui/settings.go
index 3b013326..c3ecfdd0 100644
--- a/pkg/ui/tui/settings.go
+++ b/pkg/ui/tui/settings.go
@@ -20,7 +20,9 @@
package tui
import (
+ "context"
"fmt"
+ "math"
"slices"
"strings"
"time"
@@ -34,6 +36,29 @@ import (
"github.com/rs/zerolog/log"
)
+// PageSettingsBackupSnapshots shows one source device's cloud snapshots.
+const PageSettingsBackupSnapshots = "settings_backup_snapshots"
+
+// remoteBackupSource groups an account's cloud snapshots by the device that
+// created them, snapshots newest first.
+type remoteBackupSource struct {
+ Device RemoteBackupSourceDevice
+ Backups []RemoteBackupItem
+}
+
+// backupCategoryOrder and backupCategoryLabels define the display order and
+// names for backup categories across list, detail, and confirmation views.
+var (
+ backupCategoryOrder = []string{"zaparoo", "settings", "inputs", "saves", "savestates"}
+ backupCategoryLabels = map[string]string{
+ "zaparoo": "Zaparoo",
+ "settings": "Settings",
+ "inputs": "Inputs",
+ "saves": "Saves",
+ "savestates": "Save states",
+ }
+)
+
// BuildSettingsMainMenu creates the top-level settings menu with Audio, Readers, and Advanced options.
func BuildSettingsMainMenu(
cfg *config.Instance,
@@ -110,6 +135,12 @@ func BuildSettingsMainMenuWithService(
AddNavAction("Clients", "Pair and revoke client devices", func() {
BuildClientsPage(svc, pages, app)
}).
+ AddNavAction("Backup", "Back up and restore this device", func() {
+ buildBackupSettingsMenu(svc, pages, app, func() { pages.SwitchToPage(PageSettingsMain) })
+ }).
+ AddNavAction("Online", "Zaparoo Online account and cloud features", func() {
+ buildOnlineSettingsMenu(svc, pages, app, func() { pages.SwitchToPage(PageSettingsMain) })
+ }).
AddNavAction("Advanced", "Debug and system options", func() {
buildAdvancedSettingsMenu(svc, pages, app)
}).
@@ -426,6 +457,1154 @@ func buildReadersSettingsMenu(
pages.AddAndSwitchToPage(PageSettingsReadersMenu, frame, true)
}
+// buildBackupSettingsMenu loads backup status in the background, then shows
+// the backup settings submenu. goBack runs when the page is dismissed, so
+// the page returns to wherever it was opened from.
+func buildBackupSettingsMenu(svc SettingsService, pages *tview.Pages, app *tview.Application, goBack func()) {
+ loadSettingsPage(pages, app, PageSettingsBackup,
+ []string{"Settings", "Backup"},
+ "Loading backup status...",
+ "Failed to load backup status",
+ tuiContext, goBack,
+ func(ctx context.Context) (*models.BackupStatusResponse, error) {
+ status, err := svc.GetBackupStatus(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("get backup status: %w", err)
+ }
+ return status, nil
+ },
+ func(status *models.BackupStatusResponse) {
+ renderBackupSettingsMenu(svc, pages, app, status, goBack)
+ },
+ )
+}
+
+// renderBackupSettingsMenu shows the backup settings submenu: a Local
+// section for portable ZIP backups and a Cloud section for Zaparoo Online
+// backups (or a pointer to account linking when no account is linked).
+func renderBackupSettingsMenu(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ status *models.BackupStatusResponse,
+ goBack func(),
+) {
+ frame := NewPageFrame(app).SetTitle("Settings", "Backup")
+ frame.SetOnEscape(goBack)
+ buttonBar := NewButtonBar(app).AddButton("Back", goBack).SetupNavigation(goBack)
+ frame.SetButtonBar(buttonBar)
+
+ menu := NewSettingsList(pages, PageSettingsMain).SetRebuildPrevious(goBack)
+ menu.SetDynamicHelpMode(true).SetHelpCallback(func(desc string) { frame.SetHelpText(desc) })
+ menu.SetOnNavigateOut(frame.FocusButtonBar)
+
+ rebuild := func() { buildBackupSettingsMenu(svc, pages, app, goBack) }
+
+ if status.ActiveOperation != "" {
+ description := "Backup service is busy"
+ if status.ActiveSince != nil {
+ description += " since " + *status.ActiveSince
+ }
+ menu.AddNavAction("Active operation", description, func() {
+ ShowInfoModal(
+ pages, app, "Backup in progress", status.ActiveOperation+" is currently running.",
+ func() { app.SetFocus(menu.List) },
+ )
+ })
+ }
+
+ menu.AddHeader("Local")
+ menu.AddNavAction("Back up now", "Create a portable local backup ZIP", func() {
+ runBackupAction(
+ pages,
+ app,
+ menu.List,
+ "Creating backup",
+ "Creating local backup ZIP...",
+ func(ctx context.Context) (string, error) {
+ name, backupErr := svc.CreateBackup(ctx)
+ if backupErr != nil {
+ return "", fmt.Errorf("create local backup: %w", backupErr)
+ }
+ return backupLabelFromName("Local backup", name), nil
+ },
+ func(label string) {
+ ShowInfoModal(pages, app, "Backup created", label, rebuild)
+ },
+ )
+ })
+ menu.AddNavAction("View backups", backupStatusDescription(&status.Local), func() {
+ buildBackupListPage(svc, pages, app, rebuild)
+ })
+
+ menu.AddHeader("Cloud")
+ if status.Remote.Linked {
+ addCloudBackupItems(svc, pages, app, menu, status, rebuild)
+ } else {
+ menu.AddNavAction("Link account", "Link a Zaparoo Online account to enable cloud backup", func() {
+ buildOnlineSettingsMenu(svc, pages, app, rebuild)
+ })
+ }
+
+ frame.SetContent(menu.List)
+ menu.TriggerInitialHelp()
+ frame.SetupContentToButtonNavigation()
+ pages.AddAndSwitchToPage(PageSettingsBackup, frame, true)
+}
+
+// addCloudBackupItems adds the Cloud section items available while a
+// Zaparoo Online account is linked.
+func addCloudBackupItems(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ menu *SettingsList,
+ status *models.BackupStatusResponse,
+ rebuild func(),
+) {
+ enabled := status.Remote.Enabled
+ menu.AddToggle(
+ "Automatic backup", "Back up this device to the cloud on a schedule", &enabled,
+ func(value bool) {
+ ctx, cancel := tuiContext()
+ defer cancel()
+ err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{BackupRemoteEnabled: &value})
+ if err != nil {
+ log.Warn().Err(err).Msg("error updating cloud backup setting")
+ ShowErrorModal(
+ pages, app, "Failed to save cloud backup setting", func() { app.SetFocus(menu.List) },
+ )
+ }
+ },
+ )
+ scheduleOptions := []string{"daily", "weekly", "manual"}
+ scheduleIndex := 0
+ for i, option := range scheduleOptions {
+ if option == status.Remote.Schedule {
+ scheduleIndex = i
+ break
+ }
+ }
+ menu.AddCycle(
+ "Schedule",
+ "How often automatic cloud backup runs",
+ scheduleOptions,
+ &scheduleIndex,
+ func(value string, _ int) {
+ ctx, cancel := tuiContext()
+ defer cancel()
+ err := svc.UpdateSettings(ctx, &models.UpdateSettingsParams{BackupRemoteSchedule: &value})
+ if err != nil {
+ log.Warn().Err(err).Msg("error updating cloud backup schedule")
+ ShowErrorModal(pages, app, "Failed to save cloud backup schedule", func() {
+ app.SetFocus(menu.List)
+ })
+ }
+ },
+ )
+ cloudUploadDescription := "Upload a backup of this device to the cloud"
+ if status.Remote.Availability == "unavailable" {
+ cloudUploadDescription = "Warp is required to create cloud backups"
+ }
+ // The cached availability is a display hint only: the upload itself does
+ // a fresh subscription check server-side, so a just-activated Warp
+ // subscription works immediately instead of waiting out the cache TTL.
+ menu.AddNavAction("Back up now", cloudUploadDescription, func() {
+ runBackupAction(
+ pages,
+ app,
+ menu.List,
+ "Creating cloud backup",
+ "Uploading backup to the cloud...",
+ func(ctx context.Context) (string, error) {
+ id, backupErr := svc.RunRemoteBackup(ctx)
+ if backupErr != nil {
+ return "", fmt.Errorf("create cloud backup: %w", backupErr)
+ }
+ return "Cloud backup " + id, nil
+ },
+ func(label string) {
+ ShowInfoModal(pages, app, "Cloud backup created", label, rebuild)
+ },
+ )
+ })
+ menu.AddNavAction("View backups", "List and restore cloud backup snapshots", func() {
+ buildRemoteBackupListPage(svc, pages, app, rebuild)
+ })
+ menu.AddNavAction("Status", backupStatusDescription(&status.Remote), func() {
+ ShowInfoModal(pages, app, "Cloud backup", backupStatusText(&status.Remote), func() {
+ app.SetFocus(menu.List)
+ })
+ })
+}
+
+const (
+ backupProgressModalPage = "backup_progress_modal"
+ // Sized to fit the five-line progress text plus border within the
+ // 75-column CRT view.
+ backupProgressModalWidth = 51
+ backupProgressModalHeight = 7
+)
+
+func backupActionErrorText(title string, err error) string {
+ message := strings.ToLower(err.Error())
+ var guidance string
+ switch {
+ case strings.Contains(message, "backup operation ") && strings.Contains(message, " has been running since "):
+ guidance = "Another backup or restore is already running. Wait for it to finish, then try again."
+ case strings.Contains(message, "cannot restore backup while media is active"):
+ guidance = "Stop active media before restoring this backup."
+ case strings.Contains(message, "cannot restore backup while media is launching") ||
+ strings.Contains(message, "media launch is in progress"):
+ guidance = "Wait for media launch to finish, then try restoring again."
+ case strings.Contains(message, "full-device backup is not supported on this platform"):
+ guidance = "Full-device backup is not available on this platform."
+ case strings.Contains(message, "remote backup is not available for this account"):
+ guidance = "Cloud backup requires an active Zaparoo Warp subscription."
+ case strings.Contains(message, "backup restore rollback requires recovery") ||
+ strings.Contains(message, "pending backup restore transaction exists"):
+ guidance = "Restart Zaparoo Core to complete restore recovery, then try again."
+ case strings.Contains(message, "backup restore restart is pending"):
+ guidance = "Restart Zaparoo Core before starting another backup or restore operation."
+ case strings.Contains(message, "remote backup rate limited"):
+ guidance = "A backup was uploaded recently. Wait a few minutes, then try again."
+ case strings.Contains(message, "remote backup is unlinked") || strings.Contains(message, "device not linked"):
+ guidance = "Relink this device to Zaparoo Online, then try again."
+ case strings.Contains(message, "insufficient disk space"):
+ guidance = "Free storage space on this device, then try again."
+ default:
+ guidance = "Check Core logs for details, then try again."
+ }
+ return title + " failed.\n\n" + guidance
+}
+
+func runBackupAction(
+ pages *tview.Pages,
+ app *tview.Application,
+ focus tview.Primitive,
+ title string,
+ message string,
+ run func(context.Context) (string, error),
+ onSuccess func(string),
+) {
+ ctx, cancel := backupContext()
+ started := time.Now()
+ done := make(chan struct{})
+
+ // Deliberately blocking: no buttons, and all input is swallowed.
+ // Backups and restores are foreground operations whose outcome must
+ // always be reported, and a restore ends in a Core restart. A plain
+ // bordered TextView is used instead of tview.Modal so no empty button
+ // row is reserved.
+ modal := tview.NewTextView().
+ SetTextAlign(tview.AlignCenter).
+ SetText(backupProgressText(message, started))
+ modal.SetInputCapture(func(_ *tcell.EventKey) *tcell.EventKey { return nil })
+ modal.SetBorder(true)
+ SetBoxTitle(modal.Box, title)
+ modal.SetTitleAlign(tview.AlignCenter)
+ pages.AddPage(backupProgressModalPage,
+ CenterWidget(backupProgressModalWidth, backupProgressModalHeight, modal), true, true)
+ app.SetFocus(modal)
+
+ go func() {
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-ticker.C:
+ app.QueueUpdateDraw(func() {
+ modal.SetText(backupProgressText(message, started))
+ })
+ }
+ }
+ }()
+
+ go func() {
+ label, err := run(ctx)
+ cancel()
+ close(done)
+ app.QueueUpdateDraw(func() {
+ pages.HidePage(backupProgressModalPage)
+ pages.RemovePage(backupProgressModalPage)
+ if err != nil {
+ log.Warn().Err(err).Msg("error running backup action")
+ ShowErrorModal(pages, app, backupActionErrorText(title, err), func() { app.SetFocus(focus) })
+ return
+ }
+ onSuccess(label)
+ })
+ }()
+}
+
+func backupProgressText(message string, started time.Time) string {
+ return fmt.Sprintf(
+ "%s\n\nElapsed: %s\n\nTime depends on save data size.",
+ message,
+ time.Since(started).Round(time.Second),
+ )
+}
+
+const authLinkModalPage = "authLinkModal"
+
+// startAuthLinkFlow runs the reverse device link flow: display the user code
+// and verification URL, then wait for the link to be approved online. onDone
+// rebuilds the caller's menu when the flow ends, whatever the outcome.
+func startAuthLinkFlow(svc SettingsService, pages *tview.Pages, app *tview.Application, onDone func()) {
+ ctx, cancel := tuiContext()
+ defer cancel()
+ link, err := svc.StartAuthLink(ctx)
+ if err != nil {
+ log.Warn().Err(err).Msg("error starting device link")
+ ShowErrorModal(pages, app, "Failed to start device linking", onDone)
+ return
+ }
+
+ done := make(chan struct{})
+ closeModal := func() {
+ pages.HidePage(authLinkModalPage)
+ pages.RemovePage(authLinkModalPage)
+ }
+
+ dialog := NewDialog().
+ SetText(authLinkMessage(link.VerificationURL, link.UserCode)).
+ SetTextAlign(tview.AlignLeft).
+ SetTitle("Link with Zaparoo Online").
+ AddButtons([]string{"Cancel"}).
+ SetDoneFunc(func(_ int) {
+ close(done)
+ cancelCtx, cancelCancel := tuiContext()
+ defer cancelCancel()
+ if cancelErr := svc.CancelAuthLink(cancelCtx); cancelErr != nil {
+ log.Debug().Err(cancelErr).Msg("error cancelling device link")
+ }
+ closeModal()
+ onDone()
+ })
+ pages.AddPage(authLinkModalPage, dialog, true, true)
+ app.SetFocus(dialog)
+
+ go pollAuthLinkStatus(svc, pages, app, done, closeModal, onDone)
+}
+
+// authLinkMessage lays out the link instructions so the two things the user
+// must act on — the address and the code — stand out from the fixed text.
+func authLinkMessage(verificationURL, userCode string) string {
+ t := CurrentTheme()
+ accent := colorToHex(t.LabelColor)
+ dim := colorToHex(t.SecondaryTextColor)
+ displayURL := strings.TrimPrefix(strings.TrimPrefix(verificationURL, "https://"), "http://")
+ return fmt.Sprintf(
+ "On your phone or computer, open:\n [%s::b]%s[-::-]\n\n"+
+ "and enter this code:\n [%s::b]%s[-::-]\n\n"+
+ "[%s]Waiting for approval...[-::-]",
+ accent, displayURL, accent, userCode, dim,
+ )
+}
+
+// pollAuthLinkStatus watches the link flow until it reaches a terminal state
+// and swaps the waiting modal for the outcome.
+func pollAuthLinkStatus(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ done <-chan struct{},
+ closeModal func(),
+ onDone func(),
+) {
+ ticker := time.NewTicker(2 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-done:
+ return
+ case <-ticker.C:
+ }
+
+ ctx, cancel := tuiContext()
+ status, err := svc.GetAuthLinkStatus(ctx)
+ cancel()
+ if err != nil {
+ log.Debug().Err(err).Msg("error polling device link status")
+ continue
+ }
+ switch status.Status {
+ case models.AuthLinkStatusPending:
+ continue
+ case models.AuthLinkStatusApproved:
+ app.QueueUpdateDraw(func() {
+ closeModal()
+ ShowInfoModal(pages, app, "Device linked",
+ "This device is now linked to Zaparoo Online.\n\n"+
+ "Existing backups from your account are under\n"+
+ "Cloud backup > View backups.", onDone)
+ })
+ return
+ default:
+ reason := status.Error
+ if reason == "" {
+ reason = "Device linking did not complete."
+ }
+ app.QueueUpdateDraw(func() {
+ closeModal()
+ ShowErrorModal(pages, app, reason, onDone)
+ })
+ return
+ }
+ }
+}
+
+func buildBackupListPage(svc SettingsService, pages *tview.Pages, app *tview.Application, goBack func()) {
+ loadSettingsPage(pages, app, PageSettingsBackupList,
+ []string{"Settings", "Backup", "Local"},
+ "Loading backups...",
+ "Failed to list backups",
+ tuiContext, goBack,
+ func(ctx context.Context) ([]map[string]any, error) {
+ backups, err := svc.ListBackups(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list backups: %w", err)
+ }
+ return backups, nil
+ },
+ func(backups []map[string]any) {
+ renderBackupListPage(svc, pages, app, backups, goBack)
+ },
+ )
+}
+
+func renderBackupListPage(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ backups []map[string]any,
+ goBack func(),
+) {
+ frame := NewPageFrame(app).SetTitle("Settings", "Backup", "Local")
+ frame.SetOnEscape(goBack)
+ buttonBar := NewButtonBar(app).AddButton("Back", goBack).SetupNavigation(goBack)
+ frame.SetButtonBar(buttonBar)
+
+ list := tview.NewList()
+ list.SetSecondaryTextColor(CurrentTheme().SecondaryTextColor)
+ list.ShowSecondaryText(true)
+ list.SetSelectedFocusOnly(true)
+ for _, backup := range backups {
+ name := backupString(backup, "name")
+ if name == "" {
+ continue
+ }
+ secondary := formatBackupSize(backup, "size")
+ backupName := name
+ backupInfo := backup
+ list.AddItem(backupDisplayLabel("Local backup", name, backupString(backup, "createdAt")), secondary, 0, func() {
+ showBackupManageModal(svc, pages, app, list, backupInfo, backupName, goBack)
+ })
+ }
+ if len(backups) == 0 {
+ list.AddItem("(no backups found)", "Create a backup first", 0, nil)
+ }
+
+ frame.SetContent(list)
+ frame.SetupContentToButtonNavigation()
+ pages.AddAndSwitchToPage(PageSettingsBackupList, frame, true)
+ frame.FocusContent()
+}
+
+const backupManageModalPage = "backup_manage_modal"
+
+func showBackupManageModal(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ focus tview.Primitive,
+ backup map[string]any,
+ backupName string,
+ onRestored func(),
+) {
+ label := backupDisplayLabel("Local backup", backupName, backupString(backup, "createdAt"))
+ showBackupModal(pages, app, "Backup details", "Loading backup manifest...", []string{"Back"}, func(_ int) {
+ app.SetFocus(focus)
+ })
+
+ go func() {
+ ctx, cancel := backupContext()
+ details, err := svc.InspectBackup(ctx, backupName)
+ cancel()
+ app.QueueUpdateDraw(func() {
+ if err != nil {
+ log.Warn().Err(err).Str("name", backupName).Msg("error inspecting backup")
+ showBackupModal(
+ pages,
+ app,
+ "Backup details",
+ label+"\n\nUnable to read backup manifest.\n\nRestore is disabled for this backup.",
+ []string{"Back"},
+ func(_ int) { app.SetFocus(focus) },
+ )
+ return
+ }
+ showBackupDetailsActions(svc, pages, app, focus, details, backupName, onRestored)
+ })
+ }()
+}
+
+func showBackupDetailsActions(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ focus tview.Primitive,
+ backup map[string]any,
+ backupName string,
+ onRestored func(),
+) {
+ label := backupDisplayLabel("Local backup", backupName, backupString(backup, "createdAt"))
+ showBackupModal(
+ pages,
+ app,
+ "Backup details",
+ formatBackupDetails(label, backup),
+ []string{"Restore", "Delete", "Back"},
+ func(buttonIndex int) {
+ switch buttonIndex {
+ case 0:
+ showBackupRestoreConfirm(svc, pages, app, focus, backupName, onRestored)
+ case 1:
+ showBackupDeleteConfirm(svc, pages, app, focus, label, backupName, onRestored)
+ default:
+ app.SetFocus(focus)
+ }
+ },
+ )
+}
+
+func showBackupModal(
+ pages *tview.Pages,
+ app *tview.Application,
+ title string,
+ message string,
+ buttons []string,
+ onDone func(int),
+) {
+ dialog := NewDialog().
+ SetText(message).
+ SetTitle(title).
+ AddButtons(buttons).
+ SetDoneFunc(func(buttonIndex int) {
+ pages.HidePage(backupManageModalPage)
+ pages.RemovePage(backupManageModalPage)
+ onDone(buttonIndex)
+ })
+ pages.RemovePage(backupManageModalPage)
+ pages.AddPage(backupManageModalPage, dialog, true, true)
+ app.SetFocus(dialog)
+}
+
+func showBackupDeleteConfirm(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ focus tview.Primitive,
+ label string,
+ backupName string,
+ onDeleted func(),
+) {
+ ShowConfirmModal(pages, app, "Delete "+label+"?", func() {
+ ctx, cancel := tuiContext()
+ defer cancel()
+ if err := svc.DeleteBackup(ctx, backupName); err != nil {
+ log.Warn().Err(err).Msg("error deleting backup")
+ ShowErrorModal(pages, app, "Failed to delete backup", func() { app.SetFocus(focus) })
+ return
+ }
+ ShowInfoModal(pages, app, "Backup deleted", label, onDeleted)
+ }, func() { app.SetFocus(focus) })
+}
+
+const (
+ coreRestartModalPage = "core_restart_modal"
+ coreRestartPollInterval = time.Second
+ // coreRestartNoDownGrace ends the wait when the API never drops: the
+ // restart finished before polling started, or is not coming.
+ coreRestartNoDownGrace = 15 * time.Second
+ coreRestartTimeout = 90 * time.Second
+)
+
+// waitForCoreRestart blocks the UI while Core restarts after a restore.
+// The service restarts a few seconds after the restore response, so wait
+// for the API to drop and come back before running onDone; if it never
+// drops, give up waiting after a grace period. onDone always runs.
+func waitForCoreRestart(svc SettingsService, pages *tview.Pages, app *tview.Application, onDone func()) {
+ waitForCoreRestartWith(
+ svc, pages, app, coreRestartPollInterval, coreRestartNoDownGrace, coreRestartTimeout, onDone,
+ )
+}
+
+func waitForCoreRestartWith(
+ svc SettingsService, pages *tview.Pages, app *tview.Application,
+ pollInterval, noDownGrace, timeout time.Duration, onDone func(),
+) {
+ modal := tview.NewTextView().
+ SetTextAlign(tview.AlignCenter).
+ SetText("Core is restarting.\n\nWaiting for it to come back...")
+ modal.SetInputCapture(func(_ *tcell.EventKey) *tcell.EventKey { return nil })
+ modal.SetBorder(true)
+ SetBoxTitle(modal.Box, "Restore")
+ modal.SetTitleAlign(tview.AlignCenter)
+ pages.AddPage(coreRestartModalPage, CenterWidget(45, 5, modal), true, true)
+ app.SetFocus(modal)
+
+ go func() {
+ started := time.Now()
+ sawDown := false
+ cameBack := false
+ ticker := time.NewTicker(pollInterval)
+ defer ticker.Stop()
+ for range ticker.C {
+ ctx, cancel := tuiContext()
+ _, err := svc.GetSettings(ctx)
+ cancel()
+ elapsed := time.Since(started)
+ if err != nil {
+ sawDown = true
+ if elapsed < timeout {
+ continue
+ }
+ } else {
+ if !sawDown && elapsed < noDownGrace {
+ continue
+ }
+ cameBack = true
+ }
+ break
+ }
+ app.QueueUpdateDraw(func() {
+ pages.HidePage(coreRestartModalPage)
+ pages.RemovePage(coreRestartModalPage)
+ // End with a modal that requires an explicit OK: an
+ // auto-dismissing wait hands focus straight to the rebuilt
+ // menu, where a stray Enter lands on its first action.
+ message := "Core restarted successfully."
+ if !cameBack {
+ message = "Core has not come back yet.\nCheck the service status before continuing."
+ }
+ ShowInfoModal(pages, app, "Restore", message, onDone)
+ })
+ }()
+}
+
+func showBackupRestoreConfirm(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ focus tview.Primitive,
+ backupName string,
+ onRestored func(),
+) {
+ label := backupLabelFromName("Local backup", backupName)
+ ShowConfirmModal(
+ pages, app,
+ "Restore "+label+"?\n\nCore will restart after restore.",
+ func() {
+ runBackupAction(
+ pages,
+ app,
+ focus,
+ "Restoring backup",
+ "Restoring local backup...",
+ func(ctx context.Context) (string, error) {
+ if err := svc.RestoreBackup(ctx, backupName); err != nil {
+ return "", fmt.Errorf("restore local backup: %w", err)
+ }
+ return label, nil
+ },
+ func(restoredLabel string) {
+ ShowInfoModal(pages, app, "Backup restored", restoredLabel, func() {
+ waitForCoreRestart(svc, pages, app, onRestored)
+ })
+ },
+ )
+ }, func() { app.SetFocus(focus) })
+}
+
+func buildRemoteBackupListPage(svc SettingsService, pages *tview.Pages, app *tview.Application, goBack func()) {
+ loadSettingsPage(pages, app, PageSettingsBackupList,
+ []string{"Settings", "Backup", "Cloud"},
+ "Loading cloud backups...",
+ "Failed to list cloud backups",
+ backupContext, goBack,
+ func(ctx context.Context) ([]RemoteBackupItem, error) {
+ backups, err := svc.ListRemoteBackups(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("list cloud backups: %w", err)
+ }
+ return backups, nil
+ },
+ func(backups []RemoteBackupItem) {
+ renderRemoteBackupListPage(svc, pages, app, backups, goBack)
+ },
+ )
+}
+
+// groupRemoteBackupsBySource organizes the account catalog into one entry
+// per source device: this device first, then other linked devices, then
+// unlinked devices. Items without source metadata (legacy API or current
+// device) join this device's group. Snapshots sort newest first; groups
+// within a tier sort by latest snapshot, then name, then ID.
+func groupRemoteBackupsBySource(items []RemoteBackupItem) []remoteBackupSource {
+ const currentKey = "\x00current"
+ groups := make(map[string]*remoteBackupSource)
+ order := make([]string, 0, len(items))
+ for _, item := range items {
+ key := currentKey
+ device := RemoteBackupSourceDevice{Current: true, Linked: true}
+ if item.SourceDevice != nil {
+ device = *item.SourceDevice
+ if !device.Current {
+ key = device.ID
+ }
+ }
+ group, ok := groups[key]
+ if !ok {
+ group = &remoteBackupSource{Device: device}
+ groups[key] = group
+ order = append(order, key)
+ }
+ if group.Device.Name == "" && device.Name != "" {
+ // A legacy item may create the current group before an item
+ // carrying full source metadata for this device arrives.
+ group.Device = device
+ }
+ group.Backups = append(group.Backups, item)
+ }
+
+ sources := make([]remoteBackupSource, 0, len(order))
+ for _, key := range order {
+ group := groups[key]
+ slices.SortStableFunc(group.Backups, func(a, b RemoteBackupItem) int {
+ return b.CreatedAt.Compare(a.CreatedAt)
+ })
+ sources = append(sources, *group)
+ }
+ tier := func(s *remoteBackupSource) int {
+ switch {
+ case s.Device.Current:
+ return 0
+ case s.Device.Linked:
+ return 1
+ default:
+ return 2
+ }
+ }
+ slices.SortStableFunc(sources, func(a, b remoteBackupSource) int {
+ if t := tier(&a) - tier(&b); t != 0 {
+ return t
+ }
+ if c := b.Backups[0].CreatedAt.Compare(a.Backups[0].CreatedAt); c != 0 {
+ return c
+ }
+ if c := strings.Compare(a.Device.Name, b.Device.Name); c != 0 {
+ return c
+ }
+ return strings.Compare(a.Device.ID, b.Device.ID)
+ })
+ return sources
+}
+
+// remoteBackupSourceLabel renders a source device's list label: this device
+// first-person, unlinked devices marked so the user knows the source is no
+// longer attached to the account.
+func remoteBackupSourceLabel(device *RemoteBackupSourceDevice) string {
+ switch {
+ case device.Current && device.Name == "":
+ return "This device"
+ case device.Current:
+ return device.Name + " (this device)"
+ case device.Name == "":
+ return "Unnamed device"
+ case !device.Linked:
+ return device.Name + " (unlinked)"
+ default:
+ return device.Name
+ }
+}
+
+func renderRemoteBackupListPage(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ backups []RemoteBackupItem,
+ goBack func(),
+) {
+ frame := NewPageFrame(app).SetTitle("Settings", "Backup", "Cloud")
+ frame.SetOnEscape(goBack)
+ buttonBar := NewButtonBar(app).AddButton("Back", goBack).SetupNavigation(goBack)
+ frame.SetButtonBar(buttonBar)
+
+ list := tview.NewList()
+ list.SetSecondaryTextColor(CurrentTheme().SecondaryTextColor)
+ list.ShowSecondaryText(true)
+ list.SetSelectedFocusOnly(true)
+ for _, source := range groupRemoteBackupsBySource(backups) {
+ count := "1 backup"
+ if len(source.Backups) != 1 {
+ count = fmt.Sprintf("%d backups", len(source.Backups))
+ }
+ secondary := count + " Latest " + formatBackupTime(source.Backups[0].CreatedAt)
+ list.AddItem(remoteBackupSourceLabel(&source.Device), secondary, 0, func() {
+ renderRemoteBackupSnapshotsPage(svc, pages, app, &source, func() {
+ pages.SwitchToPage(PageSettingsBackupList)
+ app.SetFocus(list)
+ }, goBack)
+ })
+ }
+ if len(backups) == 0 {
+ list.AddItem("(no cloud backups found)", "Create a cloud backup first", 0, nil)
+ }
+
+ frame.SetContent(list)
+ frame.SetupContentToButtonNavigation()
+ pages.AddAndSwitchToPage(PageSettingsBackupList, frame, true)
+ frame.FocusContent()
+}
+
+// renderRemoteBackupSnapshotsPage lists one source device's snapshots,
+// newest first. goBack returns to the source list; onRestored runs after a
+// completed restore (Core restart flow).
+func renderRemoteBackupSnapshotsPage(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ source *remoteBackupSource,
+ goBack func(),
+ onRestored func(),
+) {
+ frame := NewPageFrame(app).SetTitle("Settings", "Cloud", remoteBackupSourceLabel(&source.Device))
+ frame.SetOnEscape(goBack)
+ buttonBar := NewButtonBar(app).AddButton("Back", goBack).SetupNavigation(goBack)
+ frame.SetButtonBar(buttonBar)
+
+ list := tview.NewList()
+ list.SetSecondaryTextColor(CurrentTheme().SecondaryTextColor)
+ list.ShowSecondaryText(true)
+ list.SetSelectedFocusOnly(true)
+ for _, backup := range source.Backups {
+ if backup.ID == "" {
+ continue
+ }
+ label := "Cloud backup " + formatBackupTime(backup.CreatedAt)
+ secondary := remoteBackupTypeLabel(backup.BackupType)
+ if backup.SizeBytes > 0 {
+ secondary += " " + formatHumanBytes(backup.SizeBytes)
+ }
+ if backup.Incompatible {
+ // Committed by a newer Core: listed, but refuses to restore.
+ list.AddItem(label+" (requires newer Core)", secondary, 0, func() {
+ ShowInfoModal(pages, app, "Incompatible backup",
+ "This backup was made by a newer Core version and cannot be restored "+
+ "until this device is updated.", func() { app.SetFocus(list) })
+ })
+ continue
+ }
+ item := backup
+ list.AddItem(label, secondary, 0, func() {
+ showRemoteBackupRestoreConfirm(svc, pages, app, list, &item, &source.Device, onRestored)
+ })
+ }
+
+ frame.SetContent(list)
+ frame.SetupContentToButtonNavigation()
+ pages.AddAndSwitchToPage(PageSettingsBackupSnapshots, frame, true)
+ frame.FocusContent()
+}
+
+func remoteBackupTypeLabel(backupType string) string {
+ switch backupType {
+ case "scheduled":
+ return "Scheduled"
+ case "manual":
+ return "Manual"
+ default:
+ return "Backup"
+ }
+}
+
+// remoteBackupOverwriteSummary names the categories a restore replaces on
+// this device, in display order.
+func remoteBackupOverwriteSummary(categories map[string]RemoteBackupCategory) string {
+ names := make([]string, 0, len(categories))
+ for _, category := range backupCategoryOrder {
+ if _, ok := categories[category]; ok {
+ names = append(names, backupCategoryLabels[category])
+ }
+ }
+ if len(names) == 0 {
+ return "Backup data"
+ }
+ return strings.Join(names, ", ")
+}
+
+// showRemoteBackupRestoreConfirm explains exactly what a restore does before
+// running it: the snapshot is copied onto this device, the source backup and
+// device are untouched, and this device keeps its own Online identity.
+func showRemoteBackupRestoreConfirm(
+ svc SettingsService,
+ pages *tview.Pages,
+ app *tview.Application,
+ focus tview.Primitive,
+ backup *RemoteBackupItem,
+ source *RemoteBackupSourceDevice,
+ onRestored func(),
+) {
+ sourceName := remoteBackupSourceLabel(source)
+ message := "Restore backup from " + sourceName + "?\n\n" +
+ "Snapshot: " + formatBackupTime(backup.CreatedAt) + "\n" +
+ "Overwrites: " + remoteBackupOverwriteSummary(backup.Categories) + "\n\n" +
+ "The source backup is not changed. This device\n" +
+ "keeps its own Zaparoo Online identity. A local\n" +
+ "safety backup is created first.\n\n" +
+ "Core restarts after restore."
+ backupID := backup.ID
+ ShowConfirmModal(
+ pages, app,
+ message,
+ func() {
+ runBackupAction(
+ pages,
+ app,
+ focus,
+ "Restoring cloud backup",
+ "Downloading and restoring cloud backup...",
+ func(ctx context.Context) (string, error) {
+ if err := svc.RestoreRemoteBackup(ctx, backupID); err != nil {
+ return "", fmt.Errorf("restore cloud backup: %w", err)
+ }
+ return backupID, nil
+ },
+ func(restoredLabel string) {
+ ShowInfoModal(pages, app, "Cloud backup restored", restoredLabel, func() {
+ waitForCoreRestart(svc, pages, app, onRestored)
+ })
+ },
+ )
+ }, func() { app.SetFocus(focus) })
+}
+
+func backupStatusDescription(status *models.BackupStatusEntry) string {
+ if status.Availability == "unavailable" {
+ return "Warp unavailable; restore remains available"
+ }
+ if status.LastStatus == "partial" {
+ return fmt.Sprintf("Completed with %d warning(s)", status.SkippedFiles)
+ }
+ if status.LastStatus == "" || status.LastStatus == "never" {
+ return "No successful backup yet"
+ }
+ if status.LastSuccessAt != nil && *status.LastSuccessAt != "" {
+ return "Last success: " + *status.LastSuccessAt
+ }
+ return "Last status: " + status.LastStatus
+}
+
+func backupStatusText(status *models.BackupStatusEntry) string {
+ lines := []string{"Status: " + status.LastStatus}
+ if status.Schedule != "" {
+ lines = append(lines, "Schedule: "+status.Schedule)
+ }
+ if status.Availability != "" {
+ lines = append(lines, "Warp availability: "+status.Availability)
+ }
+ if status.AvailabilityCheckedAt != nil {
+ lines = append(lines, "Availability checked: "+*status.AvailabilityCheckedAt)
+ }
+ if status.LastSuccessAt != nil {
+ lines = append(lines, "Last success: "+*status.LastSuccessAt)
+ }
+ if status.LastError != "" {
+ lines = append(lines, "Last error: "+status.LastError)
+ }
+ if status.SkippedFiles > 0 {
+ lines = append(lines, fmt.Sprintf("Skipped paths: %d", status.SkippedFiles))
+ }
+ for _, warning := range status.Warnings {
+ lines = append(lines, "Warning: "+warning.Path+" ("+warning.Reason+")")
+ }
+ for _, category := range []string{"zaparoo", "settings", "inputs", "saves", "savestates"} {
+ entry, ok := status.Categories[category]
+ if !ok {
+ continue
+ }
+ lines = append(lines, fmt.Sprintf("%s: %d files, %d bytes", category, entry.Files, entry.Bytes))
+ }
+ return strings.Join(lines, "\n")
+}
+
+func formatBackupDetails(label string, backup map[string]any) string {
+ lines := []string{label}
+ if createdAt, ok := formatBackupTimestamp(backupString(backup, "createdAt")); ok {
+ lines = append(lines, "Created: "+createdAt)
+ }
+ if size := formatBackupSize(backup, "size"); size != "" {
+ lines = append(lines, "Size: "+size)
+ }
+ if status := backupString(backup, "status"); status != "" {
+ lines = append(lines, "Status: "+status)
+ }
+ if errText := backupString(backup, "error"); errText != "" {
+ lines = append(lines, "Error: "+errText)
+ }
+ if categoryLines := formatBackupCategories(backup); len(categoryLines) > 0 {
+ lines = append(lines, "", "Manifest:")
+ lines = append(lines, categoryLines...)
+ }
+ if warningLines := formatBackupWarnings(backup); len(warningLines) > 0 {
+ lines = append(lines, "", "Warnings:")
+ lines = append(lines, warningLines...)
+ }
+ return strings.Join(lines, "\n")
+}
+
+func formatBackupCategories(backup map[string]any) []string {
+ raw, ok := backup["categories"].(map[string]any)
+ if !ok || len(raw) == 0 {
+ return nil
+ }
+ lines := make([]string, 0, len(raw))
+ for _, category := range backupCategoryOrder {
+ entry, ok := raw[category].(map[string]any)
+ if !ok {
+ continue
+ }
+ name := backupCategoryLabels[category]
+ files, _ := backupAnyInt64(entry["files"])
+ bytes, _ := backupAnyInt64(entry["bytes"])
+ lines = append(lines, fmt.Sprintf("%s: %d files, %s", name, files, formatHumanBytes(bytes)))
+ }
+ return lines
+}
+
+func formatBackupWarnings(backup map[string]any) []string {
+ raw, ok := backup["warnings"].([]any)
+ if !ok {
+ return nil
+ }
+ lines := make([]string, 0, len(raw))
+ for _, item := range raw {
+ warning, ok := item.(map[string]any)
+ if !ok {
+ continue
+ }
+ path := backupString(warning, "path")
+ reason := backupString(warning, "reason")
+ if path == "" || reason == "" {
+ continue
+ }
+ lines = append(lines, path+" ("+reason+")")
+ }
+ return lines
+}
+
+func backupDisplayLabel(prefix, name, createdAt string) string {
+ if label, ok := formatBackupTimestamp(createdAt); ok {
+ return prefix + " " + label
+ }
+ return backupLabelFromName(prefix, name)
+}
+
+func backupLabelFromName(prefix, name string) string {
+ if label, ok := timestampFromBackupName(name); ok {
+ return prefix + " " + label
+ }
+ if name == "" {
+ return prefix
+ }
+ return name
+}
+
+func timestampFromBackupName(name string) (string, bool) {
+ trimmed := strings.TrimPrefix(name, "backup-")
+ parts := strings.SplitN(trimmed, "-", 3)
+ if len(parts) < 2 {
+ return "", false
+ }
+ parsed, err := time.ParseInLocation("20060102150405", parts[0]+parts[1], time.UTC)
+ if err != nil {
+ return "", false
+ }
+ return formatBackupTime(parsed), true
+}
+
+func formatBackupTimestamp(value string) (string, bool) {
+ if value == "" {
+ return "", false
+ }
+ parsed, err := time.Parse(time.RFC3339Nano, value)
+ if err != nil {
+ return "", false
+ }
+ return formatBackupTime(parsed), true
+}
+
+func formatBackupTime(value time.Time) string {
+ return value.UTC().Format("2006-01-02 15:04:05 UTC")
+}
+
+func formatBackupSize(backup map[string]any, key string) string {
+ value, ok := backup[key]
+ if !ok || value == nil {
+ return ""
+ }
+ bytes, ok := backupAnyInt64(value)
+ if !ok {
+ return ""
+ }
+ return formatHumanBytes(bytes)
+}
+
+func formatHumanBytes(bytes int64) string {
+ if bytes < 0 {
+ bytes = 0
+ }
+ if bytes < 1024 {
+ return fmt.Sprintf("%d B", bytes)
+ }
+ units := []string{"KB", "MB", "GB", "TB"}
+ value := float64(bytes)
+ unitIndex := -1
+ for value >= 1024 && unitIndex < len(units)-1 {
+ value /= 1024
+ unitIndex++
+ }
+ value = math.Ceil(value*10) / 10
+ if value == math.Trunc(value) {
+ return fmt.Sprintf("%.0f %s", value, units[unitIndex])
+ }
+ return fmt.Sprintf("%.1f %s", value, units[unitIndex])
+}
+
+func backupString(backup map[string]any, key string) string {
+ value, ok := backup[key]
+ if !ok || value == nil {
+ return ""
+ }
+ return fmt.Sprint(value)
+}
+
+func backupAnyInt64(value any) (int64, bool) {
+ switch typed := value.(type) {
+ case int64:
+ return typed, true
+ case int:
+ return int64(typed), true
+ case float64:
+ return int64(typed), true
+ default:
+ return 0, false
+ }
+}
+
// buildAdvancedSettingsMenu creates the advanced settings menu.
func buildAdvancedSettingsMenu(svc SettingsService, pages *tview.Pages, app *tview.Application) {
ctx, cancel := tuiContext()
diff --git a/pkg/ui/tui/settings_api_test.go b/pkg/ui/tui/settings_api_test.go
index 0a1e2762..a3883244 100644
--- a/pkg/ui/tui/settings_api_test.go
+++ b/pkg/ui/tui/settings_api_test.go
@@ -28,6 +28,7 @@ import (
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/stretchr/testify/assert"
+ testifymock "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -109,6 +110,114 @@ func TestDefaultSettingsService_UpdateSettings_Error(t *testing.T) {
mockClient.AssertExpectations(t)
}
+func TestDefaultSettingsService_LocalBackupAPIContracts(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ mockClient := mocks.NewMockAPIClient()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsBackup, "").
+ Return(`{"name":"backup-1.zip"}`, nil).Once()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsBackupList, "").
+ Return(`[{"name":"backup-1.zip","size":42}]`, nil).Once()
+ mockClient.On(
+ "Call", testifymock.Anything, models.MethodSettingsBackupInspect, `{"name":"backup-1.zip"}`,
+ ).Return(`{"name":"backup-1.zip","integrity":"unchecked"}`, nil).Once()
+ mockClient.On(
+ "Call", testifymock.Anything, models.MethodSettingsBackupDelete, `{"name":"backup-1.zip"}`,
+ ).Return(`{}`, nil).Once()
+ mockClient.On(
+ "Call", testifymock.Anything, models.MethodSettingsBackupRestore, `{"name":"backup-1.zip"}`,
+ ).Return(`{}`, nil).Once()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsBackupStatus, "").
+ Return(`{"local":{"lastStatus":"success"},"remote":{"lastStatus":"never"}}`, nil).Once()
+
+ svc := NewSettingsService(mockClient)
+ name, err := svc.CreateBackup(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "backup-1.zip", name)
+
+ backups, err := svc.ListBackups(ctx)
+ require.NoError(t, err)
+ require.Len(t, backups, 1)
+ assert.Equal(t, "backup-1.zip", backups[0]["name"])
+ assert.InDelta(t, 42, backups[0]["size"], 0)
+
+ backup, err := svc.InspectBackup(ctx, name)
+ require.NoError(t, err)
+ assert.Equal(t, "unchecked", backup["integrity"])
+ require.NoError(t, svc.DeleteBackup(ctx, name))
+ require.NoError(t, svc.RestoreBackup(ctx, name))
+
+ status, err := svc.GetBackupStatus(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, "success", status.Local.LastStatus)
+ assert.Equal(t, "never", status.Remote.LastStatus)
+ mockClient.AssertExpectations(t)
+}
+
+func TestDefaultSettingsService_RemoteBackupAPIContracts(t *testing.T) {
+ t.Parallel()
+ const backupID = "save/a%b ?\u96ea"
+ ctx := context.Background()
+ mockClient := mocks.NewMockAPIClient()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsBackupRemoteRun, "").Return(
+ `{"backup":{"id":"save/a%b ?\u96ea"}}`, nil,
+ ).Once()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsBackupRemoteList, "").Return(
+ `{"items":[{"id":"save/a%b ?\u96ea","createdAt":"2026-07-10T12:00:00Z",`+
+ `"backupType":"manual","sizeBytes":42,"categories":{"saves":{"files":1,"bytes":42}},`+
+ `"sourceDevice":{"id":"dev-2","name":"Bedroom","platform":"mister",`+
+ `"linked":true,"current":false}}]}`, nil,
+ ).Once()
+ mockClient.On(
+ "Call", testifymock.Anything, models.MethodSettingsBackupRemoteRestore,
+ "{\"id\":\"save/a%b ?\u96ea\"}",
+ ).Return(`{}`, nil).Once()
+
+ svc := NewSettingsService(mockClient)
+ id, err := svc.RunRemoteBackup(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, backupID, id)
+
+ backups, err := svc.ListRemoteBackups(ctx)
+ require.NoError(t, err)
+ require.Len(t, backups, 1)
+ assert.Equal(t, backupID, backups[0].ID)
+ assert.Equal(t, int64(42), backups[0].SizeBytes)
+ require.NotNil(t, backups[0].SourceDevice)
+ assert.Equal(t, "dev-2", backups[0].SourceDevice.ID)
+ assert.Equal(t, "Bedroom", backups[0].SourceDevice.Name)
+ assert.Equal(t, int64(1), backups[0].Categories["saves"].Files)
+ require.NoError(t, svc.RestoreRemoteBackup(ctx, backupID))
+ mockClient.AssertExpectations(t)
+}
+
+func TestDefaultSettingsService_AuthLinkAPIContracts(t *testing.T) {
+ t.Parallel()
+ ctx := context.Background()
+ mockClient := mocks.NewMockAPIClient()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsAuthLink, "").
+ Return(`{"status":"pending","userCode":"ABCD-EFGH"}`, nil).Once()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsAuthLinkStatus, "").
+ Return(`{"status":"approved"}`, nil).Once()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsAuthLinkCancel, "").
+ Return(`{}`, nil).Once()
+ mockClient.On("Call", testifymock.Anything, models.MethodSettingsAuthUnlink, "").
+ Return(`{}`, nil).Once()
+
+ svc := NewSettingsService(mockClient)
+ started, err := svc.StartAuthLink(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, models.AuthLinkStatusPending, started.Status)
+ assert.Equal(t, "ABCD-EFGH", started.UserCode)
+
+ status, err := svc.GetAuthLinkStatus(ctx)
+ require.NoError(t, err)
+ assert.Equal(t, models.AuthLinkStatusApproved, status.Status)
+ require.NoError(t, svc.CancelAuthLink(ctx))
+ require.NoError(t, svc.Unlink(ctx))
+ mockClient.AssertExpectations(t)
+}
+
func TestDefaultSettingsService_GetSystems(t *testing.T) {
t.Parallel()
diff --git a/pkg/ui/tui/settings_components.go b/pkg/ui/tui/settings_components.go
index 32860c59..d420b130 100644
--- a/pkg/ui/tui/settings_components.go
+++ b/pkg/ui/tui/settings_components.go
@@ -20,11 +20,14 @@
package tui
import (
+ "context"
"fmt"
+ "time"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/config"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
+ "github.com/rs/zerolog/log"
)
const settingsTextEditModalPage = "settings_text_edit_modal"
@@ -184,6 +187,12 @@ func formatAction(label string, selected bool) string {
t.TextColorName, t.BgColorName, label)
}
+// formatHeader renders a non-selectable section header line.
+func formatHeader(label string) string {
+ t := CurrentTheme()
+ return fmt.Sprintf("[%s:%s]── %s ──[-:-]", t.SecondaryColor, t.BgColorName, label)
+}
+
// formatNavAction renders a navigation action with arrow indicator.
func formatNavAction(label string, selected bool) string {
t := CurrentTheme()
@@ -208,6 +217,7 @@ type SettingsList struct {
pages *tview.Pages
rebuildPrevious func()
helpCallback func(string)
+ onNavigateOut func()
previousPage string
items []settingsItem
dynamicHelpMode bool
@@ -232,6 +242,13 @@ func NewSettingsList(pages *tview.Pages, previousPage string) *SettingsList {
}
list.SetChangedFunc(func(index int, _, _ string, _ rune) {
+ // Programmatic selection (SetCurrentItem, mouse) can land on a
+ // header or spacer. Nested SetCurrentItem calls are overwritten by
+ // tview once the handler returns, so don't bounce here: the next
+ // Up/Down settles on a selectable item via moveSelection.
+ if !sl.isSelectable(index) {
+ return
+ }
sl.refreshAllItems(index)
})
@@ -250,6 +267,22 @@ func NewSettingsList(pages *tview.Pages, previousPage string) *SettingsList {
case tcell.KeyEscape:
sl.goBack()
return nil
+ case tcell.KeyUp:
+ sl.moveSelection(-1)
+ return nil
+ case tcell.KeyDown:
+ sl.moveSelection(1)
+ return nil
+ case tcell.KeyHome:
+ if target := sl.scanSelectable(0, 1); target != -1 {
+ sl.SetCurrentItem(target)
+ }
+ return nil
+ case tcell.KeyEnd:
+ if target := sl.scanSelectable(len(sl.items)-1, -1); target != -1 {
+ sl.SetCurrentItem(target)
+ }
+ return nil
case tcell.KeyLeft, tcell.KeyRight:
index := sl.GetCurrentItem()
if index < 0 || index >= len(sl.items) {
@@ -288,6 +321,57 @@ func NewSettingsList(pages *tview.Pages, previousPage string) *SettingsList {
return sl
}
+// isSelectable reports whether the item at index can hold the selection.
+// Headers and spacers are display-only and are skipped by navigation.
+func (sl *SettingsList) isSelectable(index int) bool {
+ if index < 0 || index >= len(sl.items) {
+ return false
+ }
+ itemType := sl.items[index].itemType
+ return itemType != "header" && itemType != "spacer"
+}
+
+// scanSelectable returns the first selectable index walking from `from` in
+// direction `dir` (+1/-1), or -1 when none exists in that direction.
+func (sl *SettingsList) scanSelectable(from, dir int) int {
+ for i := from; i >= 0 && i < len(sl.items); i += dir {
+ if sl.isSelectable(i) {
+ return i
+ }
+ }
+ return -1
+}
+
+// moveSelection moves the selection one selectable item in direction dir,
+// skipping headers and spacers. At the list edge it hands focus to the
+// navigate-out callback (usually the page's button bar), or wraps around.
+func (sl *SettingsList) moveSelection(dir int) {
+ next := sl.scanSelectable(sl.GetCurrentItem()+dir, dir)
+ if next == -1 {
+ if sl.onNavigateOut != nil {
+ sl.onNavigateOut()
+ return
+ }
+ if dir > 0 {
+ next = sl.scanSelectable(0, 1)
+ } else {
+ next = sl.scanSelectable(len(sl.items)-1, -1)
+ }
+ }
+ if next != -1 {
+ sl.SetCurrentItem(next)
+ }
+}
+
+// SetOnNavigateOut sets a callback fired when Up/Down navigation runs past
+// the first or last selectable item. Pages with a button bar should wire
+// this to FocusButtonBar so edge navigation stays consistent on lists that
+// begin or end with a section header.
+func (sl *SettingsList) SetOnNavigateOut(fn func()) *SettingsList {
+ sl.onNavigateOut = fn
+ return sl
+}
+
// SetRebuildPrevious sets a callback to rebuild the previous page on Back navigation.
// When set, going back will call this function instead of just switching to the cached page.
func (sl *SettingsList) SetRebuildPrevious(fn func()) *SettingsList {
@@ -311,11 +395,19 @@ func (sl *SettingsList) SetDynamicHelpMode(enabled bool) *SettingsList {
return sl
}
-// TriggerInitialHelp calls the help callback with the first item's description.
+// TriggerInitialHelp settles the initial selection on a selectable item and
+// calls the help callback with its description.
// Call this after adding all items to set the initial help text.
func (sl *SettingsList) TriggerInitialHelp() *SettingsList {
- if sl.helpCallback != nil && len(sl.items) > 0 {
- sl.helpCallback(sl.items[0].description)
+ if current := sl.GetCurrentItem(); !sl.isSelectable(current) {
+ if target := sl.scanSelectable(current+1, 1); target != -1 {
+ sl.SetCurrentItem(target)
+ }
+ }
+ if sl.helpCallback != nil {
+ if idx := sl.GetCurrentItem(); idx >= 0 && idx < len(sl.items) {
+ sl.helpCallback(sl.items[idx].description)
+ }
}
return sl
}
@@ -367,6 +459,10 @@ func (sl *SettingsList) refreshAllItems(selectedIndex int) {
mainText = formatAction(item.label, selected)
case "nav":
mainText = formatNavAction(item.label, selected)
+ case "header":
+ mainText = formatHeader(item.label)
+ case "spacer":
+ mainText = ""
}
sl.SetItemText(i, mainText, desc)
@@ -622,6 +718,18 @@ func (sl *SettingsList) AddNavAction(
return sl
}
+// AddHeader adds a non-selectable section header. A blank spacer line is
+// inserted first unless the header opens the list. Navigation skips both.
+func (sl *SettingsList) AddHeader(label string) *SettingsList {
+ if sl.GetItemCount() > 0 {
+ sl.items = append(sl.items, settingsItem{itemType: "spacer"})
+ sl.AddItem("", "", 0, nil)
+ }
+ sl.items = append(sl.items, settingsItem{itemType: "header", label: label})
+ sl.AddItem(formatHeader(label), "", 0, nil)
+ return sl
+}
+
// AddBack adds a "Back" action item with default description.
func (sl *SettingsList) AddBack() *SettingsList {
return sl.AddBackWithDesc("Return to previous menu")
@@ -677,6 +785,109 @@ func (sl *SettingsList) SetupCycleKeys(
return sl
}
+// settingsLoadDelay is how long a page fetch may run before the loading
+// frame appears. Fast fetches render directly so the loader never flashes;
+// only genuinely slow fetches (e.g. cloud calls) show it.
+const settingsLoadDelay = 250 * time.Millisecond
+
+// loadSettingsPage fetches page data in the background, then renders the
+// page on the UI thread. The current page stays visible (with input held)
+// during a short grace period; a loading frame appears only when the fetch
+// outlives it. Escape while loading cancels the fetch and navigates back.
+func loadSettingsPage[T any](
+ pages *tview.Pages,
+ app *tview.Application,
+ pageName string,
+ titles []string,
+ loadingText string,
+ errorText string,
+ newCtx func() (context.Context, context.CancelFunc),
+ goBack func(),
+ fetch func(ctx context.Context) (T, error),
+ render func(T),
+) {
+ ctx, cancel := newCtx()
+ // State below is set and read only on the UI thread (the caller's event
+ // handler, the loader's Escape handler, and QueueUpdateDraw callbacks).
+ cancelled := false
+ settled := false
+
+ // Hold input while the previous page is still on screen, so a stray
+ // keypress cannot re-trigger it mid-load. Released as soon as the
+ // loader (which handles its own input) or the real page appears.
+ previousCapture := app.GetInputCapture()
+ inputHeld := true
+ releaseInput := func() {
+ if inputHeld {
+ inputHeld = false
+ app.SetInputCapture(previousCapture)
+ }
+ }
+ app.SetInputCapture(func(_ *tcell.EventKey) *tcell.EventKey { return nil })
+
+ showLoader := func() {
+ frame := NewPageFrame(app).SetTitle(titles...)
+ frame.SetOnEscape(func() {
+ cancelled = true
+ cancel()
+ goBack()
+ })
+ loading := tview.NewTextView().
+ SetTextAlign(tview.AlignCenter).
+ SetText(loadingText)
+ content := tview.NewFlex().SetDirection(tview.FlexRow)
+ content.AddItem(nil, 0, 1, false)
+ content.AddItem(loading, 1, 0, false)
+ content.AddItem(nil, 0, 1, false)
+ frame.SetContent(content)
+ pages.AddAndSwitchToPage(pageName, frame, true)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ data, err := fetch(ctx)
+ cancel()
+ close(done)
+ app.QueueUpdateDraw(func() {
+ settled = true
+ releaseInput()
+ if cancelled {
+ return
+ }
+ if err != nil {
+ log.Warn().Err(err).Str("page", pageName).Msg("error loading settings page data")
+ ShowErrorModal(pages, app, errorText, goBack)
+ return
+ }
+ render(data)
+ })
+ }()
+
+ go func() {
+ timer := time.NewTimer(settingsLoadDelay)
+ defer timer.Stop()
+ select {
+ case <-done:
+ // Fetch finished within the grace period: never show the loader.
+ case <-timer.C:
+ app.QueueUpdateDraw(func() {
+ select {
+ case <-done:
+ // Fetch just finished; its render is queued right
+ // behind this update, so don't flash the loader.
+ return
+ default:
+ }
+ if settled {
+ return
+ }
+ releaseInput()
+ showLoader()
+ })
+ }
+ }()
+}
+
// ButtonBar creates a horizontal bar of buttons with arrow key navigation.
type ButtonBar struct {
*tview.Box
diff --git a/pkg/ui/tui/settings_components_test.go b/pkg/ui/tui/settings_components_test.go
index 47c87f9e..3c177d8d 100644
--- a/pkg/ui/tui/settings_components_test.go
+++ b/pkg/ui/tui/settings_components_test.go
@@ -328,6 +328,94 @@ func TestSettingsList_AddBackWithDesc(t *testing.T) {
assert.Equal(t, "Custom description", sl.items[0].description)
}
+func TestSettingsList_AddHeader(t *testing.T) {
+ t.Parallel()
+
+ pages := tview.NewPages()
+ sl := NewSettingsList(pages, "main")
+
+ sl.AddHeader("Local")
+ assert.Equal(t, 1, sl.GetItemCount(), "leading header adds no spacer")
+ assert.Equal(t, "header", sl.items[0].itemType)
+
+ sl.AddAction("Action", "desc", func() {})
+ sl.AddHeader("Cloud")
+ assert.Equal(t, 4, sl.GetItemCount(), "later headers are preceded by a spacer")
+ assert.Equal(t, "spacer", sl.items[2].itemType)
+ assert.Equal(t, "header", sl.items[3].itemType)
+}
+
+func TestSettingsList_HeadersAreNotSelectable(t *testing.T) {
+ t.Parallel()
+
+ pages := tview.NewPages()
+ sl := NewSettingsList(pages, "main")
+ sl.AddHeader("Local")
+ sl.AddAction("First", "desc", func() {})
+ sl.AddAction("Second", "desc", func() {})
+ sl.AddHeader("Cloud")
+ sl.AddAction("Third", "desc", func() {})
+
+ assert.False(t, sl.isSelectable(0), "header")
+ assert.True(t, sl.isSelectable(1))
+ assert.True(t, sl.isSelectable(2))
+ assert.False(t, sl.isSelectable(3), "spacer")
+ assert.False(t, sl.isSelectable(4), "header")
+ assert.True(t, sl.isSelectable(5))
+
+ // Initial selection settles on the first selectable item.
+ sl.TriggerInitialHelp()
+ assert.Equal(t, 1, sl.GetCurrentItem())
+
+ // Down from Second skips the spacer and header to reach Third.
+ sl.SetCurrentItem(2)
+ sl.moveSelection(1)
+ assert.Equal(t, 5, sl.GetCurrentItem())
+
+ // Up from Third skips back to Second.
+ sl.moveSelection(-1)
+ assert.Equal(t, 2, sl.GetCurrentItem())
+}
+
+func TestSettingsList_MoveSelectionEdges(t *testing.T) {
+ t.Parallel()
+
+ pages := tview.NewPages()
+ sl := NewSettingsList(pages, "main")
+ sl.AddHeader("Section")
+ sl.AddAction("First", "desc", func() {})
+ sl.AddAction("Second", "desc", func() {})
+ sl.TriggerInitialHelp()
+
+ // Without a navigate-out callback the selection wraps within
+ // selectable items.
+ sl.moveSelection(-1)
+ assert.Equal(t, 2, sl.GetCurrentItem(), "up from first selectable wraps to last")
+
+ // With a navigate-out callback the edge hands focus off instead.
+ navigatedOut := false
+ sl.SetOnNavigateOut(func() { navigatedOut = true })
+ sl.moveSelection(1)
+ assert.True(t, navigatedOut, "down past the last selectable navigates out")
+ assert.Equal(t, 2, sl.GetCurrentItem(), "selection stays put when navigating out")
+}
+
+func TestSettingsList_MoveSelectionRecoversFromHeader(t *testing.T) {
+ t.Parallel()
+
+ pages := tview.NewPages()
+ sl := NewSettingsList(pages, "main")
+ sl.AddHeader("Section")
+ sl.AddAction("First", "desc", func() {})
+ sl.AddAction("Second", "desc", func() {})
+
+ // Programmatic selection can land on the leading header; the next
+ // Down settles on the first selectable item.
+ sl.SetCurrentItem(0)
+ sl.moveSelection(1)
+ assert.Equal(t, 1, sl.GetCurrentItem())
+}
+
func TestSettingsList_ClearItems(t *testing.T) {
t.Parallel()
diff --git a/pkg/ui/tui/settings_service.go b/pkg/ui/tui/settings_service.go
index 94428183..5064f462 100644
--- a/pkg/ui/tui/settings_service.go
+++ b/pkg/ui/tui/settings_service.go
@@ -24,11 +24,41 @@ import (
"encoding/json"
"errors"
"fmt"
+ "time"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/client"
"github.com/ZaparooProject/zaparoo-core/v2/pkg/api/models"
)
+// RemoteBackupSourceDevice identifies the account device that created a
+// cloud backup snapshot, as reported by settings.backup.remote.list.
+// Current is relative to this device; a nil source on an item means the
+// snapshot belongs to this device (legacy API or current device).
+type RemoteBackupSourceDevice struct {
+ ID string `json:"id"`
+ Name string `json:"name"`
+ Platform string `json:"platform"`
+ Linked bool `json:"linked"`
+ Current bool `json:"current"`
+}
+
+// RemoteBackupCategory summarizes one category inside a cloud snapshot.
+type RemoteBackupCategory struct {
+ Files int64 `json:"files"`
+ Bytes int64 `json:"bytes"`
+}
+
+// RemoteBackupItem is one cloud backup snapshot from the account catalog.
+type RemoteBackupItem struct {
+ CreatedAt time.Time `json:"createdAt"`
+ SourceDevice *RemoteBackupSourceDevice `json:"sourceDevice"`
+ Categories map[string]RemoteBackupCategory `json:"categories"`
+ ID string `json:"id"`
+ BackupType string `json:"backupType"`
+ SizeBytes int64 `json:"sizeBytes"`
+ Incompatible bool `json:"incompatible"`
+}
+
// SettingsService handles settings API operations.
type SettingsService interface {
// GetSettings fetches current settings from the API.
@@ -37,6 +67,45 @@ type SettingsService interface {
// UpdateSettings sends a settings update to the API.
UpdateSettings(ctx context.Context, params *models.UpdateSettingsParams) error
+ // CreateBackup creates a local backup ZIP.
+ CreateBackup(ctx context.Context) (string, error)
+
+ // ListBackups fetches local backup ZIP metadata.
+ ListBackups(ctx context.Context) ([]map[string]any, error)
+
+ // InspectBackup fetches local backup manifest details.
+ InspectBackup(ctx context.Context, name string) (map[string]any, error)
+
+ // DeleteBackup removes a local backup ZIP.
+ DeleteBackup(ctx context.Context, name string) error
+
+ // RestoreBackup restores a local backup ZIP.
+ RestoreBackup(ctx context.Context, name string) error
+
+ // GetBackupStatus fetches local/remote backup status.
+ GetBackupStatus(ctx context.Context) (*models.BackupStatusResponse, error)
+
+ // RunRemoteBackup uploads a backup to the configured remote provider.
+ RunRemoteBackup(ctx context.Context) (string, error)
+
+ // ListRemoteBackups fetches the account's cloud backup snapshots.
+ ListRemoteBackups(ctx context.Context) ([]RemoteBackupItem, error)
+
+ // RestoreRemoteBackup restores a remote backup snapshot.
+ RestoreRemoteBackup(ctx context.Context, id string) error
+
+ // StartAuthLink starts the reverse device link flow.
+ StartAuthLink(ctx context.Context) (*models.AuthLinkStatusResponse, error)
+
+ // GetAuthLinkStatus reports the active link flow's state.
+ GetAuthLinkStatus(ctx context.Context) (*models.AuthLinkStatusResponse, error)
+
+ // CancelAuthLink stops the active link flow.
+ CancelAuthLink(ctx context.Context) error
+
+ // Unlink removes the stored Zaparoo Online credentials.
+ Unlink(ctx context.Context) error
+
// GetSystems fetches available systems from the API.
GetSystems(ctx context.Context) ([]models.System, error)
@@ -97,6 +166,14 @@ type DefaultSettingsService struct {
apiClient client.APIClient
}
+type remoteBackupRunRaw struct {
+ Backup remoteBackupIDRaw `json:"backup"`
+}
+
+type remoteBackupIDRaw struct {
+ ID string `json:"id"`
+}
+
// NewSettingsService creates a SettingsService that uses the given APIClient.
func NewSettingsService(apiClient client.APIClient) *DefaultSettingsService {
return &DefaultSettingsService{apiClient: apiClient}
@@ -128,6 +205,168 @@ func (s *DefaultSettingsService) UpdateSettings(ctx context.Context, params *mod
return nil
}
+func (s *DefaultSettingsService) CreateBackup(ctx context.Context) (string, error) {
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsBackup, "")
+ if err != nil {
+ return "", fmt.Errorf("failed to create backup: %w", err)
+ }
+ var raw struct {
+ Name string `json:"name"`
+ }
+ if err := json.Unmarshal([]byte(resp), &raw); err != nil {
+ return "", fmt.Errorf("failed to parse backup result: %w", err)
+ }
+ return raw.Name, nil
+}
+
+func (s *DefaultSettingsService) ListBackups(ctx context.Context) ([]map[string]any, error) {
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsBackupList, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to list backups: %w", err)
+ }
+ var backups []map[string]any
+ if err := json.Unmarshal([]byte(resp), &backups); err != nil {
+ return nil, fmt.Errorf("failed to parse backups: %w", err)
+ }
+ return backups, nil
+}
+
+func (s *DefaultSettingsService) InspectBackup(ctx context.Context, name string) (map[string]any, error) {
+ params := models.BackupNameParams{Name: name}
+ data, err := json.Marshal(¶ms)
+ if err != nil {
+ return nil, fmt.Errorf("failed to marshal inspect params: %w", err)
+ }
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsBackupInspect, string(data))
+ if err != nil {
+ return nil, fmt.Errorf("failed to inspect backup: %w", err)
+ }
+ var backup map[string]any
+ if err := json.Unmarshal([]byte(resp), &backup); err != nil {
+ return nil, fmt.Errorf("failed to parse backup details: %w", err)
+ }
+ return backup, nil
+}
+
+func (s *DefaultSettingsService) DeleteBackup(ctx context.Context, name string) error {
+ params := models.BackupNameParams{Name: name}
+ data, err := json.Marshal(¶ms)
+ if err != nil {
+ return fmt.Errorf("failed to marshal delete params: %w", err)
+ }
+ _, err = s.apiClient.Call(ctx, models.MethodSettingsBackupDelete, string(data))
+ if err != nil {
+ return fmt.Errorf("failed to delete backup: %w", err)
+ }
+ return nil
+}
+
+func (s *DefaultSettingsService) RestoreBackup(ctx context.Context, name string) error {
+ params := models.BackupNameParams{Name: name}
+ data, err := json.Marshal(¶ms)
+ if err != nil {
+ return fmt.Errorf("failed to marshal restore params: %w", err)
+ }
+ _, err = s.apiClient.Call(ctx, models.MethodSettingsBackupRestore, string(data))
+ if err != nil {
+ return fmt.Errorf("failed to restore backup: %w", err)
+ }
+ return nil
+}
+
+func (s *DefaultSettingsService) GetBackupStatus(ctx context.Context) (*models.BackupStatusResponse, error) {
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsBackupStatus, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get backup status: %w", err)
+ }
+ var status models.BackupStatusResponse
+ if err := json.Unmarshal([]byte(resp), &status); err != nil {
+ return nil, fmt.Errorf("failed to parse backup status: %w", err)
+ }
+ return &status, nil
+}
+
+func (s *DefaultSettingsService) RunRemoteBackup(ctx context.Context) (string, error) {
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsBackupRemoteRun, "")
+ if err != nil {
+ return "", fmt.Errorf("failed to run remote backup: %w", err)
+ }
+ var raw remoteBackupRunRaw
+ if err := json.Unmarshal([]byte(resp), &raw); err != nil {
+ return "", fmt.Errorf("failed to parse remote backup result: %w", err)
+ }
+ return raw.Backup.ID, nil
+}
+
+func (s *DefaultSettingsService) ListRemoteBackups(ctx context.Context) ([]RemoteBackupItem, error) {
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsBackupRemoteList, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to list remote backups: %w", err)
+ }
+ var raw struct {
+ Items []RemoteBackupItem `json:"items"`
+ }
+ if err := json.Unmarshal([]byte(resp), &raw); err != nil {
+ return nil, fmt.Errorf("failed to parse remote backups: %w", err)
+ }
+ return raw.Items, nil
+}
+
+func (s *DefaultSettingsService) RestoreRemoteBackup(ctx context.Context, id string) error {
+ params := models.BackupRemoteRestoreParams{ID: id}
+ data, err := json.Marshal(¶ms)
+ if err != nil {
+ return fmt.Errorf("failed to marshal remote restore params: %w", err)
+ }
+ _, err = s.apiClient.Call(ctx, models.MethodSettingsBackupRemoteRestore, string(data))
+ if err != nil {
+ return fmt.Errorf("failed to restore remote backup: %w", err)
+ }
+ return nil
+}
+
+// StartAuthLink starts the reverse device link flow.
+func (s *DefaultSettingsService) StartAuthLink(ctx context.Context) (*models.AuthLinkStatusResponse, error) {
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsAuthLink, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to start device link: %w", err)
+ }
+ var link models.AuthLinkStatusResponse
+ if err := json.Unmarshal([]byte(resp), &link); err != nil {
+ return nil, fmt.Errorf("failed to parse device link response: %w", err)
+ }
+ return &link, nil
+}
+
+// GetAuthLinkStatus reports the active link flow's state.
+func (s *DefaultSettingsService) GetAuthLinkStatus(ctx context.Context) (*models.AuthLinkStatusResponse, error) {
+ resp, err := s.apiClient.Call(ctx, models.MethodSettingsAuthLinkStatus, "")
+ if err != nil {
+ return nil, fmt.Errorf("failed to get device link status: %w", err)
+ }
+ var link models.AuthLinkStatusResponse
+ if err := json.Unmarshal([]byte(resp), &link); err != nil {
+ return nil, fmt.Errorf("failed to parse device link status: %w", err)
+ }
+ return &link, nil
+}
+
+// CancelAuthLink stops the active link flow.
+func (s *DefaultSettingsService) CancelAuthLink(ctx context.Context) error {
+ if _, err := s.apiClient.Call(ctx, models.MethodSettingsAuthLinkCancel, ""); err != nil {
+ return fmt.Errorf("failed to cancel device link: %w", err)
+ }
+ return nil
+}
+
+// Unlink removes the stored Zaparoo Online credentials.
+func (s *DefaultSettingsService) Unlink(ctx context.Context) error {
+ if _, err := s.apiClient.Call(ctx, models.MethodSettingsAuthUnlink, ""); err != nil {
+ return fmt.Errorf("failed to unlink: %w", err)
+ }
+ return nil
+}
+
// GetSystems fetches available systems from the API.
func (s *DefaultSettingsService) GetSystems(ctx context.Context) ([]models.System, error) {
resp, err := s.apiClient.Call(ctx, models.MethodSystems, "")
diff --git a/pkg/ui/tui/settings_test.go b/pkg/ui/tui/settings_test.go
index a0bbadab..c287eab8 100644
--- a/pkg/ui/tui/settings_test.go
+++ b/pkg/ui/tui/settings_test.go
@@ -31,6 +31,7 @@ import (
testingmocks "github.com/ZaparooProject/zaparoo-core/v2/pkg/testing/mocks"
"github.com/rivo/tview"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
@@ -70,6 +71,7 @@ func TestBuildSettingsMainMenu_Integration(t *testing.T) {
mockSvc.SetupGetSettings(defaultTestSettings())
mockSvc.SetupGetSystems(defaultTestSystems())
mockSvc.SetupUpdateSettingsSuccess()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
cfg := &config.Instance{}
@@ -96,6 +98,8 @@ func TestBuildSettingsMainMenu_Integration(t *testing.T) {
assert.True(t, runner.ContainsText("Advanced"), "Advanced menu item should be visible")
assert.True(t, runner.ContainsText("Logs"), "Logs menu item should be visible")
assert.True(t, runner.ContainsText("About"), "About menu item should be visible")
+ assert.True(t, runner.ContainsText("Online"),
+ "the Online settings entry is always available")
}
func TestBuildSettingsMainMenu_Navigation_Integration(t *testing.T) {
@@ -110,6 +114,7 @@ func TestBuildSettingsMainMenu_Navigation_Integration(t *testing.T) {
mockSvc.SetupGetSettings(defaultTestSettings())
mockSvc.SetupGetSystems(defaultTestSystems())
mockSvc.SetupUpdateSettingsSuccess()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
cfg := &config.Instance{}
@@ -148,6 +153,7 @@ func TestBuildSettingsMainMenu_EscapeGoesBack_Integration(t *testing.T) {
mockSvc := NewMockSettingsService()
mockSvc.SetupGetSettings(defaultTestSettings())
mockSvc.SetupGetSystems(defaultTestSystems())
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
cfg := &config.Instance{}
@@ -546,6 +552,597 @@ func TestFindExitDelayIndex(t *testing.T) {
}
}
+func TestFormatBackupDetailsShowsPartialWarnings(t *testing.T) {
+ t.Parallel()
+ details := formatBackupDetails("Local backup", map[string]any{
+ "status": "partial",
+ "integrity": "unchecked",
+ "warnings": []any{
+ map[string]any{"path": "saves/broken.sav", "reason": "broken symlink"},
+ },
+ })
+ assert.NotContains(t, details, "Payload integrity")
+ assert.Contains(t, details, "saves/broken.sav (broken symlink)")
+}
+
+func TestFormatHumanBytes(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ want string
+ bytes int64
+ }{
+ {name: "bytes", bytes: 512, want: "512 B"},
+ {name: "exact kb", bytes: 2048, want: "2 KB"},
+ {name: "rounds up kb", bytes: 1537, want: "1.6 KB"},
+ {name: "mb", bytes: 5 * 1024 * 1024, want: "5 MB"},
+ {name: "gb", bytes: 3 * 1024 * 1024 * 1024, want: "3 GB"},
+ {name: "tb", bytes: 1298312830128, want: "1.2 TB"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, tt.want, formatHumanBytes(tt.bytes))
+ })
+ }
+}
+
+func backupTestStatus(linked bool) *models.BackupStatusResponse {
+ return &models.BackupStatusResponse{
+ Local: models.BackupStatusEntry{
+ Enabled: true,
+ LastStatus: "never",
+ },
+ Remote: models.BackupStatusEntry{
+ Enabled: false,
+ Linked: linked,
+ Schedule: "daily",
+ LastStatus: "never",
+ },
+ }
+}
+
+func TestBackupActionErrorTextMapsSafeGuidance(t *testing.T) {
+ t.Parallel()
+ tests := []struct {
+ name string
+ err error
+ expected string
+ }{
+ {
+ name: "busy",
+ err: errors.New(
+ "create local backup: backup operation remote-upload has been running since now",
+ ),
+ expected: "Another backup or restore is already running.",
+ },
+ {
+ name: "active media", err: errors.New("cannot restore backup while media is active"),
+ expected: "Stop active media before restoring this backup.",
+ },
+ {
+ name: "media launch", err: errors.New("cannot restore backup while media is launching"),
+ expected: "Wait for media launch to finish",
+ },
+ {
+ name: "unsupported platform", err: errors.New("full-device backup is not supported on this platform"),
+ expected: "Full-device backup is not available on this platform",
+ },
+ {
+ name: "warp", err: errors.New("remote backup is not available for this account"),
+ expected: "requires an active Zaparoo Warp subscription",
+ },
+ {
+ name: "recovery", err: errors.New("backup restore rollback requires recovery: private path"),
+ expected: "Restart Zaparoo Core to complete restore recovery",
+ },
+ {
+ name: "restart", err: errors.New("backup restore restart is pending"),
+ expected: "Restart Zaparoo Core before starting another backup",
+ },
+ {
+ name: "unlinked", err: errors.New("remote backup is unlinked"),
+ expected: "Relink this device to Zaparoo Online",
+ },
+ {
+ name: "rate limited", err: errors.New("remote backup rate limited"),
+ expected: "Wait a few minutes, then try again",
+ },
+ {
+ name: "storage", err: errors.New("insufficient disk space for backup: /private/path"),
+ expected: "Free storage space on this device",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+ text := backupActionErrorText("Backup", tt.err)
+ assert.Contains(t, text, tt.expected)
+ assert.NotContains(t, text, "private path")
+ assert.NotContains(t, text, "/private/path")
+ })
+ }
+}
+
+func TestBackupActionErrorTextRedactsUnknownError(t *testing.T) {
+ t.Parallel()
+ text := backupActionErrorText(
+ "Backup", errors.New("upload failed for /media/fat/private with bearer secret-token"),
+ )
+
+ assert.Equal(t, "Backup failed.\n\nCheck Core logs for details, then try again.", text)
+ assert.NotContains(t, text, "/media/fat")
+ assert.NotContains(t, text, "secret-token")
+}
+
+func TestBuildBackupSettingsMenu_SlowFetchShowsLoader_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ release := make(chan time.Time, 1)
+ mockSvc.On("GetBackupStatus", mock.Anything).
+ WaitUntil(release).
+ Return(backupTestStatus(false), nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ // A fetch that outlives the grace period shows the loading frame.
+ require.True(t, runner.WaitForText("Loading backup status...", time.Second))
+ release <- time.Now()
+ require.True(t, runner.WaitForText("Back up now", time.Second))
+ assert.False(t, runner.ContainsText("Loading backup status..."))
+}
+
+func TestBuildBackupSettingsMenu_FastFetchSkipsLoader_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ previous := tview.NewTextView().SetText("Previous Page")
+ pages.AddPage("previous", previous, true, true)
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ // An instant fetch renders the page directly, well inside the grace
+ // period, so the loading frame never appears.
+ require.True(t, runner.WaitForText("Back up now", 100*time.Millisecond))
+ assert.False(t, runner.ContainsText("Loading backup status..."))
+}
+
+func TestBuildBackupSettingsMenu_UnlinkedShowsCloudLinkPointer_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Back up now", 100*time.Millisecond))
+ assert.True(t, runner.ContainsText("Local"))
+ assert.True(t, runner.ContainsText("Cloud"))
+ assert.True(t, runner.ContainsText("View backups"))
+ assert.True(t, runner.ContainsText("Link account"),
+ "unlinked devices see a pointer to account linking in the Cloud section")
+ assert.False(t, runner.ContainsText("Automatic backup"))
+ assert.False(t, runner.ContainsText("Schedule"))
+}
+
+func TestBuildBackupSettingsMenu_CloudControlsVisibleWhenLinked_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
+ mockSvc.SetupUpdateSettingsSuccess()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Automatic backup", 100*time.Millisecond))
+ assert.True(t, runner.ContainsText("Local"))
+ assert.True(t, runner.ContainsText("Cloud"))
+ assert.True(t, runner.ContainsText("Schedule"))
+ assert.True(t, runner.ContainsText("Status"))
+ assert.False(t, runner.ContainsText("Link account"),
+ "linked devices manage the account from the Online page")
+}
+
+func TestBuildBackupSettingsMenu_ToggleWritesBackupRemoteEnabled_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
+ mockSvc.SetupUpdateSettingsSuccess()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+ require.True(t, runner.WaitForText("Automatic backup", 100*time.Millisecond))
+
+ // Navigate from local "Back up now" to the cloud toggle and flip it.
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateEnter()
+
+ require.True(t, runner.WaitForCondition(func() bool {
+ for _, call := range mockSvc.Calls {
+ if call.Method != "UpdateSettings" {
+ continue
+ }
+ params, ok := call.Arguments.Get(1).(*models.UpdateSettingsParams)
+ if ok && params.BackupRemoteEnabled != nil {
+ return true
+ }
+ }
+ return false
+ }, 100*time.Millisecond), "toggle should write BackupRemoteEnabled")
+}
+
+func TestBuildBackupSettingsMenu_UnavailableWarpStillAttemptsUpload_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 30)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ status := backupTestStatus(true)
+ status.Remote.Availability = "unavailable"
+ mockSvc.SetupGetBackupStatus(status)
+ mockSvc.SetupUpdateSettingsSuccess()
+ mockSvc.On("RunRemoteBackup", mock.Anything).
+ Return("", errors.New("remote backup is not available for this account"))
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Automatic backup", 100*time.Millisecond))
+ assert.True(t, runner.ContainsText("View backups"), "restore must remain available")
+ // Local: Back up now, View backups; Cloud: Automatic backup, Schedule,
+ // Back up now. Four downs land on the cloud upload action.
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ require.True(t, runner.WaitForText("Warp is required", 100*time.Millisecond))
+ // The cached "unavailable" value is only a hint: pressing the action
+ // must still attempt the upload, whose fresh server-side check is
+ // authoritative (a just-activated subscription works immediately).
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("requires an active Zaparoo Warp subscription", 500*time.Millisecond))
+ mockSvc.AssertCalled(t, "RunRemoteBackup", mock.Anything)
+}
+
+func TestBuildBackupSettingsMenu_BackupNowCallsService_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+ release := make(chan time.Time, 1)
+ mockSvc.On("CreateBackup", mock.Anything).
+ WaitUntil(release).
+ Return("backup-20260624-150405-000000000-manual.zip", nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Back up now", 100*time.Millisecond))
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Creating backup", 500*time.Millisecond))
+ require.True(t, runner.WaitForText("Elapsed:", 100*time.Millisecond))
+ assert.False(t, runner.ContainsText("Hide"), "progress modal must not offer a Hide button")
+ release <- time.Now()
+ require.True(t, runner.WaitForText("Backup created", 500*time.Millisecond))
+ mockSvc.AssertCalled(t, "CreateBackup", mock.Anything)
+}
+
+func TestBuildBackupSettingsMenu_BackupErrorShowsSafeGuidance_Integration(t *testing.T) {
+ t.Parallel()
+ runner := NewTestAppRunner(t, 100, 30)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+ mockSvc.On("CreateBackup", mock.Anything).Return(
+ "", errors.New("cannot restore backup while media is active: /private/path"),
+ )
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Back up now", 100*time.Millisecond))
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Stop active media", 500*time.Millisecond))
+ assert.False(t, runner.ContainsText("/private/path"))
+}
+
+func TestBuildBackupSettingsMenu_RemoteBackupNowCallsService_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.SetupGetBackupStatus(backupTestStatus(true))
+ mockSvc.SetupUpdateSettingsSuccess()
+ mockSvc.On("RunRemoteBackup", mock.Anything).Return("backup-42", nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupSettingsMenu(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Automatic backup", 100*time.Millisecond))
+ // Local: Back up now, View backups; Cloud: Automatic backup, Schedule,
+ // Back up now. Four downs land on the cloud upload action.
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateArrowDown()
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Cloud backup created", 500*time.Millisecond))
+ require.True(t, runner.WaitForText("Cloud backup backup-42", 100*time.Millisecond))
+ mockSvc.AssertCalled(t, "RunRemoteBackup", mock.Anything)
+}
+
+func TestWaitForCoreRestart_DownThenUp_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ mockSvc := NewMockSettingsService()
+ mockSvc.On("GetSettings", mock.Anything).Return(nil, errors.New("connection refused")).Twice()
+ mockSvc.On("GetSettings", mock.Anything).Return(&models.SettingsResponse{}, nil)
+
+ done := make(chan struct{})
+ runner.QueueUpdateDraw(func() {
+ waitForCoreRestartWith(mockSvc, pages, runner.App(),
+ 10*time.Millisecond, time.Minute, time.Minute, func() { close(done) })
+ })
+
+ require.True(t, runner.WaitForText("Core is restarting.", 100*time.Millisecond))
+ require.True(t, runner.WaitForText("Core restarted", time.Second))
+ assert.False(t, runner.ContainsText("Core is restarting."))
+ select {
+ case <-done:
+ t.Fatal("onDone must wait for the user to confirm the restart modal")
+ default:
+ }
+
+ runner.Screen().InjectEnter()
+ runner.Draw()
+ require.True(t, runner.WaitForSignal(done, time.Second),
+ "onDone should run once the restart confirmation is dismissed")
+}
+
+func TestWaitForCoreRestart_NeverDownEndsAfterGrace_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ pages.AddPage("main", tview.NewTextView().SetText("Main Page"), true, true)
+ runner.Start(pages)
+ runner.Draw()
+
+ mockSvc := NewMockSettingsService()
+ mockSvc.On("GetSettings", mock.Anything).Return(&models.SettingsResponse{}, nil)
+
+ done := make(chan struct{})
+ runner.QueueUpdateDraw(func() {
+ waitForCoreRestartWith(mockSvc, pages, runner.App(),
+ 10*time.Millisecond, 50*time.Millisecond, time.Minute, func() { close(done) })
+ })
+
+ require.True(t, runner.WaitForText("Core restarted", time.Second))
+ runner.Screen().InjectEnter()
+ runner.Draw()
+ require.True(t, runner.WaitForSignal(done, time.Second),
+ "onDone should run after the no-drop grace period and confirmation")
+}
+
+func TestBuildBackupListPage_DisplaysBackups_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ backupName := "backup-20260624-150405-000000000-manual.zip"
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+ mockSvc.SetupListBackups([]map[string]any{{"name": backupName, "createdAt": "2026-06-24T15:04:05Z"}})
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Local backup 2026-06-24 15:04:05 UTC", 100*time.Millisecond))
+ pageName, primitive := pages.GetFrontPage()
+ require.Equal(t, PageSettingsBackupList, pageName)
+ frame, ok := primitive.(*PageFrame)
+ require.True(t, ok)
+ list, ok := frame.GetContent().(*tview.List)
+ require.True(t, ok)
+ require.Positive(t, list.GetItemCount())
+ mainText, _ := list.GetItemText(0)
+ assert.Equal(t, "Local backup 2026-06-24 15:04:05 UTC", mainText)
+}
+
+func TestBuildBackupListPage_SelectShowsDetailsModal_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 40)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ backupName := "backup-20260624-150405-000000000-manual.zip"
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+ backupDetails := map[string]any{
+ "name": backupName,
+ "createdAt": "2026-06-24T15:04:05Z",
+ "size": float64(2048),
+ "status": "success",
+ "integrity": "unchecked",
+ "categories": map[string]any{
+ "zaparoo": map[string]any{"files": float64(3), "bytes": float64(1000)},
+ "settings": map[string]any{"files": float64(2), "bytes": float64(500)},
+ },
+ }
+ mockSvc.SetupListBackups([]map[string]any{{
+ "name": backupName,
+ "createdAt": "2026-06-24T15:04:05Z",
+ "size": float64(2048),
+ }})
+ mockSvc.SetupInspectBackup(backupDetails)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Local backup 2026-06-24 15:04:05 UTC", 100*time.Millisecond))
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Backup details", 100*time.Millisecond))
+ require.True(t, runner.WaitForText("Size: 2 KB", 100*time.Millisecond))
+ assert.False(t, runner.ContainsText("Payload integrity"))
+ require.True(t, runner.WaitForText("Manifest:", 100*time.Millisecond))
+ require.True(t, runner.WaitForText("Zaparoo", 100*time.Millisecond), runner.GetScreenText())
+ require.True(t, runner.WaitForText("Settings", 100*time.Millisecond), runner.GetScreenText())
+}
+
+func TestBuildBackupListPage_InspectFailureDisablesRestore_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 40)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ backupName := "backup-20260624-150405-000000000-manual.zip"
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+ mockSvc.SetupListBackups([]map[string]any{{
+ "name": backupName,
+ "createdAt": "2026-06-24T15:04:05Z",
+ "size": float64(2048),
+ }})
+ mockSvc.SetupInspectBackupError(errors.New("bad manifest"))
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Local backup 2026-06-24 15:04:05 UTC", 100*time.Millisecond))
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Unable", 500*time.Millisecond), runner.GetScreenText())
+ require.True(t, runner.WaitForText("disabled", 100*time.Millisecond), runner.GetScreenText())
+ mockSvc.AssertNotCalled(t, "RestoreBackup", mock.Anything, backupName)
+}
+
+func TestBackupDetailsModal_DeleteCallsService_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 40)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ backupName := "backup-20260624-150405-000000000-manual.zip"
+ backupDetails := map[string]any{
+ "name": backupName,
+ "createdAt": "2026-06-24T15:04:05Z",
+ "size": float64(2048),
+ "status": "success",
+ "integrity": "unchecked",
+ }
+ mockSvc.SetupGetBackupStatus(backupTestStatus(false))
+ mockSvc.SetupListBackups([]map[string]any{{
+ "name": backupName,
+ "createdAt": "2026-06-24T15:04:05Z",
+ "size": float64(2048),
+ }})
+ mockSvc.SetupInspectBackup(backupDetails)
+ mockSvc.SetupDeleteBackupSuccess()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Local backup 2026-06-24 15:04:05 UTC", 100*time.Millisecond))
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Backup details", 500*time.Millisecond))
+ runner.SimulateTab()
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Delete Local backup", 100*time.Millisecond))
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Backup deleted", 500*time.Millisecond))
+ mockSvc.AssertCalled(t, "DeleteBackup", mock.Anything, backupName)
+}
+
+func TestShowBackupRestoreConfirm_CallsService_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ focus := tview.NewTextView().SetText("Backup list")
+ pages.AddPage("main", focus, true, true)
+ mockSvc := NewMockSettingsService()
+ backupName := "backup-20260624-150405-000000000-manual.zip"
+ mockSvc.SetupRestoreBackupSuccess()
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ showBackupRestoreConfirm(mockSvc, pages, runner.App(), focus, backupName, nil)
+ })
+
+ require.True(t, runner.WaitForText("Restore Local backup", 100*time.Millisecond))
+ runner.Screen().InjectEnter()
+ runner.Draw()
+ require.True(t, runner.WaitForText("Backup restored", 100*time.Millisecond))
+ mockSvc.AssertCalled(t, "RestoreBackup", mock.Anything, backupName)
+}
+
func TestBuildAdvancedSettingsMenu_ErrorReportingVisible_Integration(t *testing.T) {
t.Parallel()
@@ -734,3 +1331,246 @@ func TestBuildAdvancedSettingsMenu_ErrorReportingDisableNoConfirm_Integration(t
assert.False(t, runner.ContainsText("Sentry"),
"No confirmation modal should appear when disabling")
}
+
+func remoteTestBackup(id string, createdAt time.Time, source *RemoteBackupSourceDevice) RemoteBackupItem {
+ return RemoteBackupItem{
+ ID: id,
+ CreatedAt: createdAt,
+ BackupType: "manual",
+ SizeBytes: 4 << 20,
+ Categories: map[string]RemoteBackupCategory{
+ "zaparoo": {Files: 2, Bytes: 100},
+ "saves": {Files: 5, Bytes: 900},
+ },
+ SourceDevice: source,
+ }
+}
+
+func TestGroupRemoteBackupsBySource_OrdersTiersAndSnapshots(t *testing.T) {
+ t.Parallel()
+ base := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
+ oldDevice := &RemoteBackupSourceDevice{ID: "dev-old", Name: "Old MiSTer", Linked: false}
+ linkedDevice := &RemoteBackupSourceDevice{ID: "dev-b", Name: "Bedroom", Linked: true}
+ currentDevice := &RemoteBackupSourceDevice{ID: "dev-cur", Name: "Living Room", Linked: true, Current: true}
+
+ sources := groupRemoteBackupsBySource([]RemoteBackupItem{
+ remoteTestBackup("old-1", base.Add(1*time.Hour), oldDevice),
+ remoteTestBackup("legacy-1", base.Add(2*time.Hour), nil),
+ remoteTestBackup("old-2", base.Add(3*time.Hour), oldDevice),
+ remoteTestBackup("cur-1", base.Add(4*time.Hour), currentDevice),
+ remoteTestBackup("linked-1", base.Add(5*time.Hour), linkedDevice),
+ })
+
+ require.Len(t, sources, 3)
+ // Current device first, merged with the legacy item, newest snapshot first.
+ assert.Equal(t, "Living Room", sources[0].Device.Name)
+ assert.True(t, sources[0].Device.Current)
+ require.Len(t, sources[0].Backups, 2)
+ assert.Equal(t, "cur-1", sources[0].Backups[0].ID)
+ assert.Equal(t, "legacy-1", sources[0].Backups[1].ID)
+ // Other linked devices next.
+ assert.Equal(t, "Bedroom", sources[1].Device.Name)
+ // Unlinked devices last, snapshots newest first.
+ assert.Equal(t, "Old MiSTer", sources[2].Device.Name)
+ require.Len(t, sources[2].Backups, 2)
+ assert.Equal(t, "old-2", sources[2].Backups[0].ID)
+ assert.Equal(t, "old-1", sources[2].Backups[1].ID)
+}
+
+func TestGroupRemoteBackupsBySource_LegacyWithoutSourceDevice(t *testing.T) {
+ t.Parallel()
+ base := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
+ sources := groupRemoteBackupsBySource([]RemoteBackupItem{
+ remoteTestBackup("a", base, nil),
+ remoteTestBackup("b", base.Add(time.Hour), nil),
+ })
+ require.Len(t, sources, 1)
+ assert.True(t, sources[0].Device.Current)
+ assert.Equal(t, "This device", remoteBackupSourceLabel(&sources[0].Device))
+ require.Len(t, sources[0].Backups, 2)
+ assert.Equal(t, "b", sources[0].Backups[0].ID)
+}
+
+func TestRemoteBackupSourceLabel(t *testing.T) {
+ t.Parallel()
+ assert.Equal(t, "This device",
+ remoteBackupSourceLabel(&RemoteBackupSourceDevice{Current: true}))
+ assert.Equal(t, "Living Room (this device)",
+ remoteBackupSourceLabel(&RemoteBackupSourceDevice{Name: "Living Room", Current: true}))
+ assert.Equal(t, "Bedroom",
+ remoteBackupSourceLabel(&RemoteBackupSourceDevice{Name: "Bedroom", Linked: true}))
+ assert.Equal(t, "Old MiSTer (unlinked)",
+ remoteBackupSourceLabel(&RemoteBackupSourceDevice{Name: "Old MiSTer"}))
+ assert.Equal(t, "Unnamed device",
+ remoteBackupSourceLabel(&RemoteBackupSourceDevice{Linked: true}))
+}
+
+func TestRemoteBackupListPage_GroupsSources_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 30)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ base := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
+ mockSvc.On("ListRemoteBackups", mock.Anything).Return([]RemoteBackupItem{
+ remoteTestBackup("old-1", base, &RemoteBackupSourceDevice{ID: "dev-old", Name: "Old MiSTer"}),
+ remoteTestBackup("cur-1", base.Add(time.Hour),
+ &RemoteBackupSourceDevice{ID: "dev-cur", Name: "Living Room", Linked: true, Current: true}),
+ remoteTestBackup("cur-2", base.Add(2*time.Hour), nil),
+ }, nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildRemoteBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("Living Room (this device)", 500*time.Millisecond))
+ assert.True(t, runner.ContainsText("Old MiSTer (unlinked)"))
+ assert.True(t, runner.ContainsText("2 backups"), "current device merges the legacy snapshot")
+ assert.True(t, runner.ContainsText("1 backup"))
+ assert.True(t, runner.ContainsText("Latest 2026-07-10 14:00:00 UTC"))
+
+ // Open the first of multiple groups to exercise its captured callback.
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Cloud backup 2026-07-10 14:00:00 UTC", 500*time.Millisecond))
+ assert.True(t, runner.ContainsText("Cloud backup 2026-07-10 13:00:00 UTC"))
+ assert.False(t, runner.ContainsText("Cloud backup 2026-07-10 12:00:00 UTC"))
+ assert.False(t, runner.ContainsText("Old MiSTer (unlinked)"))
+}
+
+func TestRemoteBackupListPage_Empty_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 80, 25)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.On("ListRemoteBackups", mock.Anything).Return([]RemoteBackupItem{}, nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildRemoteBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+
+ require.True(t, runner.WaitForText("(no cloud backups found)", 500*time.Millisecond))
+}
+
+func TestRemoteBackupSnapshotsPage_ShowsTypeAndSize_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 30)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ base := time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC)
+ scheduled := remoteTestBackup("snap-old", base, nil)
+ scheduled.BackupType = "scheduled"
+ mockSvc.On("ListRemoteBackups", mock.Anything).Return([]RemoteBackupItem{
+ scheduled,
+ remoteTestBackup("snap-new", base.Add(time.Hour), nil),
+ }, nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildRemoteBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+ require.True(t, runner.WaitForText("This device", 500*time.Millisecond))
+
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Cloud backup 2026-07-10 13:00:00 UTC", 500*time.Millisecond))
+ assert.True(t, runner.ContainsText("Cloud backup 2026-07-10 12:00:00 UTC"))
+ assert.True(t, runner.ContainsText("Manual"))
+ assert.True(t, runner.ContainsText("Scheduled"))
+ assert.True(t, runner.ContainsText("4 MB"))
+}
+
+func TestRemoteBackupSnapshotsPage_IncompatibleCannotRestore_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 30)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ incompatible := remoteTestBackup("snap-1", time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), nil)
+ incompatible.Incompatible = true
+ mockSvc.On("ListRemoteBackups", mock.Anything).Return([]RemoteBackupItem{incompatible}, nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildRemoteBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+ require.True(t, runner.WaitForText("This device", 500*time.Millisecond))
+
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("requires newer Core", 500*time.Millisecond))
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Incompatible backup", 500*time.Millisecond))
+ runner.SimulateEnter()
+ mockSvc.AssertNotCalled(t, "RestoreRemoteBackup", mock.Anything, mock.Anything)
+}
+
+func TestRemoteBackupRestoreConfirm_WordingAndRestore_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 30)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ source := &RemoteBackupSourceDevice{ID: "dev-old", Name: "Old MiSTer"}
+ mockSvc.On("ListRemoteBackups", mock.Anything).Return([]RemoteBackupItem{
+ remoteTestBackup("backup-7", time.Date(2026, 7, 10, 12, 0, 0, 0, time.UTC), source),
+ }, nil)
+ mockSvc.On("RestoreRemoteBackup", mock.Anything, "backup-7").Return(nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ buildRemoteBackupListPage(mockSvc, pages, runner.App(), func() {})
+ })
+ require.True(t, runner.WaitForText("Old MiSTer (unlinked)", 500*time.Millisecond))
+
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Cloud backup 2026-07-10 12:00:00 UTC", 500*time.Millisecond))
+ runner.SimulateEnter()
+
+ require.True(t, runner.WaitForText("Restore backup from Old MiSTer (unlinked)?", 500*time.Millisecond))
+ assert.True(t, runner.ContainsText("Snapshot: 2026-07-10 12:00:00 UTC"))
+ assert.True(t, runner.ContainsText("Overwrites: Zaparoo, Saves"))
+ assert.True(t, runner.ContainsText("The source backup is not changed."))
+ assert.True(t, runner.ContainsText("keeps its own Zaparoo Online identity."))
+ assert.True(t, runner.ContainsText("safety backup is created first."))
+ assert.True(t, runner.ContainsText("Core restarts after restore."))
+
+ // Confirm ("Yes" is focused first).
+ runner.SimulateEnter()
+ require.True(t, runner.WaitForText("Cloud backup restored", 2*time.Second))
+ mockSvc.AssertCalled(t, "RestoreRemoteBackup", mock.Anything, "backup-7")
+}
+
+func TestStartAuthLinkFlow_SuccessMentionsCloudBackups_Integration(t *testing.T) {
+ t.Parallel()
+
+ runner := NewTestAppRunner(t, 100, 30)
+ defer runner.Stop()
+ pages := tview.NewPages()
+ mockSvc := NewMockSettingsService()
+ mockSvc.On("StartAuthLink", mock.Anything).Return(&models.AuthLinkStatusResponse{
+ Status: models.AuthLinkStatusPending,
+ UserCode: "ABCD-1234",
+ VerificationURL: "https://zaparoo.com/link",
+ }, nil)
+ mockSvc.On("GetAuthLinkStatus", mock.Anything).Return(&models.AuthLinkStatusResponse{
+ Status: models.AuthLinkStatusApproved,
+ }, nil)
+
+ runner.Start(pages)
+ runner.QueueUpdateDraw(func() {
+ startAuthLinkFlow(mockSvc, pages, runner.App(), func() {})
+ })
+ require.True(t, runner.WaitForText("ABCD-1234", 500*time.Millisecond))
+
+ // The poll loop ticks every 2 seconds before observing approval.
+ require.True(t, runner.WaitForText("Device linked", 5*time.Second))
+ assert.True(t, runner.ContainsText("Existing backups from your account are under"))
+ assert.True(t, runner.ContainsText("Cloud backup > View backups."))
+}
diff --git a/pkg/ui/tui/settings_tui.go b/pkg/ui/tui/settings_tui.go
index 0dbe79c8..8ac1e5ea 100644
--- a/pkg/ui/tui/settings_tui.go
+++ b/pkg/ui/tui/settings_tui.go
@@ -191,6 +191,8 @@ var pagesToClearOnThemeChange = []string{
PageSettingsMain,
PageSettingsBasic,
PageSettingsAdvanced,
+ PageSettingsBackup,
+ PageSettingsBackupList,
PageSettingsReaderList,
PageSettingsReaderEdit,
PageSettingsIgnoreSystems,
diff --git a/pkg/ui/tui/utils.go b/pkg/ui/tui/utils.go
index bdc7ccc1..8c786750 100644
--- a/pkg/ui/tui/utils.go
+++ b/pkg/ui/tui/utils.go
@@ -52,6 +52,12 @@ func tagReadContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), TagReadTimeout)
}
+// backupContext creates a cancellable context without imposing a whole-operation deadline.
+func backupContext() (context.Context, context.CancelFunc) {
+ //nolint:gosec // G118: caller is responsible for cancel
+ return context.WithCancel(context.Background())
+}
+
// DefaultMaxWidth is the default maximum width for the TUI in non-CRT mode.
const DefaultMaxWidth = 100
@@ -147,56 +153,55 @@ const (
waitingModalPage = "waiting_modal"
oskModalPage = "osk_modal"
errorReportingPromptPage = "error_reporting_prompt"
+ encryptionPromptPage = "encryption_prompt"
)
// ShowInfoModal displays an informational modal with a title and OK button.
// onDismiss is called when the modal is closed and should restore focus. The
-// returned modal supports callers that need to update displayed information.
+// returned dialog supports callers that need to update displayed information.
func ShowInfoModal(
pages *tview.Pages, app *tview.Application, title, message string, onDismiss func(),
-) *tview.Modal {
- modal := tview.NewModal().
+) *Dialog {
+ dialog := NewDialog().
SetText(message).
+ SetTitle(title).
AddButtons([]string{"OK"}).
- SetDoneFunc(func(_ int, _ string) {
+ SetDoneFunc(func(_ int) {
pages.HidePage(infoModalPage)
pages.RemovePage(infoModalPage)
if onDismiss != nil {
onDismiss()
}
})
- modal.SetTitle(" " + title + " ").
- SetBorder(true).
- SetTitleAlign(tview.AlignCenter)
- pages.AddPage(infoModalPage, modal, false, true)
- app.SetFocus(modal)
- return modal
+ pages.AddPage(infoModalPage, dialog, true, true)
+ app.SetFocus(dialog)
+ return dialog
}
// ShowErrorModal displays an error message modal to the user.
// onDismiss is called when the modal is closed and should restore focus.
func ShowErrorModal(pages *tview.Pages, app *tview.Application, message string, onDismiss func()) {
- modal := tview.NewModal().
+ dialog := NewDialog().
SetText(message).
AddButtons([]string{"OK"}).
- SetDoneFunc(func(_ int, _ string) {
+ SetDoneFunc(func(_ int) {
pages.HidePage(errorModalPage)
pages.RemovePage(errorModalPage)
if onDismiss != nil {
onDismiss()
}
})
- pages.AddPage(errorModalPage, modal, false, true)
- app.SetFocus(modal)
+ pages.AddPage(errorModalPage, dialog, true, true)
+ app.SetFocus(dialog)
}
// ShowConfirmModal displays a confirmation dialog with Yes/No buttons.
// onYes is called when the user clicks "Yes", onNo is called for "No" or Escape.
func ShowConfirmModal(pages *tview.Pages, app *tview.Application, message string, onYes, onNo func()) {
- modal := tview.NewModal().
+ dialog := NewDialog().
SetText(message).
AddButtons([]string{"Yes", "No"}).
- SetDoneFunc(func(buttonIndex int, _ string) {
+ SetDoneFunc(func(buttonIndex int) {
pages.HidePage(confirmModalPage)
pages.RemovePage(confirmModalPage)
if buttonIndex == 0 {
@@ -209,8 +214,8 @@ func ShowConfirmModal(pages *tview.Pages, app *tview.Application, message string
}
}
})
- pages.AddPage(confirmModalPage, modal, false, true)
- app.SetFocus(modal)
+ pages.AddPage(confirmModalPage, dialog, true, true)
+ app.SetFocus(dialog)
}
// ShowErrorReportingPrompt displays the first-run error reporting opt-in modal.
@@ -222,15 +227,16 @@ func ShowErrorReportingPrompt(
app *tview.Application,
onEnable, onNotNow, onDontAsk func(),
) {
- modal := tview.NewModal().
+ dialog := NewDialog().
SetText(
"Help improve Zaparoo?\n\n" +
"Anonymous error reports help fix bugs faster.\n" +
"Only sent when errors occur. No personal data collected.\n" +
"You can change this in Settings at any time.",
).
+ SetTitle("Error Reporting").
AddButtons([]string{"Enable", "Not Now", "Don't Ask Again"}).
- SetDoneFunc(func(buttonIndex int, _ string) {
+ SetDoneFunc(func(buttonIndex int) {
pages.HidePage(errorReportingPromptPage)
pages.RemovePage(errorReportingPromptPage)
switch buttonIndex {
@@ -248,28 +254,67 @@ func ShowErrorReportingPrompt(
}
}
})
- modal.SetTitle(" Error Reporting ").
- SetBorder(true).
- SetTitleAlign(tview.AlignCenter)
- pages.AddPage(errorReportingPromptPage, modal, false, true)
- app.SetFocus(modal)
+ pages.AddPage(errorReportingPromptPage, dialog, true, true)
+ app.SetFocus(dialog)
+}
+
+// ShowEncryptionPrompt displays the first-run secure-device opt-in modal.
+// onSecure is called when the user clicks "Secure Now".
+// onNotNow is called when the user clicks "Not Now" or presses Escape.
+// onDontAsk is called when the user clicks "Don't Ask Again".
+func ShowEncryptionPrompt(
+ pages *tview.Pages,
+ app *tview.Application,
+ onSecure, onNotNow, onDontAsk func(),
+) {
+ dialog := NewDialog().
+ SetText(
+ "Right now, anyone on your network can send\n" +
+ "Zaparoo commands to this device. Securing it\n" +
+ "means only phones and apps you approve can\n" +
+ "connect to Zaparoo, each with its own level\n" +
+ "of access.\n\n" +
+ "You can change this in Settings at any time.",
+ ).
+ SetTitle("Secure Zaparoo?").
+ AddButtons([]string{"Secure Now", "Not Now", "Don't Ask Again"}).
+ SetDoneFunc(func(buttonIndex int) {
+ pages.HidePage(encryptionPromptPage)
+ pages.RemovePage(encryptionPromptPage)
+ switch buttonIndex {
+ case 0:
+ if onSecure != nil {
+ onSecure()
+ }
+ case 2:
+ if onDontAsk != nil {
+ onDontAsk()
+ }
+ default:
+ if onNotNow != nil {
+ onNotNow()
+ }
+ }
+ })
+ pages.AddPage(encryptionPromptPage, dialog, true, true)
+ app.SetFocus(dialog)
}
// ShowWaitingModal displays a modal while waiting for user action (like placing a tag).
// Returns a cleanup function that removes the modal.
func ShowWaitingModal(pages *tview.Pages, app *tview.Application, message string, onCancel func()) func() {
- modal := tview.NewModal().
+ dialog := NewDialog().
SetText(message).
AddButtons([]string{"Cancel"}).
- SetDoneFunc(func(_ int, _ string) {
+ SetDoneFunc(func(_ int) {
pages.HidePage(waitingModalPage)
pages.RemovePage(waitingModalPage)
if onCancel != nil {
onCancel()
}
})
- pages.AddPage(waitingModalPage, modal, false, true)
- app.SetFocus(modal)
+ pages.AddPage(waitingModalPage, dialog, true, true)
+ app.SetFocus(dialog)
return func() {
pages.HidePage(waitingModalPage)
@@ -375,10 +420,10 @@ func WriteTagWithModal(
ctx, ctxCancel := tagReadContext()
- modal := tview.NewModal().
+ dialog := NewDialog().
SetText("Place token on reader...").
AddButtons([]string{"Cancel"}).
- SetDoneFunc(func(_ int, _ string) {
+ SetDoneFunc(func(_ int) {
ctxCancel()
// Cancel the write operation on the server (fire and forget)
go func() {
@@ -392,8 +437,8 @@ func WriteTagWithModal(
}
})
- pages.AddPage(writeModalPage, modal, true, true)
- app.SetFocus(modal)
+ pages.AddPage(writeModalPage, dialog, true, true)
+ app.SetFocus(dialog)
go func() {
err := svc.WriteTag(ctx, text)
@@ -415,17 +460,17 @@ func WriteTagWithModal(
app.QueueUpdateDraw(func() {
pages.RemovePage(writeModalPage)
- successModal := tview.NewModal().
+ successDialog := NewDialog().
SetText("Token written successfully!").
AddButtons([]string{"OK"}).
- SetDoneFunc(func(_ int, _ string) {
+ SetDoneFunc(func(_ int) {
pages.RemovePage(successModalPage)
if onComplete != nil {
onComplete(true)
}
})
- pages.AddPage(successModalPage, successModal, true, true)
- app.SetFocus(successModal)
+ pages.AddPage(successModalPage, successDialog, true, true)
+ app.SetFocus(successDialog)
})
}()
}
diff --git a/pkg/ui/tui/utils_test.go b/pkg/ui/tui/utils_test.go
index 3bbbbf4a..76779bd4 100644
--- a/pkg/ui/tui/utils_test.go
+++ b/pkg/ui/tui/utils_test.go
@@ -20,6 +20,7 @@
package tui
import (
+ "context"
"testing"
"time"
@@ -435,6 +436,18 @@ func TestTagReadContext(t *testing.T) {
assert.True(t, deadline.After(tuiDeadline), "Tag read timeout should be longer than TUI timeout")
}
+func TestBackupContext(t *testing.T) {
+ t.Parallel()
+
+ ctx, cancel := backupContext()
+ require.NotNil(t, ctx)
+ require.NotNil(t, cancel)
+ _, hasDeadline := ctx.Deadline()
+ assert.False(t, hasDeadline)
+ cancel()
+ require.ErrorIs(t, ctx.Err(), context.Canceled)
+}
+
func TestShowInfoModal_Integration(t *testing.T) {
t.Parallel()
@@ -852,7 +865,7 @@ func TestTimeoutConstants(t *testing.T) {
assert.Equal(t, 5*time.Second, TUIRequestTimeout)
assert.Equal(t, 30*time.Second, TagReadTimeout)
- // Tag read should be longer than TUI request (for user interaction)
+ // Physical tag interaction needs a longer request window than ordinary UI work.
assert.Greater(t, TagReadTimeout, TUIRequestTimeout)
}
diff --git a/pkg/zapscript/commands.go b/pkg/zapscript/commands.go
index 625309ef..bc6384fb 100644
--- a/pkg/zapscript/commands.go
+++ b/pkg/zapscript/commands.go
@@ -49,10 +49,11 @@ import (
// RunCommandOptions groups optional services used by specific command types.
type RunCommandOptions struct {
- WaitForMediaReady func(context.Context) error
- PlaybackManager audio.PlaybackManager
- UI *uievents.Service
- LauncherManager *state.LauncherManager
+ WaitForMediaReady func(context.Context) error
+ AcquireMediaLaunch func() (func(), error)
+ PlaybackManager audio.PlaybackManager
+ UI *uievents.Service
+ LauncherManager *state.LauncherManager
}
var (
@@ -471,19 +472,20 @@ func RunCommand(
}
env := platforms.CmdEnv{
- Cmd: cmd,
- Cfg: cfg,
- ServiceCtx: serviceCtx,
- WaitForMediaReady: opts.WaitForMediaReady,
- PlaybackManager: opts.PlaybackManager,
- UI: opts.UI,
- Playlist: plsc,
- Source: token.Source,
- TotalCommands: totalCmds,
- CurrentIndex: currentIndex,
- Unsafe: unsafe,
- Database: db,
- ExprEnv: exprEnv,
+ Cmd: cmd,
+ Cfg: cfg,
+ ServiceCtx: serviceCtx,
+ WaitForMediaReady: opts.WaitForMediaReady,
+ AcquireMediaLaunch: opts.AcquireMediaLaunch,
+ PlaybackManager: opts.PlaybackManager,
+ UI: opts.UI,
+ Playlist: plsc,
+ Source: token.Source,
+ TotalCommands: totalCmds,
+ CurrentIndex: currentIndex,
+ Unsafe: unsafe,
+ Database: db,
+ ExprEnv: exprEnv,
}
if opts.LauncherManager != nil {
diff --git a/pkg/zapscript/launch.go b/pkg/zapscript/launch.go
index edd7ba8c..50bff181 100644
--- a/pkg/zapscript/launch.go
+++ b/pkg/zapscript/launch.go
@@ -646,16 +646,24 @@ func getLaunchClosure(
}
}
+ var launcher *platforms.Launcher
if launcherID != "" {
- launcher := findLauncher(pl, env, launcherID)
+ launcher = findLauncher(pl, env, launcherID)
if launcher == nil {
return fmt.Errorf("launcher not found: %s", launcherID)
}
log.Info().Msgf("launching with launcher: %s", launcherID)
- return pl.LaunchMedia(env.Cfg, target.path, launcher, env.Database, opts)
}
- return pl.LaunchMedia(env.Cfg, target.path, nil, env.Database, opts)
+ releaseLaunch := func() {}
+ if env.AcquireMediaLaunch != nil {
+ releaseLaunch, err = env.AcquireMediaLaunch()
+ if err != nil {
+ return fmt.Errorf("acquiring media launch gate: %w", err)
+ }
+ }
+ defer releaseLaunch()
+ return pl.LaunchMedia(env.Cfg, target.path, launcher, env.Database, opts)
}
}
diff --git a/pkg/zapscript/launch_test.go b/pkg/zapscript/launch_test.go
index 3afc8d40..e485fa54 100644
--- a/pkg/zapscript/launch_test.go
+++ b/pkg/zapscript/launch_test.go
@@ -668,6 +668,34 @@ func TestLaunchClosurePreservesExplicitLauncher(t *testing.T) {
mockPlatform.AssertExpectations(t)
}
+func TestLaunchClosureHoldsMediaLaunchGate(t *testing.T) {
+ t.Parallel()
+
+ cfg := &config.Instance{}
+ path := filepath.Join("games", "game.sfc")
+ mockPlatform := mocks.NewMockPlatform()
+ gateHeld := false
+ mockPlatform.On(
+ "LaunchMedia", cfg, path, (*platforms.Launcher)(nil),
+ (*database.Database)(nil), (*platforms.LaunchOptions)(nil),
+ ).Run(func(mock.Arguments) {
+ assert.True(t, gateHeld)
+ }).Return(nil).Once()
+ env := platforms.CmdEnv{
+ Cfg: cfg,
+ Cmd: zapscript.Command{AdvArgs: zapscript.NewAdvArgs(nil)},
+ AcquireMediaLaunch: func() (func(), error) {
+ gateHeld = true
+ return func() { gateHeld = false }, nil
+ },
+ }
+
+ launch := getLaunchClosure(mockPlatform, &env, true)
+ require.NoError(t, launch(launchTarget{path: path, systemID: "SNES"}))
+ assert.False(t, gateHeld)
+ mockPlatform.AssertExpectations(t)
+}
+
func TestLaunchClosureAppliesSystemDefault(t *testing.T) {
t.Parallel()