@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,609 @@
1
+ #!/usr/bin/env python3
2
+ """Disposable fixture suite for the Slice 2 backup engine."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import socket
10
+ import sqlite3
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+ from pathlib import Path
16
+
17
+
18
+ REPO = Path(__file__).resolve().parent.parent
19
+ RUNNER = REPO / "scripts/ldm-backup.sh"
20
+ ENGINE = REPO / "scripts/ldm-backup-engine.py"
21
+ PRODUCTION_POLICY = REPO / "scripts/ldm-backup-policy.json"
22
+ # Exact IDs derived from the controlling Slice 1 source-inventory tables.
23
+ SLICE1_POLICY_IDS = {
24
+ "ldm-crystal", "ldm-agents", "ldm-shared", "ldm-hooks", "ldm-library",
25
+ "ldm-templates", "ldm-catalog", "ldm-change-dependencies",
26
+ "ldm-config-dependencies", "ldm-doc-dependencies", "ldm-extension-registry",
27
+ "ldm-config", "ldm-state", "ldm-secrets", "ldm-sessions", "ldm-messages",
28
+ "ldm-local-git", "ldm-legacy-backups", "ldm-trash", "ldm-logs",
29
+ "ldm-runtime-bin", "ldm-transient-runtime", "openclaw-memory-main",
30
+ "openclaw-context-embeddings", "openclaw-workspace", "openclaw-agents",
31
+ "openclaw-config", "openclaw-credentials", "openclaw-secrets",
32
+ "openclaw-identity", "openclaw-devices", "openclaw-state",
33
+ "openclaw-legacy-sqlite", "openclaw-approval-state", "openclaw-cron",
34
+ "openclaw-tasks", "openclaw-subagents", "openclaw-delivery-queue",
35
+ "openclaw-plugin-mutable-data", "openclaw-media", "openclaw-local-git",
36
+ "openclaw-installed-code", "openclaw-transient-runtime",
37
+ "openclaw-legacy-artifacts", "claude-projects", "claude-history",
38
+ "claude-file-history", "claude-settings", "claude-sessions", "claude-plans",
39
+ "claude-tasks", "claude-jobs", "claude-session-environment",
40
+ "claude-primary-instructions", "claude-repo-instructions", "claude-readme",
41
+ "claude-mcp-config", "claude-rules", "claude-skills",
42
+ "claude-plugin-manifests", "claude-local-git", "claude-transient-runtime",
43
+ "claude-backups", "codex-sessions", "codex-session-index", "codex-history",
44
+ "codex-memories", "codex-memory-index", "codex-config", "codex-auth",
45
+ "codex-agents-instructions", "codex-rules", "codex-skills", "codex-plugins",
46
+ "codex-global-state", "codex-state-databases", "codex-goals-databases",
47
+ "codex-logs-databases", "codex-pets", "codex-generated-artifacts",
48
+ "codex-transient-runtime", "codex-local-git", "home-ssh", "home-gnupg",
49
+ "home-config", "home-npmrc", "home-gitconfig", "home-zshrc",
50
+ "home-keychains", "launchagent-backup", "launchagent-healthcheck",
51
+ "launchagent-gateway", "workspace-local-state", "workspace-repo-inventory",
52
+ "workspace-remote-tracked", "workspace-uncommitted-state",
53
+ "workspace-worktrees", "workspace-dependency-caches",
54
+ "workspace-compiled-output", "workspace-third-party-clones",
55
+ "workspace-temp-roots", "workspace-trash", "workspace-root-git",
56
+ "system-installed-versions", "system-macos-manifest",
57
+ "system-onepassword-bootstrap", "system-package-inventories",
58
+ "system-reconstruction-runbook",
59
+ }
60
+
61
+
62
+ def json_bytes(value):
63
+ return (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
64
+
65
+
66
+ class Fixture:
67
+ def __init__(self):
68
+ self.temp = tempfile.TemporaryDirectory(prefix="ldm-backup-fixture-")
69
+ self.root = Path(self.temp.name).resolve()
70
+ self.home = self.root / "home"
71
+ self.ldm = self.root / "ldm"
72
+ self.openclaw = self.root / "openclaw"
73
+ self.claude = self.root / "claude"
74
+ self.codex = self.root / "codex"
75
+ self.workspace = self.root / "workspace"
76
+ self.backup = self.root / "backup"
77
+ self.state = self.root / "state"
78
+ self.icloud = self.root / "icloud"
79
+ self.lock = self.root / "locks/backup.lock"
80
+ for path in (self.home, self.ldm, self.openclaw, self.claude, self.codex, self.workspace, self.backup, self.state, self.icloud, self.lock.parent):
81
+ path.mkdir(parents=True, exist_ok=True)
82
+ (self.ldm / "safe.txt").write_text("ldm fixture\n", encoding="utf-8")
83
+ (self.openclaw / "data").mkdir()
84
+ (self.openclaw / "data/keep.txt").write_text("openclaw fixture\n", encoding="utf-8")
85
+ (self.openclaw / "data/ignored.tmp").write_text("ignored\n", encoding="utf-8")
86
+ self.sqlite_path = self.codex / "state.db"
87
+ db = sqlite3.connect(self.sqlite_path)
88
+ db.execute("CREATE TABLE facts(value TEXT)")
89
+ db.execute("INSERT INTO facts VALUES ('fixture')")
90
+ db.commit()
91
+ db.close()
92
+ self.policy_path = self.root / "policy.json"
93
+ self.policy = {
94
+ "schema_version": 1,
95
+ "rule_version": "fixture-v1",
96
+ "evidence_status_values": ["complete", "deferred-after-write-failure"],
97
+ "disk_floors": {"warning_percent": 15, "critical_percent": 10, "emergency_percent": 5, "start_reserve_percent": 10},
98
+ "sources": [
99
+ {
100
+ "id": "fixture-file", "root": "LDM_HOME", "path": "safe.txt",
101
+ "display_path": "$LDM_HOME/safe.txt", "destination": "ldm/safe.txt",
102
+ "type": "file", "classification": ["required"], "required": True,
103
+ "action": "capture", "exclusions": [],
104
+ },
105
+ {
106
+ "id": "fixture-tree", "root": "OPENCLAW_HOME", "path": "data",
107
+ "display_path": "$OPENCLAW_HOME/data/", "destination": "openclaw/data",
108
+ "type": "tree", "classification": ["required"], "required": True,
109
+ "action": "capture", "exclusions": [
110
+ {"pattern": "*.tmp", "reason": "fixture transient", "reconstruction_source": "fixture generator", "rule_version": "fixture-v1"}
111
+ ],
112
+ },
113
+ {
114
+ "id": "fixture-sqlite", "root": "CODEX_HOME", "path": "state.db",
115
+ "display_path": "$CODEX_HOME/state.db", "destination": "codex/state.db",
116
+ "type": "sqlite", "classification": ["required"], "required": True,
117
+ "action": "capture", "exclusions": [],
118
+ },
119
+ ],
120
+ }
121
+ self.write_policy()
122
+
123
+ def write_policy(self):
124
+ self.policy_path.write_text(json.dumps(self.policy, indent=2) + "\n", encoding="utf-8")
125
+
126
+ def env(self, **extra):
127
+ values = os.environ.copy()
128
+ values.update({
129
+ "LDM_BACKUP_REQUIRE_FIXTURE": "1",
130
+ "LDM_BACKUP_FIXTURE_ROOT": str(self.root),
131
+ "LDM_BACKUP_HOME": str(self.home),
132
+ "LDM_BACKUP_LDM_HOME": str(self.ldm),
133
+ "LDM_BACKUP_OPENCLAW_HOME": str(self.openclaw),
134
+ "LDM_BACKUP_CLAUDE_HOME": str(self.claude),
135
+ "LDM_BACKUP_CODEX_HOME": str(self.codex),
136
+ "LDM_BACKUP_WORKSPACE": str(self.workspace),
137
+ "LDM_BACKUP_DESTINATION": str(self.backup),
138
+ "LDM_BACKUP_STATE_DIR": str(self.state),
139
+ "LDM_BACKUP_ICLOUD_SUBSTITUTE": str(self.icloud),
140
+ "LDM_BACKUP_LOCK_PATH": str(self.lock),
141
+ "LDM_BACKUP_ENGINE": str(ENGINE),
142
+ "LDM_BACKUP_POLICY": str(self.policy_path),
143
+ "LDM_BACKUP_TEST_TOTAL_BYTES": str(10_000_000),
144
+ "LDM_BACKUP_TEST_AVAILABLE_BYTES": str(9_000_000),
145
+ "PYTHONDONTWRITEBYTECODE": "1",
146
+ })
147
+ values.update({key: str(value) for key, value in extra.items()})
148
+ return values
149
+
150
+ def run(self, *args, env=None):
151
+ return subprocess.run([str(RUNNER), *args], env=env or self.env(), text=True, capture_output=True)
152
+
153
+ def dry_plan(self):
154
+ result = self.run("--dry-run", "--json")
155
+ if result.returncode != 0:
156
+ raise AssertionError(result.stderr + result.stdout)
157
+ return json.loads(result.stdout)
158
+
159
+ def snapshots(self):
160
+ return sorted(path for path in self.backup.iterdir() if path.is_dir() and not path.name.startswith(".in-progress-"))
161
+
162
+ def close(self):
163
+ self.temp.cleanup()
164
+
165
+
166
+ class BackupEngineTests(unittest.TestCase):
167
+ def setUp(self):
168
+ self.fixture = Fixture()
169
+
170
+ def tearDown(self):
171
+ self.fixture.close()
172
+
173
+ def assert_failed_without_success(self, result):
174
+ self.assertNotEqual(result.returncode, 0)
175
+ self.assertNotIn("Backup complete", result.stdout)
176
+ self.assertFalse(any((path / "completion.json").exists() for path in self.fixture.backup.glob(".in-progress-*")))
177
+
178
+ def test_successful_snapshot_and_sqlite_quick_check(self):
179
+ result = self.fixture.run()
180
+ self.assertEqual(result.returncode, 0, result.stderr)
181
+ snapshot = self.fixture.snapshots()[0]
182
+ self.assertTrue((snapshot / "completion.json").is_file())
183
+ db = sqlite3.connect(snapshot / "codex/state.db")
184
+ self.assertEqual(db.execute("PRAGMA quick_check").fetchone()[0], "ok")
185
+ db.close()
186
+
187
+ def test_production_policy_exactly_matches_slice1_inventory_ids(self):
188
+ policy = json.loads(PRODUCTION_POLICY.read_text())
189
+ self.assertEqual(set(policy["evidence_status_values"]), {"complete", "deferred-after-write-failure"})
190
+ ids = {source["id"] for source in policy["sources"]}
191
+ self.assertEqual(ids, SLICE1_POLICY_IDS)
192
+ for source in policy["sources"]:
193
+ if source["required"] and source["action"] == "manifest":
194
+ self.assertEqual(source.get("manifest_generator"), "path-inventory", source["id"])
195
+ sources = {source["id"]: source for source in policy["sources"]}
196
+ for source_id in (
197
+ "ldm-local-git",
198
+ "openclaw-local-git",
199
+ "claude-local-git",
200
+ "system-macos-manifest",
201
+ "system-onepassword-bootstrap",
202
+ "system-package-inventories",
203
+ ):
204
+ self.assertEqual(sources[source_id]["action"], "blocked")
205
+
206
+ def test_package_deploys_runner_engine_and_policy(self):
207
+ package = json.loads((REPO / "package.json").read_text())
208
+ entries = {entry["name"]: entry for entry in package["wipLdmOs"]["binFiles"]}
209
+ self.assertEqual(entries["ldm-backup.sh"]["source"], "scripts/ldm-backup.sh")
210
+ self.assertEqual(entries["ldm-backup-engine.py"]["source"], "scripts/ldm-backup-engine.py")
211
+ self.assertEqual(entries["ldm-backup-policy.json"]["source"], "scripts/ldm-backup-policy.json")
212
+ self.assertFalse(entries["ldm-backup-policy.json"]["executable"])
213
+
214
+ def test_production_policy_plans_inside_fixture_without_writes(self):
215
+ env = self.fixture.env(LDM_BACKUP_POLICY=PRODUCTION_POLICY)
216
+ result = self.fixture.run("--dry-run", "--json", env=env)
217
+ self.assertNotEqual(result.returncode, 0)
218
+ plan = json.loads(result.stdout)
219
+ self.assertEqual(plan["policy_rule_version"], "slice1-2026-07-11")
220
+ self.assertGreater(len(plan["sources"]), 30)
221
+ self.assertFalse(list(self.fixture.state.iterdir()))
222
+ self.assertFalse(self.fixture.lock.exists())
223
+
224
+ def test_concurrent_run_lockout(self):
225
+ self.fixture.lock.write_text(json.dumps({"pid": os.getpid()}), encoding="utf-8")
226
+ result = self.fixture.run()
227
+ self.assert_failed_without_success(result)
228
+ self.assertIn("concurrent-run", result.stderr)
229
+
230
+ def test_stale_lock_is_reported_and_not_stolen(self):
231
+ self.fixture.lock.write_text(json.dumps({"pid": 99999999}), encoding="utf-8")
232
+ result = self.fixture.run()
233
+ self.assert_failed_without_success(result)
234
+ self.assertIn("stale-lock", result.stderr)
235
+ self.assertTrue(self.fixture.lock.exists())
236
+
237
+ def test_required_source_missing(self):
238
+ (self.fixture.ldm / "safe.txt").unlink()
239
+ result = self.fixture.run()
240
+ self.assert_failed_without_success(result)
241
+ state = json.loads((self.fixture.state / "result.json").read_text())
242
+ self.assertEqual(state["failure_code"], "preflight-failed")
243
+
244
+ def add_required_manifest_source(self, create=True):
245
+ path = self.fixture.ldm / "git-state"
246
+ if create:
247
+ path.mkdir()
248
+ (path / "HEAD").write_text("ref: refs/heads/main\n", encoding="utf-8")
249
+ self.fixture.policy["sources"].append({
250
+ "id": "fixture-manifest", "root": "LDM_HOME", "path": "git-state",
251
+ "display_path": "$LDM_HOME/git-state/", "destination": None, "type": "tree",
252
+ "classification": ["required"], "required": True, "action": "manifest",
253
+ "manifest_generator": "path-inventory", "exclusions": [],
254
+ })
255
+ self.fixture.write_policy()
256
+
257
+ def test_missing_required_manifest_evidence_fails_preflight(self):
258
+ self.add_required_manifest_source(create=False)
259
+ result = self.fixture.run()
260
+ self.assert_failed_without_success(result)
261
+ self.assertIn("preflight-failed", result.stderr)
262
+ self.assertFalse(list(self.fixture.backup.glob(".in-progress-*")))
263
+
264
+ def test_failed_manifest_generation_cannot_complete(self):
265
+ self.add_required_manifest_source()
266
+ result = self.fixture.run(env=self.fixture.env(
267
+ LDM_BACKUP_TEST_MANIFEST_FAIL_ID="fixture-manifest",
268
+ ))
269
+ self.assert_failed_without_success(result)
270
+ self.assertIn("manifest-evidence-failed", result.stderr)
271
+ staging = list(self.fixture.backup.glob(".in-progress-*"))[0]
272
+ self.assertFalse((staging / "completion.json").exists())
273
+
274
+ def test_valid_manifest_action_produces_machine_readable_evidence(self):
275
+ self.add_required_manifest_source()
276
+ result = self.fixture.run()
277
+ self.assertEqual(result.returncode, 0, result.stderr)
278
+ manifest = json.loads((self.fixture.snapshots()[0] / "manifest.json").read_text())
279
+ evidence = next(item for item in manifest["manifest_evidence"] if item["source_id"] == "fixture-manifest")
280
+ self.assertEqual(evidence["generator"], "path-inventory")
281
+ self.assertEqual(evidence["status"], "valid")
282
+ self.assertGreaterEqual(evidence["member_count"], 2)
283
+ self.assertEqual(
284
+ evidence["inventory_sha256"],
285
+ hashlib.sha256(json_bytes(evidence["members"])).hexdigest(),
286
+ )
287
+
288
+ def test_completion_validation_requires_manifest_evidence(self):
289
+ self.add_required_manifest_source()
290
+ result = self.fixture.run(env=self.fixture.env(
291
+ LDM_BACKUP_TEST_DROP_MANIFEST_EVIDENCE_ID="fixture-manifest",
292
+ ))
293
+ self.assert_failed_without_success(result)
294
+ self.assertIn("manifest-required-evidence-missing", result.stderr)
295
+
296
+ def test_generatorless_manifest_tree_with_socket_short_circuits_before_walk(self):
297
+ socket_path = self.fixture.home / "s"
298
+ listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
299
+ listener.bind(str(socket_path))
300
+ try:
301
+ self.fixture.policy["sources"].append({
302
+ "id": "fixture-generatorless-manifest",
303
+ "root": "HOME",
304
+ "path": ".",
305
+ "display_path": "$HOME/",
306
+ "destination": None,
307
+ "type": "tree",
308
+ "classification": ["rebuildable"],
309
+ "required": False,
310
+ "action": "manifest",
311
+ "exclusions": [],
312
+ })
313
+ self.fixture.write_policy()
314
+ dry_run = self.fixture.run("--dry-run", "--json")
315
+ self.assertEqual(dry_run.returncode, 0, dry_run.stderr)
316
+ plan = json.loads(dry_run.stdout)
317
+ source = next(
318
+ item for item in plan["sources"]
319
+ if item["id"] == "fixture-generatorless-manifest"
320
+ )
321
+ self.assertEqual(source["status"], "blocked")
322
+ self.assertEqual(source["block_reason"], "manifest generator is not implemented")
323
+ result = self.fixture.run()
324
+ self.assertEqual(result.returncode, 0, result.stderr)
325
+ manifest = json.loads((self.fixture.snapshots()[0] / "manifest.json").read_text())
326
+ exclusion = next(
327
+ item for item in manifest["exclusions"]
328
+ if item["source_id"] == "fixture-generatorless-manifest"
329
+ )
330
+ self.assertEqual(exclusion["reason"], "manifest generator is not implemented")
331
+ finally:
332
+ listener.close()
333
+
334
+ def test_blocked_required_source_fails_dry_run_without_secret_output(self):
335
+ secret = "do-not-print-this-secret"
336
+ (self.fixture.ldm / "secret-token.txt").write_text(secret, encoding="utf-8")
337
+ self.fixture.policy["sources"].append({
338
+ "id": "fixture-secret", "root": "LDM_HOME", "path": "secret-token.txt",
339
+ "display_path": "$LDM_HOME/[sensitive]", "destination": None, "type": "file",
340
+ "classification": ["required", "sensitive", "blocked"], "required": True,
341
+ "action": "blocked", "block_reason": "encryption required", "exclusions": [],
342
+ })
343
+ self.fixture.write_policy()
344
+ result = self.fixture.run("--dry-run")
345
+ self.assert_failed_without_success(result)
346
+ self.assertNotIn(secret, result.stdout + result.stderr)
347
+ self.assertFalse(self.fixture.snapshots())
348
+
349
+ def test_sqlite_validation_failure(self):
350
+ result = self.fixture.run(env=self.fixture.env(LDM_BACKUP_TEST_SQLITE_FAIL_ID="fixture-sqlite"))
351
+ self.assert_failed_without_success(result)
352
+ self.assertIn("sqlite-validation-failed", result.stderr)
353
+
354
+ def test_disk_full_during_capture(self):
355
+ result = self.fixture.run(env=self.fixture.env(LDM_BACKUP_TEST_FAIL_AFTER_BYTES=1))
356
+ self.assert_failed_without_success(result)
357
+ self.assertTrue(list(self.fixture.backup.glob(".in-progress-*")))
358
+ self.assertFalse(self.fixture.snapshots())
359
+
360
+ def test_preflight_failure_happens_before_staging(self):
361
+ result = self.fixture.run(env=self.fixture.env(LDM_BACKUP_TEST_TOTAL_BYTES=1000, LDM_BACKUP_TEST_AVAILABLE_BYTES=100))
362
+ self.assert_failed_without_success(result)
363
+ self.assertFalse(list(self.fixture.backup.glob(".in-progress-*")))
364
+ self.assertTrue((self.fixture.state / "attempt.json").is_file())
365
+
366
+ def test_dry_run_destination_failure_is_nonzero_and_read_only(self):
367
+ blocked_parent = self.fixture.root / "destination-is-a-file"
368
+ blocked_parent.write_text("not a directory\n", encoding="utf-8")
369
+ env = self.fixture.env(LDM_BACKUP_DESTINATION=blocked_parent / "backup")
370
+ result = self.fixture.run("--dry-run", "--json", env=env)
371
+ self.assertNotEqual(result.returncode, 0)
372
+ plan = json.loads(result.stdout)
373
+ self.assertIn("destination-unavailable", plan["blocking_reasons"])
374
+ self.assertFalse(list(self.fixture.state.iterdir()))
375
+
376
+ def test_crash_during_staging_never_promotes(self):
377
+ result = self.fixture.run(env=self.fixture.env(LDM_BACKUP_TEST_CRASH_AFTER_FILES=1))
378
+ self.assertEqual(result.returncode, 88)
379
+ self.assertTrue(list(self.fixture.backup.glob(".in-progress-*")))
380
+ self.assertFalse(self.fixture.snapshots())
381
+
382
+ def make_verified_snapshot(self, name, pinned=False):
383
+ path = self.fixture.backup / name
384
+ path.mkdir()
385
+ manifest = {"terminal_result": "success"}
386
+ (path / "manifest.json").write_bytes(json_bytes(manifest))
387
+ marker = {"status": "complete", "manifest_sha256": hashlib.sha256((path / "manifest.json").read_bytes()).hexdigest()}
388
+ (path / "completion.json").write_bytes(json_bytes(marker))
389
+ if pinned:
390
+ (path / ".pinned").write_text("keep\n", encoding="utf-8")
391
+ return path
392
+
393
+ def test_pinned_snapshot_does_not_consume_retention_slot(self):
394
+ pinned = self.make_verified_snapshot("2026-01-01--00-00-00", pinned=True)
395
+ old1 = self.make_verified_snapshot("2026-01-02--00-00-00")
396
+ old2 = self.make_verified_snapshot("2026-01-03--00-00-00")
397
+ self.make_verified_snapshot("2026-01-04--00-00-00")
398
+ unverified = self.fixture.backup / "2026-01-05--00-00-00"
399
+ unverified.mkdir()
400
+ result = self.fixture.run("--keep", "2")
401
+ self.assertEqual(result.returncode, 0, result.stderr)
402
+ self.assertTrue(pinned.exists())
403
+ self.assertFalse(old1.exists())
404
+ self.assertFalse(old2.exists())
405
+ self.assertTrue(unverified.exists())
406
+
407
+ def test_retention_requires_at_least_one_unpinned_slot(self):
408
+ result = self.fixture.run("--keep", "0")
409
+ self.assert_failed_without_success(result)
410
+ self.assertIn("keep-invalid", result.stderr)
411
+
412
+ def test_estimate_matches_captured_policy_payload(self):
413
+ plan = self.fixture.dry_plan()
414
+ result = self.fixture.run()
415
+ self.assertEqual(result.returncode, 0, result.stderr)
416
+ snapshot = self.fixture.snapshots()[0]
417
+ manifest = json.loads((snapshot / "manifest.json").read_text())
418
+ captured = sum(item["size"] for item in manifest["sources"])
419
+ self.assertEqual(plan["estimated_payload_bytes"], captured)
420
+ logical_snapshot_bytes = sum(path.stat().st_size for path in snapshot.rglob("*") if path.is_file())
421
+ self.assertEqual(plan["estimated_new_bytes"], logical_snapshot_bytes)
422
+
423
+ def test_size_change_before_capture_is_recorded_and_captured_consistently(self):
424
+ result = self.fixture.run(env=self.fixture.env(
425
+ LDM_BACKUP_TEST_MUTATE_SOURCE_ID="fixture-file",
426
+ LDM_BACKUP_TEST_MUTATE_MODE="size",
427
+ ))
428
+ self.assertEqual(result.returncode, 0, result.stderr)
429
+ manifest = json.loads((self.fixture.snapshots()[0] / "manifest.json").read_text())
430
+ record = next(item for item in manifest["sources"] if item["source_id"] == "fixture-file")
431
+ observation = next(item for item in manifest["source_observations"] if item["source_id"] == "fixture-file")
432
+ self.assertTrue(record["changed_since_plan"])
433
+ self.assertTrue(observation["changed_since_plan"])
434
+ self.assertNotEqual(record["planned_size"], record["size"])
435
+ self.assertNotEqual(observation["planned_payload_bytes"], observation["capture_payload_bytes"])
436
+ self.assertEqual(record["consistency_contract"], "capture-time-stable")
437
+
438
+ def configure_single_file_reserve_fixture(self):
439
+ (self.fixture.ldm / "safe.txt").write_bytes(b"a")
440
+ self.fixture.policy["sources"] = [self.fixture.policy["sources"][0]]
441
+ self.fixture.write_policy()
442
+ plan = self.fixture.dry_plan()
443
+ total = 1_000_000
444
+ required_reserve = total // 10
445
+ return total, required_reserve, plan["estimated_new_bytes"]
446
+
447
+ def test_source_growth_within_updated_reserve_succeeds(self):
448
+ total, required_reserve, planned_new = self.configure_single_file_reserve_fixture()
449
+ available = planned_new + required_reserve + 1000
450
+ result = self.fixture.run(env=self.fixture.env(
451
+ LDM_BACKUP_TEST_TOTAL_BYTES=total,
452
+ LDM_BACKUP_TEST_AVAILABLE_BYTES=available,
453
+ LDM_BACKUP_TEST_MUTATE_SOURCE_ID="fixture-file",
454
+ LDM_BACKUP_TEST_MUTATE_MODE="size",
455
+ ))
456
+ self.assertEqual(result.returncode, 0, result.stderr)
457
+ manifest = json.loads((self.fixture.snapshots()[0] / "manifest.json").read_text())
458
+ self.assertEqual(manifest["captured_payload_bytes"], 9)
459
+
460
+ def test_source_growth_crossing_reserve_fails_before_copy(self):
461
+ total, required_reserve, planned_new = self.configure_single_file_reserve_fixture()
462
+ available = planned_new + required_reserve + 4
463
+ result = self.fixture.run(env=self.fixture.env(
464
+ LDM_BACKUP_TEST_TOTAL_BYTES=total,
465
+ LDM_BACKUP_TEST_AVAILABLE_BYTES=available,
466
+ LDM_BACKUP_TEST_MUTATE_SOURCE_ID="fixture-file",
467
+ LDM_BACKUP_TEST_MUTATE_MODE="size",
468
+ ))
469
+ self.assert_failed_without_success(result)
470
+ self.assertIn("capture-reserve-gate", result.stderr)
471
+ staging = list(self.fixture.backup.glob(".in-progress-*"))[0]
472
+ self.assertFalse((staging / "ldm/safe.txt").exists())
473
+ state = json.loads((self.fixture.state / "result.json").read_text())
474
+ self.assertEqual(state["failure_code"], "capture-reserve-gate")
475
+ self.assertEqual(state["estimated_payload_bytes"], 9)
476
+ self.assertLess(state["reserve_after_bytes"], state["required_reserve_bytes"])
477
+
478
+ def test_same_size_change_before_capture_is_recorded_by_checksum(self):
479
+ result = self.fixture.run(env=self.fixture.env(
480
+ LDM_BACKUP_TEST_MUTATE_SOURCE_ID="fixture-file",
481
+ LDM_BACKUP_TEST_MUTATE_MODE="same-size",
482
+ ))
483
+ self.assertEqual(result.returncode, 0, result.stderr)
484
+ manifest = json.loads((self.fixture.snapshots()[0] / "manifest.json").read_text())
485
+ record = next(item for item in manifest["sources"] if item["source_id"] == "fixture-file")
486
+ observation = next(item for item in manifest["source_observations"] if item["source_id"] == "fixture-file")
487
+ self.assertTrue(record["changed_since_plan"])
488
+ self.assertTrue(observation["changed_since_plan"])
489
+ self.assertEqual(record["planned_size"], record["size"])
490
+ self.assertNotEqual(record["planned_checksum_sha256"], record["checksum_sha256"])
491
+ self.assertNotEqual(observation["planned_inventory_sha256"], observation["capture_inventory_sha256"])
492
+
493
+ def test_change_during_capture_is_fatal(self):
494
+ result = self.fixture.run(env=self.fixture.env(
495
+ LDM_BACKUP_TEST_MUTATE_SOURCE_ID="fixture-file",
496
+ LDM_BACKUP_TEST_MUTATE_PHASE="during-capture",
497
+ LDM_BACKUP_TEST_MUTATE_MODE="same-size",
498
+ ))
499
+ self.assert_failed_without_success(result)
500
+ self.assertIn("source-changed-during-capture", result.stderr)
501
+
502
+ def test_completion_marker_missing_is_visible_failure(self):
503
+ result = self.fixture.run()
504
+ self.assertEqual(result.returncode, 0, result.stderr)
505
+ snapshot = self.fixture.snapshots()[0]
506
+ (snapshot / "completion.json").unlink()
507
+ verify = subprocess.run([sys.executable, str(ENGINE), "--verify-snapshot", str(snapshot)], text=True, capture_output=True)
508
+ self.assertNotEqual(verify.returncode, 0)
509
+
510
+ def test_completion_marker_is_written_only_after_all_gates(self):
511
+ result = self.fixture.run(env=self.fixture.env(LDM_BACKUP_TEST_FAIL_BEFORE_COMPLETION=1))
512
+ self.assert_failed_without_success(result)
513
+ staging = list(self.fixture.backup.glob(".in-progress-*"))[0]
514
+ self.assertTrue((staging / "manifest.json").is_file())
515
+ self.assertFalse((staging / "completion.json").exists())
516
+ self.assertFalse(self.fixture.snapshots())
517
+
518
+ def test_state_files_are_atomic_and_valid(self):
519
+ result = self.fixture.run()
520
+ self.assertEqual(result.returncode, 0, result.stderr)
521
+ for name in ("schedule.json", "attempt.json", "result.json", "success-local.json"):
522
+ json.loads((self.fixture.state / name).read_text())
523
+ self.assertFalse(list(self.fixture.state.rglob("*.tmp")))
524
+
525
+ def test_evidence_status_enum_rejects_placeholder(self):
526
+ self.fixture.policy["evidence_status_values"] = ["complete-or-deferred-after-write-failure"]
527
+ self.fixture.write_policy()
528
+ result = self.fixture.run("--dry-run")
529
+ self.assert_failed_without_success(result)
530
+ self.assertIn("policy-enum-invalid", result.stderr)
531
+
532
+ def test_runtime_evidence_status_enum_is_explicit(self):
533
+ (self.fixture.state / "containment.json").write_text(
534
+ json.dumps({"evidence_status": "complete-or-deferred-after-write-failure"}),
535
+ encoding="utf-8",
536
+ )
537
+ result = self.fixture.run()
538
+ self.assert_failed_without_success(result)
539
+ self.assertIn("state-enum-invalid", result.stderr)
540
+
541
+ def test_schedule_evidence_status_is_unknown_without_marker(self):
542
+ result = self.fixture.run()
543
+ self.assertEqual(result.returncode, 0, result.stderr)
544
+ schedule = json.loads((self.fixture.state / "schedule.json").read_text())
545
+ self.assertEqual(schedule["evidence_status"], "unknown")
546
+
547
+ def test_corrupt_config_is_fatal(self):
548
+ (self.fixture.ldm / "config.json").write_text("{not-json", encoding="utf-8")
549
+ result = self.fixture.run("--dry-run")
550
+ self.assert_failed_without_success(result)
551
+ self.assertIn("Backup failed [config-invalid]", result.stderr)
552
+ self.assertNotIn("Traceback", result.stderr)
553
+
554
+ def test_null_config_paths_is_fatal(self):
555
+ (self.fixture.ldm / "config.json").write_text('{"paths": null}\n', encoding="utf-8")
556
+ result = self.fixture.run("--dry-run")
557
+ self.assert_failed_without_success(result)
558
+ self.assertIn("config-invalid", result.stderr)
559
+
560
+ def test_snapshot_identifiers_use_utc(self):
561
+ plan = self.fixture.dry_plan()
562
+ self.assertRegex(plan["snapshot_id"], r"^\d{4}-\d{2}-\d{2}--\d{2}-\d{2}-\d{2}Z$")
563
+
564
+ def test_runner_has_structured_python3_guard(self):
565
+ runner = RUNNER.read_text(encoding="utf-8")
566
+ self.assertIn("command -v python3", runner)
567
+ self.assertIn("Backup failed [python3-missing]", runner)
568
+
569
+ def test_dry_run_is_read_only_and_matches_real_plan(self):
570
+ before = sorted(str(path.relative_to(self.fixture.root)) for path in self.fixture.root.rglob("*"))
571
+ plan = self.fixture.dry_plan()
572
+ after = sorted(str(path.relative_to(self.fixture.root)) for path in self.fixture.root.rglob("*"))
573
+ self.assertEqual(before, after)
574
+ self.assertFalse(self.fixture.lock.exists())
575
+ self.assertFalse(list(self.fixture.state.iterdir()))
576
+ result = self.fixture.run()
577
+ self.assertEqual(result.returncode, 0, result.stderr)
578
+ manifest = json.loads((self.fixture.snapshots()[0] / "manifest.json").read_text())
579
+ self.assertEqual(plan["estimated_payload_bytes"], sum(item["size"] for item in manifest["sources"]))
580
+
581
+ def test_fixture_path_escape_aborts_before_writes(self):
582
+ env = self.fixture.env(LDM_BACKUP_WORKSPACE="/tmp/not-inside-fixture")
583
+ result = self.fixture.run("--dry-run", env=env)
584
+ self.assert_failed_without_success(result)
585
+ self.assertIn("fixture-path-escape", result.stderr)
586
+ self.assertFalse(list(self.fixture.state.iterdir()))
587
+
588
+ def test_fixture_source_symlink_escape_aborts_before_writes(self):
589
+ outside = Path("/tmp")
590
+ (self.fixture.ldm / "escape").symlink_to(outside, target_is_directory=True)
591
+ self.fixture.policy["sources"][0]["path"] = "escape"
592
+ self.fixture.policy["sources"][0]["type"] = "tree"
593
+ self.fixture.write_policy()
594
+ result = self.fixture.run("--dry-run")
595
+ self.assert_failed_without_success(result)
596
+ self.assertIn("fixture-source-escape", result.stderr)
597
+ self.assertFalse(list(self.fixture.state.iterdir()))
598
+
599
+ def test_policy_destination_traversal_is_rejected(self):
600
+ self.fixture.policy["sources"][0]["destination"] = "../escape"
601
+ self.fixture.write_policy()
602
+ result = self.fixture.run("--dry-run")
603
+ self.assert_failed_without_success(result)
604
+ self.assertIn("policy-invalid", result.stderr)
605
+ self.assertFalse(list(self.fixture.state.iterdir()))
606
+
607
+
608
+ if __name__ == "__main__":
609
+ unittest.main(verbosity=2)