toolframe-backup 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,511 @@
1
+ """Bounded local archive catalog and explicit, verified retention."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import re
9
+ import secrets
10
+ import stat
11
+ import threading
12
+ import time
13
+ from dataclasses import dataclass
14
+ from datetime import UTC, datetime, timedelta
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from .archive import BackupError, verify_backup
19
+
20
+ MAX_CANDIDATES = 1000
21
+ MAX_DIRECTORY_ENTRIES = 10_000
22
+ MAX_SCAN_BYTES = 4 * 1024**3
23
+ PLAN_LIFETIME_SECONDS = 600
24
+ _BACKUP_NAME = re.compile(r"backup-([0-9a-f]{32})\.zip\Z")
25
+ _BACKUP_ID = re.compile(r"[0-9a-f]{32}\Z")
26
+ _PLAN_TOKEN = re.compile(r"[0-9a-f]{64}\Z")
27
+ GUIDANCE = (
28
+ "Stop the application, then use toolframe-backup create with the configured "
29
+ "destination. Restart the application to inspect the archive here."
30
+ )
31
+
32
+
33
+ def _now() -> str:
34
+ return datetime.now(UTC).isoformat().replace("+00:00", "Z")
35
+
36
+
37
+ def _error(code: str, message: str) -> BackupError:
38
+ return BackupError(code, message)
39
+
40
+
41
+ def bounded_integer(value: Any, name: str, maximum: int) -> int:
42
+ if (
43
+ isinstance(value, bool)
44
+ or not isinstance(value, int)
45
+ or not 1 <= value <= maximum
46
+ ):
47
+ raise _error("backup_validation", f"{name} must be between 1 and {maximum}.")
48
+ return value
49
+
50
+
51
+ def validate_backup_id(value: Any) -> str:
52
+ if not isinstance(value, str) or not _BACKUP_ID.fullmatch(value):
53
+ raise _error(
54
+ "backup_validation",
55
+ "backup_id must be 32 lowercase hexadecimal characters.",
56
+ )
57
+ return value
58
+
59
+
60
+ def _stamp(value: os.stat_result) -> tuple[int, ...]:
61
+ return (
62
+ value.st_dev,
63
+ value.st_ino,
64
+ value.st_mode,
65
+ value.st_uid,
66
+ value.st_size,
67
+ value.st_mtime_ns,
68
+ value.st_ctime_ns,
69
+ )
70
+
71
+
72
+ def _protected(value: os.stat_result, *, directory: bool) -> bool:
73
+ correct_kind = (
74
+ stat.S_ISDIR(value.st_mode) if directory else stat.S_ISREG(value.st_mode)
75
+ )
76
+ return (
77
+ correct_kind
78
+ and value.st_uid == os.geteuid()
79
+ and stat.S_IMODE(value.st_mode) == (0o700 if directory else 0o600)
80
+ )
81
+
82
+
83
+ @dataclass(frozen=True)
84
+ class _Scan:
85
+ backups: list[dict[str, Any]]
86
+ rejected: list[dict[str, str]]
87
+ fingerprint: str
88
+ stamps: dict[str, tuple[int, ...]]
89
+ directory_stamp: tuple[int, int] | None
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class _Plan:
94
+ application_id: str
95
+ fingerprint: str
96
+ delete: list[dict[str, Any]]
97
+ expires: float
98
+
99
+
100
+ class BackupCatalog:
101
+ """The directory is authoritative; caches never authorize deletion."""
102
+
103
+ def __init__(self, destination: str | Path, *, retention_keep: int = 10):
104
+ if not isinstance(destination, (str, Path)) or not str(destination):
105
+ raise _error(
106
+ "backup_configuration",
107
+ "Configure an absolute local destination directory.",
108
+ )
109
+ self.destination = Path(destination)
110
+ if (
111
+ not self.destination.is_absolute()
112
+ or ".." in self.destination.parts
113
+ or "\x00" in str(self.destination)
114
+ ):
115
+ raise _error(
116
+ "backup_configuration",
117
+ "Destination must be an absolute local path without traversal.",
118
+ )
119
+ self.retention_keep = bounded_integer(retention_keep, "retention_keep", 1000)
120
+ self._lock = threading.RLock()
121
+ self._plans: dict[str, _Plan] = {}
122
+ self._directory()
123
+
124
+ def _directory(self) -> os.stat_result | None:
125
+ """Reject symlink components without resolving them into a different directory."""
126
+ current = Path(self.destination.anchor)
127
+ for part in self.destination.parts[1:]:
128
+ current /= part
129
+ try:
130
+ value = current.lstat()
131
+ except FileNotFoundError:
132
+ return None
133
+ except OSError:
134
+ raise _error(
135
+ "backup_destination", "Backup destination cannot be inspected."
136
+ ) from None
137
+ if stat.S_ISLNK(value.st_mode) or not stat.S_ISDIR(value.st_mode):
138
+ raise _error(
139
+ "backup_destination",
140
+ "Destination components must be directories without symlinks.",
141
+ )
142
+ try:
143
+ value = self.destination.lstat()
144
+ except OSError:
145
+ raise _error(
146
+ "backup_destination", "Backup destination changed during inspection."
147
+ ) from None
148
+ if not _protected(value, directory=True):
149
+ raise _error(
150
+ "backup_permissions",
151
+ "Backup destination must be operator-owned with permissions 0700.",
152
+ )
153
+ return value
154
+
155
+ def _entries(
156
+ self,
157
+ ) -> tuple[list[tuple[str, os.stat_result]], tuple[int, int] | None]:
158
+ directory = self._directory()
159
+ if directory is None:
160
+ return [], None
161
+ candidates: list[tuple[str, os.stat_result]] = []
162
+ entries = 0
163
+ total_bytes = 0
164
+ try:
165
+ with os.scandir(self.destination) as iterator:
166
+ for entry in iterator:
167
+ entries += 1
168
+ if entries > MAX_DIRECTORY_ENTRIES:
169
+ raise _error(
170
+ "backup_catalog_limit",
171
+ "Destination has too many directory entries to scan safely.",
172
+ )
173
+ if not _BACKUP_NAME.fullmatch(entry.name):
174
+ continue
175
+ if len(candidates) >= MAX_CANDIDATES:
176
+ raise _error(
177
+ "backup_catalog_limit",
178
+ "Destination has more than 1000 backup candidates.",
179
+ )
180
+ value = entry.stat(follow_symlinks=False)
181
+ if stat.S_ISREG(value.st_mode):
182
+ total_bytes += value.st_size
183
+ if total_bytes > MAX_SCAN_BYTES:
184
+ raise _error(
185
+ "backup_catalog_limit",
186
+ "Backup candidates exceed the 4 GiB scan budget.",
187
+ )
188
+ candidates.append((entry.name, value))
189
+ except OSError:
190
+ raise _error(
191
+ "backup_destination", "Backup destination changed or cannot be scanned."
192
+ ) from None
193
+ current = self._directory()
194
+ if current is None or (current.st_dev, current.st_ino) != (
195
+ directory.st_dev,
196
+ directory.st_ino,
197
+ ):
198
+ raise _error(
199
+ "backup_catalog_changed", "Backup destination changed. Try again."
200
+ )
201
+ return sorted(candidates), (directory.st_dev, directory.st_ino)
202
+
203
+ def _verified(self, name: str, before: os.stat_result) -> dict[str, Any]:
204
+ if not _protected(before, directory=False):
205
+ raise _error(
206
+ "backup_permissions",
207
+ "Archive must be an operator-owned regular file with permissions 0600.",
208
+ )
209
+ verified = verify_backup(self.destination / name)
210
+ summary = verified.summary()
211
+ if name != f"backup-{summary['id']}.zip":
212
+ raise _error(
213
+ "backup_identity", "Archive name does not match its backup identity."
214
+ )
215
+ try:
216
+ after = (self.destination / name).lstat()
217
+ except OSError:
218
+ raise _error(
219
+ "backup_catalog_changed",
220
+ "Archive changed during verification. Try again.",
221
+ ) from None
222
+ if _stamp(after) != _stamp(before):
223
+ raise _error(
224
+ "backup_catalog_changed",
225
+ "Archive changed during verification. Try again.",
226
+ )
227
+ return {**summary, "last_verified_at": _now()}
228
+
229
+ def _scan(self) -> _Scan:
230
+ entries, directory_stamp = self._entries()
231
+ backups: list[dict[str, Any]] = []
232
+ rejected: list[dict[str, str]] = []
233
+ stamps: dict[str, tuple[int, ...]] = {}
234
+ fingerprint: list[Any] = [directory_stamp]
235
+ for name, value in entries:
236
+ backup_id = _BACKUP_NAME.fullmatch(name).group(1)
237
+ stamps[backup_id] = _stamp(value)
238
+ try:
239
+ summary = self._verified(name, value)
240
+ except BackupError as error:
241
+ rejected.append({"id": backup_id, "error_code": error.code})
242
+ fingerprint.append([name, _stamp(value), error.code])
243
+ else:
244
+ backups.append(summary)
245
+ fingerprint.append([name, _stamp(value), summary["archive_sha256"]])
246
+ # A scan is a single directory observation. A concurrently created or
247
+ # replaced candidate invalidates it even when all individual checks pass.
248
+ after_entries, after_directory = self._entries()
249
+ if after_directory != directory_stamp or [
250
+ (name, _stamp(value)) for name, value in after_entries
251
+ ] != [(name, _stamp(value)) for name, value in entries]:
252
+ raise _error(
253
+ "backup_catalog_changed",
254
+ "Backup catalog changed during verification. Try again.",
255
+ )
256
+ backups.sort(
257
+ key=lambda item: (datetime.fromisoformat(item["created_at"]), item["id"]),
258
+ reverse=True,
259
+ )
260
+ encoded = json.dumps(fingerprint, separators=(",", ":"), sort_keys=True)
261
+ return _Scan(
262
+ backups,
263
+ rejected,
264
+ hashlib.sha256(encoded.encode()).hexdigest(),
265
+ stamps,
266
+ directory_stamp,
267
+ )
268
+
269
+ def list_backups(
270
+ self, limit: int = 50, *, application_id: str | None = None
271
+ ) -> dict[str, Any]:
272
+ limit = bounded_integer(limit, "limit", 100)
273
+ with self._lock:
274
+ scan = self._scan()
275
+ eligible = [
276
+ item
277
+ for item in scan.backups
278
+ if application_id is None or item["application_id"] == application_id
279
+ ]
280
+ return {
281
+ "backups": eligible[:limit],
282
+ "count": len(eligible[:limit]),
283
+ "verified_count": len(eligible),
284
+ "rejected_count": len(scan.rejected),
285
+ "rejected": scan.rejected[:limit],
286
+ "limit": limit,
287
+ "destination": str(self.destination),
288
+ "destination_kind": "local",
289
+ "retention_keep": self.retention_keep,
290
+ "status": "ready"
291
+ if scan.directory_stamp is not None
292
+ else "destination_missing",
293
+ "guidance": GUIDANCE,
294
+ }
295
+
296
+ def verify(
297
+ self, backup_id: str, *, application_id: str | None = None
298
+ ) -> dict[str, Any]:
299
+ backup_id = validate_backup_id(backup_id)
300
+ with self._lock:
301
+ self._directory()
302
+ name = f"backup-{backup_id}.zip"
303
+ try:
304
+ before = (self.destination / name).lstat()
305
+ except FileNotFoundError:
306
+ raise _error(
307
+ "backup_missing",
308
+ "Backup is absent from the configured destination.",
309
+ ) from None
310
+ except OSError:
311
+ raise _error(
312
+ "backup_destination", "Backup cannot be inspected."
313
+ ) from None
314
+ summary = self._verified(name, before)
315
+ if (
316
+ application_id is not None
317
+ and summary["application_id"] != application_id
318
+ ):
319
+ raise _error(
320
+ "backup_identity", "Backup belongs to a different application."
321
+ )
322
+ return summary
323
+
324
+ def retention_preview(
325
+ self, max_delete: int = 100, *, application_id: str
326
+ ) -> dict[str, Any]:
327
+ max_delete = bounded_integer(max_delete, "max_delete", 100)
328
+ if not isinstance(application_id, str) or not application_id:
329
+ raise _error(
330
+ "backup_identity_missing",
331
+ "Retention requires a host-issued application identity.",
332
+ )
333
+ with self._lock:
334
+ scan = self._scan()
335
+ eligible = [
336
+ item
337
+ for item in scan.backups
338
+ if item["application_id"] == application_id
339
+ ]
340
+ delete = list(reversed(eligible[self.retention_keep :]))[:max_delete]
341
+ self._plans = {
342
+ key: plan
343
+ for key, plan in self._plans.items()
344
+ if plan.expires > time.monotonic()
345
+ }
346
+ if len(self._plans) >= 10:
347
+ self._plans.pop(next(iter(self._plans)))
348
+ token = secrets.token_hex(32)
349
+ self._plans[token] = _Plan(
350
+ application_id,
351
+ scan.fingerprint,
352
+ delete,
353
+ time.monotonic() + PLAN_LIFETIME_SECONDS,
354
+ )
355
+ return {
356
+ "plan_token": token,
357
+ "expires_at": (
358
+ datetime.now(UTC) + timedelta(seconds=PLAN_LIFETIME_SECONDS)
359
+ )
360
+ .isoformat()
361
+ .replace("+00:00", "Z"),
362
+ "retention_keep": self.retention_keep,
363
+ "eligible_count": len(eligible),
364
+ "delete_count": len(delete),
365
+ "delete": delete,
366
+ "remaining_count": len(eligible) - len(delete),
367
+ "max_delete": max_delete,
368
+ "preserved_rejected_count": len(scan.rejected),
369
+ }
370
+
371
+ def apply_retention(
372
+ self, plan_token: str, *, application_id: str
373
+ ) -> dict[str, Any]:
374
+ if not isinstance(plan_token, str) or not _PLAN_TOKEN.fullmatch(plan_token):
375
+ raise _error(
376
+ "backup_validation", "plan_token must be a retention preview token."
377
+ )
378
+ with self._lock:
379
+ plan = self._plans.pop(plan_token, None)
380
+ if plan is None or plan.expires <= time.monotonic():
381
+ raise _error(
382
+ "backup_retention_stale",
383
+ "Retention preview expired or was already used. Preview again.",
384
+ )
385
+ if plan.application_id != application_id:
386
+ raise _error(
387
+ "backup_identity",
388
+ "Retention preview belongs to a different application.",
389
+ )
390
+ scan = self._scan()
391
+ if scan.fingerprint != plan.fingerprint:
392
+ raise _error(
393
+ "backup_retention_stale",
394
+ "Backup catalog changed. Preview retention again.",
395
+ )
396
+ if not plan.delete:
397
+ return {"deleted_ids": [], "deleted_count": 0}
398
+ return self._delete_verified(plan, scan)
399
+
400
+ def _delete_verified(self, plan: _Plan, scan: _Scan) -> dict[str, Any]:
401
+ """Move candidates into a private staging directory and recheck before unlink."""
402
+ flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW
403
+ try:
404
+ directory_fd = os.open(self.destination, flags)
405
+ except OSError:
406
+ raise _error(
407
+ "backup_retention_failed", "Backup destination cannot be opened safely."
408
+ ) from None
409
+ staging_name = f".backup-retention-{secrets.token_hex(16)}"
410
+ staging_fd: int | None = None
411
+ moved: list[str] = []
412
+ deleted: list[str] = []
413
+ staged_stamps: dict[str, tuple[int, ...]] = {}
414
+ try:
415
+ opened = os.fstat(directory_fd)
416
+ if (opened.st_dev, opened.st_ino) != scan.directory_stamp:
417
+ raise _error(
418
+ "backup_retention_stale",
419
+ "Backup destination changed. Preview retention again.",
420
+ )
421
+ os.mkdir(staging_name, mode=0o700, dir_fd=directory_fd)
422
+ staging_fd = os.open(staging_name, flags, dir_fd=directory_fd)
423
+ for item in plan.delete:
424
+ name = f"backup-{item['id']}.zip"
425
+ value = os.stat(name, dir_fd=directory_fd, follow_symlinks=False)
426
+ if _stamp(value) != scan.stamps[item["id"]]:
427
+ raise _error(
428
+ "backup_retention_stale",
429
+ "Backup changed. Preview retention again.",
430
+ )
431
+ os.rename(name, name, src_dir_fd=directory_fd, dst_dir_fd=staging_fd)
432
+ moved.append(name)
433
+ before_verify = os.stat(name, dir_fd=staging_fd, follow_symlinks=False)
434
+ staged = self.destination / staging_name / name
435
+ verified = verify_backup(staged).summary()
436
+ current = os.stat(name, dir_fd=staging_fd, follow_symlinks=False)
437
+ if (
438
+ not _protected(current, directory=False)
439
+ or _stamp(current) != _stamp(before_verify)
440
+ or verified["id"] != item["id"]
441
+ or verified["application_id"] != plan.application_id
442
+ or verified["archive_sha256"] != item["archive_sha256"]
443
+ ):
444
+ raise _error(
445
+ "backup_retention_stale",
446
+ "Backup changed. Preview retention again.",
447
+ )
448
+ staged_stamps[name] = _stamp(current)
449
+ # All staged archives passed the same verification as the preview.
450
+ # Unknown entries and rejected archives are never moved or removed.
451
+ for name in moved[:]:
452
+ current = os.stat(name, dir_fd=staging_fd, follow_symlinks=False)
453
+ if _stamp(current) != staged_stamps[name]:
454
+ raise _error(
455
+ "backup_retention_stale",
456
+ "Backup changed after verification. Preview retention again.",
457
+ )
458
+ os.unlink(name, dir_fd=staging_fd)
459
+ moved.remove(name)
460
+ deleted.append(_BACKUP_NAME.fullmatch(name).group(1))
461
+ except (OSError, BackupError) as error:
462
+ recovery_failed = False
463
+ for name in moved[:]:
464
+ try:
465
+ # link is exclusive: an externally created replacement at
466
+ # the original name is preserved rather than overwritten.
467
+ os.link(
468
+ name,
469
+ name,
470
+ src_dir_fd=staging_fd,
471
+ dst_dir_fd=directory_fd,
472
+ follow_symlinks=False,
473
+ )
474
+ os.unlink(name, dir_fd=staging_fd)
475
+ moved.remove(name)
476
+ except OSError:
477
+ recovery_failed = True
478
+ if recovery_failed:
479
+ raise _error(
480
+ "backup_retention_recovery",
481
+ "Retention stopped. Preserved archives remain in a protected .backup-retention directory for operator recovery.",
482
+ ) from None
483
+ if isinstance(error, BackupError):
484
+ raise
485
+ raise _error(
486
+ "backup_retention_failed",
487
+ "Retention stopped because the destination changed or could not be updated.",
488
+ ) from None
489
+ finally:
490
+ if staging_fd is not None:
491
+ os.close(staging_fd)
492
+ if not moved:
493
+ try:
494
+ os.rmdir(staging_name, dir_fd=directory_fd)
495
+ except OSError:
496
+ # Preserve a changed staging directory rather than
497
+ # recursively deleting an unexpected entry inside it.
498
+ pass
499
+ os.close(directory_fd)
500
+ return {"deleted_ids": deleted, "deleted_count": len(deleted)}
501
+
502
+
503
+ Catalog = BackupCatalog
504
+
505
+ __all__ = [
506
+ "GUIDANCE",
507
+ "BackupCatalog",
508
+ "Catalog",
509
+ "bounded_integer",
510
+ "validate_backup_id",
511
+ ]
@@ -0,0 +1,82 @@
1
+ """Operator CLI with nonsecret output and no dependency on a running host."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from . import __version__
9
+ from .archive import BackupError, create_backup, restore_backup, verify_backup
10
+
11
+
12
+ def parser() -> argparse.ArgumentParser:
13
+ result = argparse.ArgumentParser(prog="toolframe-backup")
14
+ result.add_argument("--version", action="version", version=__version__)
15
+ commands = result.add_subparsers(dest="command", required=True)
16
+ create = commands.add_parser(
17
+ "create", help="Back up a cleanly stopped local application"
18
+ )
19
+ create.add_argument("--home", type=Path, required=True)
20
+ create.add_argument("--directory", type=Path, required=True)
21
+ verify = commands.add_parser(
22
+ "verify", help="Validate an archive without extracting"
23
+ )
24
+ verify.add_argument("archive", type=Path)
25
+ restore = commands.add_parser(
26
+ "restore", help="Validate and restore into a new application home"
27
+ )
28
+ restore.add_argument("archive", type=Path)
29
+ restore.add_argument("--destination", type=Path, required=True)
30
+ restore.add_argument("--uv-path", default="uv")
31
+ restore.add_argument("--wheelhouse", type=Path)
32
+ restore.add_argument(
33
+ "--offline",
34
+ action="store_true",
35
+ help="Reconstruct dependencies from available local assets only",
36
+ )
37
+ listing = commands.add_parser(
38
+ "list", help="Scan a protected local backup directory"
39
+ )
40
+ listing.add_argument("--directory", type=Path, required=True)
41
+ listing.add_argument("--limit", type=int, default=50)
42
+ return result
43
+
44
+
45
+ def main(argv: list[str] | None = None) -> int:
46
+ arguments = parser().parse_args(argv)
47
+ try:
48
+ if arguments.command == "create":
49
+ result = create_backup(arguments.home, arguments.directory)
50
+ elif arguments.command == "verify":
51
+ result = verify_backup(arguments.archive).summary()
52
+ elif arguments.command == "restore":
53
+ result = restore_backup(
54
+ arguments.archive,
55
+ arguments.destination,
56
+ uv_path=arguments.uv_path,
57
+ wheelhouse=arguments.wheelhouse,
58
+ offline=arguments.offline,
59
+ )
60
+ else:
61
+ from .catalog import Catalog
62
+
63
+ result = Catalog(arguments.directory).list_backups(arguments.limit)
64
+ print(json.dumps(result, sort_keys=True))
65
+ return 0
66
+ except BackupError as error:
67
+ print(
68
+ json.dumps({"error": error.code, "message": error.message}), file=sys.stderr
69
+ )
70
+ return 2
71
+ except KeyboardInterrupt:
72
+ print(
73
+ json.dumps(
74
+ {"error": "interrupted", "message": "Backup operation interrupted."}
75
+ ),
76
+ file=sys.stderr,
77
+ )
78
+ return 130
79
+
80
+
81
+ if __name__ == "__main__":
82
+ raise SystemExit(main())
@@ -0,0 +1,88 @@
1
+ # Backup catalog and recovery workflow
2
+
3
+ Backup catalogs local recovery archives created by the `toolframe-backup`
4
+ operator CLI. Its Dashboard and read-only MCP tool inspect archives without
5
+ copying a running application's files. The source application must be cleanly
6
+ stopped while the CLI captures its public maintenance inventory.
7
+
8
+ Install the plugin package in the application's environment and configure a
9
+ fixed, absolute local backup directory through the application's plugin
10
+ configuration:
11
+
12
+ ```toml
13
+ [plugins.backup.config]
14
+ destination = "/srv/toolframe-backups"
15
+ retention_keep = 10
16
+ ```
17
+
18
+ The destination must belong to the operator, have mode `0700`, and have no
19
+ symlink components. Archive files must belong to the operator and have no group
20
+ or other permissions; the CLI creates them with mode `0600`. The plugin permits
21
+ a missing destination so the stopped CLI can create it. It never accepts paths
22
+ or arbitrary filesystem actions from Dashboard, MCP, or RPC clients. Only local
23
+ filesystem destinations are supported; S3 and other external destinations are
24
+ unimplemented.
25
+
26
+ To capture an application, stop it using its ordinary shutdown flow, then run:
27
+
28
+ ```sh
29
+ toolframe-backup create --home /srv/toolframe-app --directory /srv/toolframe-backups
30
+ toolframe-backup list --directory /srv/toolframe-backups
31
+ ```
32
+
33
+ The operator installation needs the `operator` extra for creation. Verification,
34
+ listing, and restoration work without a running host. Restart the source
35
+ application to inspect the catalog. Read the package README for the complete
36
+ creation and restoration support limits.
37
+
38
+ The destination directory is the catalog's source of truth. No catalog file is
39
+ written into the source application by the offline CLI, and the runtime plugin
40
+ does not require a storage, Jobs, or Audit provider. Each scan fully verifies
41
+ the own-format files named `backup-<32 lowercase hexadecimal characters>.zip`
42
+ before showing them. The `last_verified_at` timestamp reports that scan's
43
+ verification, rather than a cached assertion. When the host supplies its
44
+ application identity, catalog results and individual verification are restricted
45
+ to that application. Invalid candidates are reported by ID and error code and
46
+ preserved. Unknown filenames are ignored and preserved.
47
+
48
+ Scans are bounded to 1,000 archive candidates, 10,000 directory entries, and a
49
+ 4 GiB total archive scan budget. Each archive also has the library's file-count,
50
+ compressed-size, payload-size, and decompression limits. The read interface shows
51
+ at most 100 verified backups, newest first, with a default limit of 50. Split
52
+ larger catalogs into separate operator-managed directories before scanning.
53
+
54
+ The public `backup.catalog` service is version 1:
55
+
56
+ | Method | Scope | Behavior |
57
+ | --- | --- | --- |
58
+ | `list_backups(limit=50)` | `backup.read` | Scan and list at most 100 verified archives; read-only MCP uses this method. |
59
+ | `verify_backup(backup_id)` | `system.plugins.manage` | Recheck one validated ID in the configured destination. |
60
+ | `retention_preview(max_delete=100)` | `system.plugins.manage` | Preview deletion of at most 100 oldest verified archives beyond the configured keep count. |
61
+ | `apply_retention(plan_token)` | `system.plugins.manage` | Reverify and apply the unchanged, unexpired preview once. |
62
+
63
+ `backup.read` is a business scope. Verification and retention require the
64
+ existing **Manage plugins** operator permission, `system.plugins.manage`; an
65
+ all-business grant does not permit these actions. The Dashboard requires
66
+ `backup.read` and hides management controls without `system.plugins.manage`.
67
+ All service methods require scoped calls and are
68
+ excluded from anonymous public API routes. MCP exposes only listing.
69
+
70
+ Retention is explicit, never automatic. `retention_keep` defaults to 10 and is
71
+ bounded to 1–1,000. A preview requires the host-issued application identity,
72
+ keeps the newest matching verified archives, and selects the oldest excess
73
+ archives up to `max_delete` (1–100). Unknown entries, rejected candidates, and
74
+ archives for other applications remain untouched. Preview tokens live in
75
+ process memory for ten minutes and can be used once; restarting the plugin
76
+ invalidates them. Catalog changes invalidate a preview. Confirmation moves
77
+ selected files into a protected temporary directory and verifies them again
78
+ before deleting them. If a destination change prevents safe recovery, preserved
79
+ files may remain under `.backup-retention-*` for operator recovery; these entries
80
+ are never adopted by the catalog or automatically deleted.
81
+
82
+ Archives are **unencrypted** and can contain credentials and retained key
83
+ payloads. Protect the destination and archive permissions. Public catalog
84
+ summaries contain identity, composition revision, counts, timestamps, and
85
+ checksums, never payload contents or dependency credential URLs. SHA-256 checks
86
+ detect corruption; an archive is not signed or authenticated. Restoration
87
+ preserves the original application identity for recovery, so do not concurrently
88
+ run a restored clone with the same external identity.