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.
- worktree_controller/__init__.py +3 -0
- worktree_controller/activation_layout.py +483 -0
- worktree_controller/branch_retirement.py +1865 -0
- worktree_controller/canonical_refresh.py +150 -0
- worktree_controller/canonical_transition.py +324 -0
- worktree_controller/cleanup_attempts.py +442 -0
- worktree_controller/cleanup_evidence.py +75 -0
- worktree_controller/cleanup_recovery.py +912 -0
- worktree_controller/cli.py +1907 -0
- worktree_controller/controller.py +1974 -0
- worktree_controller/convergence.py +893 -0
- worktree_controller/delivered_closeout.py +928 -0
- worktree_controller/disposable.py +677 -0
- worktree_controller/environment.py +1038 -0
- worktree_controller/frozen_recovery.py +291 -0
- worktree_controller/git_backend.py +682 -0
- worktree_controller/governance.py +365 -0
- worktree_controller/hardlinks.py +294 -0
- worktree_controller/integration_cleanup.py +92 -0
- worktree_controller/integration_conflict.py +117 -0
- worktree_controller/integration_retry.py +526 -0
- worktree_controller/keyed_document_scope.py +162 -0
- worktree_controller/lifecycle.py +8992 -0
- worktree_controller/lifecycle_history.py +358 -0
- worktree_controller/lifecycle_observation.py +234 -0
- worktree_controller/metadata_codec.py +1694 -0
- worktree_controller/metadata_relocation.py +131 -0
- worktree_controller/models.py +546 -0
- worktree_controller/mount_diagnostics.py +324 -0
- worktree_controller/multiple_cleanup_recovery.py +730 -0
- worktree_controller/namespace_migration.py +1412 -0
- worktree_controller/native_session_recovery.py +171 -0
- worktree_controller/owner_actions.py +34 -0
- worktree_controller/policy.py +333 -0
- worktree_controller/preservation.py +983 -0
- worktree_controller/preserved_cleanup.py +849 -0
- worktree_controller/publication_execution.py +361 -0
- worktree_controller/reconciler.py +430 -0
- worktree_controller/recovery_guidance.py +205 -0
- worktree_controller/registration_backfill.py +166 -0
- worktree_controller/registration_prune.py +391 -0
- worktree_controller/rendering.py +179 -0
- worktree_controller/resource_admission.py +1736 -0
- worktree_controller/retirement_adapters.py +130 -0
- worktree_controller/retirement_execution.py +234 -0
- worktree_controller/semantics.py +459 -0
- worktree_controller/session.py +505 -0
- worktree_controller/session_admission.py +265 -0
- worktree_controller/source_cleanup.py +515 -0
- worktree_controller/source_preservation.py +692 -0
- worktree_controller/source_recovery.py +592 -0
- worktree_controller/store.py +1228 -0
- worktree_controller/tool_cli_contract.json +1791 -0
- worktree_controller/transaction_scope.py +876 -0
- worktree_controller/writer_lease.py +662 -0
- worktree_controller/writer_session.py +887 -0
- worktree_controller-1.30.4.dist-info/METADATA +3149 -0
- worktree_controller-1.30.4.dist-info/RECORD +61 -0
- worktree_controller-1.30.4.dist-info/WHEEL +4 -0
- worktree_controller-1.30.4.dist-info/entry_points.txt +5 -0
- worktree_controller-1.30.4.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
"""Exact immutable preservation receipt verification and FD-bound legacy deletion."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from . import preservation as p
|
|
15
|
+
from .disposable import _delete_fd_tree, _fd_inventory, _unlink_leaf
|
|
16
|
+
from .git_backend import head_paths, path_is_ignored, tracked_paths
|
|
17
|
+
from .models import ControllerError, MetadataRecord
|
|
18
|
+
|
|
19
|
+
CONTRACT = "legacy-preserved-state-cleanup/v1"
|
|
20
|
+
MAX_EVIDENCE_BYTES = 64 * 1024 * 1024
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _blocked(message: str) -> ControllerError:
|
|
24
|
+
return ControllerError("PRESERVATION_COVERAGE_INVALID", message)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _document(directory: p._Directory, name: str) -> tuple[dict[str, Any], str]:
|
|
28
|
+
metadata = os.stat(name, dir_fd=directory.fd, follow_symlinks=False)
|
|
29
|
+
if metadata.st_size > MAX_EVIDENCE_BYTES:
|
|
30
|
+
raise _blocked("preservation evidence exceeds its bound")
|
|
31
|
+
with (
|
|
32
|
+
p._regular_fd(directory.fd, name, p._identity(metadata)) as fd,
|
|
33
|
+
os.fdopen(os.dup(fd), "rb") as stream,
|
|
34
|
+
):
|
|
35
|
+
raw = stream.read(MAX_EVIDENCE_BYTES + 1)
|
|
36
|
+
if len(raw) > MAX_EVIDENCE_BYTES:
|
|
37
|
+
raise _blocked("preservation evidence exceeds its bound")
|
|
38
|
+
directory.check()
|
|
39
|
+
value = json.loads(raw)
|
|
40
|
+
if not isinstance(value, dict):
|
|
41
|
+
raise _blocked("invalid preservation document")
|
|
42
|
+
return value, hashlib.sha256(raw).hexdigest()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def coverage(
|
|
46
|
+
receipt_path: Path,
|
|
47
|
+
receipt_sha256: str,
|
|
48
|
+
*,
|
|
49
|
+
identity: dict[str, Any] | None = None,
|
|
50
|
+
verify_source: bool = True,
|
|
51
|
+
source_topology: dict[str, Any] | None = None,
|
|
52
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
53
|
+
"""Verify raw evidence hashes and both trees; never changes Controller metadata."""
|
|
54
|
+
try:
|
|
55
|
+
return _coverage(
|
|
56
|
+
receipt_path,
|
|
57
|
+
receipt_sha256,
|
|
58
|
+
identity=identity,
|
|
59
|
+
verify_source=verify_source,
|
|
60
|
+
source_topology=source_topology,
|
|
61
|
+
)
|
|
62
|
+
except ControllerError:
|
|
63
|
+
raise
|
|
64
|
+
except (OSError, ValueError, TypeError, KeyError) as exc:
|
|
65
|
+
raise _blocked("preservation evidence is unavailable, malformed or changed") from exc
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _coverage(
|
|
69
|
+
receipt_path: Path,
|
|
70
|
+
receipt_sha256: str,
|
|
71
|
+
*,
|
|
72
|
+
identity: dict[str, Any] | None,
|
|
73
|
+
verify_source: bool,
|
|
74
|
+
source_topology: dict[str, Any] | None,
|
|
75
|
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
76
|
+
if not re.fullmatch(r"[0-9a-f]{64}", receipt_sha256 or ""):
|
|
77
|
+
raise _blocked("an explicit trusted preservation receipt SHA-256 is required")
|
|
78
|
+
if not receipt_path.is_absolute() or receipt_path.name != "receipt.json":
|
|
79
|
+
raise _blocked("canonical preservation receipt path required")
|
|
80
|
+
generation = receipt_path.parent
|
|
81
|
+
with p._absolute_directory(generation) as chain:
|
|
82
|
+
directory = chain[-1]
|
|
83
|
+
receipt, actual_receipt_sha = _document(directory, "receipt.json")
|
|
84
|
+
if actual_receipt_sha != receipt_sha256:
|
|
85
|
+
raise _blocked("preservation receipt bytes changed")
|
|
86
|
+
plan, _ = _document(directory, "plan.json")
|
|
87
|
+
manifest, manifest_sha = _document(directory, "manifest.json")
|
|
88
|
+
bound = plan["authority"]["identity"]
|
|
89
|
+
if identity is not None and bound != identity:
|
|
90
|
+
raise _blocked("preservation belongs to another allocation/worktree identity")
|
|
91
|
+
for value in (bound["repository_id"], bound["allocation_id"], plan["generation"]):
|
|
92
|
+
if str(uuid.UUID(value)) != value:
|
|
93
|
+
raise _blocked("noncanonical preservation identity")
|
|
94
|
+
expected = (
|
|
95
|
+
p.evidence_root() / bound["repository_id"] / bound["allocation_id"] / plan["generation"]
|
|
96
|
+
)
|
|
97
|
+
source_rel = plan.get("source_relative") or (
|
|
98
|
+
str(Path(plan["source"]).relative_to(Path(bound["target_path"])))
|
|
99
|
+
if "source" in plan
|
|
100
|
+
else p.SOURCE
|
|
101
|
+
)
|
|
102
|
+
source_rel = p.source_relative_path(source_rel)
|
|
103
|
+
source = Path(bound["target_path"]) / source_rel
|
|
104
|
+
if (
|
|
105
|
+
generation != expected
|
|
106
|
+
or generation.is_relative_to(Path(bound["target_path"]))
|
|
107
|
+
or plan["contract"] != p.CONTRACT
|
|
108
|
+
or plan["ready"] is not True
|
|
109
|
+
or plan["state_root"] != str(p.evidence_root())
|
|
110
|
+
or plan["source"] != str(source)
|
|
111
|
+
or plan["destination"] != str(generation)
|
|
112
|
+
or p._sha({k: v for k, v in plan.items() if k != "plan_sha256"}) != plan["plan_sha256"]
|
|
113
|
+
or receipt.get("contract") != p.CONTRACT
|
|
114
|
+
or receipt.get("verification") != "PASS"
|
|
115
|
+
or receipt.get("result") != "LEGACY_PRESERVATION_COMPLETE"
|
|
116
|
+
or receipt.get("receipt_id") != plan["generation"]
|
|
117
|
+
or receipt.get("allocation_source_identity") != bound
|
|
118
|
+
or receipt.get("source") != str(source)
|
|
119
|
+
or receipt.get("source_anchor") != plan["source_anchor"]
|
|
120
|
+
or receipt.get("generation") != str(generation)
|
|
121
|
+
or receipt.get("generation_identity") != directory.identity
|
|
122
|
+
or receipt.get("destination") != str(generation / "tree")
|
|
123
|
+
or receipt.get("manifest_sha256") != manifest_sha
|
|
124
|
+
or receipt.get("plan_sha256") != plan["plan_sha256"]
|
|
125
|
+
or manifest != plan["source_tree"]
|
|
126
|
+
or manifest.get("errors")
|
|
127
|
+
or receipt.get("inventory") != manifest["inventory"]
|
|
128
|
+
or receipt.get("source_tree_sha256") != manifest["tree_sha256"]
|
|
129
|
+
or receipt.get("preserved_tree_sha256") != manifest["tree_sha256"]
|
|
130
|
+
or receipt.get("source_identity_sha256") != p._sha(manifest["source_identities"])
|
|
131
|
+
):
|
|
132
|
+
raise _blocked("receipt, manifest, generation or source binding differs")
|
|
133
|
+
p._destination_anchor(chain, plan["destination_anchor"])
|
|
134
|
+
if "cleanup_topology" in plan:
|
|
135
|
+
from .hardlinks import validate
|
|
136
|
+
|
|
137
|
+
validate(plan["cleanup_topology"])
|
|
138
|
+
if receipt.get("cleanup_topology_sha256") != plan["cleanup_topology"]["sha256"]:
|
|
139
|
+
raise _blocked("preservation topology evidence changed")
|
|
140
|
+
with p._open_node(directory.fd, "tree") as tree:
|
|
141
|
+
preserved = p.scan_tree(tree, (*chain, tree))
|
|
142
|
+
p._same_tree(preserved, manifest)
|
|
143
|
+
if p._sha(preserved.identities) != receipt["destination_identity_sha256"]:
|
|
144
|
+
raise _blocked("preserved node identity changed")
|
|
145
|
+
if verify_source:
|
|
146
|
+
worktree_root = Path(bound["target_path"])
|
|
147
|
+
if tracked_paths(worktree_root, source_rel) or head_paths(worktree_root, source_rel):
|
|
148
|
+
raise _blocked("preserved source contains tracked/staged files")
|
|
149
|
+
if not path_is_ignored(worktree_root, source_rel):
|
|
150
|
+
raise _blocked("preserved source is not ignored legacy state")
|
|
151
|
+
with p._absolute_source(source) as source_chain:
|
|
152
|
+
p._source_anchor(source_chain, plan)
|
|
153
|
+
expected = manifest
|
|
154
|
+
if source_topology is not None:
|
|
155
|
+
from .hardlinks import source_baseline
|
|
156
|
+
|
|
157
|
+
expected = source_baseline(manifest, source_topology, resource=source_rel)
|
|
158
|
+
p._same_source(p.scan_tree(source_chain[-1], source_chain), expected)
|
|
159
|
+
evidence = {
|
|
160
|
+
"contract": CONTRACT,
|
|
161
|
+
"receipt_path": str(receipt_path),
|
|
162
|
+
"receipt_sha256": receipt_sha256,
|
|
163
|
+
"manifest_sha256": manifest_sha,
|
|
164
|
+
"preservation_plan_sha256": plan["plan_sha256"],
|
|
165
|
+
"generation": str(generation),
|
|
166
|
+
"generation_identity": directory.identity,
|
|
167
|
+
"tree_sha256": manifest["tree_sha256"],
|
|
168
|
+
"source_anchor": plan["source_anchor"],
|
|
169
|
+
"source_identity_sha256": receipt["source_identity_sha256"],
|
|
170
|
+
"destination_identity_sha256": receipt["destination_identity_sha256"],
|
|
171
|
+
"inventory": manifest["inventory"],
|
|
172
|
+
"allocation_source_identity": bound,
|
|
173
|
+
}
|
|
174
|
+
return evidence, plan
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def completed_actions(
|
|
178
|
+
store, transaction: str, proofs: tuple[dict[str, Any], ...],
|
|
179
|
+
*, initial_topology: dict[str, Any], cleanup_paths: list[str],
|
|
180
|
+
) -> tuple[set[str], dict[str, Any]]:
|
|
181
|
+
from . import cleanup_evidence as e
|
|
182
|
+
from . import hardlinks
|
|
183
|
+
|
|
184
|
+
if str(uuid.UUID(transaction)) != transaction:
|
|
185
|
+
raise _blocked("canonical cleanup transaction identity required")
|
|
186
|
+
if [proof["resource"] for proof in proofs] != cleanup_paths[:len(proofs)]:
|
|
187
|
+
raise _blocked("cleanup action proofs differ from the exact ordered plan")
|
|
188
|
+
hardlinks.validate(initial_topology)
|
|
189
|
+
if initial_topology["resources"] != cleanup_paths:
|
|
190
|
+
raise _blocked("cleanup topology does not cover every planned resource")
|
|
191
|
+
completed: set[str] = set()
|
|
192
|
+
preceding = initial_topology
|
|
193
|
+
for step, proof in enumerate(proofs):
|
|
194
|
+
resource = p.source_relative_path(proof["resource"])
|
|
195
|
+
if resource in completed:
|
|
196
|
+
raise _blocked("duplicate cleanup action proof")
|
|
197
|
+
directory = store.cleanup_actions_dir / transaction
|
|
198
|
+
if not e.matches_reference(proof["before"], directory / f"{step:04d}-before.json") or not e.matches_reference(
|
|
199
|
+
proof["after"], directory / f"{step:04d}-after.json"
|
|
200
|
+
):
|
|
201
|
+
raise _blocked("cleanup proof path differs from its exact transaction step")
|
|
202
|
+
before, _ = e.read(Path(proof["before"]["path"]), proof["before"]["sha256"])
|
|
203
|
+
after, _ = e.read(Path(proof["after"]["path"]), proof["after"]["sha256"])
|
|
204
|
+
if (
|
|
205
|
+
before.get("phase") != "BEFORE" or before.get("execution_state") != "ACTION_STARTED"
|
|
206
|
+
or after.get("phase") != "COMPLETED" or after.get("execution_state") != "ACTION_COMPLETED"
|
|
207
|
+
or before.get("resource") != resource or after.get("resource") != resource
|
|
208
|
+
or after.get("before") != proof["before"] or before["topology"] != preceding
|
|
209
|
+
):
|
|
210
|
+
raise _blocked("cleanup action evidence does not prove exact completion")
|
|
211
|
+
mutation = hardlinks.transition(
|
|
212
|
+
before["topology"], after["topology"], resource,
|
|
213
|
+
after["mutation"].get("external_survivors"),
|
|
214
|
+
)
|
|
215
|
+
if after["mutation"] != mutation or before.get("contract") != mutation["contract"] or after.get("contract") != mutation["contract"]:
|
|
216
|
+
raise _blocked("cleanup transition evidence changed")
|
|
217
|
+
preceding = after["topology"]
|
|
218
|
+
completed.add(resource)
|
|
219
|
+
return completed, preceding
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def common_source_root(plans: list[dict[str, Any]], target: Path) -> dict[str, int]:
|
|
223
|
+
"""Bind cleanup to an original root pin, including compatible old nested proofs."""
|
|
224
|
+
pins = []
|
|
225
|
+
for plan in plans:
|
|
226
|
+
relative = p.source_relative_path(Path(plan["source"]).relative_to(target).as_posix())
|
|
227
|
+
ancestors = plan.get("source_ancestor_identities")
|
|
228
|
+
if ancestors is not None:
|
|
229
|
+
pin = ancestors.get(str(target))
|
|
230
|
+
if pin is None:
|
|
231
|
+
raise _blocked("nested preservation has no original checkout-root pin")
|
|
232
|
+
elif len(Path(relative).parts) == 1:
|
|
233
|
+
pin = plan["source_anchor"]["worktree"]
|
|
234
|
+
else:
|
|
235
|
+
continue
|
|
236
|
+
if set(pin) != {"device", "inode", "file_type"} or pin["file_type"] != stat.S_IFDIR:
|
|
237
|
+
raise _blocked("invalid original checkout-root identity")
|
|
238
|
+
pins.append(pin)
|
|
239
|
+
if not pins or any(pin != pins[0] for pin in pins):
|
|
240
|
+
raise _blocked("all preservation generations require one original checkout-root identity")
|
|
241
|
+
with p._absolute_directory(target) as chain:
|
|
242
|
+
if chain[-1].identity != pins[0]:
|
|
243
|
+
raise _blocked("checkout root differs from the preserved original identity")
|
|
244
|
+
return pins[0]
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def source_absent(plan: dict[str, Any], target: Path) -> bool:
|
|
248
|
+
relative = p.source_relative_path(Path(plan["source"]).relative_to(target).as_posix())
|
|
249
|
+
source = target / relative
|
|
250
|
+
with p._absolute_directory(source.parent) as chain:
|
|
251
|
+
if chain[-1].identity != plan["source_anchor"]["worktree"]:
|
|
252
|
+
raise _blocked("original source parent identity changed")
|
|
253
|
+
if "source_ancestor_identities" in plan and p._chain_identities(chain) != plan["source_ancestor_identities"]:
|
|
254
|
+
raise _blocked("original source ancestor chain changed")
|
|
255
|
+
try:
|
|
256
|
+
os.stat(source.name, dir_fd=chain[-1].fd, follow_symlinks=False)
|
|
257
|
+
except FileNotFoundError:
|
|
258
|
+
return True
|
|
259
|
+
return False
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _source_key(plan: dict[str, Any]) -> str:
|
|
263
|
+
return p._sha({
|
|
264
|
+
"identity": plan["authority"]["identity"],
|
|
265
|
+
**{key: plan[key] for key in ("source", "source_anchor", "source_tree")},
|
|
266
|
+
})
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _reopened_source_identity(
|
|
270
|
+
previous: dict[str, Any], current: dict[str, Any], target: Path,
|
|
271
|
+
record: MetadataRecord | None,
|
|
272
|
+
) -> bool:
|
|
273
|
+
from .git_backend import inventory, is_ancestor, resolve_commit
|
|
274
|
+
|
|
275
|
+
stable = {"repository_id", "allocation_id", "target_path", "owner", "authority_key"}
|
|
276
|
+
if (
|
|
277
|
+
record is None or set(previous) != set(current)
|
|
278
|
+
or set(current) != stable | {"expected_head", "expected_record_version"}
|
|
279
|
+
or any(previous[key] != current[key] for key in stable)
|
|
280
|
+
or current["target_path"] != str(target)
|
|
281
|
+
or current["allocation_id"] != record.allocation_id
|
|
282
|
+
or current["owner"] != record.owner or current["authority_key"] != record.authority_key
|
|
283
|
+
or current["expected_record_version"] != record.record_version
|
|
284
|
+
or record.record_version != len(record.history)
|
|
285
|
+
or record.role not in {"feature", "audit", "hotfix"}
|
|
286
|
+
or not isinstance(previous["expected_record_version"], int)
|
|
287
|
+
or isinstance(previous["expected_record_version"], bool)
|
|
288
|
+
or not 1 <= previous["expected_record_version"] < record.record_version
|
|
289
|
+
or record.lifecycle not in {"HANDOFF_READY", "PRESERVED"}
|
|
290
|
+
or not record.finalizer_record_id
|
|
291
|
+
or any(oid != current["expected_head"] for oid in (
|
|
292
|
+
record.handoff_commit, record.task_commit, record.finalizer_commit,
|
|
293
|
+
))
|
|
294
|
+
):
|
|
295
|
+
return False
|
|
296
|
+
# Supported records start with v1/history[0]; every official update appends
|
|
297
|
+
# exactly one event and advances CAS by one. Unknown/shortened history cannot
|
|
298
|
+
# establish which frozen allocation version a historical copy belonged to.
|
|
299
|
+
state = None
|
|
300
|
+
for event in record.history:
|
|
301
|
+
if event.get("from") != state or not event.get("to"):
|
|
302
|
+
return False
|
|
303
|
+
state = event["to"]
|
|
304
|
+
if state != record.lifecycle:
|
|
305
|
+
return False
|
|
306
|
+
old_version = previous["expected_record_version"]
|
|
307
|
+
state = record.history[old_version - 1]["to"]
|
|
308
|
+
if state not in {"HANDOFF_READY", "PRESERVED"}:
|
|
309
|
+
return False
|
|
310
|
+
opened = False
|
|
311
|
+
completed_cycle = False
|
|
312
|
+
for event in record.history[old_version:]:
|
|
313
|
+
if event.get("owner") != record.owner:
|
|
314
|
+
return False
|
|
315
|
+
kind, next_state = event.get("event"), event["to"]
|
|
316
|
+
if kind == "REOPENED" and state in {"HANDOFF_READY", "PRESERVED"} and next_state == "ACTIVE":
|
|
317
|
+
opened = True
|
|
318
|
+
elif (
|
|
319
|
+
opened and state == "ACTIVE"
|
|
320
|
+
and (kind, next_state) in {
|
|
321
|
+
("HANDOFF_INTEGRATION_READY", "HANDOFF_READY"),
|
|
322
|
+
("HANDOFF_PRESERVED", "PRESERVED"),
|
|
323
|
+
}
|
|
324
|
+
):
|
|
325
|
+
opened = False
|
|
326
|
+
completed_cycle = True
|
|
327
|
+
elif kind not in {"REVIEWED", "SCOPE_AMENDED", "DISPOSABLE_REGISTERED"} or next_state != state:
|
|
328
|
+
return False
|
|
329
|
+
state = next_state
|
|
330
|
+
if opened or not completed_cycle or record.history[old_version - 1].get("owner") != record.owner:
|
|
331
|
+
return False
|
|
332
|
+
repository = inventory(target)
|
|
333
|
+
item = next((row for row in repository.worktrees if row.path == target), None)
|
|
334
|
+
return bool(
|
|
335
|
+
item is not None and not item.detached and item.branch
|
|
336
|
+
and item.head == current["expected_head"]
|
|
337
|
+
and resolve_commit(target, f"refs/heads/{item.branch}") == item.head
|
|
338
|
+
and resolve_commit(target, previous["expected_head"]) == previous["expected_head"]
|
|
339
|
+
and record.base_commit
|
|
340
|
+
and is_ancestor(target, record.base_commit, previous["expected_head"])
|
|
341
|
+
and is_ancestor(target, previous["expected_head"], current["expected_head"])
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _same_completed_source(
|
|
346
|
+
first: dict[str, Any], second: dict[str, Any], target: Path,
|
|
347
|
+
*, record: MetadataRecord | None = None,
|
|
348
|
+
) -> bool:
|
|
349
|
+
# Only an actual completed action supplies the second plan. A rebuilt source
|
|
350
|
+
# node may supersede an older copy only across verified reopen/handoff history.
|
|
351
|
+
if (
|
|
352
|
+
first["source"] != second["source"]
|
|
353
|
+
or first.get("source_ancestor_identities") != second.get("source_ancestor_identities")
|
|
354
|
+
):
|
|
355
|
+
return False
|
|
356
|
+
same_identity = first["authority"]["identity"] == second["authority"]["identity"]
|
|
357
|
+
if same_identity:
|
|
358
|
+
if first["source_anchor"] != second["source_anchor"]:
|
|
359
|
+
return False
|
|
360
|
+
elif (
|
|
361
|
+
first["source_anchor"]["worktree"] != second["source_anchor"]["worktree"]
|
|
362
|
+
or any(first["source_anchor"]["source"][key] != second["source_anchor"]["source"][key]
|
|
363
|
+
for key in ("device", "file_type"))
|
|
364
|
+
or not _reopened_source_identity(
|
|
365
|
+
first["authority"]["identity"], second["authority"]["identity"], target, record,
|
|
366
|
+
)
|
|
367
|
+
):
|
|
368
|
+
return False
|
|
369
|
+
common_source_root([first, second], target)
|
|
370
|
+
return True
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _resumed_cleanup_identity(
|
|
374
|
+
previous: dict[str, Any], current: dict[str, Any], record: MetadataRecord | None,
|
|
375
|
+
) -> bool:
|
|
376
|
+
"""Reuse completed cleanup only after a same-head retained/resumed handoff.
|
|
377
|
+
|
|
378
|
+
The receipt keeps its original CAS. This proves a narrow official history
|
|
379
|
+
suffix, not a general exemption for older receipts or changed HEADs.
|
|
380
|
+
"""
|
|
381
|
+
stable = {"repository_id", "allocation_id", "target_path", "expected_head", "owner", "authority_key"}
|
|
382
|
+
old = previous.get("expected_record_version")
|
|
383
|
+
if (
|
|
384
|
+
record is None or set(previous) != stable | {"expected_record_version"}
|
|
385
|
+
or set(current) != set(previous)
|
|
386
|
+
or any(previous[key] != current[key] for key in stable)
|
|
387
|
+
or not isinstance(old, int) or isinstance(old, bool)
|
|
388
|
+
or not 1 <= old < record.record_version
|
|
389
|
+
or current["expected_record_version"] != record.record_version
|
|
390
|
+
or record.record_version != len(record.history)
|
|
391
|
+
or record.role not in {"feature", "audit"}
|
|
392
|
+
or record.lifecycle != "HANDOFF_READY"
|
|
393
|
+
or record.owner != current["owner"] or record.authority_key != current["authority_key"]
|
|
394
|
+
or record.allocation_id != current["allocation_id"]
|
|
395
|
+
or not record.finalizer_record_id
|
|
396
|
+
or any(oid != current["expected_head"] for oid in (
|
|
397
|
+
record.task_commit, record.handoff_commit, record.finalizer_commit,
|
|
398
|
+
))
|
|
399
|
+
):
|
|
400
|
+
return False
|
|
401
|
+
state = None
|
|
402
|
+
for event in record.history[:old]:
|
|
403
|
+
if event.get("from") != state or not event.get("to") or event.get("owner") != record.owner:
|
|
404
|
+
return False
|
|
405
|
+
state = event["to"]
|
|
406
|
+
if state != "RETAINED":
|
|
407
|
+
return False
|
|
408
|
+
transitions = {
|
|
409
|
+
("RESUMED", "RETAINED", "ACTIVE"),
|
|
410
|
+
("PARKED", "ACTIVE", "RETAINED"),
|
|
411
|
+
("HANDOFF_INTEGRATION_READY", "ACTIVE", "HANDOFF_READY"),
|
|
412
|
+
}
|
|
413
|
+
for event in record.history[old:]:
|
|
414
|
+
if (
|
|
415
|
+
event.get("from") != state or event.get("owner") != record.owner
|
|
416
|
+
or (event.get("event"), state, event.get("to")) not in transitions
|
|
417
|
+
):
|
|
418
|
+
return False
|
|
419
|
+
state = event["to"]
|
|
420
|
+
return state == record.lifecycle
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def _require_completion_coverage(
|
|
424
|
+
bound: dict[str, Any], plans: list[dict[str, Any]], *, record: MetadataRecord | None = None,
|
|
425
|
+
) -> None:
|
|
426
|
+
"""A completed copy with an absent source requires actual action evidence.
|
|
427
|
+
|
|
428
|
+
Inspect only this allocation's official generation metadata. Unreferenced
|
|
429
|
+
generation payloads are not opened, and source absence grants no authority.
|
|
430
|
+
"""
|
|
431
|
+
from .source_preservation import CONTRACT as SOURCE_CONTRACT
|
|
432
|
+
|
|
433
|
+
allocation = p.evidence_root() / bound["repository_id"] / bound["allocation_id"]
|
|
434
|
+
if not os.path.lexists(allocation):
|
|
435
|
+
return
|
|
436
|
+
if any(
|
|
437
|
+
plan["authority"]["identity"] != bound
|
|
438
|
+
and not _resumed_cleanup_identity(plan["authority"]["identity"], bound, record)
|
|
439
|
+
for plan in plans
|
|
440
|
+
):
|
|
441
|
+
raise _blocked("completed preservation belongs to another exact allocation identity")
|
|
442
|
+
covered = {_source_key(plan) for plan in plans}
|
|
443
|
+
with p._absolute_directory(allocation) as chain:
|
|
444
|
+
for name in sorted(os.listdir(chain[-1].fd)):
|
|
445
|
+
try:
|
|
446
|
+
if str(uuid.UUID(name)) != name:
|
|
447
|
+
raise _blocked("allocation contains a noncanonical generation entry")
|
|
448
|
+
except ValueError as exc:
|
|
449
|
+
raise _blocked("allocation contains a noncanonical generation entry") from exc
|
|
450
|
+
with p._absolute_directory(allocation / name) as generation:
|
|
451
|
+
directory = generation[-1]
|
|
452
|
+
try:
|
|
453
|
+
os.stat("receipt.json", dir_fd=directory.fd, follow_symlinks=False)
|
|
454
|
+
except FileNotFoundError:
|
|
455
|
+
continue
|
|
456
|
+
receipt, _raw_sha = _document(directory, "receipt.json")
|
|
457
|
+
plan, _ = _document(directory, "plan.json")
|
|
458
|
+
if (
|
|
459
|
+
receipt.get("contract") not in {p.CONTRACT, SOURCE_CONTRACT}
|
|
460
|
+
or receipt.get("contract") != plan.get("contract")
|
|
461
|
+
or receipt.get("verification") != "PASS" or receipt.get("receipt_id") != name
|
|
462
|
+
or plan.get("generation") != name
|
|
463
|
+
or receipt.get("generation") != str(allocation / name)
|
|
464
|
+
or receipt.get("generation_identity") != directory.identity
|
|
465
|
+
or receipt.get("allocation_source_identity") != plan["authority"]["identity"]
|
|
466
|
+
or plan.get("state_root") != str(p.evidence_root())
|
|
467
|
+
or plan.get("destination") != str(allocation / name)
|
|
468
|
+
or receipt.get("plan_sha256") != plan.get("plan_sha256")
|
|
469
|
+
or p._sha({k: v for k, v in plan.items() if k != "plan_sha256"}) != plan["plan_sha256"]
|
|
470
|
+
or plan["authority"]["identity"].get("repository_id") != bound["repository_id"]
|
|
471
|
+
or plan["authority"]["identity"].get("allocation_id") != bound["allocation_id"]
|
|
472
|
+
or plan["authority"]["identity"].get("target_path") != bound["target_path"]
|
|
473
|
+
):
|
|
474
|
+
raise _blocked("allocation generation metadata changed")
|
|
475
|
+
if plan["contract"] == SOURCE_CONTRACT:
|
|
476
|
+
tree = plan["source_tree"]
|
|
477
|
+
portable = {key: tree[key] for key in (
|
|
478
|
+
"base_head", "base_tree", "dirty_paths", "files", "reconstructed_tree_oid",
|
|
479
|
+
)}
|
|
480
|
+
if (
|
|
481
|
+
receipt.get("result") != "SOURCE_PRESERVATION_COMPLETE"
|
|
482
|
+
or plan.get("cleanup_authorized") is not False
|
|
483
|
+
or receipt.get("cleanup_authorized") is not False
|
|
484
|
+
or any(key in plan or key in receipt for key in (
|
|
485
|
+
"source", "source_relative", "source_anchor", "source_ancestor_identities",
|
|
486
|
+
))
|
|
487
|
+
or tree.get("base_head") != plan["authority"]["identity"]["expected_head"]
|
|
488
|
+
or not isinstance(tree.get("files"), list)
|
|
489
|
+
or tree.get("dirty_paths") != [row["path"] for row in tree["files"]]
|
|
490
|
+
or any(key in tree for key in ("manifest", "inventory", "errors"))
|
|
491
|
+
or tree["tree_sha256"] != p._sha(portable)
|
|
492
|
+
or receipt.get("tree_sha256") != tree["tree_sha256"]
|
|
493
|
+
or receipt.get("reconstructed_tree_oid") != tree["reconstructed_tree_oid"]
|
|
494
|
+
):
|
|
495
|
+
raise _blocked("tracked-source generation metadata changed")
|
|
496
|
+
# Tracked-source restoration has its own completed-action verifier.
|
|
497
|
+
# Its copy grants no ignored-resource cleanup authority.
|
|
498
|
+
continue
|
|
499
|
+
tree = plan["source_tree"]
|
|
500
|
+
relative = p.source_relative_path(Path(plan["source"]).relative_to(Path(bound["target_path"])).as_posix())
|
|
501
|
+
if (
|
|
502
|
+
receipt.get("result") != "LEGACY_PRESERVATION_COMPLETE" or plan.get("ready") is not True
|
|
503
|
+
or plan.get("source_relative", relative) != relative
|
|
504
|
+
or receipt.get("source") != plan["source"] or receipt.get("source_anchor") != plan["source_anchor"]
|
|
505
|
+
or receipt.get("destination") != str(allocation / name / "tree")
|
|
506
|
+
or tree.get("errors") != [] or not isinstance(tree.get("manifest"), list)
|
|
507
|
+
or receipt.get("inventory") != tree["inventory"]
|
|
508
|
+
or receipt.get("source_tree_sha256") != tree["tree_sha256"]
|
|
509
|
+
or receipt.get("preserved_tree_sha256") != tree["tree_sha256"]
|
|
510
|
+
or receipt.get("source_identity_sha256") != p._sha(tree["source_identities"])
|
|
511
|
+
or tree["tree_sha256"] != p._sha([
|
|
512
|
+
{key: value for key, value in row.items() if key != "ownership"}
|
|
513
|
+
for row in tree["manifest"]
|
|
514
|
+
])
|
|
515
|
+
):
|
|
516
|
+
raise _blocked("legacy generation metadata changed")
|
|
517
|
+
if source_absent(plan, Path(bound["target_path"])) and (
|
|
518
|
+
not (plan["authority"]["identity"] == bound and _source_key(plan) in covered)
|
|
519
|
+
and not any(_same_completed_source(
|
|
520
|
+
plan, completed, Path(bound["target_path"]), record=record,
|
|
521
|
+
) for completed in plans)
|
|
522
|
+
):
|
|
523
|
+
raise _blocked("absent preserved source has no actual completed action evidence")
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _pending_preservation_plans(
|
|
527
|
+
store, item, bound: dict[str, Any], transaction: str, cleanup_recovery: dict[str, Any] | None,
|
|
528
|
+
) -> list[dict[str, Any]]:
|
|
529
|
+
from . import cleanup_evidence as e
|
|
530
|
+
from .lifecycle import _release_plan_id
|
|
531
|
+
|
|
532
|
+
if str(uuid.UUID(transaction)) != transaction:
|
|
533
|
+
raise _blocked("canonical cleanup transaction identity required")
|
|
534
|
+
journal = store._read_json(store.transactions_dir / f"{transaction}.json")
|
|
535
|
+
plan = journal["cleanup_plan"]
|
|
536
|
+
completed = [row["path"] for row in journal["actions"] if row["status"] == "COMPLETED"]
|
|
537
|
+
if (
|
|
538
|
+
journal.get("transaction_id") != transaction or journal.get("command") != "resource-clean"
|
|
539
|
+
or journal.get("phase") != "PREPARED" or journal.get("branch") != item.branch
|
|
540
|
+
or any(journal.get(key) != bound[key] for key in (
|
|
541
|
+
"repository_id", "allocation_id", "target_path", "expected_head", "expected_record_version",
|
|
542
|
+
))
|
|
543
|
+
or any(plan.get(key) != value for key, value in bound.items())
|
|
544
|
+
or plan.get("plan_id") != _release_plan_id({k: v for k, v in plan.items() if k != "plan_id"})
|
|
545
|
+
or [row["path"] for row in journal["actions"]] != plan["cleanup_paths"]
|
|
546
|
+
or completed != plan["cleanup_paths"][:len(completed)]
|
|
547
|
+
or (cleanup_recovery is not None and plan.get("recovery") != cleanup_recovery)
|
|
548
|
+
):
|
|
549
|
+
raise _blocked("in-progress cleanup identity or action prefix changed")
|
|
550
|
+
proofs = []
|
|
551
|
+
for step, relative in enumerate(completed):
|
|
552
|
+
path = store.cleanup_actions_dir / transaction / f"{step:04d}-after.json"
|
|
553
|
+
after, digest = e.read(path)
|
|
554
|
+
proofs.append({
|
|
555
|
+
"resource": relative, "before": after["before"],
|
|
556
|
+
"after": {"path": str(path), "sha256": digest},
|
|
557
|
+
})
|
|
558
|
+
done, _ = completed_actions(
|
|
559
|
+
store, transaction, tuple(proofs), initial_topology=plan["hardlink_topology"], cleanup_paths=plan["cleanup_paths"],
|
|
560
|
+
)
|
|
561
|
+
evidence_set = plan.get("preservation_evidences")
|
|
562
|
+
if evidence_set is None:
|
|
563
|
+
evidence_set = [{"evidence": plan["preservation_evidence"]}] if plan.get("preservation_evidence") else []
|
|
564
|
+
preserved_plans = []
|
|
565
|
+
completed_plans = []
|
|
566
|
+
for entry in evidence_set:
|
|
567
|
+
evidence = entry["evidence"]
|
|
568
|
+
verified, preserved = coverage(
|
|
569
|
+
Path(evidence["receipt_path"]), evidence["receipt_sha256"], identity=bound, verify_source=False,
|
|
570
|
+
)
|
|
571
|
+
if verified != evidence:
|
|
572
|
+
raise _blocked("in-progress preservation generation changed")
|
|
573
|
+
preserved_plans.append(preserved)
|
|
574
|
+
relative = p.source_relative_path(Path(preserved["source"]).relative_to(item.path).as_posix())
|
|
575
|
+
if relative in done:
|
|
576
|
+
if not source_absent(preserved, item.path):
|
|
577
|
+
raise _blocked("completed preserved source reappeared")
|
|
578
|
+
completed_plans.append(preserved)
|
|
579
|
+
if preserved_plans:
|
|
580
|
+
common_source_root(preserved_plans, item.path)
|
|
581
|
+
return completed_plans
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def cleanup_receipts(
|
|
585
|
+
store, item, record, repository_id: str | None, *,
|
|
586
|
+
expected_refs: list[dict[str, str]] | None = None,
|
|
587
|
+
cleanup_recovery: dict[str, Any] | None = None,
|
|
588
|
+
cleanup_transaction_id: str | None = None,
|
|
589
|
+
allow_pending_typed_no_action: bool = False,
|
|
590
|
+
include_no_action_refs: bool = False,
|
|
591
|
+
) -> list[dict[str, str]]:
|
|
592
|
+
"""Reverify exact-CAS completion bytes, action chain and preservation generations.
|
|
593
|
+
|
|
594
|
+
Verified zero-effect refusals are included only for the release core's
|
|
595
|
+
writer/runtime guard; archive consumers freeze successful completions only.
|
|
596
|
+
"""
|
|
597
|
+
from . import cleanup_evidence as e
|
|
598
|
+
from . import hardlinks
|
|
599
|
+
from .lifecycle import (
|
|
600
|
+
MULTIPLE_PRESERVED_CLEANUP_CONTRACT,
|
|
601
|
+
TYPED_RESOURCE_CONTRACT,
|
|
602
|
+
_preservation_source_relative,
|
|
603
|
+
_release_plan_id,
|
|
604
|
+
_validate_multiple_preservation_cleanup,
|
|
605
|
+
)
|
|
606
|
+
|
|
607
|
+
bound = {
|
|
608
|
+
"repository_id": repository_id, "allocation_id": record.allocation_id,
|
|
609
|
+
"target_path": str(item.path), "expected_head": item.head,
|
|
610
|
+
"expected_record_version": record.record_version,
|
|
611
|
+
"owner": record.owner, "authority_key": record.authority_key,
|
|
612
|
+
}
|
|
613
|
+
refs = []
|
|
614
|
+
completed_preservation_plans = []
|
|
615
|
+
try:
|
|
616
|
+
if cleanup_recovery is not None:
|
|
617
|
+
from .cleanup_recovery import original
|
|
618
|
+
|
|
619
|
+
recovered_receipt, recovered_plan = original(
|
|
620
|
+
store, cleanup_recovery, target=item.path, allocation_id=record.allocation_id,
|
|
621
|
+
)
|
|
622
|
+
if (
|
|
623
|
+
any(recovered_receipt["plan"].get(key) != value for key, value in bound.items())
|
|
624
|
+
or recovered_plan["authority"]["identity"] != bound
|
|
625
|
+
):
|
|
626
|
+
raise _blocked("original recovery identity or exact CAS changed")
|
|
627
|
+
from . import multiple_cleanup_recovery as multi_recovery
|
|
628
|
+
|
|
629
|
+
if cleanup_recovery.get("contract") == multi_recovery.CONTRACT:
|
|
630
|
+
_, preserved_plans, original_completed, _ = multi_recovery.details(
|
|
631
|
+
store, cleanup_recovery
|
|
632
|
+
)
|
|
633
|
+
completed_preservation_plans.extend(
|
|
634
|
+
plan for plan in preserved_plans
|
|
635
|
+
if _preservation_source_relative(plan, item.path) in original_completed
|
|
636
|
+
)
|
|
637
|
+
for path in sorted(store.completion_receipts_dir.glob("*.json")):
|
|
638
|
+
with p._absolute_directory(path.parent) as chain:
|
|
639
|
+
receipt, raw_sha = _document(chain[-1], path.name)
|
|
640
|
+
plan = receipt.get("plan", {})
|
|
641
|
+
if plan.get("allocation_id") != record.allocation_id:
|
|
642
|
+
continue
|
|
643
|
+
if plan.get("integration_abort_evidence") is not None:
|
|
644
|
+
from .integration_cleanup import abort_evidence
|
|
645
|
+
|
|
646
|
+
if abort_evidence(store, store._load_unlocked(), item, record) != plan["integration_abort_evidence"]:
|
|
647
|
+
raise _blocked("integration abort evidence changed after cleanup")
|
|
648
|
+
if cleanup_recovery is not None and path.stem == cleanup_recovery["original_transaction_id"]:
|
|
649
|
+
continue
|
|
650
|
+
if "preservation_evidences" in plan and (
|
|
651
|
+
receipt.get("contract") != MULTIPLE_PRESERVED_CLEANUP_CONTRACT
|
|
652
|
+
or receipt.get("result") != "CLEANED"
|
|
653
|
+
):
|
|
654
|
+
from . import multiple_cleanup_recovery as multi_recovery
|
|
655
|
+
|
|
656
|
+
if multi_recovery.completed(store, path.stem, verify_generations=True):
|
|
657
|
+
completed_preservation_plans.extend(
|
|
658
|
+
multi_recovery.verified_completion(store, path.stem, bound=bound)
|
|
659
|
+
)
|
|
660
|
+
continue
|
|
661
|
+
raise _blocked("multiple cleanup is incomplete or downgraded")
|
|
662
|
+
if receipt.get("contract") not in {
|
|
663
|
+
CONTRACT, MULTIPLE_PRESERVED_CLEANUP_CONTRACT, TYPED_RESOURCE_CONTRACT,
|
|
664
|
+
}:
|
|
665
|
+
continue
|
|
666
|
+
from . import multiple_cleanup_recovery as multi_recovery
|
|
667
|
+
|
|
668
|
+
if plan.get("recovery", {}).get("contract") == multi_recovery.CONTRACT:
|
|
669
|
+
completed_preservation_plans.extend(
|
|
670
|
+
multi_recovery.recovered_receipt(store, receipt, bound)
|
|
671
|
+
)
|
|
672
|
+
refs.append({"receipt_id": path.stem, "path": str(path), "sha256": raw_sha})
|
|
673
|
+
continue
|
|
674
|
+
if receipt.get("result") == "FAILED_OR_PARTIAL":
|
|
675
|
+
from .cleanup_recovery import completed
|
|
676
|
+
|
|
677
|
+
if receipt.get("contract") == TYPED_RESOURCE_CONTRACT:
|
|
678
|
+
from .cleanup_attempts import _typed_journal, typed_terminal
|
|
679
|
+
|
|
680
|
+
if typed_terminal(store, path.stem) or allow_pending_typed_no_action:
|
|
681
|
+
journal, digest = e.read(store.transactions_dir / path.name)
|
|
682
|
+
verified_journal = _typed_journal(store, path.stem, digest)
|
|
683
|
+
if (
|
|
684
|
+
journal != verified_journal or journal["cleanup_plan"] != plan
|
|
685
|
+
or any(plan.get(key) != value for key, value in bound.items())
|
|
686
|
+
or plan.get("blockers") != []
|
|
687
|
+
or _release_plan_id({k: v for k, v in plan.items() if k != "plan_id"}) != plan.get("plan_id")
|
|
688
|
+
or plan.get("preservation_evidence") is not None or "preservation_evidences" in plan
|
|
689
|
+
or any(row.get("cleanup_action") == "remove-preserved-tree"
|
|
690
|
+
or row.get("classification") == "exact-preserved" for row in plan["resources"])
|
|
691
|
+
):
|
|
692
|
+
raise _blocked("typed no-action refusal identity or proof shape changed")
|
|
693
|
+
if include_no_action_refs:
|
|
694
|
+
refs.append({"receipt_id": path.stem, "path": str(path), "sha256": raw_sha})
|
|
695
|
+
continue
|
|
696
|
+
if receipt.get("contract") == CONTRACT and completed(store, path.stem, verify_generations=True):
|
|
697
|
+
evidence = plan["preservation_evidence"]
|
|
698
|
+
verified, preserved = coverage(
|
|
699
|
+
Path(evidence["receipt_path"]), evidence["receipt_sha256"],
|
|
700
|
+
identity=bound, verify_source=False,
|
|
701
|
+
)
|
|
702
|
+
if verified != evidence:
|
|
703
|
+
raise _blocked("recovered original generation changed")
|
|
704
|
+
completed_preservation_plans.append(preserved)
|
|
705
|
+
continue
|
|
706
|
+
receipt_bound = {key: plan.get(key) for key in bound}
|
|
707
|
+
if receipt_bound != bound and not _resumed_cleanup_identity(receipt_bound, bound, record):
|
|
708
|
+
raise _blocked("cleanup completion identity or exact CAS changed")
|
|
709
|
+
if (
|
|
710
|
+
str(uuid.UUID(path.stem)) != path.stem
|
|
711
|
+
or receipt.get("result") != "CLEANED" or receipt.get("receipt_id") != path.stem
|
|
712
|
+
or plan.get("contract") != receipt["contract"]
|
|
713
|
+
or plan.get("blockers") != []
|
|
714
|
+
or _release_plan_id({k: v for k, v in plan.items() if k != "plan_id"}) != plan.get("plan_id")
|
|
715
|
+
):
|
|
716
|
+
raise _blocked("cleanup completion identity or exact CAS changed")
|
|
717
|
+
preserved_rows = [
|
|
718
|
+
row for row in plan["resources"]
|
|
719
|
+
if row["cleanup_action"] == "remove-preserved-tree" or row["classification"] == "exact-preserved"
|
|
720
|
+
]
|
|
721
|
+
expected_contract = (
|
|
722
|
+
MULTIPLE_PRESERVED_CLEANUP_CONTRACT if "preservation_evidences" in plan
|
|
723
|
+
else CONTRACT if plan.get("preservation_evidence") is not None or preserved_rows
|
|
724
|
+
else TYPED_RESOURCE_CONTRACT
|
|
725
|
+
)
|
|
726
|
+
if receipt["contract"] != expected_contract or receipt["post_scan"].get("contract") != expected_contract:
|
|
727
|
+
raise _blocked("cleanup contract differs from its exact preservation proof shape")
|
|
728
|
+
paths = plan["cleanup_paths"]
|
|
729
|
+
if not paths or len(set(paths)) != len(paths):
|
|
730
|
+
raise _blocked("cleanup completion has no exact resources")
|
|
731
|
+
for relative in paths:
|
|
732
|
+
p.source_relative_path(relative)
|
|
733
|
+
planned_paths = [r["path"] for r in plan["resources"] if r["cleanup_action"] == "remove-tree"] + [
|
|
734
|
+
r["path"] for r in plan["resources"] if r["cleanup_action"] == "remove-preserved-tree"
|
|
735
|
+
]
|
|
736
|
+
proofs = tuple(receipt.get("action_evidence", []))
|
|
737
|
+
if (
|
|
738
|
+
paths != planned_paths
|
|
739
|
+
or receipt.get("actions") != [{"path": name, "status": "COMPLETED"} for name in paths]
|
|
740
|
+
or len(proofs) != len(paths)
|
|
741
|
+
):
|
|
742
|
+
raise _blocked("cleanup completion has missing action proofs")
|
|
743
|
+
done, topology = completed_actions(
|
|
744
|
+
store, path.stem, proofs, initial_topology=plan["hardlink_topology"], cleanup_paths=paths,
|
|
745
|
+
)
|
|
746
|
+
post = receipt["post_scan"]
|
|
747
|
+
if (
|
|
748
|
+
done != set(paths) or topology != post.get("hardlink_topology")
|
|
749
|
+
or post.get("blockers") != [] or post.get("cleanup_paths") != []
|
|
750
|
+
or any(post.get(key) != value for key, value in receipt_bound.items())
|
|
751
|
+
or _release_plan_id({k: v for k, v in post.items() if k != "plan_id"}) != post.get("plan_id")
|
|
752
|
+
or any(os.path.lexists(item.path / relative) for relative in paths)
|
|
753
|
+
):
|
|
754
|
+
raise _blocked("cleanup post-scan or completed action chain changed")
|
|
755
|
+
hardlinks.validate(topology)
|
|
756
|
+
preservation_plans = []
|
|
757
|
+
if receipt["contract"] == MULTIPLE_PRESERVED_CLEANUP_CONTRACT:
|
|
758
|
+
preservation_plans = _validate_multiple_preservation_cleanup(receipt, item, receipt_bound)
|
|
759
|
+
elif receipt["contract"] == CONTRACT:
|
|
760
|
+
evidence = plan["preservation_evidence"]
|
|
761
|
+
verified, preserved = coverage(
|
|
762
|
+
Path(evidence["receipt_path"]), evidence["receipt_sha256"],
|
|
763
|
+
identity=receipt_bound, verify_source=False,
|
|
764
|
+
)
|
|
765
|
+
relative = _preservation_source_relative(preserved, item.path)
|
|
766
|
+
resources = [r for r in plan["resources"] if r["cleanup_action"] == "remove-preserved-tree"]
|
|
767
|
+
if (
|
|
768
|
+
verified != evidence or len(resources) != 1
|
|
769
|
+
or resources[0]["path"] != relative or resources[0].get("evidence") != evidence
|
|
770
|
+
or resources[0].get("classification") != "exact-preserved" or relative not in done
|
|
771
|
+
or post.get("preservation_evidence") != evidence
|
|
772
|
+
):
|
|
773
|
+
raise _blocked("single preservation proof differs from its exact source")
|
|
774
|
+
preservation_plans = [preserved]
|
|
775
|
+
for preserved in preservation_plans:
|
|
776
|
+
if not source_absent(preserved, item.path):
|
|
777
|
+
raise _blocked("completed preserved source reappeared")
|
|
778
|
+
completed_preservation_plans.extend(preservation_plans)
|
|
779
|
+
if preservation_plans:
|
|
780
|
+
original_root = common_source_root(preservation_plans, item.path)
|
|
781
|
+
else:
|
|
782
|
+
with p._absolute_directory(item.path) as chain:
|
|
783
|
+
original_root = chain[-1].identity
|
|
784
|
+
if any(anchor["worktree"] != original_root for anchor in plan["hardlink_topology"]["anchors"].values()):
|
|
785
|
+
raise _blocked("cleanup graph is bound to another checkout root")
|
|
786
|
+
refs.append({"receipt_id": path.stem, "path": str(path), "sha256": raw_sha})
|
|
787
|
+
if cleanup_transaction_id is not None:
|
|
788
|
+
completed_preservation_plans.extend(_pending_preservation_plans(
|
|
789
|
+
store, item, bound, cleanup_transaction_id, cleanup_recovery,
|
|
790
|
+
))
|
|
791
|
+
_require_completion_coverage(bound, completed_preservation_plans, record=record)
|
|
792
|
+
if expected_refs is not None:
|
|
793
|
+
if not isinstance(expected_refs, list) or len(expected_refs) != len(refs):
|
|
794
|
+
raise _blocked("archive cleanup receipt set changed")
|
|
795
|
+
for frozen, actual in zip(expected_refs, refs, strict=True):
|
|
796
|
+
if (
|
|
797
|
+
set(frozen) != {"receipt_id", "path", "sha256"}
|
|
798
|
+
or frozen["receipt_id"] != actual["receipt_id"] or frozen["sha256"] != actual["sha256"]
|
|
799
|
+
or not e.matches_reference(frozen, Path(actual["path"]))
|
|
800
|
+
):
|
|
801
|
+
raise _blocked("archive cleanup receipt bytes or exact path changed")
|
|
802
|
+
except ControllerError:
|
|
803
|
+
raise
|
|
804
|
+
except (OSError, ValueError, TypeError, KeyError, IndexError, StopIteration) as exc:
|
|
805
|
+
raise _blocked("cleanup completion evidence is unavailable, malformed or changed") from exc
|
|
806
|
+
return refs
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
def delete_preserved_state(root: Path, evidence: dict[str, Any]) -> dict[str, Any]:
|
|
810
|
+
from .hardlinks import external_contents, source_baseline
|
|
811
|
+
|
|
812
|
+
topology = evidence.get("source_topology")
|
|
813
|
+
original_evidence = {k: v for k, v in evidence.items() if k != "source_topology"}
|
|
814
|
+
current, plan = coverage(
|
|
815
|
+
Path(evidence["receipt_path"]), evidence["receipt_sha256"],
|
|
816
|
+
identity=evidence["allocation_source_identity"], source_topology=topology,
|
|
817
|
+
)
|
|
818
|
+
source_rel = p.source_relative_path(Path(plan["source"]).relative_to(root).as_posix())
|
|
819
|
+
source_full = root / source_rel
|
|
820
|
+
if current != original_evidence or plan["source"] != str(source_full):
|
|
821
|
+
raise _blocked("cleanup evidence changed")
|
|
822
|
+
with p._absolute_directory(source_full.parent) as ancestors:
|
|
823
|
+
with p._open_node(ancestors[-1].fd, source_full.name) as source:
|
|
824
|
+
chain = (*ancestors, source)
|
|
825
|
+
p._source_anchor(chain, plan)
|
|
826
|
+
expected = (
|
|
827
|
+
source_baseline(plan["source_tree"], topology, resource=source_rel)
|
|
828
|
+
if topology is not None else plan["source_tree"]
|
|
829
|
+
)
|
|
830
|
+
p._same_source(p.scan_tree(source, chain), expected)
|
|
831
|
+
leaf = not stat.S_ISDIR(expected["source_identities"]["."][2])
|
|
832
|
+
if not leaf:
|
|
833
|
+
entries, _ = _fd_inventory(source.fd, preserved_fifo=True)
|
|
834
|
+
if {path: p._identity(entry.metadata) for path, entry in entries.items()} != expected["source_identities"]:
|
|
835
|
+
raise _blocked("source changed after its final content verification")
|
|
836
|
+
return _delete_fd_tree(
|
|
837
|
+
source, ".", entries, chain,
|
|
838
|
+
external_contents=external_contents(topology, source_rel) if topology is not None else None,
|
|
839
|
+
)
|
|
840
|
+
# Close the inspection context before unlinking its name. A fresh O_PATH
|
|
841
|
+
# descriptor binds the exact leaf; all parent FDs remain pinned and checked.
|
|
842
|
+
row = expected["manifest"][0]
|
|
843
|
+
external = external_contents(topology, source_rel).get(".") if topology is not None else None
|
|
844
|
+
proof = _unlink_leaf(
|
|
845
|
+
ancestors[-1].fd, source_full.name, expected["source_identities"]["."],
|
|
846
|
+
link=row.get("target"), chain=ancestors, external=external,
|
|
847
|
+
)
|
|
848
|
+
key = f"{proof['identity'][0]}:{proof['identity'][1]}"
|
|
849
|
+
return {key: proof} if external is not None else {}
|