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.
- toolframe_backup/__init__.py +3 -0
- toolframe_backup/archive.py +907 -0
- toolframe_backup/catalog.py +511 -0
- toolframe_backup/cli.py +82 -0
- toolframe_backup/docs/usage.md +88 -0
- toolframe_backup/environments.py +493 -0
- toolframe_backup/page.py +150 -0
- toolframe_backup/plugin.py +220 -0
- toolframe_backup/service.py +105 -0
- toolframe_backup/zipguard.py +178 -0
- toolframe_backup-0.1.0.dist-info/METADATA +148 -0
- toolframe_backup-0.1.0.dist-info/RECORD +16 -0
- toolframe_backup-0.1.0.dist-info/WHEEL +4 -0
- toolframe_backup-0.1.0.dist-info/entry_points.txt +5 -0
- toolframe_backup-0.1.0.dist-info/licenses/LICENSE +202 -0
- toolframe_backup-0.1.0.dist-info/licenses/NOTICE +4 -0
|
@@ -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)
|