@wipcomputer/wip-ldm-os 0.4.86 → 0.4.87-alpha.2

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,1176 @@
1
+ #!/usr/bin/env python3
2
+ """LDM OS backup engine.
3
+
4
+ All capture, estimation, manifest, and exclusion decisions come from one JSON
5
+ policy. The test suite injects every path beneath a disposable fixture root.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import datetime as dt
12
+ import errno
13
+ import fnmatch
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import shutil
18
+ import sqlite3
19
+ import stat
20
+ import sys
21
+ import tempfile
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+
27
+ TOOL_VERSION = "2.0.0"
28
+ SCHEDULE_ID = "ai.openclaw.ldm-backup"
29
+ VALID_EVIDENCE_STATUS = {"complete", "deferred-after-write-failure"}
30
+ JSON_SEPARATORS = (",", ":")
31
+
32
+
33
+ class BackupError(RuntimeError):
34
+ def __init__(self, code: str, message: str):
35
+ super().__init__(message)
36
+ self.code = code
37
+
38
+
39
+ class SimulatedDiskFull(BackupError):
40
+ def __init__(self) -> None:
41
+ super().__init__("disk-full-during-capture", "simulated disk full during capture")
42
+
43
+
44
+ @dataclass(frozen=True)
45
+ class Roots:
46
+ home: Path
47
+ ldm: Path
48
+ openclaw: Path
49
+ claude: Path
50
+ codex: Path
51
+ workspace: Path
52
+ backup: Path
53
+ state: Path
54
+ icloud: Path
55
+ lock: Path
56
+
57
+ def mapping(self) -> dict[str, Path]:
58
+ return {
59
+ "HOME": self.home,
60
+ "LDM_HOME": self.ldm,
61
+ "OPENCLAW_HOME": self.openclaw,
62
+ "CLAUDE_HOME": self.claude,
63
+ "CODEX_HOME": self.codex,
64
+ "WORKSPACE": self.workspace,
65
+ }
66
+
67
+
68
+ def utc_now() -> str:
69
+ return dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
70
+
71
+
72
+ def snapshot_id() -> str:
73
+ return dt.datetime.now(dt.timezone.utc).strftime("%Y-%m-%d--%H-%M-%SZ")
74
+
75
+
76
+ def json_bytes(value: Any) -> bytes:
77
+ return (json.dumps(value, sort_keys=True, separators=JSON_SEPARATORS) + "\n").encode("utf-8")
78
+
79
+
80
+ def sha256_file(path: Path) -> str:
81
+ digest = hashlib.sha256()
82
+ with path.open("rb") as handle:
83
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
84
+ digest.update(chunk)
85
+ return digest.hexdigest()
86
+
87
+
88
+ def sha256_text(value: str) -> str:
89
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
90
+
91
+
92
+ def write_atomic(path: Path, value: Any) -> None:
93
+ path.parent.mkdir(parents=True, exist_ok=True)
94
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
95
+ try:
96
+ with os.fdopen(fd, "wb") as handle:
97
+ handle.write(json_bytes(value))
98
+ handle.flush()
99
+ os.fsync(handle.fileno())
100
+ os.replace(temp_name, path)
101
+ directory_fd = os.open(path.parent, os.O_RDONLY)
102
+ try:
103
+ os.fsync(directory_fd)
104
+ finally:
105
+ os.close(directory_fd)
106
+ finally:
107
+ try:
108
+ os.unlink(temp_name)
109
+ except FileNotFoundError:
110
+ pass
111
+
112
+
113
+ def nearest_existing(path: Path) -> Path:
114
+ current = path.resolve(strict=False)
115
+ while not current.exists() and current != current.parent:
116
+ current = current.parent
117
+ return current
118
+
119
+
120
+ def path_within(path: Path, root: Path) -> bool:
121
+ try:
122
+ path.resolve(strict=False).relative_to(root.resolve(strict=True))
123
+ return True
124
+ except (ValueError, FileNotFoundError):
125
+ return False
126
+
127
+
128
+ def load_config(ldm_home: Path) -> dict[str, Any]:
129
+ config_path = ldm_home / "config.json"
130
+ try:
131
+ value = json.loads(config_path.read_text(encoding="utf-8"))
132
+ except FileNotFoundError:
133
+ return {}
134
+ except (json.JSONDecodeError, OSError) as exc:
135
+ raise BackupError("config-invalid", f"cannot load {config_path}: {exc}") from exc
136
+ if not isinstance(value, dict):
137
+ raise BackupError("config-invalid", "config.json must contain a JSON object")
138
+ for key in ("paths", "backup"):
139
+ if key in value and not isinstance(value[key], dict):
140
+ raise BackupError("config-invalid", f"config.json field {key} must be an object")
141
+ return value
142
+
143
+
144
+ def resolve_roots() -> Roots:
145
+ home = Path(os.environ.get("LDM_BACKUP_HOME", os.environ.get("HOME", "~"))).expanduser()
146
+ ldm = Path(os.environ.get("LDM_BACKUP_LDM_HOME", home / ".ldm")).expanduser()
147
+ config = load_config(ldm)
148
+ config_workspace = config.get("workspace") or config.get("paths", {}).get("workspace") or home / "wipcomputerinc"
149
+ config_icloud = config.get("paths", {}).get("icloudBackup") or home / "Library/Mobile Documents/com~apple~CloudDocs/ldm-backups"
150
+ openclaw = Path(os.environ.get("LDM_BACKUP_OPENCLAW_HOME", home / ".openclaw")).expanduser()
151
+ claude = Path(os.environ.get("LDM_BACKUP_CLAUDE_HOME", home / ".claude")).expanduser()
152
+ codex = Path(os.environ.get("LDM_BACKUP_CODEX_HOME", home / ".codex")).expanduser()
153
+ workspace = Path(os.environ.get("LDM_BACKUP_WORKSPACE", config_workspace)).expanduser()
154
+ backup = Path(os.environ.get("LDM_BACKUP_DESTINATION", ldm / "backups/v1/internal")).expanduser()
155
+ state = Path(os.environ.get("LDM_BACKUP_STATE_DIR", ldm / "state/backup/v1")).expanduser()
156
+ icloud = Path(os.environ.get("LDM_BACKUP_ICLOUD_SUBSTITUTE", config_icloud)).expanduser()
157
+ lock = Path(os.environ.get("LDM_BACKUP_LOCK_PATH", state / "backup.lock")).expanduser()
158
+ return Roots(home, ldm, openclaw, claude, codex, workspace, backup, state, icloud, lock)
159
+
160
+
161
+ def prove_fixture_isolation(roots: Roots) -> Path | None:
162
+ required = os.environ.get("LDM_BACKUP_REQUIRE_FIXTURE") == "1"
163
+ fixture_text = os.environ.get("LDM_BACKUP_FIXTURE_ROOT", "")
164
+ if not required and not fixture_text:
165
+ return None
166
+ if not fixture_text:
167
+ raise BackupError("fixture-root-missing", "fixture mode requires LDM_BACKUP_FIXTURE_ROOT")
168
+ fixture = Path(fixture_text).resolve(strict=True)
169
+ if not fixture.name.startswith("ldm-backup-fixture-"):
170
+ raise BackupError("fixture-root-unsafe", "fixture root must use the ldm-backup-fixture- prefix")
171
+ checked = {
172
+ "home": roots.home,
173
+ "ldm": roots.ldm,
174
+ "openclaw": roots.openclaw,
175
+ "claude": roots.claude,
176
+ "codex": roots.codex,
177
+ "workspace": roots.workspace,
178
+ "backup": roots.backup,
179
+ "state": roots.state,
180
+ "icloud": roots.icloud,
181
+ "lock": roots.lock,
182
+ }
183
+ unsafe = [name for name, value in checked.items() if not path_within(value, fixture)]
184
+ if unsafe:
185
+ raise BackupError("fixture-path-escape", f"fixture paths escape root: {', '.join(sorted(unsafe))}")
186
+ return fixture
187
+
188
+
189
+ def load_policy(path: Path) -> dict[str, Any]:
190
+ try:
191
+ policy = json.loads(path.read_text(encoding="utf-8"))
192
+ except (FileNotFoundError, json.JSONDecodeError, OSError) as exc:
193
+ raise BackupError("policy-invalid", f"cannot load policy: {exc}") from exc
194
+ if policy.get("schema_version") != 1 or not isinstance(policy.get("sources"), list):
195
+ raise BackupError("policy-invalid", "policy schema_version must be 1 and sources must be a list")
196
+ evidence_values = set(policy.get("evidence_status_values", []))
197
+ if evidence_values != VALID_EVIDENCE_STATUS:
198
+ raise BackupError("policy-enum-invalid", "evidence_status enum must contain only complete and deferred-after-write-failure")
199
+ if "complete-or-deferred-after-write-failure" in evidence_values:
200
+ raise BackupError("policy-enum-invalid", "placeholder evidence_status is forbidden")
201
+ ids: set[str] = set()
202
+ for source in policy["sources"]:
203
+ source_id = source.get("id")
204
+ if not source_id or source_id in ids:
205
+ raise BackupError("policy-invalid", "every source requires a unique id")
206
+ ids.add(source_id)
207
+ if source.get("action") not in {"capture", "blocked", "manifest", "exclude"}:
208
+ raise BackupError("policy-invalid", f"invalid action for {source_id}")
209
+ if source.get("type") not in {"file", "tree", "sqlite"}:
210
+ raise BackupError("policy-invalid", f"invalid type for {source_id}")
211
+ destination = source.get("destination")
212
+ if destination is not None:
213
+ destination_path = Path(destination)
214
+ if destination_path.is_absolute() or ".." in destination_path.parts:
215
+ raise BackupError("policy-invalid", f"destination must stay relative for {source_id}")
216
+ return policy
217
+
218
+
219
+ def prove_policy_fixture_isolation(policy: dict[str, Any], roots: Roots, fixture: Path | None) -> None:
220
+ if fixture is None:
221
+ return
222
+ for source in policy["sources"]:
223
+ resolved = source_path(source, roots)
224
+ if not path_within(resolved, fixture):
225
+ raise BackupError("fixture-source-escape", f"source path escapes fixture root: {source['id']}")
226
+ destination = source.get("destination")
227
+ if destination and not path_within(roots.backup / destination, fixture):
228
+ raise BackupError("fixture-destination-escape", f"destination path escapes fixture root: {source['id']}")
229
+
230
+
231
+ def is_excluded(relative: str, rules: list[dict[str, Any]]) -> dict[str, Any] | None:
232
+ value = relative.replace(os.sep, "/")
233
+ for rule in rules:
234
+ pattern = rule["pattern"]
235
+ if fnmatch.fnmatch(value, pattern) or (pattern.startswith("**/") and fnmatch.fnmatch(value, pattern[3:])):
236
+ return rule
237
+ return None
238
+
239
+
240
+ def tree_members(
241
+ root: Path,
242
+ rules: list[dict[str, Any]],
243
+ *,
244
+ hash_content: bool = True,
245
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
246
+ root_info = root.lstat()
247
+ members: list[dict[str, Any]] = [{"relative": "", "kind": "directory", "size": 0, "mtime_ns": root_info.st_mtime_ns, "checksum": sha256_text("")}]
248
+ exclusions: dict[str, dict[str, Any]] = {}
249
+ for base, dirs, files in os.walk(root, topdown=True, followlinks=False):
250
+ base_path = Path(base)
251
+ kept_dirs: list[str] = []
252
+ for name in sorted(dirs):
253
+ directory_path = base_path / name
254
+ relative = str(directory_path.relative_to(root))
255
+ rule = is_excluded(relative + "/", rules)
256
+ if rule:
257
+ exclusions.setdefault(rule["pattern"], {**rule, "matched_count": 0})["matched_count"] += 1
258
+ elif directory_path.is_symlink():
259
+ target = os.readlink(directory_path)
260
+ info = directory_path.lstat()
261
+ members.append({"relative": relative, "kind": "symlink", "size": len(target.encode()), "mtime_ns": info.st_mtime_ns, "checksum": sha256_text(target)})
262
+ else:
263
+ kept_dirs.append(name)
264
+ info = directory_path.lstat()
265
+ members.append({"relative": relative, "kind": "directory", "size": 0, "mtime_ns": info.st_mtime_ns, "checksum": sha256_text("")})
266
+ dirs[:] = kept_dirs
267
+ for name in sorted(files):
268
+ source = base_path / name
269
+ relative = str(source.relative_to(root))
270
+ rule = is_excluded(relative, rules)
271
+ if rule:
272
+ exclusions.setdefault(rule["pattern"], {**rule, "matched_count": 0})["matched_count"] += 1
273
+ continue
274
+ info = source.lstat()
275
+ if stat.S_ISLNK(info.st_mode):
276
+ target = os.readlink(source)
277
+ members.append({"relative": relative, "kind": "symlink", "size": len(target.encode()), "mtime_ns": info.st_mtime_ns, "checksum": sha256_text(target)})
278
+ elif stat.S_ISREG(info.st_mode):
279
+ checksum = sha256_file(source) if hash_content else "0" * 64
280
+ members.append({"relative": relative, "kind": "file", "size": info.st_size, "mtime_ns": info.st_mtime_ns, "checksum": checksum})
281
+ else:
282
+ raise BackupError("unsupported-source-type", f"unsupported file type in source {root.name}")
283
+ for rule in rules:
284
+ exclusions.setdefault(rule["pattern"], {**rule, "matched_count": 0})
285
+ return members, list(exclusions.values())
286
+
287
+
288
+ def sqlite_planned_size(path: Path) -> int:
289
+ try:
290
+ connection = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
291
+ try:
292
+ page_count = int(connection.execute("PRAGMA page_count").fetchone()[0])
293
+ page_size = int(connection.execute("PRAGMA page_size").fetchone()[0])
294
+ return page_count * page_size
295
+ finally:
296
+ connection.close()
297
+ except sqlite3.Error as exc:
298
+ raise BackupError("sqlite-source-invalid", f"SQLite preflight failed for source id: {path.name}") from exc
299
+
300
+
301
+ def source_path(source: dict[str, Any], roots: Roots) -> Path:
302
+ root_name = source.get("root")
303
+ if root_name not in roots.mapping():
304
+ raise BackupError("policy-invalid", f"unknown root {root_name}")
305
+ return (roots.mapping()[root_name] / source.get("path", ".")).resolve(strict=False)
306
+
307
+
308
+ def make_source_plan(
309
+ source: dict[str, Any],
310
+ roots: Roots,
311
+ *,
312
+ hash_content: bool = True,
313
+ ) -> dict[str, Any]:
314
+ path = source_path(source, roots)
315
+ classification = list(source.get("classification", []))
316
+ base = {
317
+ "id": source["id"],
318
+ "display_path": source.get("display_path", f"${source['root']}/{source.get('path', '')}"),
319
+ "classification": classification,
320
+ "required": bool(source.get("required")),
321
+ "action": source["action"],
322
+ "type": source["type"],
323
+ "destination": source.get("destination"),
324
+ "estimated_bytes": 0,
325
+ "status": "planned",
326
+ "members": [],
327
+ "exclusions": [],
328
+ "source_path": str(path),
329
+ "source_definition": source,
330
+ }
331
+ if source["action"] == "blocked":
332
+ base["status"] = "blocked"
333
+ base["block_reason"] = source.get("block_reason", "policy gate is unresolved")
334
+ return base
335
+ if source["action"] == "manifest" and source.get("manifest_generator") != "path-inventory":
336
+ base["status"] = "blocked"
337
+ base["block_reason"] = "manifest generator is not implemented"
338
+ return base
339
+ if source["action"] == "exclude":
340
+ base["status"] = "exclude"
341
+ base["exclusions"] = source.get("exclusions", [])
342
+ return base
343
+ if not path.exists():
344
+ base["status"] = "missing"
345
+ return base
346
+ info = path.lstat()
347
+ if source["type"] == "tree":
348
+ if not path.is_dir():
349
+ base["status"] = "type-mismatch"
350
+ return base
351
+ members, exclusions = tree_members(
352
+ path,
353
+ source.get("exclusions", []),
354
+ hash_content=hash_content,
355
+ )
356
+ base["members"] = members
357
+ base["exclusions"] = exclusions
358
+ base["estimated_bytes"] = sum(item["size"] for item in members)
359
+ elif source["type"] == "sqlite":
360
+ if not path.is_file():
361
+ base["status"] = "type-mismatch"
362
+ return base
363
+ size = sqlite_planned_size(path)
364
+ base["members"] = [{"relative": "", "kind": "sqlite", "size": size, "mtime_ns": info.st_mtime_ns, "checksum": None}]
365
+ base["estimated_bytes"] = size
366
+ else:
367
+ if not path.is_file():
368
+ base["status"] = "type-mismatch"
369
+ return base
370
+ if stat.S_ISLNK(info.st_mode):
371
+ target = os.readlink(path)
372
+ member = {"relative": "", "kind": "symlink", "size": len(target.encode()), "mtime_ns": info.st_mtime_ns, "checksum": sha256_text(target)}
373
+ else:
374
+ checksum = sha256_file(path) if hash_content else "0" * 64
375
+ member = {"relative": "", "kind": "file", "size": info.st_size, "mtime_ns": info.st_mtime_ns, "checksum": checksum}
376
+ base["members"] = [member]
377
+ base["estimated_bytes"] = member["size"]
378
+ if source["action"] == "manifest":
379
+ base["estimated_bytes"] = 0
380
+ return base
381
+
382
+
383
+ def disk_values(path: Path) -> tuple[int, int]:
384
+ test_total = os.environ.get("LDM_BACKUP_TEST_TOTAL_BYTES")
385
+ test_available = os.environ.get("LDM_BACKUP_TEST_AVAILABLE_BYTES")
386
+ if test_total is not None or test_available is not None:
387
+ if test_total is None or test_available is None:
388
+ raise BackupError("test-disk-values-incomplete", "both test disk values are required")
389
+ return int(test_total), int(test_available)
390
+ usage = shutil.disk_usage(nearest_existing(path))
391
+ return usage.total, usage.free
392
+
393
+
394
+ def planned_manifest(run_id: str, started_at: str, policy: dict[str, Any], sources: list[dict[str, Any]]) -> dict[str, Any]:
395
+ records = []
396
+ observations = []
397
+ evidence = []
398
+ exclusions = []
399
+ for item in sources:
400
+ if item["action"] == "capture" and item["status"] == "planned":
401
+ observations.append({
402
+ "source_id": item["id"],
403
+ "consistency_contract": (
404
+ "sqlite-backup-api-transaction-snapshot"
405
+ if item["type"] == "sqlite"
406
+ else "capture-time-stable"
407
+ ),
408
+ "planned_member_count": len(item["members"]),
409
+ "planned_payload_bytes": sum(member["size"] for member in item["members"]),
410
+ "planned_inventory_sha256": members_fingerprint(item["members"]),
411
+ "capture_member_count": len(item["members"]),
412
+ "capture_payload_bytes": sum(member["size"] for member in item["members"]),
413
+ "capture_inventory_sha256": members_fingerprint(item["members"]),
414
+ "changed_since_plan": None if item["type"] == "sqlite" else False,
415
+ "metadata_changed_since_plan": False,
416
+ })
417
+ for member in item["members"]:
418
+ relative_destination = item["destination"]
419
+ if member["relative"]:
420
+ relative_destination = f"{relative_destination}/{member['relative']}"
421
+ records.append({
422
+ "source_id": item["id"],
423
+ "source_path": item["source_path"],
424
+ "destination": relative_destination,
425
+ "type": member["kind"],
426
+ "size": member["size"],
427
+ "mtime_ns": member["mtime_ns"],
428
+ "checksum_sha256": "0" * 64 if member["kind"] == "sqlite" else member["checksum"],
429
+ "planned_size": member["size"],
430
+ "planned_mtime_ns": member["mtime_ns"],
431
+ "planned_checksum_sha256": member["checksum"],
432
+ "changed_since_plan": None if member["kind"] == "sqlite" else False,
433
+ "consistency_contract": (
434
+ "sqlite-backup-api-transaction-snapshot"
435
+ if member["kind"] == "sqlite"
436
+ else "capture-time-stable"
437
+ ),
438
+ })
439
+ if item["action"] == "manifest" and item["status"] == "planned":
440
+ evidence.append(manifest_evidence(item))
441
+ for exclusion in item.get("exclusions", []):
442
+ exclusions.append({
443
+ "source_id": item["id"],
444
+ "path": exclusion.get("pattern", item["display_path"]),
445
+ "reason": exclusion.get("reason", item.get("block_reason", item["status"])),
446
+ "reconstruction_source": exclusion.get("reconstruction_source", "policy gate required"),
447
+ "rule_version": exclusion.get("rule_version", policy["rule_version"]),
448
+ "matched_count": exclusion.get("matched_count", 0),
449
+ })
450
+ if item["status"] in {"blocked", "missing", "type-mismatch"}:
451
+ exclusions.append({
452
+ "source_id": item["id"],
453
+ "path": item["display_path"],
454
+ "reason": item.get("block_reason", item["status"]),
455
+ "reconstruction_source": "unresolved required-source gate",
456
+ "rule_version": policy["rule_version"],
457
+ "matched_count": 1,
458
+ })
459
+ return {
460
+ "schema_version": 1,
461
+ "snapshot_id": run_id,
462
+ "tool_version": TOOL_VERSION,
463
+ "policy_rule_version": policy["rule_version"],
464
+ "started_at": started_at,
465
+ "completed_at": "0000-00-00T00:00:00Z",
466
+ "terminal_result": "success",
467
+ "sources": records,
468
+ "source_observations": observations,
469
+ "manifest_evidence": evidence,
470
+ "exclusions": exclusions,
471
+ }
472
+
473
+
474
+ def completion_value(run_id: str) -> dict[str, Any]:
475
+ return {
476
+ "schema_version": 1,
477
+ "snapshot_id": run_id,
478
+ "status": "complete",
479
+ "manifest_sha256": "0" * 64,
480
+ "completed_at": "0000-00-00T00:00:00Z",
481
+ }
482
+
483
+
484
+ def build_plan(policy: dict[str, Any], roots: Roots, run_id: str, started_at: str) -> dict[str, Any]:
485
+ sources = [make_source_plan(source, roots) for source in policy["sources"]]
486
+ blockers = [item for item in sources if item["required"] and item["status"] != "planned" and item["action"] == "capture"]
487
+ blockers.extend(item for item in sources if item["required"] and item["status"] != "planned" and item["action"] == "manifest")
488
+ blockers.extend(item for item in sources if item["required"] and item["status"] == "blocked")
489
+ payload_bytes = sum(item["estimated_bytes"] for item in sources if item["status"] == "planned")
490
+ manifest = planned_manifest(run_id, started_at, policy, sources)
491
+ manifest["planned_payload_bytes"] = payload_bytes
492
+ manifest["captured_payload_bytes"] = payload_bytes
493
+ metadata_bytes = len(json_bytes(manifest)) + len(json_bytes(completion_value(run_id)))
494
+ total, available = disk_values(roots.backup)
495
+ reserve_percent = int(policy["disk_floors"]["start_reserve_percent"])
496
+ required_reserve = (total * reserve_percent + 99) // 100
497
+ estimated_new = payload_bytes + metadata_bytes
498
+ reserve_after = available - estimated_new
499
+ destination_parent = nearest_existing(roots.backup)
500
+ destination_ok = destination_parent.is_dir() and os.access(destination_parent, os.W_OK)
501
+ allowed = not blockers and destination_ok and reserve_after >= required_reserve
502
+ reasons = []
503
+ if blockers:
504
+ reasons.append("required-source-gate")
505
+ if not destination_ok:
506
+ reasons.append("destination-unavailable")
507
+ if reserve_after < required_reserve:
508
+ reasons.append("reserve-gate")
509
+ return {
510
+ "schema_version": 1,
511
+ "dry_run": False,
512
+ "snapshot_id": run_id,
513
+ "tool_version": TOOL_VERSION,
514
+ "policy_rule_version": policy["rule_version"],
515
+ "planned_staging_path": str(roots.backup / f".in-progress-{run_id}"),
516
+ "planned_destination_path": str(roots.backup / run_id),
517
+ "icloud_tier": {"path": str(roots.icloud), "status": "not-in-slice-4", "write": False},
518
+ "vault_tier": {"path": None, "status": "not-in-slice-3", "write": False},
519
+ "sources": sources,
520
+ "initial_estimated_payload_bytes": payload_bytes,
521
+ "estimated_payload_bytes": payload_bytes,
522
+ "initial_estimated_metadata_bytes": metadata_bytes,
523
+ "estimated_metadata_bytes": metadata_bytes,
524
+ "estimated_new_bytes": estimated_new,
525
+ "disk_total_bytes": total,
526
+ "disk_available_bytes": available,
527
+ "required_reserve_bytes": required_reserve,
528
+ "reserve_after_bytes": reserve_after,
529
+ "preflight_allowed": allowed,
530
+ "blocking_reasons": reasons,
531
+ "manifest_template": manifest,
532
+ "policy": policy,
533
+ }
534
+
535
+
536
+ def public_plan(plan: dict[str, Any]) -> dict[str, Any]:
537
+ safe_sources = []
538
+ for item in plan["sources"]:
539
+ safe_sources.append({key: item[key] for key in (
540
+ "id", "display_path", "classification", "required", "action", "type",
541
+ "destination", "estimated_bytes", "status",
542
+ )} | ({"block_reason": item["block_reason"]} if "block_reason" in item else {}) | {
543
+ "exclusions": [
544
+ {key: value for key, value in exclusion.items() if key in {"pattern", "reason", "reconstruction_source", "rule_version", "matched_count"}}
545
+ for exclusion in item.get("exclusions", [])
546
+ ]
547
+ })
548
+ return {key: value for key, value in plan.items() if key not in {"sources", "manifest_template", "policy"}} | {"sources": safe_sources}
549
+
550
+
551
+ def print_plan(plan: dict[str, Any], as_json: bool) -> None:
552
+ safe = public_plan(plan)
553
+ if as_json:
554
+ print(json.dumps(safe, indent=2, sort_keys=True))
555
+ return
556
+ print("=== LDM Backup dry run ===")
557
+ print(f"Policy: {safe['policy_rule_version']}")
558
+ for item in safe["sources"]:
559
+ classes = ", ".join(item["classification"])
560
+ print(f"- {item['id']}: {item['display_path']}")
561
+ print(f" class={classes} action={item['action']} status={item['status']} estimated_bytes={item['estimated_bytes']}")
562
+ if item.get("block_reason"):
563
+ print(f" blocked: {item['block_reason']}")
564
+ for exclusion in item.get("exclusions", []):
565
+ print(f" exclude {exclusion.get('pattern', 'policy')}: {exclusion.get('reason', 'policy rule')}")
566
+ print(f"Total estimated new bytes: {safe['estimated_new_bytes']}")
567
+ print(f"Disk capacity bytes: {safe['disk_total_bytes']}")
568
+ print(f"Disk available bytes: {safe['disk_available_bytes']}")
569
+ print(f"Required reserve bytes: {safe['required_reserve_bytes']}")
570
+ print(f"Reserve after proposed backup: {safe['reserve_after_bytes']}")
571
+ print(f"Planned staging path: {safe['planned_staging_path']}")
572
+ print(f"Planned destination path: {safe['planned_destination_path']}")
573
+ print(f"Preflight: {'PASS' if safe['preflight_allowed'] else 'FAIL'}")
574
+ if safe["blocking_reasons"]:
575
+ print(f"Blocking reasons: {', '.join(safe['blocking_reasons'])}")
576
+ print("[DRY RUN] No files modified.")
577
+
578
+
579
+ def pid_alive(pid: int) -> bool:
580
+ try:
581
+ os.kill(pid, 0)
582
+ return True
583
+ except ProcessLookupError:
584
+ return False
585
+ except PermissionError:
586
+ return True
587
+
588
+
589
+ def acquire_lock(path: Path, run_id: str) -> int:
590
+ path.parent.mkdir(parents=True, exist_ok=True)
591
+ try:
592
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
593
+ except FileExistsError as exc:
594
+ try:
595
+ lock = json.loads(path.read_text(encoding="utf-8"))
596
+ pid = int(lock.get("pid", 0))
597
+ except (OSError, ValueError, json.JSONDecodeError):
598
+ raise BackupError("lock-invalid", "existing lock is unreadable and was not replaced") from exc
599
+ if pid and pid_alive(pid):
600
+ raise BackupError("concurrent-run", f"backup already running with pid {pid}") from exc
601
+ raise BackupError("stale-lock", f"stale lock detected for dead pid {pid}; lock was not stolen") from exc
602
+ payload = json_bytes({"schema_version": 1, "pid": os.getpid(), "snapshot_id": run_id, "created_at": utc_now()})
603
+ if os.write(fd, payload) != len(payload):
604
+ os.close(fd)
605
+ raise BackupError("lock-write-failed", "lock payload write was incomplete")
606
+ os.fsync(fd)
607
+ return fd
608
+
609
+
610
+ def release_lock(path: Path, fd: int | None) -> None:
611
+ if fd is None:
612
+ return
613
+ owned = os.fstat(fd)
614
+ try:
615
+ current = path.lstat()
616
+ if current.st_dev == owned.st_dev and current.st_ino == owned.st_ino:
617
+ path.unlink()
618
+ except FileNotFoundError:
619
+ pass
620
+ finally:
621
+ os.close(fd)
622
+
623
+
624
+ def copy_member(source_root: Path, destination_root: Path, member: dict[str, Any]) -> int:
625
+ relative = Path(member["relative"]) if member["relative"] else Path()
626
+ source = source_root / relative
627
+ destination = destination_root / relative
628
+ destination.parent.mkdir(parents=True, exist_ok=True)
629
+ if member["kind"] == "directory":
630
+ destination.mkdir(parents=True, exist_ok=True)
631
+ shutil.copystat(source, destination, follow_symlinks=False)
632
+ return 0
633
+ if member["kind"] == "symlink":
634
+ os.symlink(os.readlink(source), destination)
635
+ return member["size"]
636
+ shutil.copy2(source, destination, follow_symlinks=False)
637
+ return destination.stat().st_size
638
+
639
+
640
+ def member_key(member: dict[str, Any]) -> tuple[str, str]:
641
+ return member["relative"], member["kind"]
642
+
643
+
644
+ def member_map(members: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
645
+ return {member_key(member): member for member in members}
646
+
647
+
648
+ def members_changed(before: list[dict[str, Any]], after: list[dict[str, Any]]) -> bool:
649
+ return member_map(before) != member_map(after)
650
+
651
+
652
+ def members_fingerprint(members: list[dict[str, Any]]) -> str:
653
+ return hashlib.sha256(json_bytes(members)).hexdigest()
654
+
655
+
656
+ def manifest_evidence(item: dict[str, Any]) -> dict[str, Any]:
657
+ members = item["members"]
658
+ return {
659
+ "source_id": item["id"],
660
+ "generator": item["source_definition"].get("manifest_generator"),
661
+ "source_path": item["source_path"],
662
+ "source_type": item["type"],
663
+ "member_count": len(members),
664
+ "logical_bytes": sum(member["size"] for member in members),
665
+ "inventory_sha256": members_fingerprint(members),
666
+ "members": members,
667
+ "generated_at": "0000-00-00T00:00:00Z",
668
+ "status": "valid",
669
+ }
670
+
671
+
672
+ def apply_test_source_mutation(item: dict[str, Any], phase: str) -> None:
673
+ if os.environ.get("LDM_BACKUP_TEST_MUTATE_SOURCE_ID") != item["id"]:
674
+ return
675
+ if os.environ.get("LDM_BACKUP_TEST_MUTATE_PHASE", "before-capture") != phase:
676
+ return
677
+ mode = os.environ.get("LDM_BACKUP_TEST_MUTATE_MODE", "same-size")
678
+ source_root = Path(item["source_path"])
679
+ member = next((value for value in item["members"] if value["kind"] == "file"), None)
680
+ if member is None:
681
+ raise BackupError("test-mutation-invalid", "test mutation requires a regular file member")
682
+ target = source_root / member["relative"] if member["relative"] else source_root
683
+ original = target.read_bytes()
684
+ if mode == "same-size":
685
+ replacement = (b"X" if original[:1] != b"X" else b"Y") + original[1:]
686
+ elif mode == "size":
687
+ replacement = original + b"-changed"
688
+ else:
689
+ raise BackupError("test-mutation-invalid", f"unknown mutation mode: {mode}")
690
+ target.write_bytes(replacement)
691
+
692
+
693
+ def capture_sqlite(source: Path, destination: Path, source_id: str) -> tuple[int, str]:
694
+ destination.parent.mkdir(parents=True, exist_ok=True)
695
+ source_db = sqlite3.connect(f"file:{source}?mode=ro", uri=True)
696
+ destination_db = sqlite3.connect(destination)
697
+ try:
698
+ source_db.backup(destination_db)
699
+ finally:
700
+ destination_db.close()
701
+ source_db.close()
702
+ if os.environ.get("LDM_BACKUP_TEST_SQLITE_FAIL_ID") == source_id:
703
+ destination.write_bytes(b"not-a-database")
704
+ try:
705
+ check_db = sqlite3.connect(f"file:{destination}?mode=ro", uri=True)
706
+ try:
707
+ result = check_db.execute("PRAGMA quick_check").fetchone()[0]
708
+ finally:
709
+ check_db.close()
710
+ except sqlite3.Error as exc:
711
+ raise BackupError("sqlite-validation-failed", f"quick_check failed for source id {source_id}") from exc
712
+ if result != "ok":
713
+ raise BackupError("sqlite-validation-failed", f"quick_check failed for source id {source_id}")
714
+ return destination.stat().st_size, sha256_file(destination)
715
+
716
+
717
+ def source_metadata_projection_bytes(item: dict[str, Any], plan: dict[str, Any]) -> int:
718
+ fragment = planned_manifest(
719
+ plan["snapshot_id"],
720
+ plan["manifest_template"]["started_at"],
721
+ plan["policy"],
722
+ [item],
723
+ )
724
+ empty = planned_manifest(
725
+ plan["snapshot_id"],
726
+ plan["manifest_template"]["started_at"],
727
+ plan["policy"],
728
+ [],
729
+ )
730
+ return len(json_bytes(fragment)) - len(json_bytes(empty))
731
+
732
+
733
+ def projected_metadata_bytes(
734
+ plan: dict[str, Any],
735
+ refreshed: dict[str, dict[str, Any]],
736
+ generated_evidence: list[dict[str, Any]],
737
+ ) -> int:
738
+ metadata_bytes = plan["initial_estimated_metadata_bytes"]
739
+ original = {item["id"]: item for item in plan["sources"]}
740
+ for source_id, observation in refreshed.items():
741
+ before = source_metadata_projection_bytes(original[source_id], plan)
742
+ after = source_metadata_projection_bytes(observation, plan)
743
+ metadata_bytes += max(0, after - before)
744
+ planned_evidence = {
745
+ item["source_id"]: item
746
+ for item in plan["manifest_template"]["manifest_evidence"]
747
+ }
748
+ for item in generated_evidence:
749
+ before = planned_evidence.get(item["source_id"])
750
+ before_bytes = len(json_bytes(before)) if before is not None else 0
751
+ metadata_bytes += max(0, len(json_bytes(item)) - before_bytes)
752
+ return metadata_bytes
753
+
754
+
755
+ def refresh_remaining_capture_sources(
756
+ plan: dict[str, Any],
757
+ start_index: int,
758
+ roots: Roots,
759
+ ) -> dict[str, dict[str, Any]]:
760
+ refreshed: dict[str, dict[str, Any]] = {}
761
+ for item in plan["sources"][start_index:]:
762
+ if item["action"] != "capture" or item["status"] != "planned":
763
+ continue
764
+ observation = make_source_plan(
765
+ item["source_definition"],
766
+ roots,
767
+ hash_content=False,
768
+ )
769
+ if observation["status"] != "planned":
770
+ raise BackupError("source-changed-during-run", f"source became unavailable before capture: {item['id']}")
771
+ refreshed[item["id"]] = observation
772
+ return refreshed
773
+
774
+
775
+ def revalidate_capture_reserve(
776
+ plan: dict[str, Any],
777
+ roots: Roots,
778
+ written: int,
779
+ refreshed: dict[str, dict[str, Any]],
780
+ generated_evidence: list[dict[str, Any]],
781
+ ) -> None:
782
+ remaining_payload = sum(item["estimated_bytes"] for item in refreshed.values())
783
+ metadata_bytes = projected_metadata_bytes(plan, refreshed, generated_evidence)
784
+ _, current_available = disk_values(roots.backup)
785
+ available_after_staged = min(current_available, plan["disk_available_bytes"] - written)
786
+ reserve_after = available_after_staged - remaining_payload - metadata_bytes
787
+ updated_payload = written + remaining_payload
788
+ plan["estimated_payload_bytes"] = updated_payload
789
+ plan["estimated_metadata_bytes"] = metadata_bytes
790
+ plan["estimated_new_bytes"] = updated_payload + metadata_bytes
791
+ plan["reserve_after_bytes"] = reserve_after
792
+ plan["capture_time_available_bytes"] = available_after_staged
793
+ if reserve_after < plan["required_reserve_bytes"]:
794
+ plan["preflight_allowed"] = False
795
+ if "capture-reserve-gate" not in plan["blocking_reasons"]:
796
+ plan["blocking_reasons"].append("capture-reserve-gate")
797
+ raise BackupError(
798
+ "capture-reserve-gate",
799
+ "updated capture estimate would cross the required disk reserve",
800
+ )
801
+
802
+
803
+ def revalidate_final_metadata_reserve(
804
+ plan: dict[str, Any],
805
+ roots: Roots,
806
+ payload_written: int,
807
+ manifest: dict[str, Any],
808
+ ) -> None:
809
+ metadata_bytes = len(json_bytes(manifest)) + len(json_bytes(completion_value(plan["snapshot_id"])))
810
+ _, current_available = disk_values(roots.backup)
811
+ available_after_staged = min(current_available, plan["disk_available_bytes"] - payload_written)
812
+ reserve_after = available_after_staged - metadata_bytes
813
+ plan["estimated_payload_bytes"] = payload_written
814
+ plan["estimated_metadata_bytes"] = metadata_bytes
815
+ plan["estimated_new_bytes"] = payload_written + metadata_bytes
816
+ plan["reserve_after_bytes"] = reserve_after
817
+ plan["capture_time_available_bytes"] = available_after_staged
818
+ if reserve_after < plan["required_reserve_bytes"]:
819
+ plan["preflight_allowed"] = False
820
+ if "capture-reserve-gate" not in plan["blocking_reasons"]:
821
+ plan["blocking_reasons"].append("capture-reserve-gate")
822
+ raise BackupError(
823
+ "capture-reserve-gate",
824
+ "final manifest metadata would cross the required disk reserve",
825
+ )
826
+
827
+
828
+ def capture(
829
+ plan: dict[str, Any],
830
+ staging: Path,
831
+ roots: Roots,
832
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]], list[dict[str, Any]], int]:
833
+ records: list[dict[str, Any]] = []
834
+ observations: list[dict[str, Any]] = []
835
+ evidence: list[dict[str, Any]] = []
836
+ written = 0
837
+ fail_after = int(os.environ.get("LDM_BACKUP_TEST_FAIL_AFTER_BYTES", "-1"))
838
+ crash_after = int(os.environ.get("LDM_BACKUP_TEST_CRASH_AFTER_FILES", "-1"))
839
+ captured_files = 0
840
+ for index, item in enumerate(plan["sources"]):
841
+ if item["action"] == "manifest" and item["status"] == "planned":
842
+ if os.environ.get("LDM_BACKUP_TEST_MANIFEST_FAIL_ID") == item["id"]:
843
+ raise BackupError("manifest-evidence-failed", f"manifest generator failed: {item['id']}")
844
+ observation = make_source_plan(item["source_definition"], roots)
845
+ if observation["status"] != "planned":
846
+ raise BackupError("manifest-evidence-failed", f"manifest evidence unavailable: {item['id']}")
847
+ record = manifest_evidence(observation)
848
+ record["generated_at"] = utc_now()
849
+ evidence.append(record)
850
+ refreshed = refresh_remaining_capture_sources(plan, index + 1, roots)
851
+ revalidate_capture_reserve(plan, roots, written, refreshed, evidence)
852
+ continue
853
+ if item["action"] != "capture" or item["status"] != "planned":
854
+ continue
855
+ apply_test_source_mutation(item, "before-capture")
856
+ refreshed = refresh_remaining_capture_sources(plan, index, roots)
857
+ capture_observation = make_source_plan(item["source_definition"], roots)
858
+ if capture_observation["status"] != "planned":
859
+ raise BackupError("source-changed-during-run", f"source became unavailable before capture: {item['id']}")
860
+ refreshed[item["id"]] = capture_observation
861
+ revalidate_capture_reserve(plan, roots, written, refreshed, evidence)
862
+ plan_changed = members_changed(item["members"], capture_observation["members"])
863
+ observations.append({
864
+ "source_id": item["id"],
865
+ "consistency_contract": (
866
+ "sqlite-backup-api-transaction-snapshot"
867
+ if item["type"] == "sqlite"
868
+ else "capture-time-stable"
869
+ ),
870
+ "planned_member_count": len(item["members"]),
871
+ "planned_payload_bytes": sum(member["size"] for member in item["members"]),
872
+ "planned_inventory_sha256": members_fingerprint(item["members"]),
873
+ "capture_member_count": len(capture_observation["members"]),
874
+ "capture_payload_bytes": sum(member["size"] for member in capture_observation["members"]),
875
+ "capture_inventory_sha256": members_fingerprint(capture_observation["members"]),
876
+ "changed_since_plan": None if item["type"] == "sqlite" else plan_changed,
877
+ "metadata_changed_since_plan": plan_changed,
878
+ })
879
+ source_root = Path(item["source_path"])
880
+ destination_root = staging / item["destination"]
881
+ if item["type"] == "sqlite":
882
+ capture_info = source_root.lstat()
883
+ size, checksum = capture_sqlite(source_root, destination_root, item["id"])
884
+ member = item["members"][0]
885
+ records.append({
886
+ "source_id": item["id"], "source_path": item["source_path"],
887
+ "destination": item["destination"], "type": "sqlite", "size": size,
888
+ "mtime_ns": capture_info.st_mtime_ns, "checksum_sha256": checksum,
889
+ "planned_size": member["size"], "planned_mtime_ns": member["mtime_ns"],
890
+ "planned_checksum_sha256": None, "changed_since_plan": None,
891
+ "consistency_contract": "sqlite-backup-api-transaction-snapshot",
892
+ })
893
+ written += size
894
+ captured_files += 1
895
+ else:
896
+ for member in capture_observation["members"]:
897
+ destination = destination_root
898
+ if member["relative"]:
899
+ destination = destination_root / member["relative"]
900
+ size = copy_member(source_root, destination_root, member)
901
+ if member["kind"] == "directory":
902
+ checksum = sha256_text("")
903
+ elif member["kind"] == "symlink":
904
+ checksum = sha256_text(os.readlink(destination))
905
+ else:
906
+ checksum = sha256_file(destination)
907
+ if size != member["size"] or checksum != member["checksum"]:
908
+ raise BackupError("source-capture-mismatch", f"captured bytes do not match source observation: {item['id']}")
909
+ relative_destination = item["destination"]
910
+ if member["relative"]:
911
+ relative_destination = f"{relative_destination}/{member['relative']}"
912
+ records.append({
913
+ "source_id": item["id"], "source_path": item["source_path"],
914
+ "destination": relative_destination, "type": member["kind"], "size": size,
915
+ "mtime_ns": member["mtime_ns"], "checksum_sha256": checksum,
916
+ "planned_size": member_map(item["members"]).get(member_key(member), {}).get("size"),
917
+ "planned_mtime_ns": member_map(item["members"]).get(member_key(member), {}).get("mtime_ns"),
918
+ "planned_checksum_sha256": member_map(item["members"]).get(member_key(member), {}).get("checksum"),
919
+ "changed_since_plan": plan_changed,
920
+ "consistency_contract": "capture-time-stable",
921
+ })
922
+ written += size
923
+ captured_files += 1
924
+ if fail_after >= 0 and written >= fail_after:
925
+ raise SimulatedDiskFull()
926
+ if crash_after >= 0 and captured_files >= crash_after:
927
+ os._exit(88)
928
+ apply_test_source_mutation(capture_observation, "during-capture")
929
+ final_observation = make_source_plan(item["source_definition"], roots)
930
+ if final_observation["status"] != "planned" or members_changed(capture_observation["members"], final_observation["members"]):
931
+ raise BackupError("source-changed-during-capture", f"source changed during capture: {item['id']}")
932
+ return records, observations, evidence, written
933
+
934
+
935
+ def read_evidence_status(state_dir: Path) -> str:
936
+ marker = state_dir / "containment.json"
937
+ try:
938
+ value = json.loads(marker.read_text(encoding="utf-8"))
939
+ except FileNotFoundError:
940
+ return "unknown"
941
+ except (OSError, json.JSONDecodeError) as exc:
942
+ raise BackupError("containment-marker-invalid", f"cannot read containment marker: {exc}") from exc
943
+ evidence_status = value.get("evidence_status")
944
+ if evidence_status not in VALID_EVIDENCE_STATUS:
945
+ raise BackupError("state-enum-invalid", "containment marker has invalid evidence_status")
946
+ return evidence_status
947
+
948
+
949
+ def validate_manifest(manifest: dict[str, Any], staging: Path, plan: dict[str, Any]) -> None:
950
+ if manifest.get("terminal_result") != "success":
951
+ raise BackupError("manifest-invalid", "manifest result is not success")
952
+ expected_ids = {item["id"] for item in plan["sources"] if item["required"] and item["action"] == "capture"}
953
+ actual_ids = {item["source_id"] for item in manifest["sources"]}
954
+ if expected_ids - actual_ids:
955
+ raise BackupError("manifest-required-source-missing", "manifest is missing a required source")
956
+ expected_evidence_ids = {
957
+ item["id"]
958
+ for item in plan["sources"]
959
+ if item["required"] and item["action"] == "manifest"
960
+ }
961
+ evidence = manifest.get("manifest_evidence")
962
+ if not isinstance(evidence, list):
963
+ raise BackupError("manifest-evidence-invalid", "manifest evidence must be a list")
964
+ actual_evidence_ids = {item.get("source_id") for item in evidence}
965
+ if expected_evidence_ids - actual_evidence_ids:
966
+ raise BackupError("manifest-required-evidence-missing", "manifest is missing required evidence")
967
+ for item in evidence:
968
+ members = item.get("members")
969
+ if (
970
+ item.get("status") != "valid"
971
+ or item.get("generator") != "path-inventory"
972
+ or not isinstance(members, list)
973
+ or item.get("member_count") != len(members)
974
+ or item.get("logical_bytes") != sum(member.get("size", -1) for member in members)
975
+ or item.get("inventory_sha256") != members_fingerprint(members)
976
+ ):
977
+ raise BackupError("manifest-evidence-invalid", f"invalid manifest evidence: {item.get('source_id')}")
978
+ for record in manifest["sources"]:
979
+ destination = staging / record["destination"]
980
+ if not destination.exists() and not destination.is_symlink():
981
+ raise BackupError("manifest-file-missing", "manifest destination is missing")
982
+ if record["type"] == "directory":
983
+ if not destination.is_dir():
984
+ raise BackupError("manifest-file-missing", "manifest directory is missing")
985
+ checksum = sha256_text("")
986
+ elif destination.is_symlink():
987
+ checksum = sha256_text(os.readlink(destination))
988
+ else:
989
+ checksum = sha256_file(destination)
990
+ if checksum != record["checksum_sha256"]:
991
+ raise BackupError("manifest-checksum-mismatch", "manifest checksum mismatch")
992
+
993
+
994
+ def verified_snapshot(path: Path) -> bool:
995
+ marker = path / "completion.json"
996
+ manifest = path / "manifest.json"
997
+ if not path.is_dir() or not marker.is_file() or not manifest.is_file():
998
+ return False
999
+ try:
1000
+ marker_value = json.loads(marker.read_text(encoding="utf-8"))
1001
+ manifest_value = json.loads(manifest.read_text(encoding="utf-8"))
1002
+ except (OSError, json.JSONDecodeError):
1003
+ return False
1004
+ return marker_value.get("status") == "complete" and manifest_value.get("terminal_result") == "success" and marker_value.get("manifest_sha256") == sha256_file(manifest)
1005
+
1006
+
1007
+ def rotate(root: Path, keep: int) -> list[str]:
1008
+ eligible = sorted(
1009
+ path for path in root.iterdir()
1010
+ if path.is_dir() and not path.name.startswith(".in-progress-") and verified_snapshot(path) and not (path / ".pinned").exists()
1011
+ ) if root.exists() else []
1012
+ remove_count = max(0, len(eligible) - keep)
1013
+ removed = []
1014
+ for path in eligible[:remove_count]:
1015
+ shutil.rmtree(path)
1016
+ removed.append(path.name)
1017
+ return removed
1018
+
1019
+
1020
+ def result_value(run_id: str, status: str, code: str | None, message: str | None, plan: dict[str, Any] | None) -> dict[str, Any]:
1021
+ return {
1022
+ "schema_version": 1,
1023
+ "snapshot_id": run_id,
1024
+ "status": status,
1025
+ "failure_code": code,
1026
+ "message": message,
1027
+ "finished_at": utc_now(),
1028
+ "estimated_new_bytes": plan.get("estimated_new_bytes") if plan else None,
1029
+ "estimated_payload_bytes": plan.get("estimated_payload_bytes") if plan else None,
1030
+ "estimated_metadata_bytes": plan.get("estimated_metadata_bytes") if plan else None,
1031
+ "required_reserve_bytes": plan.get("required_reserve_bytes") if plan else None,
1032
+ "reserve_after_bytes": plan.get("reserve_after_bytes") if plan else None,
1033
+ "preflight_allowed": plan.get("preflight_allowed") if plan else False,
1034
+ "tier_results": {"internal": status, "vault": "not-in-slice", "icloud": "not-in-slice"},
1035
+ }
1036
+
1037
+
1038
+ def run_backup(policy: dict[str, Any], roots: Roots, keep: int) -> int:
1039
+ run_id = snapshot_id()
1040
+ started_at = utc_now()
1041
+ lock_fd: int | None = None
1042
+ plan: dict[str, Any] | None = None
1043
+ staging = roots.backup / f".in-progress-{run_id}"
1044
+ final = roots.backup / run_id
1045
+ try:
1046
+ lock_fd = acquire_lock(roots.lock, run_id)
1047
+ plan = build_plan(policy, roots, run_id, started_at)
1048
+ schedule = {
1049
+ "schema_version": 1,
1050
+ "schedule_id": SCHEDULE_ID,
1051
+ "timezone": "America/Los_Angeles",
1052
+ "expected_slot": "03:00",
1053
+ "containment_status": os.environ.get("LDM_BACKUP_CONTAINMENT_STATUS", "unknown"),
1054
+ "evidence_status": read_evidence_status(roots.state),
1055
+ "next_due_time": None,
1056
+ }
1057
+ write_atomic(roots.state / "schedule.json", schedule)
1058
+ write_atomic(roots.state / "attempt.json", {
1059
+ "schema_version": 1, "snapshot_id": run_id, "slot_id": run_id,
1060
+ "start_time": started_at, "pid": os.getpid(), "tool_version": TOOL_VERSION,
1061
+ "preflight": {key: plan[key] for key in (
1062
+ "estimated_new_bytes", "disk_total_bytes", "disk_available_bytes",
1063
+ "required_reserve_bytes", "reserve_after_bytes", "preflight_allowed",
1064
+ )},
1065
+ })
1066
+ if not plan["preflight_allowed"]:
1067
+ raise BackupError("preflight-failed", ",".join(plan["blocking_reasons"]) or "preflight failed")
1068
+ if staging.exists() or final.exists():
1069
+ raise BackupError("snapshot-collision", "snapshot path already exists")
1070
+ roots.backup.mkdir(parents=True, exist_ok=True)
1071
+ staging.mkdir(mode=0o700)
1072
+ records, observations, evidence, payload_written = capture(plan, staging, roots)
1073
+ drop_evidence_id = os.environ.get("LDM_BACKUP_TEST_DROP_MANIFEST_EVIDENCE_ID")
1074
+ if drop_evidence_id:
1075
+ evidence = [item for item in evidence if item["source_id"] != drop_evidence_id]
1076
+ manifest = plan["manifest_template"]
1077
+ manifest["sources"] = records
1078
+ manifest["source_observations"] = observations
1079
+ manifest["manifest_evidence"] = evidence
1080
+ manifest["planned_payload_bytes"] = plan["initial_estimated_payload_bytes"]
1081
+ manifest["captured_payload_bytes"] = payload_written
1082
+ manifest["completed_at"] = utc_now()
1083
+ revalidate_final_metadata_reserve(plan, roots, payload_written, manifest)
1084
+ validate_manifest(manifest, staging, plan)
1085
+ write_atomic(staging / "manifest.json", manifest)
1086
+ if os.environ.get("LDM_BACKUP_TEST_FAIL_BEFORE_COMPLETION") == "1":
1087
+ raise BackupError("completion-gate-failed", "simulated failure before completion marker")
1088
+ marker = completion_value(run_id)
1089
+ marker["manifest_sha256"] = sha256_file(staging / "manifest.json")
1090
+ marker["completed_at"] = utc_now()
1091
+ write_atomic(staging / "completion.json", marker)
1092
+ if not verified_snapshot(staging):
1093
+ raise BackupError("completion-validation-failed", "completion proof failed before promotion")
1094
+ os.replace(staging, final)
1095
+ success = result_value(run_id, "success", None, None, plan)
1096
+ success["snapshot_path"] = str(final)
1097
+ success["manifest_sha256"] = marker["manifest_sha256"]
1098
+ write_atomic(roots.state / "result.json", success)
1099
+ write_atomic(roots.state / "success-local.json", success)
1100
+ removed = rotate(roots.backup, keep)
1101
+ print("=== Backup complete ===")
1102
+ print(f"Location: {final}")
1103
+ print(f"Estimated and captured payload bytes: {payload_written}")
1104
+ if removed:
1105
+ print(f"Rotated verified snapshots: {', '.join(removed)}")
1106
+ return 0
1107
+ except BackupError as exc:
1108
+ if lock_fd is not None:
1109
+ try:
1110
+ write_atomic(roots.state / "result.json", result_value(run_id, "failure", exc.code, str(exc), plan))
1111
+ except OSError:
1112
+ pass
1113
+ print(f"Backup failed [{exc.code}]: {exc}", file=sys.stderr)
1114
+ return 1
1115
+ except OSError as exc:
1116
+ code = "disk-full-during-capture" if exc.errno == errno.ENOSPC else "io-failure"
1117
+ try:
1118
+ write_atomic(roots.state / "result.json", result_value(run_id, "failure", code, str(exc), plan))
1119
+ except OSError:
1120
+ pass
1121
+ print(f"Backup failed [{code}]: {exc}", file=sys.stderr)
1122
+ return 1
1123
+ finally:
1124
+ release_lock(roots.lock, lock_fd)
1125
+
1126
+
1127
+ def verify_snapshot(path: Path) -> int:
1128
+ if verified_snapshot(path):
1129
+ print("Snapshot verified complete")
1130
+ return 0
1131
+ print("Snapshot is missing valid completion proof", file=sys.stderr)
1132
+ return 1
1133
+
1134
+
1135
+ def parse_args(argv: list[str]) -> argparse.Namespace:
1136
+ parser = argparse.ArgumentParser(description="LDM OS backup engine")
1137
+ parser.add_argument("--policy", type=Path)
1138
+ parser.add_argument("--dry-run", action="store_true")
1139
+ parser.add_argument("--json", action="store_true")
1140
+ parser.add_argument("--keep", type=int, default=None)
1141
+ parser.add_argument("--include-secrets", action="store_true")
1142
+ parser.add_argument("--verify-snapshot", type=Path)
1143
+ return parser.parse_args(argv)
1144
+
1145
+
1146
+ def main(argv: list[str] | None = None) -> int:
1147
+ args = parse_args(argv or sys.argv[1:])
1148
+ if args.verify_snapshot:
1149
+ return verify_snapshot(args.verify_snapshot)
1150
+ try:
1151
+ roots = resolve_roots()
1152
+ fixture = prove_fixture_isolation(roots)
1153
+ policy_path = args.policy or Path(__file__).with_name("ldm-backup-policy.json")
1154
+ policy = load_policy(policy_path)
1155
+ prove_policy_fixture_isolation(policy, roots, fixture)
1156
+ if args.include_secrets:
1157
+ raise BackupError("encryption-not-implemented", "--include-secrets is blocked until artifact encryption ships")
1158
+ if args.dry_run:
1159
+ run_id = snapshot_id()
1160
+ started_at = utc_now()
1161
+ plan = build_plan(policy, roots, run_id, started_at)
1162
+ plan["dry_run"] = True
1163
+ print_plan(plan, args.json)
1164
+ return 0 if plan["preflight_allowed"] else 1
1165
+ config = load_config(roots.ldm)
1166
+ keep = args.keep if args.keep is not None else int(config.get("backup", {}).get("keep", 7))
1167
+ if keep < 1:
1168
+ raise BackupError("keep-invalid", "keep must be one or greater")
1169
+ return run_backup(policy, roots, keep)
1170
+ except BackupError as exc:
1171
+ print(f"Backup failed [{exc.code}]: {exc}", file=sys.stderr)
1172
+ return 1
1173
+
1174
+
1175
+ if __name__ == "__main__":
1176
+ raise SystemExit(main())