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,515 @@
|
|
|
1
|
+
"""Restore only exact-receipt-covered tracked files to their bound HEAD bytes.
|
|
2
|
+
|
|
3
|
+
No index write, Git reset/stash, lifecycle transition or allocation release. An
|
|
4
|
+
interruption keeps the normal Controller transaction gate closed; no blind replay.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import stat
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import cleanup_evidence as evidence
|
|
18
|
+
from . import preservation as p
|
|
19
|
+
from . import source_preservation as s
|
|
20
|
+
from .git_backend import _git
|
|
21
|
+
from .models import ControllerError
|
|
22
|
+
from .store import MetadataStore, now_iso
|
|
23
|
+
|
|
24
|
+
CONTRACT = "tracked-source-cleanup/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _store(plan: dict) -> MetadataStore:
|
|
28
|
+
from .lifecycle import inventory
|
|
29
|
+
|
|
30
|
+
return MetadataStore(inventory(Path(plan["repository"])).common_dir)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _base(plan: dict) -> tuple[list[dict], dict[str, bytes]]:
|
|
34
|
+
target = Path(plan["authority"]["identity"]["target_path"])
|
|
35
|
+
rows, payloads = [], {}
|
|
36
|
+
for source in plan["source_tree"]["files"]:
|
|
37
|
+
path = source["path"]
|
|
38
|
+
if plan["source_tree"]["source_identities"][path][8] != 1:
|
|
39
|
+
raise s._fail("SOURCE_CLEANUP_HARDLINK_BLOCKED")
|
|
40
|
+
mode, blob = source["base_entry"]
|
|
41
|
+
size = int(_git(target, "cat-file", "-s", blob).stdout)
|
|
42
|
+
if size > s.MAX_FILE_BYTES:
|
|
43
|
+
raise s._fail("SOURCE_SIZE_LIMIT")
|
|
44
|
+
data = _git(target, "cat-file", "blob", blob).stdout
|
|
45
|
+
if len(data) != size or s._blob(data) != blob:
|
|
46
|
+
raise s._fail("SOURCE_BASE_BLOB_MISMATCH")
|
|
47
|
+
if s._SECRET_BYTES.search(data):
|
|
48
|
+
raise s._fail("SOURCE_SECRET_MATERIAL")
|
|
49
|
+
rows.append(
|
|
50
|
+
{
|
|
51
|
+
"path": path,
|
|
52
|
+
"size": size,
|
|
53
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
54
|
+
"mode": 0o755 if mode == "100755" else 0o644,
|
|
55
|
+
"git_blob": blob,
|
|
56
|
+
}
|
|
57
|
+
)
|
|
58
|
+
payloads[path] = data
|
|
59
|
+
if sum(row["size"] for row in rows) > s.MAX_TOTAL_BYTES:
|
|
60
|
+
raise s._fail("SOURCE_SIZE_LIMIT")
|
|
61
|
+
return rows, payloads
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def plan_cleanup(plan: dict, receipt_sha256: str, *, state_root: Path | None = None) -> dict:
|
|
65
|
+
from .source_recovery import authorization
|
|
66
|
+
|
|
67
|
+
verified = s.verify_preservation(plan, receipt_sha256, state_root=state_root)
|
|
68
|
+
rows, _ = _base(plan)
|
|
69
|
+
value = {
|
|
70
|
+
"contract": CONTRACT,
|
|
71
|
+
"preservation_plan": plan,
|
|
72
|
+
"preservation_receipt_sha256": receipt_sha256,
|
|
73
|
+
"verification": verified,
|
|
74
|
+
"restore": rows,
|
|
75
|
+
"index_action": "none",
|
|
76
|
+
"lifecycle_action": "none",
|
|
77
|
+
"partial_failure": "retain-transaction-no-replay",
|
|
78
|
+
}
|
|
79
|
+
reconciliation = authorization(_store(plan), plan, receipt_sha256)
|
|
80
|
+
if reconciliation is not None:
|
|
81
|
+
value["reconciliation"] = reconciliation
|
|
82
|
+
return {**value, "plan_id": p._sha(value), "result": "SOURCE_CLEANUP_READY"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _state(
|
|
86
|
+
cleanup: dict,
|
|
87
|
+
completed: dict,
|
|
88
|
+
*,
|
|
89
|
+
journal: dict | None = None,
|
|
90
|
+
journal_ref: dict | None = None,
|
|
91
|
+
state_root: Path | None = None,
|
|
92
|
+
_migration_store: MetadataStore | None = None,
|
|
93
|
+
) -> dict:
|
|
94
|
+
plan = cleanup["preservation_plan"]
|
|
95
|
+
identity, tree = plan["authority"]["identity"], plan["source_tree"]
|
|
96
|
+
if cleanup.get("reconciliation"):
|
|
97
|
+
ref = cleanup["reconciliation"]
|
|
98
|
+
e_value, _ = evidence.read(Path(ref["path"]), ref["sha256"])
|
|
99
|
+
if e_value["new_attempt_id"] != ref["new_attempt_id"]:
|
|
100
|
+
raise s._fail("SOURCE_RECOVERY_BLOCKED")
|
|
101
|
+
if journal_ref is not None:
|
|
102
|
+
actual, _ = evidence.read(Path(journal_ref["path"]), journal_ref["sha256"])
|
|
103
|
+
if actual != journal:
|
|
104
|
+
raise s._fail("SOURCE_TRANSACTION_CHANGED")
|
|
105
|
+
authority = s._authority(
|
|
106
|
+
Path(plan["repository"]),
|
|
107
|
+
Path(identity["target_path"]),
|
|
108
|
+
identity["allocation_id"],
|
|
109
|
+
identity["owner"],
|
|
110
|
+
identity["expected_record_version"],
|
|
111
|
+
pending_transaction=journal,
|
|
112
|
+
_migration_store=_migration_store,
|
|
113
|
+
)
|
|
114
|
+
if authority["identity"] != identity:
|
|
115
|
+
raise s._fail("SOURCE_AUTHORITY_CHANGED")
|
|
116
|
+
generation = s._verify_generation(plan, cleanup["preservation_receipt_sha256"], state_root)
|
|
117
|
+
target = Path(identity["target_path"])
|
|
118
|
+
git_state = s._git_state(
|
|
119
|
+
target, Path(identity["git_dir"]), identity["expected_head"], allow_clean=True
|
|
120
|
+
)
|
|
121
|
+
for key in ("base_head", "base_tree", "index_identity", "index_sha256", "autocrlf"):
|
|
122
|
+
if git_state[key] != tree[key]:
|
|
123
|
+
raise s._fail("SOURCE_GIT_CHANGED")
|
|
124
|
+
remaining = sorted(set(tree["dirty_paths"]) - set(completed))
|
|
125
|
+
if git_state["dirty_paths"] != remaining:
|
|
126
|
+
raise s._fail("SOURCE_DIRTY_SET_CHANGED")
|
|
127
|
+
observed = {}
|
|
128
|
+
with p._absolute_directory(target) as chain:
|
|
129
|
+
if chain[-1].identity != tree["root_identity"]:
|
|
130
|
+
raise s._fail("SOURCE_CHANGED")
|
|
131
|
+
for source in tree["files"]:
|
|
132
|
+
path = source["path"]
|
|
133
|
+
data, metadata, parents = s._read(chain, path)
|
|
134
|
+
expected = completed.get(path)
|
|
135
|
+
row = next(r for r in cleanup["restore"] if r["path"] == path) if expected else source
|
|
136
|
+
if (
|
|
137
|
+
metadata != (expected or tree["source_identities"][path])
|
|
138
|
+
or parents != tree["parent_identities"][path]
|
|
139
|
+
or len(data) != row["size"]
|
|
140
|
+
or hashlib.sha256(data).hexdigest() != row["sha256"]
|
|
141
|
+
or stat.S_IMODE(metadata[2]) != row["mode"]
|
|
142
|
+
):
|
|
143
|
+
raise s._fail("SOURCE_CHANGED")
|
|
144
|
+
observed[path] = {"identity": metadata, "sha256": row["sha256"], "mode": row["mode"]}
|
|
145
|
+
return {
|
|
146
|
+
"head": git_state["base_head"],
|
|
147
|
+
"dirty_paths": remaining,
|
|
148
|
+
"dirty_digest": p._sha({path: observed[path] for path in remaining}),
|
|
149
|
+
"observed": observed,
|
|
150
|
+
"index_sha256": git_state["index_sha256"],
|
|
151
|
+
"preservation": generation,
|
|
152
|
+
"runtime_evidence": authority["runtime_evidence"],
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _restore(plan: dict, row: dict, data: bytes) -> list[int]:
|
|
157
|
+
"""All writes go to one verified single-link regular FD, never a path reopen.
|
|
158
|
+
|
|
159
|
+
In-place writes avoid replacing an unverified racing directory entry. A short
|
|
160
|
+
write/interruption may leave this covered file partial: its exact preservation
|
|
161
|
+
and the outstanding transaction remain. There is deliberately no implicit undo.
|
|
162
|
+
"""
|
|
163
|
+
path, tree = row["path"], plan["source_tree"]
|
|
164
|
+
expected = tree["source_identities"][path]
|
|
165
|
+
target = Path(plan["authority"]["identity"]["target_path"])
|
|
166
|
+
with p._absolute_directory(target) as root, s._parent(root, path) as (chain, name):
|
|
167
|
+
if {str(i): d.identity for i, d in enumerate(chain)} != tree["parent_identities"][path]:
|
|
168
|
+
raise s._fail("SOURCE_CHANGED")
|
|
169
|
+
pin = os.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=chain[-1].fd)
|
|
170
|
+
try:
|
|
171
|
+
metadata = os.fstat(pin)
|
|
172
|
+
if (
|
|
173
|
+
not stat.S_ISREG(metadata.st_mode)
|
|
174
|
+
or p._identity(metadata) != expected
|
|
175
|
+
or metadata.st_nlink != 1
|
|
176
|
+
):
|
|
177
|
+
raise s._fail("SOURCE_CHANGED")
|
|
178
|
+
fd = os.open(f"/proc/self/fd/{pin}", os.O_RDWR | os.O_NOATIME | os.O_CLOEXEC)
|
|
179
|
+
try:
|
|
180
|
+
source = next(r for r in tree["files"] if r["path"] == path)
|
|
181
|
+
if p._identity(os.fstat(fd)) != expected or p._transfer(fd) != (
|
|
182
|
+
source["size"],
|
|
183
|
+
source["sha256"],
|
|
184
|
+
):
|
|
185
|
+
raise s._fail("SOURCE_CHANGED")
|
|
186
|
+
p._check_chain(chain)
|
|
187
|
+
p._check_entry(chain[-1].fd, name, expected)
|
|
188
|
+
if p._identity(os.fstat(fd)) != expected:
|
|
189
|
+
raise s._fail("SOURCE_CHANGED")
|
|
190
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
191
|
+
view = memoryview(data)
|
|
192
|
+
while view:
|
|
193
|
+
count = os.write(fd, view)
|
|
194
|
+
if not count:
|
|
195
|
+
raise s._fail("SOURCE_WRITE_FAILED")
|
|
196
|
+
view = view[count:]
|
|
197
|
+
os.ftruncate(fd, len(data))
|
|
198
|
+
os.fchmod(fd, row["mode"])
|
|
199
|
+
os.fsync(fd)
|
|
200
|
+
actual = p._identity(os.fstat(fd))
|
|
201
|
+
if (
|
|
202
|
+
actual[:2] != expected[:2]
|
|
203
|
+
or actual[6:] != expected[6:]
|
|
204
|
+
or stat.S_IMODE(actual[2]) != row["mode"]
|
|
205
|
+
):
|
|
206
|
+
raise s._fail("SOURCE_CHANGED")
|
|
207
|
+
os.lseek(fd, 0, os.SEEK_SET)
|
|
208
|
+
if p._transfer(fd) != (row["size"], row["sha256"]):
|
|
209
|
+
raise s._fail("SOURCE_WRITE_FAILED")
|
|
210
|
+
p._check_chain(chain)
|
|
211
|
+
p._check_entry(chain[-1].fd, name, actual)
|
|
212
|
+
return actual
|
|
213
|
+
finally:
|
|
214
|
+
os.close(fd)
|
|
215
|
+
finally:
|
|
216
|
+
os.close(pin)
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cleanup_source(cleanup: dict, *, state_root: Path | None = None) -> dict:
|
|
220
|
+
plan, receipt_sha = cleanup["preservation_plan"], cleanup["preservation_receipt_sha256"]
|
|
221
|
+
if plan_cleanup(plan, receipt_sha, state_root=state_root) != cleanup:
|
|
222
|
+
raise s._fail("SOURCE_CLEANUP_PLAN_MISMATCH")
|
|
223
|
+
rows, payloads = _base(plan)
|
|
224
|
+
identity = plan["authority"]["identity"]
|
|
225
|
+
store = _store(plan)
|
|
226
|
+
reconciliation = cleanup.get("reconciliation")
|
|
227
|
+
transaction_id = reconciliation["new_attempt_id"] if reconciliation else str(uuid.uuid4())
|
|
228
|
+
directory = store.root / "source-cleanup" / transaction_id
|
|
229
|
+
from .lifecycle import _release_transaction, _transaction, inventory
|
|
230
|
+
from .semantics import assert_record_version as _cas
|
|
231
|
+
from .semantics import managed_record as _managed_record
|
|
232
|
+
|
|
233
|
+
with store.lock(exclusive=True):
|
|
234
|
+
snapshot = store._load_unlocked()
|
|
235
|
+
item, record = _managed_record(
|
|
236
|
+
inventory(Path(plan["repository"])), snapshot, Path(identity["target_path"])
|
|
237
|
+
)
|
|
238
|
+
_cas(record, identity["expected_record_version"])
|
|
239
|
+
if _release_transaction(
|
|
240
|
+
store, target_path=str(item.path), allocation_id=record.allocation_id
|
|
241
|
+
):
|
|
242
|
+
raise s._fail("INTERRUPTED_TRANSACTION")
|
|
243
|
+
journal = _transaction(
|
|
244
|
+
command="resource-clean",
|
|
245
|
+
transaction_id=transaction_id,
|
|
246
|
+
repository_id=identity["repository_id"],
|
|
247
|
+
target_path=item.path,
|
|
248
|
+
allocation_id=record.allocation_id,
|
|
249
|
+
branch=item.branch,
|
|
250
|
+
expected_head=item.head,
|
|
251
|
+
expected_record_version=record.record_version,
|
|
252
|
+
phase="PREPARED",
|
|
253
|
+
created_at=now_iso(),
|
|
254
|
+
)
|
|
255
|
+
journal["cleanup_plan"] = {
|
|
256
|
+
"contract": CONTRACT,
|
|
257
|
+
**{
|
|
258
|
+
key: journal[key]
|
|
259
|
+
for key in (
|
|
260
|
+
"repository_id",
|
|
261
|
+
"allocation_id",
|
|
262
|
+
"target_path",
|
|
263
|
+
"expected_head",
|
|
264
|
+
"expected_record_version",
|
|
265
|
+
)
|
|
266
|
+
},
|
|
267
|
+
"source_plan": evidence.write(directory, "plan.json", cleanup),
|
|
268
|
+
}
|
|
269
|
+
journal_ref = evidence.write(store.transactions_dir, f"{transaction_id}.json", journal)
|
|
270
|
+
actions, completed, checks = [], {}, []
|
|
271
|
+
try:
|
|
272
|
+
for step, row in enumerate(rows):
|
|
273
|
+
before = _state(
|
|
274
|
+
cleanup, completed, journal=journal, journal_ref=journal_ref, state_root=state_root
|
|
275
|
+
)
|
|
276
|
+
checks.append(before)
|
|
277
|
+
action = {"path": row["path"], "status": "ACTION_STARTED"}
|
|
278
|
+
actions.append(action)
|
|
279
|
+
action["before"] = evidence.write(
|
|
280
|
+
directory,
|
|
281
|
+
f"{step:04d}-before.json",
|
|
282
|
+
{
|
|
283
|
+
"contract": CONTRACT,
|
|
284
|
+
"transaction_id": transaction_id,
|
|
285
|
+
"path": row["path"],
|
|
286
|
+
"state": before,
|
|
287
|
+
"restore": row,
|
|
288
|
+
"execution_state": "ACTION_STARTED",
|
|
289
|
+
"timestamp": now_iso(),
|
|
290
|
+
},
|
|
291
|
+
)
|
|
292
|
+
completed[row["path"]] = _restore(plan, row, payloads[row["path"]])
|
|
293
|
+
after = _state(
|
|
294
|
+
cleanup, completed, journal=journal, journal_ref=journal_ref, state_root=state_root
|
|
295
|
+
)
|
|
296
|
+
action["after"] = evidence.write(
|
|
297
|
+
directory,
|
|
298
|
+
f"{step:04d}-after.json",
|
|
299
|
+
{
|
|
300
|
+
"contract": CONTRACT,
|
|
301
|
+
"transaction_id": transaction_id,
|
|
302
|
+
"path": row["path"],
|
|
303
|
+
"before": action["before"],
|
|
304
|
+
"state": after,
|
|
305
|
+
"execution_state": "ACTION_COMPLETED",
|
|
306
|
+
"timestamp": now_iso(),
|
|
307
|
+
},
|
|
308
|
+
)
|
|
309
|
+
action["status"] = "ACTION_COMPLETED"
|
|
310
|
+
checks.append(after)
|
|
311
|
+
post = _state(
|
|
312
|
+
cleanup, completed, journal=journal, journal_ref=journal_ref, state_root=state_root
|
|
313
|
+
)
|
|
314
|
+
if post["dirty_paths"]:
|
|
315
|
+
raise s._fail("SOURCE_CLEANUP_INCOMPLETE")
|
|
316
|
+
except (ControllerError, OSError, ValueError, TypeError, KeyError) as exc:
|
|
317
|
+
from .cleanup_attempts import underlying
|
|
318
|
+
|
|
319
|
+
refusal = underlying(exception=exc)
|
|
320
|
+
failed = None
|
|
321
|
+
evidence_error = None
|
|
322
|
+
try:
|
|
323
|
+
failed = evidence.write(
|
|
324
|
+
store.completion_receipts_dir,
|
|
325
|
+
f"{transaction_id}.json",
|
|
326
|
+
{
|
|
327
|
+
"contract": CONTRACT,
|
|
328
|
+
"receipt_id": transaction_id,
|
|
329
|
+
"kind": "tracked_source_cleanup",
|
|
330
|
+
"result": "FAILED_OR_PARTIAL",
|
|
331
|
+
"error_code": getattr(exc, "code", "SOURCE_CLEANUP_IO_ERROR"),
|
|
332
|
+
**(
|
|
333
|
+
{"runtime_evidence": exc.runtime_evidence}
|
|
334
|
+
if isinstance(exc, s.AuthorityBlocked)
|
|
335
|
+
else {}
|
|
336
|
+
),
|
|
337
|
+
"journal": journal_ref,
|
|
338
|
+
"actions": actions,
|
|
339
|
+
"checks": checks,
|
|
340
|
+
"underlying": refusal,
|
|
341
|
+
"timestamp": now_iso(),
|
|
342
|
+
},
|
|
343
|
+
)
|
|
344
|
+
except (ControllerError, OSError, ValueError, TypeError, KeyError) as write_error:
|
|
345
|
+
evidence_error = underlying(exception=write_error)
|
|
346
|
+
return {
|
|
347
|
+
"result": "SOURCE_CLEANUP_BLOCKED",
|
|
348
|
+
"transaction_id": transaction_id,
|
|
349
|
+
"receipt": failed,
|
|
350
|
+
"code": getattr(exc, "code", "SOURCE_CLEANUP_IO_ERROR"),
|
|
351
|
+
"underlying": refusal,
|
|
352
|
+
"evidence_write_error": evidence_error,
|
|
353
|
+
"release_authorized": False,
|
|
354
|
+
}
|
|
355
|
+
receipt = evidence.write(
|
|
356
|
+
store.completion_receipts_dir,
|
|
357
|
+
f"{transaction_id}.json",
|
|
358
|
+
{
|
|
359
|
+
"contract": CONTRACT,
|
|
360
|
+
"receipt_id": transaction_id,
|
|
361
|
+
"kind": "tracked_source_cleanup",
|
|
362
|
+
"result": "SOURCE_CLEANUP_COMPLETE",
|
|
363
|
+
"allocation_source_identity": identity,
|
|
364
|
+
"journal": journal_ref,
|
|
365
|
+
"plan": journal["cleanup_plan"]["source_plan"],
|
|
366
|
+
"actions": actions,
|
|
367
|
+
"checks": checks,
|
|
368
|
+
"post_scan": post,
|
|
369
|
+
"index_unchanged": True,
|
|
370
|
+
"release_authorized": False,
|
|
371
|
+
"timestamp": now_iso(),
|
|
372
|
+
},
|
|
373
|
+
)
|
|
374
|
+
with store.lock(exclusive=True):
|
|
375
|
+
actual, _ = evidence.read(Path(journal_ref["path"]), journal_ref["sha256"])
|
|
376
|
+
if actual != journal:
|
|
377
|
+
raise s._fail("SOURCE_TRANSACTION_CHANGED")
|
|
378
|
+
# Keep the exact journal as immutable evidence before the formal removal.
|
|
379
|
+
evidence.write(directory, "journal.json", journal)
|
|
380
|
+
evidence.write(directory, "completed.json", {"contract": CONTRACT, "receipt": receipt})
|
|
381
|
+
store.remove_transaction(transaction_id)
|
|
382
|
+
return {
|
|
383
|
+
"result": "SOURCE_CLEANUP_COMPLETE",
|
|
384
|
+
"receipt": receipt,
|
|
385
|
+
"post_scan": post,
|
|
386
|
+
"transaction_id": transaction_id,
|
|
387
|
+
"release_authorized": False,
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def verify_completed(store: MetadataStore, item, record) -> list[dict]:
|
|
392
|
+
"""Reverify archived bytes and restored source at completion/release boundaries.
|
|
393
|
+
|
|
394
|
+
This is not a handoff or publication assertion. It binds the current no-commit
|
|
395
|
+
closeout to independently recorded archival and restoration evidence.
|
|
396
|
+
"""
|
|
397
|
+
verified = []
|
|
398
|
+
for directory in sorted((store.root / "source-cleanup").glob("*")):
|
|
399
|
+
cleanup, plan_digest = evidence.read(directory / "plan.json")
|
|
400
|
+
plan = cleanup["preservation_plan"]
|
|
401
|
+
identity = plan["authority"]["identity"]
|
|
402
|
+
if identity["allocation_id"] != record.allocation_id:
|
|
403
|
+
continue
|
|
404
|
+
from .source_recovery import terminal
|
|
405
|
+
|
|
406
|
+
if terminal(store, directory.name, verify_generation=True):
|
|
407
|
+
continue # Terminal historical attempts grant no cleanup completion or release.
|
|
408
|
+
try:
|
|
409
|
+
marker, _ = evidence.read(directory / "completed.json")
|
|
410
|
+
ref = marker["receipt"]
|
|
411
|
+
if (
|
|
412
|
+
marker["contract"] != CONTRACT
|
|
413
|
+
or not evidence.matches_reference(
|
|
414
|
+
ref, store.completion_receipts_dir / f"{directory.name}.json"
|
|
415
|
+
)
|
|
416
|
+
or identity["target_path"] != str(item.path)
|
|
417
|
+
or identity["expected_head"] != item.head
|
|
418
|
+
or identity["owner"] != record.owner
|
|
419
|
+
or identity["authority_key"] != record.authority_key
|
|
420
|
+
or identity["expected_record_version"] > record.record_version
|
|
421
|
+
):
|
|
422
|
+
raise s._fail("SOURCE_CLEANUP_EVIDENCE_INVALID")
|
|
423
|
+
receipt, _ = evidence.read(Path(ref["path"]), ref["sha256"])
|
|
424
|
+
if cleanup.get("reconciliation"):
|
|
425
|
+
predecessor = cleanup["reconciliation"]
|
|
426
|
+
evidence.read(Path(predecessor["path"]), predecessor["sha256"])
|
|
427
|
+
if (
|
|
428
|
+
receipt["contract"] != CONTRACT
|
|
429
|
+
or receipt["result"] != "SOURCE_CLEANUP_COMPLETE"
|
|
430
|
+
or receipt["allocation_source_identity"] != identity
|
|
431
|
+
or receipt["plan"]["sha256"] != plan_digest
|
|
432
|
+
or not evidence.matches_reference(receipt["plan"], directory / "plan.json")
|
|
433
|
+
or receipt["post_scan"]["dirty_paths"]
|
|
434
|
+
or [a["path"] for a in receipt["actions"]] != plan["source_tree"]["dirty_paths"]
|
|
435
|
+
or any(a["status"] != "ACTION_COMPLETED" for a in receipt["actions"])
|
|
436
|
+
):
|
|
437
|
+
raise s._fail("SOURCE_CLEANUP_EVIDENCE_INVALID")
|
|
438
|
+
for action in receipt["actions"]:
|
|
439
|
+
for key in ("before", "after"):
|
|
440
|
+
evidence.read(Path(action[key]["path"]), action[key]["sha256"])
|
|
441
|
+
s._verify_generation(plan, cleanup["preservation_receipt_sha256"])
|
|
442
|
+
current = s._git_state(
|
|
443
|
+
item.path, Path(identity["git_dir"]), item.head, allow_clean=True
|
|
444
|
+
)
|
|
445
|
+
if current["dirty_paths"] or any(
|
|
446
|
+
current[key] != plan["source_tree"][key]
|
|
447
|
+
for key in ("base_head", "base_tree", "autocrlf")
|
|
448
|
+
):
|
|
449
|
+
raise s._fail("SOURCE_GIT_CHANGED")
|
|
450
|
+
# The immutable action evidence binds the unchanged index during
|
|
451
|
+
# restoration. After completion Git may rewrite its stat cache.
|
|
452
|
+
# _git_state still verifies every stage/mode/blob and index flag
|
|
453
|
+
# against HEAD; only this observation must remain physically stable.
|
|
454
|
+
with p._absolute_directory(item.path) as root:
|
|
455
|
+
for row in cleanup["restore"]:
|
|
456
|
+
data, metadata, parents = s._read(root, row["path"])
|
|
457
|
+
expected = receipt["post_scan"]["observed"][row["path"]]
|
|
458
|
+
if (
|
|
459
|
+
metadata != expected["identity"]
|
|
460
|
+
or parents != plan["source_tree"]["parent_identities"][row["path"]]
|
|
461
|
+
or hashlib.sha256(data).hexdigest() != row["sha256"]
|
|
462
|
+
or len(data) != row["size"]
|
|
463
|
+
or stat.S_IMODE(metadata[2]) != row["mode"]
|
|
464
|
+
):
|
|
465
|
+
raise s._fail("SOURCE_CHANGED")
|
|
466
|
+
if s._git_state(
|
|
467
|
+
item.path, Path(identity["git_dir"]), item.head, allow_clean=True
|
|
468
|
+
) != current:
|
|
469
|
+
raise s._fail("SOURCE_GIT_CHANGED")
|
|
470
|
+
verified.append(ref)
|
|
471
|
+
except (OSError, ValueError, KeyError, TypeError) as exc:
|
|
472
|
+
raise s._fail("SOURCE_CLEANUP_EVIDENCE_INVALID") from exc
|
|
473
|
+
return verified
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def main(argv: list[str] | None = None) -> int:
|
|
477
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
478
|
+
parser.add_argument("command", choices=("plan", "execute"))
|
|
479
|
+
parser.add_argument("--plan-file", required=True, type=Path, help="pinned preservation plan")
|
|
480
|
+
parser.add_argument("--receipt-sha256", required=True)
|
|
481
|
+
parser.add_argument("--expected-plan-id")
|
|
482
|
+
args = parser.parse_args(argv)
|
|
483
|
+
try:
|
|
484
|
+
plan, _ = evidence.read(args.plan_file)
|
|
485
|
+
cleanup = plan_cleanup(plan, args.receipt_sha256)
|
|
486
|
+
if args.command == "execute":
|
|
487
|
+
if args.expected_plan_id != cleanup["plan_id"]:
|
|
488
|
+
raise s._fail("SOURCE_CLEANUP_PLAN_MISMATCH")
|
|
489
|
+
result = cleanup_source(cleanup)
|
|
490
|
+
else:
|
|
491
|
+
result = cleanup
|
|
492
|
+
print(json.dumps(result, sort_keys=True))
|
|
493
|
+
return 0 if result["result"] in {"SOURCE_CLEANUP_READY", "SOURCE_CLEANUP_COMPLETE"} else 1
|
|
494
|
+
except (ControllerError, OSError, ValueError, TypeError, KeyError) as exc:
|
|
495
|
+
from .cleanup_attempts import underlying
|
|
496
|
+
|
|
497
|
+
print(
|
|
498
|
+
json.dumps(
|
|
499
|
+
{
|
|
500
|
+
"result": "SOURCE_CLEANUP_BLOCKED",
|
|
501
|
+
"code": getattr(exc, "code", "SOURCE_CLEANUP_IO_ERROR"),
|
|
502
|
+
"underlying": underlying(exception=exc),
|
|
503
|
+
**(
|
|
504
|
+
{"runtime_evidence": exc.runtime_evidence}
|
|
505
|
+
if isinstance(exc, s.AuthorityBlocked)
|
|
506
|
+
else {}
|
|
507
|
+
),
|
|
508
|
+
}
|
|
509
|
+
)
|
|
510
|
+
)
|
|
511
|
+
return 1
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
if __name__ == "__main__":
|
|
515
|
+
raise SystemExit(main())
|