worktree-controller 1.30.4__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.
Files changed (61) hide show
  1. worktree_controller/__init__.py +3 -0
  2. worktree_controller/activation_layout.py +483 -0
  3. worktree_controller/branch_retirement.py +1865 -0
  4. worktree_controller/canonical_refresh.py +150 -0
  5. worktree_controller/canonical_transition.py +324 -0
  6. worktree_controller/cleanup_attempts.py +442 -0
  7. worktree_controller/cleanup_evidence.py +75 -0
  8. worktree_controller/cleanup_recovery.py +912 -0
  9. worktree_controller/cli.py +1907 -0
  10. worktree_controller/controller.py +1974 -0
  11. worktree_controller/convergence.py +893 -0
  12. worktree_controller/delivered_closeout.py +928 -0
  13. worktree_controller/disposable.py +677 -0
  14. worktree_controller/environment.py +1038 -0
  15. worktree_controller/frozen_recovery.py +291 -0
  16. worktree_controller/git_backend.py +682 -0
  17. worktree_controller/governance.py +365 -0
  18. worktree_controller/hardlinks.py +294 -0
  19. worktree_controller/integration_cleanup.py +92 -0
  20. worktree_controller/integration_conflict.py +117 -0
  21. worktree_controller/integration_retry.py +526 -0
  22. worktree_controller/keyed_document_scope.py +162 -0
  23. worktree_controller/lifecycle.py +8992 -0
  24. worktree_controller/lifecycle_history.py +358 -0
  25. worktree_controller/lifecycle_observation.py +234 -0
  26. worktree_controller/metadata_codec.py +1694 -0
  27. worktree_controller/metadata_relocation.py +131 -0
  28. worktree_controller/models.py +546 -0
  29. worktree_controller/mount_diagnostics.py +324 -0
  30. worktree_controller/multiple_cleanup_recovery.py +730 -0
  31. worktree_controller/namespace_migration.py +1412 -0
  32. worktree_controller/native_session_recovery.py +171 -0
  33. worktree_controller/owner_actions.py +34 -0
  34. worktree_controller/policy.py +333 -0
  35. worktree_controller/preservation.py +983 -0
  36. worktree_controller/preserved_cleanup.py +849 -0
  37. worktree_controller/publication_execution.py +361 -0
  38. worktree_controller/reconciler.py +430 -0
  39. worktree_controller/recovery_guidance.py +205 -0
  40. worktree_controller/registration_backfill.py +166 -0
  41. worktree_controller/registration_prune.py +391 -0
  42. worktree_controller/rendering.py +179 -0
  43. worktree_controller/resource_admission.py +1736 -0
  44. worktree_controller/retirement_adapters.py +130 -0
  45. worktree_controller/retirement_execution.py +234 -0
  46. worktree_controller/semantics.py +459 -0
  47. worktree_controller/session.py +505 -0
  48. worktree_controller/session_admission.py +265 -0
  49. worktree_controller/source_cleanup.py +515 -0
  50. worktree_controller/source_preservation.py +692 -0
  51. worktree_controller/source_recovery.py +592 -0
  52. worktree_controller/store.py +1228 -0
  53. worktree_controller/tool_cli_contract.json +1791 -0
  54. worktree_controller/transaction_scope.py +876 -0
  55. worktree_controller/writer_lease.py +662 -0
  56. worktree_controller/writer_session.py +887 -0
  57. worktree_controller-1.30.4.dist-info/METADATA +3149 -0
  58. worktree_controller-1.30.4.dist-info/RECORD +61 -0
  59. worktree_controller-1.30.4.dist-info/WHEEL +4 -0
  60. worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
  61. worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,692 @@
