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,907 @@
1
+ """Bounded recovery archive operations, independent of the running application.
2
+
3
+ Creation and environment restoration lazily use Core's public maintenance contexts.
4
+ Verification imports neither Core nor the plugin runtime.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import shutil
14
+ import sqlite3
15
+ import stat
16
+ import tempfile
17
+ import uuid
18
+ import zipfile
19
+ import zlib
20
+ from contextlib import ExitStack, closing, contextmanager
21
+ from dataclasses import dataclass
22
+ from datetime import UTC, datetime
23
+ from pathlib import Path, PurePosixPath
24
+ from typing import Any
25
+
26
+ FORMAT = "toolframe-backup"
27
+ FORMAT_VERSION = 1
28
+ BACKUP_ID = re.compile(r"[0-9a-f]{32}\Z")
29
+ SHA256 = re.compile(r"[0-9a-f]{64}\Z")
30
+ MANIFEST = "manifest.json"
31
+ RECOVERY = "recovery/environment-locks.json"
32
+ CHUNK = 1024 * 1024
33
+
34
+
35
+ class BackupError(Exception):
36
+ """A safe operator-facing error; no payload contents are included."""
37
+
38
+ def __init__(self, code: str, message: str):
39
+ self.code, self.message = code, message
40
+ super().__init__(message)
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Limits:
45
+ max_files: int = 10_000
46
+ max_total_bytes: int = 2 * 1024**3
47
+ max_file_bytes: int = 512 * 1024**2
48
+ max_manifest_bytes: int = 4 * 1024**2
49
+ max_ratio: int = 200
50
+ max_archive_bytes: int = 3 * 1024**3
51
+ max_recovery_bytes: int = 16 * 1024**2
52
+
53
+
54
+ DEFAULT_LIMITS = Limits()
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class VerifiedBackup:
59
+ manifest: dict[str, Any]
60
+ archive_sha256: str
61
+
62
+ def summary(self) -> dict[str, Any]:
63
+ inventory = self.manifest["inventory"]
64
+ return {
65
+ "id": self.manifest["id"],
66
+ "created_at": self.manifest["created_at"],
67
+ "application_id": inventory["application_id"],
68
+ "application_name": inventory["application_name"],
69
+ "composition_revision": inventory["composition_revision"],
70
+ "file_count": len(self.manifest["entries"]),
71
+ "total_bytes": sum(item["size"] for item in self.manifest["entries"]),
72
+ "archive_sha256": self.archive_sha256,
73
+ "verified": True,
74
+ }
75
+
76
+
77
+ def _fail(code: str = "invalid_archive", message: str = "Backup archive is invalid."):
78
+ raise BackupError(code, message)
79
+
80
+
81
+ def safe_path(value: Any) -> str:
82
+ """Accept exactly a portable, relative POSIX file path."""
83
+ if not isinstance(value, str) or not value or len(value) > 1024:
84
+ _fail("unsafe_path", "Backup contains an unsafe path.")
85
+ if any(ord(character) < 32 for character in value) or "\\" in value or ":" in value:
86
+ _fail("unsafe_path", "Backup contains an unsafe path.")
87
+ path = PurePosixPath(value)
88
+ if path.is_absolute() or any(part in {"", ".", ".."} for part in value.split("/")):
89
+ _fail("unsafe_path", "Backup contains an unsafe path.")
90
+ return value
91
+
92
+
93
+ def _no_symlinks(path: Path) -> None:
94
+ for part in (path, *path.parents):
95
+ if part.is_symlink():
96
+ _fail("unsafe_path", "Symlinked paths are not supported.")
97
+
98
+
99
+ def protected_directory(path: Path, *, create: bool = False) -> Path:
100
+ path = Path(os.path.abspath(path))
101
+ _no_symlinks(path)
102
+ if create:
103
+ missing = []
104
+ current = path
105
+ while not current.exists():
106
+ missing.append(current)
107
+ current = current.parent
108
+ for directory in reversed(missing):
109
+ directory.mkdir(mode=0o700)
110
+ if not path.is_dir():
111
+ _fail("destination_missing", "A local backup directory is required.")
112
+ metadata = path.stat()
113
+ if stat.S_IMODE(metadata.st_mode) != 0o700 or metadata.st_uid != os.geteuid():
114
+ _fail("destination_permissions", "Backup directory must have mode 0700.")
115
+ return path
116
+
117
+
118
+ def _sha_file(path: Path, limit: int) -> str:
119
+ digest = hashlib.sha256()
120
+ total = 0
121
+ with path.open("rb") as stream:
122
+ while data := stream.read(CHUNK):
123
+ total += len(data)
124
+ if total > limit:
125
+ _fail("limit_exceeded", "Backup exceeds the configured size limit.")
126
+ digest.update(data)
127
+ return digest.hexdigest()
128
+
129
+
130
+ def _text(value: Any, *, nullable: bool = False) -> str | None:
131
+ if nullable and value is None:
132
+ return None
133
+ if not isinstance(value, str) or not value or len(value) > 1024 or "://" in value:
134
+ _fail("invalid_metadata", "Backup metadata is invalid.")
135
+ if any(ord(character) < 32 for character in value):
136
+ _fail("invalid_metadata", "Backup metadata is invalid.")
137
+ return value
138
+
139
+
140
+ def _integer(value: Any, maximum: int, *, minimum: int = 0) -> int:
141
+ if type(value) is not int or not minimum <= value <= maximum:
142
+ _fail("invalid_metadata", "Backup metadata exceeds supported limits.")
143
+ return value
144
+
145
+
146
+ def _hash(value: Any) -> str:
147
+ if not isinstance(value, str) or not SHA256.fullmatch(value):
148
+ _fail("invalid_metadata", "Backup checksum metadata is invalid.")
149
+ return value
150
+
151
+
152
+ def _object(value: Any, keys: set[str]) -> dict:
153
+ if not isinstance(value, dict) or set(value) != keys:
154
+ _fail("invalid_metadata", "Backup metadata fields are invalid.")
155
+ return value
156
+
157
+
158
+ def _list(value: Any, limits: Limits) -> list:
159
+ if not isinstance(value, list) or len(value) > limits.max_files:
160
+ _fail("limit_exceeded", "Backup has too many metadata entries.")
161
+ return value
162
+
163
+
164
+ def _validate_inventory(inventory: dict, entries: list, limits: Limits) -> None:
165
+ _object(
166
+ inventory,
167
+ {
168
+ "format_version",
169
+ "status",
170
+ "application_id",
171
+ "application_name",
172
+ "composition_revision",
173
+ "files",
174
+ "stores",
175
+ "retained_keys",
176
+ "dependency_wheels",
177
+ "environment_locks",
178
+ },
179
+ )
180
+ if inventory["format_version"] != 1 or inventory["status"] != "STOPPED":
181
+ _fail(
182
+ "unsupported_format",
183
+ "Backup requires inventory version 1 and STOPPED status.",
184
+ )
185
+ for field in ("application_id", "application_name"):
186
+ _text(inventory[field])
187
+ _text(inventory["composition_revision"], nullable=True)
188
+ files = {}
189
+ for item in _list(inventory["files"], limits):
190
+ _object(item, {"path", "role", "size", "mode", "sha256"})
191
+ path = safe_path(item["path"])
192
+ if path in files:
193
+ _fail()
194
+ _text(item["role"])
195
+ _integer(item["size"], limits.max_file_bytes)
196
+ _integer(item["mode"], 0o777)
197
+ _hash(item["sha256"])
198
+ files[path] = item
199
+ if set(files) != {entry["path"] for entry in entries}:
200
+ _fail("inventory_mismatch", "Backup inventory and payload disagree.")
201
+ stores = set()
202
+ for store in _list(inventory["stores"], limits):
203
+ _object(store, {"path", "owner", "provider"})
204
+ path = safe_path(store["path"])
205
+ if path not in files or path in stores:
206
+ _fail("inventory_mismatch", "Backup store identity is invalid.")
207
+ stores.add(path)
208
+ _text(store["owner"], nullable=True)
209
+ _text(store["provider"])
210
+ for key in _list(inventory["retained_keys"], limits):
211
+ _object(key, {"path", "owner", "name"})
212
+ if safe_path(key["path"]) not in files:
213
+ _fail("inventory_mismatch", "A retained key payload is missing.")
214
+ _text(key["owner"], nullable=True)
215
+ _text(key["name"])
216
+ for wheel in _list(inventory["dependency_wheels"], limits):
217
+ from packaging.utils import (
218
+ InvalidWheelFilename,
219
+ canonicalize_name,
220
+ parse_wheel_filename,
221
+ )
222
+ from packaging.version import InvalidVersion, Version
223
+
224
+ _object(
225
+ wheel,
226
+ {"environment_id", "distribution", "version", "filename", "sha256", "path"},
227
+ )
228
+ for field in ("environment_id", "distribution", "version"):
229
+ _text(wheel[field])
230
+ if "/" in safe_path(wheel["filename"]) or not wheel["filename"].endswith(
231
+ ".whl"
232
+ ):
233
+ _fail()
234
+ try:
235
+ name, version, _, _ = parse_wheel_filename(wheel["filename"])
236
+ matches = name == canonicalize_name(
237
+ wheel["distribution"]
238
+ ) and version == Version(wheel["version"])
239
+ except InvalidWheelFilename, InvalidVersion:
240
+ _fail()
241
+ if not matches:
242
+ _fail()
243
+ _hash(wheel["sha256"])
244
+ if wheel["path"] is not None:
245
+ path = safe_path(wheel["path"])
246
+ if path not in files or files[path]["sha256"] != wheel["sha256"]:
247
+ _fail("inventory_mismatch", "A retained dependency wheel is missing.")
248
+ for lock in _list(inventory["environment_locks"], limits):
249
+ from .environments import LOCK_FIELDS
250
+
251
+ _object(lock, set(LOCK_FIELDS))
252
+ if not isinstance(lock["purpose"], str) or lock["purpose"] not in {
253
+ "backend",
254
+ "ui",
255
+ }:
256
+ _fail()
257
+ _text(lock["candidate_id"])
258
+ _text(lock["environment_id"])
259
+ _hash(lock["sha256"])
260
+ for field in LOCK_FIELDS[4:]:
261
+ _text(lock[field])
262
+ for entry in entries:
263
+ source = files[entry["path"]]
264
+ if (
265
+ entry["source_sha256"] != source["sha256"]
266
+ or entry["role"] != source["role"]
267
+ ):
268
+ _fail(
269
+ "inventory_mismatch", "Source artifact metadata disagrees with payload."
270
+ )
271
+ if entry["snapshot"] == "sqlite" and entry["path"] not in stores:
272
+ _fail()
273
+ if entry["snapshot"] == "file" and (
274
+ entry["sha256"] != source["sha256"] or entry["size"] != source["size"]
275
+ ):
276
+ _fail()
277
+
278
+
279
+ def _validate_manifest(manifest: Any, limits: Limits) -> dict:
280
+ _object(
281
+ manifest,
282
+ {"format", "format_version", "id", "created_at", "inventory", "entries"}
283
+ | (
284
+ {"environment_payload"}
285
+ if isinstance(manifest, dict) and "environment_payload" in manifest
286
+ else set()
287
+ ),
288
+ )
289
+ if manifest["format"] != FORMAT or manifest["format_version"] != FORMAT_VERSION:
290
+ _fail("unsupported_format", "Unsupported backup archive version.")
291
+ if not isinstance(manifest["id"], str) or not BACKUP_ID.fullmatch(manifest["id"]):
292
+ _fail()
293
+ try:
294
+ timestamp = datetime.fromisoformat(manifest["created_at"])
295
+ if timestamp.utcoffset() is None:
296
+ _fail()
297
+ except TypeError, ValueError:
298
+ _fail()
299
+ paths = set()
300
+ total = 0
301
+ for entry in _list(manifest["entries"], limits):
302
+ _object(
303
+ entry,
304
+ {"path", "role", "size", "mode", "sha256", "source_sha256", "snapshot"},
305
+ )
306
+ path = safe_path(entry["path"])
307
+ if path in paths or any(
308
+ path.startswith(previous + "/") or previous.startswith(path + "/")
309
+ for previous in paths
310
+ ):
311
+ _fail("duplicate_entry", "Backup has conflicting file paths.")
312
+ paths.add(path)
313
+ _text(entry["role"])
314
+ total += _integer(entry["size"], limits.max_file_bytes)
315
+ if total > limits.max_total_bytes:
316
+ _fail("limit_exceeded", "Backup exceeds total uncompressed size limit.")
317
+ # All restored payloads remain operator-only, including formerly executable files.
318
+ if (
319
+ entry["mode"] != 0o600
320
+ or not isinstance(entry["snapshot"], str)
321
+ or entry["snapshot"] not in {"file", "sqlite"}
322
+ ):
323
+ _fail()
324
+ _hash(entry["sha256"])
325
+ _hash(entry["source_sha256"])
326
+ _validate_inventory(manifest["inventory"], manifest["entries"], limits)
327
+ if bool(manifest["inventory"]["environment_locks"]) != (
328
+ "environment_payload" in manifest
329
+ ):
330
+ _fail("missing_entry", "Protected exact environment locks are required.")
331
+ if "environment_payload" in manifest:
332
+ payload = _object(manifest["environment_payload"], {"size", "sha256"})
333
+ size = _integer(payload["size"], limits.max_recovery_bytes)
334
+ _hash(payload["sha256"])
335
+ if total + size > limits.max_total_bytes:
336
+ _fail("limit_exceeded", "Backup exceeds total uncompressed size limit.")
337
+ return manifest
338
+
339
+
340
+ def _unique_json(pairs):
341
+ result = {}
342
+ for key, value in pairs:
343
+ if key in result:
344
+ _fail("duplicate_entry", "Backup has duplicate metadata fields.")
345
+ result[key] = value
346
+ return result
347
+
348
+
349
+ def _inspect(archive: zipfile.ZipFile, limits: Limits) -> dict:
350
+ members = archive.infolist()
351
+ if len(members) > limits.max_files + 2:
352
+ _fail("limit_exceeded", "Backup contains too many archive members.")
353
+ names = set()
354
+ total = 0
355
+ for member in members:
356
+ name = safe_path(member.orig_filename)
357
+ if name != member.filename:
358
+ _fail("unsafe_path", "Backup contains a truncated archive path.")
359
+ mode = member.external_attr >> 16
360
+ if name in names:
361
+ _fail("duplicate_entry", "Backup has duplicate archive members.")
362
+ names.add(name)
363
+ if (
364
+ stat.S_IFMT(mode) not in {0, stat.S_IFREG}
365
+ or member.is_dir()
366
+ or member.flag_bits & 1
367
+ or member.compress_type not in {zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED}
368
+ ):
369
+ _fail("unsafe_member", "Backup contains unsupported archive members.")
370
+ maximum = (
371
+ limits.max_manifest_bytes
372
+ if name == MANIFEST
373
+ else limits.max_recovery_bytes
374
+ if name == RECOVERY
375
+ else limits.max_file_bytes
376
+ )
377
+ if (
378
+ member.file_size > maximum
379
+ or member.file_size > max(1, member.compress_size) * limits.max_ratio
380
+ ):
381
+ _fail(
382
+ "limit_exceeded",
383
+ "Backup exceeds a member size or decompression ratio limit.",
384
+ )
385
+ total += member.file_size
386
+ if total > limits.max_total_bytes + limits.max_manifest_bytes:
387
+ _fail("limit_exceeded", "Backup exceeds total uncompressed size limit.")
388
+ if MANIFEST not in names:
389
+ _fail("missing_entry", "Backup manifest is missing.")
390
+ try:
391
+ manifest = json.loads(archive.read(MANIFEST), object_pairs_hook=_unique_json)
392
+ except ValueError, UnicodeError, RecursionError:
393
+ _fail("invalid_metadata", "Backup manifest is invalid JSON.")
394
+ _validate_manifest(manifest, limits)
395
+ expected = {MANIFEST, *("payload/" + item["path"] for item in manifest["entries"])}
396
+ if "environment_payload" in manifest:
397
+ expected.add(RECOVERY)
398
+ if names != expected:
399
+ _fail("unexpected_entry", "Backup has unexpected or missing payload members.")
400
+ if "environment_payload" in manifest:
401
+ from .environments import validate
402
+
403
+ raw = archive.read(RECOVERY)
404
+ payload = manifest["environment_payload"]
405
+ if (
406
+ len(raw) != payload["size"]
407
+ or hashlib.sha256(raw).hexdigest() != payload["sha256"]
408
+ ):
409
+ _fail(
410
+ "checksum_mismatch",
411
+ "Protected environment payload checksum disagrees with metadata.",
412
+ )
413
+ validate(raw, manifest["inventory"]["environment_locks"])
414
+ for entry in manifest["entries"]:
415
+ member = archive.getinfo("payload/" + entry["path"])
416
+ if member.file_size != entry["size"]:
417
+ _fail("checksum_mismatch", "Backup payload size disagrees with manifest.")
418
+ digest, size = hashlib.sha256(), 0
419
+ with archive.open(member) as source:
420
+ while data := source.read(CHUNK):
421
+ size += len(data)
422
+ if size > entry["size"]:
423
+ _fail("limit_exceeded", "Backup payload exceeds declared size.")
424
+ digest.update(data)
425
+ if size != entry["size"] or digest.hexdigest() != entry["sha256"]:
426
+ _fail(
427
+ "checksum_mismatch", "Backup payload checksum disagrees with manifest."
428
+ )
429
+ return manifest
430
+
431
+
432
+ @contextmanager
433
+ def _open_verified(path: Path, limits: Limits):
434
+ from .zipguard import check_zip_directory
435
+
436
+ path = Path(os.path.abspath(path))
437
+ _no_symlinks(path)
438
+ with ExitStack() as resources:
439
+ try:
440
+ descriptor = os.open(
441
+ path,
442
+ os.O_RDONLY
443
+ | getattr(os, "O_NOFOLLOW", 0)
444
+ | getattr(os, "O_NONBLOCK", 0),
445
+ )
446
+ stream = resources.enter_context(os.fdopen(descriptor, "rb"))
447
+ metadata = os.fstat(stream.fileno())
448
+ if (
449
+ not stat.S_ISREG(metadata.st_mode)
450
+ or metadata.st_size > limits.max_archive_bytes
451
+ ):
452
+ _fail(
453
+ "limit_exceeded",
454
+ "Backup file is not regular or exceeds archive size limit.",
455
+ )
456
+ try:
457
+ check_zip_directory(
458
+ stream,
459
+ max_members=limits.max_files + 2,
460
+ max_directory_bytes=(limits.max_files + 2) * 2048,
461
+ )
462
+ except ValueError:
463
+ _fail(
464
+ "invalid_archive",
465
+ "Backup ZIP directory exceeds bounds or is invalid.",
466
+ )
467
+ digest, count = hashlib.sha256(), 0
468
+ while data := stream.read(CHUNK):
469
+ count += len(data)
470
+ if count > limits.max_archive_bytes:
471
+ _fail(
472
+ "limit_exceeded",
473
+ "Backup changed beyond the archive size limit.",
474
+ )
475
+ digest.update(data)
476
+ stream.seek(0)
477
+ archive = resources.enter_context(zipfile.ZipFile(stream))
478
+ manifest = _inspect(archive, limits)
479
+ after = os.fstat(stream.fileno())
480
+ if (metadata.st_size, metadata.st_mtime_ns, metadata.st_ctime_ns) != (
481
+ after.st_size,
482
+ after.st_mtime_ns,
483
+ after.st_ctime_ns,
484
+ ):
485
+ _fail("archive_changed", "Backup changed during verification.")
486
+ except BackupError:
487
+ raise
488
+ except (
489
+ OSError,
490
+ zipfile.BadZipFile,
491
+ zipfile.LargeZipFile,
492
+ RuntimeError,
493
+ EOFError,
494
+ zlib.error,
495
+ UnicodeError,
496
+ ):
497
+ _fail("invalid_archive", "Backup could not be read or verified.")
498
+ # Exceptions from restoration/rehydration belong to the caller, not ZIP parsing.
499
+ yield archive, VerifiedBackup(manifest, digest.hexdigest())
500
+
501
+
502
+ def verify_backup(path: Path, *, limits: Limits = DEFAULT_LIMITS) -> VerifiedBackup:
503
+ """Verify every member and checksum without extracting payloads."""
504
+ with _open_verified(Path(path), limits) as (_, result):
505
+ return result
506
+
507
+
508
+ def restore_backup(
509
+ path: Path,
510
+ destination: Path,
511
+ *,
512
+ limits: Limits = DEFAULT_LIMITS,
513
+ uv_path: str = "uv",
514
+ wheelhouse: Path | None = None,
515
+ offline: bool = False,
516
+ ) -> dict:
517
+ """Validate, publish under HostLock, then reconstruct exact final-path environments."""
518
+ from .environments import check_compatibility, validate
519
+
520
+ destination = Path(os.path.abspath(destination))
521
+ if any(char in str(destination) for char in ('"', "'", "\\")) or any(
522
+ ord(char) < 32 for char in str(destination)
523
+ ):
524
+ _fail(
525
+ "unsupported_destination",
526
+ "Restore paths cannot contain quotes, backslashes or control characters.",
527
+ )
528
+ _no_symlinks(destination)
529
+ if destination.exists():
530
+ _fail("destination_exists", "Restore requires an entirely new destination.")
531
+ if not destination.parent.is_dir():
532
+ _fail("destination_missing", "Restore parent directory must already exist.")
533
+ staging = None
534
+ try:
535
+ with _open_verified(Path(path), limits) as (archive, result):
536
+ metadata = result.manifest["inventory"]["environment_locks"]
537
+ locks = validate(archive.read(RECOVERY), metadata) if metadata else []
538
+ check_compatibility(
539
+ metadata,
540
+ locks,
541
+ dependency_wheels=result.manifest["inventory"]["dependency_wheels"],
542
+ )
543
+ staging = Path(
544
+ tempfile.mkdtemp(prefix=".backup-restore-", dir=destination.parent)
545
+ )
546
+ for entry in result.manifest["entries"]:
547
+ target = staging / entry["path"]
548
+ missing = []
549
+ current = target.parent
550
+ while current != staging and not current.exists():
551
+ missing.append(current)
552
+ current = current.parent
553
+ for directory in reversed(missing):
554
+ directory.mkdir(mode=0o700)
555
+ fd = os.open(target, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
556
+ digest, size = hashlib.sha256(), 0
557
+ with (
558
+ os.fdopen(fd, "wb") as output,
559
+ archive.open("payload/" + entry["path"]) as source,
560
+ ):
561
+ while data := source.read(CHUNK):
562
+ size += len(data)
563
+ if size > entry["size"]:
564
+ _fail("limit_exceeded", "Backup changed during extraction.")
565
+ digest.update(data)
566
+ output.write(data)
567
+ output.flush()
568
+ os.fsync(output.fileno())
569
+ if size != entry["size"] or digest.hexdigest() != entry["sha256"]:
570
+ _fail("checksum_mismatch", "Backup changed during extraction.")
571
+ original = staging.stat()
572
+ # Empty inventories need no environment construction. When environments
573
+ # exist, the public guard follows the lock inode through publication.
574
+ guard = _application_lock(staging) if locks else _empty_guard()
575
+ with guard:
576
+ _rename_new_directory(staging, destination)
577
+ try:
578
+ count = _rehydrate_environments(
579
+ destination,
580
+ locks,
581
+ uv_path=uv_path,
582
+ wheelhouse=wheelhouse,
583
+ offline=offline,
584
+ )
585
+ except BaseException:
586
+ current = destination.stat()
587
+ if (current.st_dev, current.st_ino) != (
588
+ original.st_dev,
589
+ original.st_ino,
590
+ ):
591
+ _fail(
592
+ "restore_conflict",
593
+ "Restore destination changed; unrelated content was preserved.",
594
+ )
595
+ # Remove the public name while its HostLock is still held;
596
+ # clean only our newly created tree after rolling it back.
597
+ _rename_new_directory(destination, staging)
598
+ raise
599
+ staging = None
600
+ return {**result.summary(), "ready": True, "rehydrated_environments": count}
601
+ except BackupError:
602
+ raise
603
+ except OSError:
604
+ _fail(
605
+ "restore_failed",
606
+ "Backup restoration failed; final destination was not published.",
607
+ )
608
+ except Exception: # noqa: BLE001 - redact optional public lock/uv diagnostics after rollback
609
+ _fail("restore_failed", "Backup restoration failed; no recovery home is ready.")
610
+ finally:
611
+ if staging is not None:
612
+ shutil.rmtree(staging, ignore_errors=True)
613
+
614
+
615
+ @contextmanager
616
+ def _empty_guard():
617
+ yield
618
+
619
+
620
+ def _application_lock(home: Path):
621
+ try:
622
+ from toolframe_core.maintenance import application_lock
623
+ except ImportError:
624
+ _fail(
625
+ "operator_dependency_missing",
626
+ "Restore requires toolframe-backup[operator] with the public Core application lock API.",
627
+ )
628
+ return application_lock(home)
629
+
630
+
631
+ def _rehydrate_environments(home: Path, locks: list[dict], **kwargs) -> int:
632
+ from .environments import rehydrate
633
+
634
+ return rehydrate(home, locks, **kwargs)
635
+
636
+
637
+ def _rename_new_directory(source: Path, destination: Path) -> None:
638
+ """Use OS no-replace rename; POSIX rename alone can overwrite an empty home."""
639
+ import ctypes
640
+ import errno
641
+ import sys
642
+
643
+ if sys.platform == "darwin":
644
+ libc = ctypes.CDLL(None, use_errno=True)
645
+ result = libc.renamex_np(
646
+ os.fsencode(source), os.fsencode(destination), 0x00000004
647
+ )
648
+ elif sys.platform.startswith("linux"):
649
+ libc = ctypes.CDLL(None, use_errno=True)
650
+ result = libc.renameat2(
651
+ -100, os.fsencode(source), -100, os.fsencode(destination), 1
652
+ )
653
+ else:
654
+ _fail("unsupported_platform", "Atomic restore requires Linux or macOS.")
655
+ if result != 0:
656
+ if ctypes.get_errno() in {errno.EEXIST, errno.ENOTEMPTY}:
657
+ _fail(
658
+ "destination_exists", "Restore destination appeared during extraction."
659
+ )
660
+ raise OSError(ctypes.get_errno(), "Atomic restore failed")
661
+
662
+
663
+ def _stopped_application(home: Path):
664
+ try:
665
+ from toolframe_core.maintenance import stopped_application
666
+ except ImportError:
667
+ _fail(
668
+ "operator_dependency_missing",
669
+ "Create requires toolframe-backup[operator] with the public Core maintenance API.",
670
+ )
671
+ return stopped_application(home)
672
+
673
+
674
+ def _public_inventory(inventory) -> dict:
675
+ """Copy only documented public metadata, never journal lock contents or URLs."""
676
+ data = inventory.as_dict()
677
+ result = {
678
+ "format_version": data.get("format_version"),
679
+ "status": data.get("status"),
680
+ "application_id": data.get("application_id"),
681
+ "application_name": data.get("application_name"),
682
+ "composition_revision": data.get("composition_revision"),
683
+ }
684
+ fields = {
685
+ "files": ("path", "role", "size", "mode", "sha256"),
686
+ "stores": ("path", "owner", "provider"),
687
+ "retained_keys": ("path", "owner", "name"),
688
+ "dependency_wheels": (
689
+ "environment_id",
690
+ "distribution",
691
+ "version",
692
+ "filename",
693
+ "sha256",
694
+ "path",
695
+ ),
696
+ "environment_locks": (
697
+ "candidate_id",
698
+ "purpose",
699
+ "environment_id",
700
+ "sha256",
701
+ "python_version",
702
+ "python_implementation",
703
+ "platform_system",
704
+ "platform_machine",
705
+ "requires_python",
706
+ ),
707
+ }
708
+ for name, keys in fields.items():
709
+ result[name] = [
710
+ {key: item.get(key) for key in keys} for item in data.get(name, ())
711
+ ]
712
+ return result
713
+
714
+
715
+ def _copy_inventory(inventory, staging: Path, limits: Limits) -> tuple[dict, list]:
716
+ metadata = _public_inventory(inventory)
717
+ home = Path(inventory.home)
718
+ if len(metadata["files"]) > limits.max_files:
719
+ _fail("limit_exceeded", "Source inventory contains too many files.")
720
+ entries, total, seen = [], 0, set()
721
+ for item in metadata["files"]:
722
+ path = safe_path(item["path"])
723
+ if path in seen:
724
+ _fail()
725
+ seen.add(path)
726
+ source = home / path
727
+ _no_symlinks(source)
728
+ target = staging / path
729
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
730
+ digest, size = hashlib.sha256(), 0
731
+ with source.open("rb") as stream:
732
+ before = os.fstat(stream.fileno())
733
+ if not stat.S_ISREG(before.st_mode):
734
+ _fail("unsafe_member", "Source inventory has a nonregular file.")
735
+ with target.open("xb") as output:
736
+ os.chmod(target, 0o600)
737
+ while data := stream.read(CHUNK):
738
+ size += len(data)
739
+ total += len(data)
740
+ if size > limits.max_file_bytes or total > limits.max_total_bytes:
741
+ _fail(
742
+ "limit_exceeded", "Source exceeds configured backup limits."
743
+ )
744
+ digest.update(data)
745
+ output.write(data)
746
+ after = os.fstat(stream.fileno())
747
+ if (
748
+ size != item["size"]
749
+ or before.st_size != after.st_size
750
+ or before.st_mtime_ns != after.st_mtime_ns
751
+ ):
752
+ _fail("source_changed", "Source changed during stopped backup.")
753
+ checksum = digest.hexdigest()
754
+ if item["sha256"] is not None and checksum != item["sha256"]:
755
+ _fail("source_changed", "Source checksum changed during stopped backup.")
756
+ item["sha256"] = checksum
757
+ entries.append(
758
+ {
759
+ "path": path,
760
+ "role": item["role"],
761
+ "size": size,
762
+ "mode": 0o600,
763
+ "sha256": checksum,
764
+ "source_sha256": checksum,
765
+ "snapshot": "file",
766
+ }
767
+ )
768
+ by_path = {entry["path"]: entry for entry in entries}
769
+ for store in metadata["stores"]:
770
+ path = safe_path(store["path"])
771
+ if path not in by_path:
772
+ _fail("inventory_mismatch", "SQLite store is absent from inventory files.")
773
+ source = staging / path
774
+ snapshot = source.with_name(source.name + ".snapshot-" + uuid.uuid4().hex)
775
+ try:
776
+ with (
777
+ closing(
778
+ sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)
779
+ ) as original,
780
+ closing(sqlite3.connect(snapshot)) as output,
781
+ ):
782
+ original.backup(output)
783
+ if output.execute("PRAGMA integrity_check").fetchone() != ("ok",):
784
+ _fail(
785
+ "invalid_store",
786
+ "SQLite snapshot failed integrity verification.",
787
+ )
788
+ snapshot.chmod(0o600)
789
+ os.replace(snapshot, source)
790
+ by_path[path].update(
791
+ size=source.stat().st_size,
792
+ sha256=_sha_file(source, limits.max_file_bytes),
793
+ snapshot="sqlite",
794
+ )
795
+ except sqlite3.Error:
796
+ _fail("invalid_store", "SQLite store could not be snapshotted.")
797
+ finally:
798
+ snapshot.unlink(missing_ok=True)
799
+ return metadata, entries
800
+
801
+
802
+ def create_backup(
803
+ home: Path, directory: Path, *, limits: Limits = DEFAULT_LIMITS
804
+ ) -> dict:
805
+ """Hold Core's stopped HostLock through every source read and archive publication."""
806
+ home = Path(os.path.abspath(home))
807
+ directory = Path(os.path.abspath(directory))
808
+ _no_symlinks(home)
809
+ _no_symlinks(directory)
810
+ if directory == home or home in directory.parents:
811
+ _fail(
812
+ "unsafe_destination",
813
+ "Backup output must be outside the source application home.",
814
+ )
815
+ directory = protected_directory(directory, create=True)
816
+ identifier = uuid.uuid4().hex
817
+ final = directory / f"backup-{identifier}.zip"
818
+ partial = None
819
+ published = False
820
+ succeeded = False
821
+ try:
822
+ with (
823
+ _stopped_application(home) as inventory,
824
+ tempfile.TemporaryDirectory(
825
+ prefix=".backup-create-", dir=directory
826
+ ) as temporary,
827
+ ):
828
+ metadata, entries = _copy_inventory(inventory, Path(temporary), limits)
829
+ manifest = {
830
+ "format": FORMAT,
831
+ "format_version": FORMAT_VERSION,
832
+ "id": identifier,
833
+ "created_at": datetime.now(UTC).isoformat(),
834
+ "inventory": metadata,
835
+ "entries": entries,
836
+ }
837
+ raw = None
838
+ if metadata["environment_locks"]:
839
+ from .environments import capture
840
+
841
+ raw = capture(inventory, metadata["environment_locks"])
842
+ manifest["environment_payload"] = {
843
+ "size": len(raw),
844
+ "sha256": hashlib.sha256(raw).hexdigest(),
845
+ }
846
+ _validate_manifest(manifest, limits)
847
+ encoded = json.dumps(
848
+ manifest, sort_keys=True, separators=(",", ":"), allow_nan=False
849
+ ).encode()
850
+ if len(encoded) > limits.max_manifest_bytes:
851
+ _fail(
852
+ "limit_exceeded",
853
+ "Backup manifest exceeds the configured size limit.",
854
+ )
855
+ fd, filename = tempfile.mkstemp(
856
+ prefix=".backup-", suffix=".partial", dir=directory
857
+ )
858
+ partial = Path(filename)
859
+ with os.fdopen(fd, "w+b") as stream:
860
+ with zipfile.ZipFile(
861
+ stream, "w", compression=zipfile.ZIP_STORED
862
+ ) as archive:
863
+ info = zipfile.ZipInfo(MANIFEST)
864
+ info.create_system, info.external_attr = (
865
+ 3,
866
+ (stat.S_IFREG | 0o600) << 16,
867
+ )
868
+ archive.writestr(info, encoded)
869
+ if raw is not None:
870
+ info = zipfile.ZipInfo(RECOVERY)
871
+ info.create_system, info.external_attr = (
872
+ 3,
873
+ (stat.S_IFREG | 0o600) << 16,
874
+ )
875
+ archive.writestr(info, raw)
876
+ for entry in entries:
877
+ archive.write(
878
+ Path(temporary) / entry["path"],
879
+ "payload/" + entry["path"],
880
+ )
881
+ stream.flush()
882
+ os.fsync(stream.fileno())
883
+ result = verify_backup(partial, limits=limits)
884
+ # Same-directory hard link publishes without replacing any existing archive.
885
+ os.link(partial, final)
886
+ published = True
887
+ partial.unlink()
888
+ partial = None
889
+ succeeded = True
890
+ return result.summary()
891
+ except BackupError:
892
+ raise
893
+ except sqlite3.Error, OSError:
894
+ _fail(
895
+ "create_failed",
896
+ "Backup creation failed; no complete archive was published.",
897
+ )
898
+ except Exception: # noqa: BLE001 - redact maintenance diagnostics at the operator boundary
899
+ _fail(
900
+ "maintenance_refused",
901
+ "Core refused the stopped source inventory; check application maintenance prerequisites.",
902
+ )
903
+ finally:
904
+ if partial is not None:
905
+ partial.unlink(missing_ok=True)
906
+ if published and not succeeded:
907
+ final.unlink(missing_ok=True)