1
+ """Exact unstaged tracked-source preservation; grants no cleanup/release authority.
2
+
3
+ The base Git tree plus the recorded file overlay reconstructs the dirty source.
4
+ Ignored resources are outside this contract. Payloads are never redacted, parsed
5
+ as provider state, or written back to the source worktree/index.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import hashlib
12
+ import json
13
+ import os
14
+ import re
15
+ import stat
16
+ import uuid
17
+ from collections.abc import Iterator
18
+ from contextlib import ExitStack, contextmanager, nullcontext
19
+ from datetime import UTC, datetime
20
+ from pathlib import Path, PurePosixPath
21
+ from typing import Any
22
+
23
+ from . import preservation as p
24
+ from .disposable import _Directory, _inode, _open_directory
25
+ from .git_backend import _git
26
+ from .models import ControllerError
27
+ from .preserved_cleanup import _document
28
+ from .store import MetadataStore
29
+
30
+ CONTRACT = "tracked-source-preservation/v1"
31
+ MAX_FILES = 256
32
+ MAX_FILE_BYTES = 64 * 1024 * 1024
33
+ MAX_TOTAL_BYTES = 128 * 1024 * 1024
34
+ _SECRET_NAMES = {
35
+ ".env",
36
+ ".netrc",
37
+ ".npmrc",
38
+ ".pypirc",
39
+ "credentials",
40
+ "credentials.json",
41
+ "credentials.toml",
42
+ "auth.json",
43
+ "secrets.toml",
44
+ "service-account.json",
45
+ "id_rsa",
46
+ "id_ed25519",
47
+ }
48
+ _SECRET_PARTS = {".ssh", ".aws", "provider-state"}
49
+ _SECRET_BYTES = re.compile(
50
+ rb"-----BEGIN (?:[A-Z0-9 ]+ )?PRIVATE KEY-----|"
51
+ rb"\bsk-(?:proj-|ant-)?[A-Za-z0-9_-]{24,}|"
52
+ rb"\bgh[pousr]_[A-Za-z0-9]{24,}|\bAKIA[A-Z0-9]{16}\b"
53
+ )
54
+
55
+
56
+ def _fail(code: str) -> ControllerError:
57
+ # No payload, Git stderr, or exception text may enter preservation diagnostics.
58
+ return ControllerError(code, "exact tracked-source preservation guard refused")
59
+
60
+
61
+ class AuthorityBlocked(ControllerError):
62
+ def __init__(self, code: str, runtime_evidence: dict):
63
+ super().__init__(code, "allocation runtime authority refused preservation")
64
+ self.runtime_evidence = runtime_evidence
65
+
66
+
67
+ def _path(value: str) -> PurePosixPath:
68
+ result = PurePosixPath(value)
69
+ if (
70
+ not value
71
+ or result.is_absolute()
72
+ or result.as_posix() != value
73
+ or any(part in {".", "..", ".git"} for part in result.parts)
74
+ or "\x00" in value
75
+ ):
76
+ raise _fail("SOURCE_PATH_INVALID")
77
+ parts = {part.lower() for part in result.parts}
78
+ name = result.name.lower()
79
+ if (
80
+ parts & _SECRET_PARTS
81
+ or any(part.endswith("-home") for part in parts)
82
+ or name in _SECRET_NAMES
83
+ or name.startswith(".env.")
84
+ or name.endswith((".pem", ".key", ".p12", ".pfx"))
85
+ ):
86
+ raise _fail("SOURCE_CREDENTIAL_PATH")
87
+ return result
88
+
89
+
90
+ def _authority(
91
+ repo: Path,
92
+ target: Path,
93
+ allocation: str,
94
+ owner: str,
95
+ version: int,
96
+ *,
97
+ pending_transaction: dict | None = None,
98
+ _migration_store: MetadataStore | None = None,
99
+ ) -> dict:
100
+ from .lifecycle import _typed_runtime_blockers, _typed_runtime_evidence, inventory
101
+ from .semantics import assert_record_version as _cas
102
+ from .semantics import managed_record as _managed_record
103
+
104
+ with p._absolute_directory(target):
105
+ repository = inventory(repo)
106
+ store = _migration_store or MetadataStore(repository.common_dir)
107
+ if store.root.parent.parent != repository.common_dir:
108
+ raise _fail("SOURCE_AUTHORITY_MISMATCH")
109
+ # Layout preflight already holds this repository's lock while inspecting the
110
+ # explicit previous namespace. Reopening flock would deadlock its own writer.
111
+ with nullcontext() if _migration_store is not None else store.lock(exclusive=False):
112
+ snapshot = store._load_unlocked()
113
+ item, record = _managed_record(repository, snapshot, target)
114
+ _cas(record, version)
115
+ if record.allocation_id != allocation or record.owner != owner:
116
+ raise _fail("SOURCE_AUTHORITY_MISMATCH")
117
+ if (
118
+ record.lifecycle != "ACTIVE"
119
+ or record.role not in {"feature", "audit", "hotfix"}
120
+ or item.canonical
121
+ or item.detached
122
+ or item.locked
123
+ or item.prunable
124
+ or record.handoff_commit
125
+ ):
126
+ raise _fail("SOURCE_LIFECYCLE_BLOCKED")
127
+ from .transaction_scope import check_source
128
+
129
+ check_source(
130
+ store,
131
+ allocation,
132
+ target,
133
+ pending_transaction,
134
+ assume_serialized=_migration_store is not None,
135
+ )
136
+ runtime = _typed_runtime_evidence(item, record, snapshot)
137
+ blockers = _typed_runtime_blockers(runtime)
138
+ if blockers:
139
+ raise AuthorityBlocked(blockers[0]["code"], runtime)
140
+ return {
141
+ "identity": {
142
+ "repository_id": snapshot.repository_id,
143
+ "allocation_id": allocation,
144
+ "target_path": str(item.path),
145
+ "git_dir": str(item.git_dir),
146
+ "expected_head": item.head,
147
+ "expected_record_version": version,
148
+ "owner": owner,
149
+ "authority_key": record.authority_key,
150
+ "task_key": record.task_key,
151
+ "branch": item.branch,
152
+ },
153
+ "runtime_evidence": runtime,
154
+ "worktree_paths": [str(w.path) for w in repository.worktrees],
155
+ }
156
+
157
+
158
+ def _git_state(
159
+ target: Path, git_dir: Path, expected_head: str, *, allow_clean: bool = False
160
+ ) -> dict:
161
+ head = _git(target, "rev-parse", "HEAD").stdout.decode().strip()
162
+ if head != expected_head:
163
+ raise _fail("SOURCE_HEAD_MISMATCH")
164
+ base: dict[str, list[str]] = {}
165
+ for entry in _git(target, "ls-tree", "-rz", head).stdout.split(b"\0"):
166
+ if not entry:
167
+ continue
168
+ metadata, name = entry.split(b"\t", 1)
169
+ mode, kind, oid = metadata.decode().split()
170
+ if kind != "blob":
171
+ raise _fail("SOURCE_GIT_STATE_UNSUPPORTED")
172
+ base[name.decode("utf-8")] = [mode, oid]
173
+ index = {}
174
+ for entry in _git(target, "ls-files", "--stage", "-z").stdout.split(b"\0"):
175
+ if not entry:
176
+ continue
177
+ metadata, name = entry.split(b"\t", 1)
178
+ mode, oid, stage = metadata.decode().split()
179
+ if stage != "0":
180
+ raise _fail("SOURCE_STAGED_OR_UNMERGED")
181
+ index[name.decode("utf-8")] = [mode, oid]
182
+ if index != base:
183
+ raise _fail("SOURCE_STAGED_OR_UNMERGED")
184
+ flags = _git(target, "ls-files", "-v", "-z").stdout.split(b"\0")
185
+ if any(entry and not entry.startswith(b"H ") for entry in flags):
186
+ raise _fail("SOURCE_INDEX_FLAGS_UNSUPPORTED")
187
+ paths = []
188
+ for entry in _git(target, "status", "--porcelain=v1", "-z", "-uall").stdout.split(b"\0"):
189
+ if not entry:
190
+ continue
191
+ if entry[:3] != b" M ":
192
+ raise _fail("SOURCE_DIRTY_KIND_UNSUPPORTED")
193
+ relative = entry[3:].decode("utf-8")
194
+ _path(relative) # Refuse credential paths before opening source payloads.
195
+ if relative not in base or base[relative][0] not in {"100644", "100755"}:
196
+ raise _fail("SOURCE_NODE_UNSUPPORTED")
197
+ paths.append(relative)
198
+ if (not paths and not allow_clean) or len(paths) > MAX_FILES or len(paths) != len(set(paths)):
199
+ raise _fail("SOURCE_PATH_SET_UNSUPPORTED")
200
+ # Git filters/encoding can make a clean logical blob differ from exact bytes.
201
+ # This first contract supports raw source bytes, not filter reconstruction.
202
+ attributes = (
203
+ _git(
204
+ target,
205
+ "check-attr",
206
+ "-z",
207
+ "filter",
208
+ "working-tree-encoding",
209
+ "text",
210
+ "ident",
211
+ "--",
212
+ *paths,
213
+ ).stdout.split(b"\0")
214
+ if paths
215
+ else []
216
+ )
217
+ if any(value not in {b"unspecified", b"unset"} for value in attributes[2::3]):
218
+ raise _fail("SOURCE_FILTER_UNSUPPORTED")
219
+ autocrlf = _git(target, "config", "--get", "core.autocrlf", check=False)
220
+ # "input" leaves checkout bytes alone. No staging/clean conversion is used:
221
+ # the reconstructed tree deliberately contains the exact raw overlay bytes.
222
+ if autocrlf.stdout.strip() not in {b"", b"false", b"input"}:
223
+ raise _fail("SOURCE_FILTER_UNSUPPORTED")
224
+ with p._absolute_directory(git_dir) as chain:
225
+ metadata = os.stat("index", dir_fd=chain[-1].fd, follow_symlinks=False)
226
+ identity = p._identity(metadata)
227
+ with p._regular_fd(chain[-1].fd, "index", identity) as fd:
228
+ _, index_digest = p._transfer(fd)
229
+ return {
230
+ "base_head": head,
231
+ "base_tree": _git(target, "rev-parse", f"{head}^{{tree}}").stdout.decode().strip(),
232
+ "base_entries": base,
233
+ "dirty_paths": sorted(paths),
234
+ "index_identity": identity,
235
+ "index_sha256": index_digest,
236
+ "autocrlf": autocrlf.stdout.decode().strip(),
237
+ }
238
+
239
+
240
+ @contextmanager
241
+ def _parent(chain: tuple[_Directory, ...], relative: str, *, create: bool = False) -> Iterator:
242
+ path = _path(relative)
243
+ with ExitStack() as stack:
244
+ for name in path.parts[:-1]:
245
+ p._check_chain(chain)
246
+ try:
247
+ metadata = os.stat(name, dir_fd=chain[-1].fd, follow_symlinks=False)
248
+ except FileNotFoundError:
249
+ if not create:
250
+ raise
251
+ os.mkdir(name, 0o700, dir_fd=chain[-1].fd)
252
+ os.fsync(chain[-1].fd)
253
+ metadata = os.stat(name, dir_fd=chain[-1].fd, follow_symlinks=False)
254
+ directory = stack.enter_context(_open_directory(chain[-1].fd, name, _inode(metadata)))
255
+ chain = (*chain, directory)
256
+ p._check_chain(chain)
257
+ yield chain, path.name
258
+ p._check_chain(chain)
259
+
260
+
261
+ def _read(chain: tuple[_Directory, ...], relative: str) -> tuple[bytes, list[int], dict]:
262
+ with _parent(chain, relative) as (parents, name):
263
+ metadata = os.stat(name, dir_fd=parents[-1].fd, follow_symlinks=False)
264
+ identity = p._identity(metadata)
265
+ if not stat.S_ISREG(metadata.st_mode) or metadata.st_size > MAX_FILE_BYTES:
266
+ raise _fail("SOURCE_NODE_UNSUPPORTED")
267
+ with p._regular_fd(parents[-1].fd, name, identity) as fd:
268
+ blocks = []
269
+ size = 0
270
+ while block := os.read(fd, p.CHUNK):
271
+ size += len(block)
272
+ if size > MAX_FILE_BYTES:
273
+ raise _fail("SOURCE_SIZE_LIMIT")
274
+ blocks.append(block)
275
+ data = b"".join(blocks)
276
+ if len(data) != metadata.st_size:
277
+ raise _fail("SOURCE_CHANGED")
278
+ if _SECRET_BYTES.search(data):
279
+ raise _fail("SOURCE_SECRET_MATERIAL")
280
+ anchors = {str(i): directory.identity for i, directory in enumerate(parents)}
281
+ return data, identity, anchors
282
+
283
+
284
+ def _blob(data: bytes) -> str:
285
+ return hashlib.sha1(b"blob " + str(len(data)).encode() + b"\0" + data).hexdigest()
286
+
287
+
288
+ def _tree(entries: dict[str, list[str]]) -> str:
289
+ root: dict[str, Any] = {}
290
+ for path, entry in entries.items():
291
+ parts = PurePosixPath(path).parts
292
+ parent = root
293
+ for part in parts[:-1]:
294
+ parent = parent.setdefault(part, {})
295
+ parent[parts[-1]] = entry
296
+
297
+ def tree(directory: dict) -> str:
298
+ rows = []
299
+ for name, entry in directory.items():
300
+ mode, oid = ("40000", tree(entry)) if isinstance(entry, dict) else entry
301
+ key = name.encode() + (b"/" if mode == "40000" else b"\0")
302
+ rows.append((key, mode.encode() + b" " + name.encode() + b"\0" + bytes.fromhex(oid)))
303
+ data = b"".join(row for _, row in sorted(rows))
304
+ return hashlib.sha1(b"tree " + str(len(data)).encode() + b"\0" + data).hexdigest()
305
+
306
+ return tree(root)
307
+
308
+
309
+ def _scan(identity: dict) -> dict:
310
+ target = Path(identity["target_path"])
311
+ with p._absolute_directory(target) as chain:
312
+ before = _git_state(target, Path(identity["git_dir"]), identity["expected_head"])
313
+ manifest, identities, parents = [], {}, {}
314
+ overlay = dict(before["base_entries"])
315
+ for relative in before["dirty_paths"]:
316
+ data, metadata, anchors = _read(chain, relative)
317
+ mode = stat.S_IMODE(metadata[2])
318
+ blob = _blob(data)
319
+ manifest.append(
320
+ {
321
+ "path": relative,
322
+ "git_status": " M",
323
+ "mode": mode,
324
+ "size": len(data),
325
+ "sha256": hashlib.sha256(data).hexdigest(),
326
+ "git_blob": blob,
327
+ "base_entry": before["base_entries"][relative],
328
+ }
329
+ )
330
+ identities[relative], parents[relative] = metadata, anchors
331
+ overlay[relative] = ["100755" if mode & 0o111 else "100644", blob]
332
+ if sum(row["size"] for row in manifest) > MAX_TOTAL_BYTES:
333
+ raise _fail("SOURCE_SIZE_LIMIT")
334
+ if _git_state(target, Path(identity["git_dir"]), identity["expected_head"]) != before:
335
+ raise _fail("SOURCE_GIT_CHANGED")
336
+ for relative, expected in identities.items():
337
+ with _parent(chain, relative) as (parents_chain, name):
338
+ p._check_entry(parents_chain[-1].fd, name, expected)
339
+ portable = {
340
+ "base_head": before["base_head"],
341
+ "base_tree": before["base_tree"],
342
+ "dirty_paths": before["dirty_paths"],
343
+ "files": manifest,
344
+ "reconstructed_tree_oid": _tree(overlay),
345
+ }
346
+ return {
347
+ **portable,
348
+ "tree_sha256": p._sha(portable),
349
+ "source_identities": identities,
350
+ "parent_identities": parents,
351
+ "index_identity": before["index_identity"],
352
+ "index_sha256": before["index_sha256"],
353
+ "autocrlf": before["autocrlf"],
354
+ "root_identity": chain[-1].identity,
355
+ }
356
+
357
+
358
+ def _current(plan: dict) -> dict:
359
+ identity = plan["authority"]["identity"]
360
+ authority = _authority(
361
+ Path(plan["repository"]),
362
+ Path(identity["target_path"]),
363
+ identity["allocation_id"],
364
+ identity["owner"],
365
+ identity["expected_record_version"],
366
+ )
367
+ if authority["identity"] != identity:
368
+ raise _fail("SOURCE_AUTHORITY_CHANGED")
369
+ if _scan(identity) != plan["source_tree"]:
370
+ raise _fail("SOURCE_CHANGED")
371
+ return authority
372
+
373
+
374
+ def plan_preservation(
375
+ repo: Path,
376
+ target: Path,
377
+ allocation: str,
378
+ owner: str,
379
+ version: int,
380
+ *,
381
+ expected_head: str,
382
+ state_root: Path | None = None,
383
+ generation: str | None = None,
384
+ ) -> dict:
385
+ authority = _authority(repo, target, allocation, owner, version)
386
+ if authority["identity"]["expected_head"] != expected_head:
387
+ raise _fail("SOURCE_HEAD_MISMATCH")
388
+ tree = _scan(authority["identity"])
389
+ generation = generation or str(uuid.uuid4())
390
+ root = state_root or p.evidence_root()
391
+ destination = p._destination(root, authority, generation)
392
+ plan = {
393
+ "contract": CONTRACT,
394
+ "repository": str(repo),
395
+ "authority": authority,
396
+ "source_tree": tree,
397
+ "generation": generation,
398
+ "state_root": str(root),
399
+ "destination": str(destination),
400
+ "destination_anchor": p._destination_preflight(destination),
401
+ "cleanup_authorized": False,
402
+ }
403
+ after = _current(plan)
404
+ plan["runtime_checks"] = [authority["runtime_evidence"], after["runtime_evidence"]]
405
+ return {**plan, "plan_sha256": p._sha(plan)}
406
+
407
+
408
+ def _validate(plan: dict, state_root: Path | None) -> Path:
409
+ if (
410
+ plan.get("contract") != CONTRACT
411
+ or plan.get("cleanup_authorized") is not False
412
+ or p._sha({k: v for k, v in plan.items() if k != "plan_sha256"}) != plan.get("plan_sha256")
413
+ ):
414
+ raise _fail("SOURCE_PLAN_INVALID")
415
+ root = state_root or p.evidence_root()
416
+ destination = p._destination(root, plan["authority"], plan["generation"])
417
+ if str(root) != plan["state_root"] or str(destination) != plan["destination"]:
418
+ raise _fail("SOURCE_DESTINATION_INVALID")
419
+ return destination
420
+
421
+
422
+ def _copy_files(source_chain: tuple, destination_chain: tuple, tree: dict) -> None:
423
+ for row in tree["files"]:
424
+ relative = row["path"]
425
+ data, identity, parents = _read(source_chain, relative)
426
+ if (
427
+ identity != tree["source_identities"][relative]
428
+ or parents != tree["parent_identities"][relative]
429
+ or len(data) != row["size"]
430
+ or hashlib.sha256(data).hexdigest() != row["sha256"]
431
+ ):
432
+ raise _fail("SOURCE_CHANGED")
433
+ with _parent(destination_chain, relative, create=True) as (chain, name):
434
+ parent = chain[-1]
435
+ fd = os.open(
436
+ name,
437
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
438
+ 0o600,
439
+ dir_fd=parent.fd,
440
+ )
441
+ try:
442
+ expected = _inode(os.fstat(fd))
443
+ view = memoryview(data)
444
+ while view:
445
+ written = os.write(fd, view)
446
+ if not written:
447
+ raise _fail("SOURCE_COPY_FAILED")
448
+ view = view[written:]
449
+ os.fchmod(fd, 0o400)
450
+ os.fsync(fd)
451
+ if _inode(os.stat(name, dir_fd=parent.fd, follow_symlinks=False)) != expected:
452
+ raise _fail("SOURCE_DESTINATION_CHANGED")
453
+ p._check_chain(chain)
454
+ os.fsync(parent.fd)
455
+ finally:
456
+ os.close(fd)
457
+
458
+
459
+ def _seal(directory: _Directory, chain: tuple) -> None:
460
+ for name in os.listdir(directory.fd):
461
+ metadata = os.stat(name, dir_fd=directory.fd, follow_symlinks=False)
462
+ if stat.S_ISDIR(metadata.st_mode):
463
+ with _open_directory(directory.fd, name, _inode(metadata)) as child:
464
+ _seal(child, (*chain, child))
465
+ elif not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o400:
466
+ raise _fail("SOURCE_DESTINATION_CHANGED")
467
+ p._check_chain(chain)
468
+ os.fchmod(directory.fd, 0o500)
469
+ os.fsync(directory.fd)
470
+
471
+
472
+ def _verify_files(chain: tuple, expected: dict) -> dict:
473
+ tree = p.scan_tree(chain[-1], chain)
474
+ tree.require_complete()
475
+ files = {row["path"]: row for row in tree.manifest if row["type"] == "regular"}
476
+ parents = {"."}
477
+ for row in expected["files"]:
478
+ parents.update(str(v) for v in PurePosixPath(row["path"]).parents)
479
+ if (
480
+ set(files) != set(expected["dirty_paths"])
481
+ or {r["path"] for r in tree.manifest if r["type"] == "directory"} != parents
482
+ or len(tree.manifest) != len(files) + len(parents)
483
+ or any(r["mode"] != (0o400 if r["type"] == "regular" else 0o500) for r in tree.manifest)
484
+ ):
485
+ raise _fail("SOURCE_DESTINATION_INCOMPLETE")
486
+ for row in expected["files"]:
487
+ copied = files[row["path"]]
488
+ if copied["size"] != row["size"] or copied["content_sha256"] != row["sha256"]:
489
+ raise _fail("SOURCE_DESTINATION_DIGEST_MISMATCH")
490
+ return tree.identities
491
+
492
+
493
+ def copy_preservation(plan: dict, *, state_root: Path | None = None) -> dict:
494
+ destination = _validate(plan, state_root)
495
+ _current(plan)
496
+ p._destination_preflight(destination)
497
+ with p._absolute_directory(destination.parent, create=True) as parent_chain:
498
+ parent = parent_chain[-1]
499
+ p._destination_anchor(parent_chain, plan["destination_anchor"])
500
+ os.mkdir(destination.name, 0o700, dir_fd=parent.fd)
501
+ os.fsync(parent.fd)
502
+ identity = _inode(os.stat(destination.name, dir_fd=parent.fd, follow_symlinks=False))
503
+ with _open_directory(parent.fd, destination.name, identity) as generation:
504
+ chain = (*parent_chain, generation)
505
+ p._write_once(generation, "plan.json", plan)
506
+ os.mkdir("tree", 0o700, dir_fd=generation.fd)
507
+ metadata = os.stat("tree", dir_fd=generation.fd, follow_symlinks=False)
508
+ with _open_directory(generation.fd, "tree", _inode(metadata)) as tree:
509
+ tree_chain = (*chain, tree)
510
+ with p._absolute_directory(
511
+ Path(plan["authority"]["identity"]["target_path"])
512
+ ) as src:
513
+ if src[-1].identity != plan["source_tree"]["root_identity"]:
514
+ raise _fail("SOURCE_CHANGED")
515
+ _copy_files(src, tree_chain, plan["source_tree"])
516
+ _seal(tree, tree_chain)
517
+ destination_identities = _verify_files(tree_chain, plan["source_tree"])
518
+ manifest_digest = p._write_once(generation, "manifest.json", plan["source_tree"])
519
+ after = _current(plan)
520
+ with p._absolute_directory(destination) as chain:
521
+ generation = chain[-1]
522
+ p._destination_anchor(chain, plan["destination_anchor"])
523
+ if generation.identity != identity:
524
+ raise _fail("SOURCE_DESTINATION_CHANGED")
525
+ metadata = os.stat("tree", dir_fd=generation.fd, follow_symlinks=False)
526
+ with _open_directory(generation.fd, "tree", _inode(metadata)) as tree:
527
+ if _verify_files((*chain, tree), plan["source_tree"]) != destination_identities:
528
+ raise _fail("SOURCE_DESTINATION_CHANGED")
529
+ receipt = {
530
+ "contract": CONTRACT,
531
+ "result": "SOURCE_PRESERVATION_COMPLETE",
532
+ "verification": "PASS",
533
+ "receipt_id": plan["generation"],
534
+ "timestamp": datetime.now(UTC).isoformat(),
535
+ "allocation_source_identity": plan["authority"]["identity"],
536
+ "generation": str(destination),
537
+ "generation_identity": identity,
538
+ "plan_sha256": plan["plan_sha256"],
539
+ "manifest_sha256": manifest_digest,
540
+ "tree_sha256": plan["source_tree"]["tree_sha256"],
541
+ "reconstructed_tree_oid": plan["source_tree"]["reconstructed_tree_oid"],
542
+ "destination_identity_sha256": p._sha(destination_identities),
543
+ "file_count": len(plan["source_tree"]["files"]),
544
+ "total_bytes": sum(row["size"] for row in plan["source_tree"]["files"]),
545
+ "runtime_checks": [*plan["runtime_checks"], after["runtime_evidence"]],
546
+ "cleanup_authorized": False,
547
+ "implementation_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
548
+ }
549
+ digest = p._write_once(generation, "receipt.json", receipt)
550
+ os.fchmod(generation.fd, 0o500)
551
+ os.fsync(generation.fd)
552
+ os.fsync(generation.parent_fd)
553
+ return {
554
+ "result": receipt["result"],
555
+ "receipt": str(destination / "receipt.json"),
556
+ "receipt_sha256": digest,
557
+ "generation": str(destination),
558
+ "tree_sha256": receipt["tree_sha256"],
559
+ "reconstructed_tree_oid": receipt["reconstructed_tree_oid"],
560
+ "file_count": receipt["file_count"],
561
+ "total_bytes": receipt["total_bytes"],
562
+ }
563
+
564
+
565
+ def _verify_generation(plan: dict, receipt_sha256: str, state_root: Path | None = None) -> dict:
566
+ destination = _validate(plan, state_root)
567
+ with p._absolute_directory(destination) as chain:
568
+ generation = chain[-1]
569
+ p._destination_anchor(chain, plan["destination_anchor"])
570
+ if stat.S_IMODE(os.fstat(generation.fd).st_mode) != 0o500:
571
+ raise _fail("SOURCE_GENERATION_NOT_SEALED")
572
+ if set(os.listdir(generation.fd)) != {"plan.json", "manifest.json", "receipt.json", "tree"}:
573
+ raise _fail("SOURCE_GENERATION_NOT_SEALED")
574
+ for name in ("plan.json", "manifest.json", "receipt.json"):
575
+ st = os.stat(name, dir_fd=generation.fd, follow_symlinks=False)
576
+ if (
577
+ not stat.S_ISREG(st.st_mode)
578
+ or stat.S_IMODE(st.st_mode) != 0o400
579
+ or st.st_nlink != 1
580
+ ):
581
+ raise _fail("SOURCE_GENERATION_NOT_SEALED")
582
+ receipt, receipt_digest = _document(generation, "receipt.json")
583
+ manifest, manifest_digest = _document(generation, "manifest.json")
584
+ saved_plan, saved_plan_digest = _document(generation, "plan.json")
585
+ if (
586
+ receipt_digest != receipt_sha256
587
+ or receipt.get("contract") != CONTRACT
588
+ or receipt.get("verification") != "PASS"
589
+ or receipt.get("cleanup_authorized") is not False
590
+ or receipt.get("generation_identity") != generation.identity
591
+ or receipt.get("generation") != str(destination)
592
+ or receipt.get("allocation_source_identity") != plan["authority"]["identity"]
593
+ or receipt.get("plan_sha256") != plan["plan_sha256"]
594
+ or saved_plan != plan
595
+ or saved_plan_digest != p._sha(plan)
596
+ or receipt.get("manifest_sha256") != manifest_digest
597
+ or manifest != plan["source_tree"]
598
+ or receipt.get("tree_sha256") != manifest["tree_sha256"]
599
+ or receipt.get("reconstructed_tree_oid") != manifest["reconstructed_tree_oid"]
600
+ ):
601
+ raise _fail("SOURCE_RECEIPT_INVALID")
602
+ metadata = os.stat("tree", dir_fd=generation.fd, follow_symlinks=False)
603
+ with _open_directory(generation.fd, "tree", _inode(metadata)) as tree:
604
+ identities = _verify_files((*chain, tree), manifest)
605
+ if p._sha(identities) != receipt["destination_identity_sha256"]:
606
+ raise _fail("SOURCE_DESTINATION_CHANGED")
607
+ return {
608
+ "result": "SOURCE_PRESERVATION_COMPLETE",
609
+ "verification": "PASS",
610
+ "tree_sha256": manifest["tree_sha256"],
611
+ "reconstructed_tree_oid": manifest["reconstructed_tree_oid"],
612
+ "receipt": str(destination / "receipt.json"),
613
+ "cleanup_authorized": False,
614
+ }
615
+
616
+
617
+ def verify_preservation(plan: dict, receipt_sha256: str, *, state_root: Path | None = None) -> dict:
618
+ _current(plan)
619
+ result = _verify_generation(plan, receipt_sha256, state_root)
620
+ _current(plan)
621
+ return result
622
+
623
+
624
+ def main(argv: list[str] | None = None) -> int:
625
+ parser = argparse.ArgumentParser(description=__doc__)
626
+ commands = parser.add_subparsers(dest="command", required=True)
627
+ create = commands.add_parser("plan")
628
+ for name in ("repo", "path"):
629
+ create.add_argument(f"--{name}", required=True, type=Path)
630
+ for name in ("allocation-id", "owner", "expected-head"):
631
+ create.add_argument(f"--{name}", required=True)
632
+ create.add_argument("--expected-record-version", required=True, type=int)
633
+ create.add_argument("--plan-file", required=True, type=Path)
634
+ for command in ("copy", "verify"):
635
+ sub = commands.add_parser(command)
636
+ sub.add_argument("--plan-file", required=True, type=Path)
637
+ if command == "verify":
638
+ sub.add_argument("--receipt-sha256", required=True)
639
+ args = parser.parse_args(argv)
640
+ try:
641
+ if args.command == "plan":
642
+ plan = plan_preservation(
643
+ args.repo,
644
+ args.path,
645
+ args.allocation_id,
646
+ args.owner,
647
+ args.expected_record_version,
648
+ expected_head=args.expected_head,
649
+ )
650
+ with p._absolute_directory(args.plan_file.parent) as chain:
651
+ p._write_once(chain[-1], args.plan_file.name, plan)
652
+ result = {
653
+ "result": "SOURCE_PRESERVATION_PLAN_READY",
654
+ "plan_file": str(args.plan_file),
655
+ "plan_sha256": plan["plan_sha256"],
656
+ "destination": plan["destination"],
657
+ "files": plan["source_tree"]["files"],
658
+ "tree_sha256": plan["source_tree"]["tree_sha256"],
659
+ "runtime_evidence": plan["runtime_checks"][-1],
660
+ }
661
+ else:
662
+ with p._absolute_directory(args.plan_file.parent) as chain:
663
+ plan = p._read_json(chain[-1], args.plan_file.name)
664
+ result = (
665
+ copy_preservation(plan)
666
+ if args.command == "copy"
667
+ else verify_preservation(plan, args.receipt_sha256)
668
+ )
669
+ print(json.dumps(result, sort_keys=True))
670
+ return 0
671
+ except (ControllerError, OSError, ValueError, KeyError, TypeError):
672
+ import sys
673
+
674
+ exc = sys.exception()
675
+ print(
676
+ json.dumps(
677
+ {
678
+ "result": "SOURCE_PRESERVATION_BLOCKED",
679
+ "code": getattr(exc, "code", "SOURCE_PRESERVATION_IO_ERROR"),
680
+ **(
681
+ {"runtime_evidence": exc.runtime_evidence}
682
+ if isinstance(exc, AuthorityBlocked)
683
+ else {}
684
+ ),
685
+ }
686
+ )
687
+ )
688
+ return 1
689
+
690
+
691
+ if __name__ == "__main__":
692
+ raise SystemExit(main())