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,983 @@
|
|
|
1
|
+
"""Explicit legacy state preservation; never a cleanup or release authority.
|
|
2
|
+
|
|
3
|
+
Payloads are opaque bytes. Only files, directories, symlinks and FIFO nodes are
|
|
4
|
+
supported. Source inode identities detect races; portable tree equality excludes
|
|
5
|
+
inodes and ownership (ownership is recorded, never restored with chown).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import stat
|
|
15
|
+
import sys
|
|
16
|
+
import uuid
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from contextlib import ExitStack, contextmanager
|
|
19
|
+
from dataclasses import dataclass
|
|
20
|
+
from datetime import UTC, datetime
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from .disposable import DIRECTORY_FLAGS, _Directory, _inode, _open_directory
|
|
25
|
+
from .git_backend import head_paths, inventory, path_is_ignored, tracked_paths
|
|
26
|
+
from .models import ControllerError
|
|
27
|
+
from .store import MetadataStore
|
|
28
|
+
|
|
29
|
+
CONTRACT = "legacy-preserved-state/v1"
|
|
30
|
+
SOURCE = ".venv"
|
|
31
|
+
MAX_ENTRIES = 200_000
|
|
32
|
+
CHUNK = 1024 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _fail(code: str, message: str) -> ControllerError:
|
|
36
|
+
return ControllerError(code, message)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _json_bytes(value: object) -> bytes:
|
|
40
|
+
return (
|
|
41
|
+
json.dumps(value, sort_keys=True, ensure_ascii=True, separators=(",", ":")) + "\n"
|
|
42
|
+
).encode()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _sha(value: object) -> str:
|
|
46
|
+
return hashlib.sha256(_json_bytes(value)).hexdigest()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _identity(metadata: os.stat_result) -> list[int]:
|
|
50
|
+
return [
|
|
51
|
+
metadata.st_dev,
|
|
52
|
+
metadata.st_ino,
|
|
53
|
+
metadata.st_mode,
|
|
54
|
+
metadata.st_size,
|
|
55
|
+
metadata.st_mtime_ns,
|
|
56
|
+
metadata.st_ctime_ns,
|
|
57
|
+
metadata.st_uid,
|
|
58
|
+
metadata.st_gid,
|
|
59
|
+
metadata.st_nlink,
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _check_entry(fd: int, name: str, expected: list[int]) -> None:
|
|
64
|
+
if _identity(os.stat(name, dir_fd=fd, follow_symlinks=False)) != expected:
|
|
65
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "filesystem entry identity changed")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _check_chain(chain: tuple[_Directory, ...]) -> None:
|
|
69
|
+
for directory in chain:
|
|
70
|
+
directory.check()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@contextmanager
|
|
74
|
+
def _absolute_directory(path: Path, *, create: bool = False) -> Iterator[tuple[_Directory, ...]]:
|
|
75
|
+
if not path.is_absolute() or ".." in path.parts or path == Path("/"):
|
|
76
|
+
raise _fail("PRESERVATION_PATH_INVALID", "an absolute canonical directory is required")
|
|
77
|
+
with ExitStack() as stack:
|
|
78
|
+
fd = os.open("/", DIRECTORY_FLAGS)
|
|
79
|
+
stack.callback(os.close, fd)
|
|
80
|
+
chain: tuple[_Directory, ...] = ()
|
|
81
|
+
for name in path.parts[1:]:
|
|
82
|
+
_check_chain(chain)
|
|
83
|
+
try:
|
|
84
|
+
metadata = os.stat(name, dir_fd=fd, follow_symlinks=False)
|
|
85
|
+
except FileNotFoundError:
|
|
86
|
+
if not create:
|
|
87
|
+
raise
|
|
88
|
+
os.mkdir(name, 0o700, dir_fd=fd)
|
|
89
|
+
os.fsync(fd)
|
|
90
|
+
metadata = os.stat(name, dir_fd=fd, follow_symlinks=False)
|
|
91
|
+
opened = stack.enter_context(_open_directory(fd, name, _inode(metadata)))
|
|
92
|
+
chain = (*chain, opened)
|
|
93
|
+
fd = opened.fd
|
|
94
|
+
_check_chain(chain)
|
|
95
|
+
yield chain
|
|
96
|
+
_check_chain(chain)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def source_relative_path(value: str) -> str:
|
|
100
|
+
if (
|
|
101
|
+
not isinstance(value, str)
|
|
102
|
+
or not value
|
|
103
|
+
or value != Path(value).as_posix()
|
|
104
|
+
or Path(value).is_absolute()
|
|
105
|
+
or any(part in {".", "..", ".git"} for part in value.split("/"))
|
|
106
|
+
):
|
|
107
|
+
raise _fail("PRESERVATION_PATH_INVALID", "an exact canonical relative source is required")
|
|
108
|
+
return value
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@contextmanager
|
|
112
|
+
def _open_node(parent: int, name: str) -> Iterator[_Directory]:
|
|
113
|
+
metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
114
|
+
identity = _inode(metadata)
|
|
115
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
116
|
+
with _open_directory(parent, name, identity) as node:
|
|
117
|
+
yield node
|
|
118
|
+
elif stat.S_ISREG(metadata.st_mode) or stat.S_ISLNK(metadata.st_mode):
|
|
119
|
+
fd = os.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
|
|
120
|
+
try:
|
|
121
|
+
node = _Directory(parent, name, fd, identity)
|
|
122
|
+
node.check()
|
|
123
|
+
yield node
|
|
124
|
+
node.check()
|
|
125
|
+
finally:
|
|
126
|
+
os.close(fd)
|
|
127
|
+
else:
|
|
128
|
+
raise _fail("PRESERVATION_SOURCE_INVALID", "source root must be a directory or regular/symlink leaf")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@contextmanager
|
|
132
|
+
def _absolute_source(path: Path) -> Iterator[tuple[_Directory, ...]]:
|
|
133
|
+
with (
|
|
134
|
+
_absolute_directory(path.parent) as ancestors,
|
|
135
|
+
_open_node(ancestors[-1].fd, path.name) as source,
|
|
136
|
+
):
|
|
137
|
+
yield (*ancestors, source)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@contextmanager
|
|
141
|
+
def _regular_fd(parent: int, name: str, expected: list[int]) -> Iterator[int]:
|
|
142
|
+
# O_PATH performs no file/FIFO I/O. Reopen only a verified regular inode via
|
|
143
|
+
# the kernel's own descriptor link, never via the mutable source pathname.
|
|
144
|
+
pin = os.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
|
|
145
|
+
try:
|
|
146
|
+
metadata = os.fstat(pin)
|
|
147
|
+
if not stat.S_ISREG(metadata.st_mode) or _identity(metadata) != expected:
|
|
148
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "regular file identity changed")
|
|
149
|
+
fd = os.open(f"/proc/self/fd/{pin}", os.O_RDONLY | os.O_CLOEXEC | os.O_NOATIME)
|
|
150
|
+
try:
|
|
151
|
+
if _identity(os.fstat(fd)) != expected:
|
|
152
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "opened file identity changed")
|
|
153
|
+
yield fd
|
|
154
|
+
if _identity(os.fstat(fd)) != expected:
|
|
155
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "file changed during byte transfer")
|
|
156
|
+
_check_entry(parent, name, expected)
|
|
157
|
+
finally:
|
|
158
|
+
os.close(fd)
|
|
159
|
+
finally:
|
|
160
|
+
os.close(pin)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _transfer(fd: int, destination: int | None = None) -> tuple[int, str]:
|
|
164
|
+
digest = hashlib.sha256()
|
|
165
|
+
size = 0
|
|
166
|
+
while block := os.read(fd, CHUNK):
|
|
167
|
+
digest.update(block)
|
|
168
|
+
size += len(block)
|
|
169
|
+
if destination is not None:
|
|
170
|
+
view = memoryview(block)
|
|
171
|
+
while view:
|
|
172
|
+
written = os.write(destination, view)
|
|
173
|
+
if not written:
|
|
174
|
+
raise _fail("PRESERVATION_COPY_FAILED", "short destination write")
|
|
175
|
+
view = view[written:]
|
|
176
|
+
return size, digest.hexdigest()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass
|
|
180
|
+
class Tree:
|
|
181
|
+
manifest: list[dict[str, Any]]
|
|
182
|
+
identities: dict[str, list[int]]
|
|
183
|
+
inventory: dict[str, int]
|
|
184
|
+
errors: list[dict[str, Any]]
|
|
185
|
+
|
|
186
|
+
@property
|
|
187
|
+
def digest(self) -> str:
|
|
188
|
+
# Ownership belongs to source evidence. No chown or inode equivalence is
|
|
189
|
+
# required to reconstruct the portable filesystem tree.
|
|
190
|
+
return _sha([{k: v for k, v in row.items() if k != "ownership"} for row in self.manifest])
|
|
191
|
+
|
|
192
|
+
def payload(self) -> dict[str, Any]:
|
|
193
|
+
return {
|
|
194
|
+
"manifest": self.manifest,
|
|
195
|
+
"source_identities": self.identities,
|
|
196
|
+
"inventory": self.inventory,
|
|
197
|
+
"errors": self.errors,
|
|
198
|
+
"tree_sha256": self.digest,
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
def require_complete(self) -> None:
|
|
202
|
+
if self.errors:
|
|
203
|
+
raise _fail("PRESERVATION_SCAN_BLOCKED", "source/destination scan is incomplete")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def scan_tree(directory: _Directory, chain: tuple[_Directory, ...]) -> Tree:
|
|
207
|
+
"""Hash every regular file; never follow links or open FIFO endpoints."""
|
|
208
|
+
manifest: list[dict[str, Any]] = []
|
|
209
|
+
identities: dict[str, list[int]] = {}
|
|
210
|
+
counts = dict(
|
|
211
|
+
regular_files=0,
|
|
212
|
+
directories=0,
|
|
213
|
+
symlinks=0,
|
|
214
|
+
fifos=0,
|
|
215
|
+
other_special_nodes=0,
|
|
216
|
+
total_regular_file_bytes=0,
|
|
217
|
+
metadata_errors=0,
|
|
218
|
+
unreadable_regular_files=0,
|
|
219
|
+
)
|
|
220
|
+
errors: list[dict[str, Any]] = []
|
|
221
|
+
|
|
222
|
+
def visit(parent: int, name: str, relative: str, anchors: tuple[_Directory, ...]) -> None:
|
|
223
|
+
if len(identities) >= MAX_ENTRIES:
|
|
224
|
+
raise _fail("PRESERVATION_SCAN_LIMIT", "tree exceeds the preservation entry bound")
|
|
225
|
+
_check_chain(anchors)
|
|
226
|
+
try:
|
|
227
|
+
metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
counts["metadata_errors"] += 1
|
|
230
|
+
errors.append({"path": relative, "code": "METADATA_UNREADABLE", "errno": exc.errno})
|
|
231
|
+
return
|
|
232
|
+
identity = _identity(metadata)
|
|
233
|
+
identities[relative] = identity
|
|
234
|
+
row: dict[str, Any] = {
|
|
235
|
+
"path": relative,
|
|
236
|
+
"mode": stat.S_IMODE(metadata.st_mode),
|
|
237
|
+
"ownership": {"uid": metadata.st_uid, "gid": metadata.st_gid},
|
|
238
|
+
}
|
|
239
|
+
manifest.append(row)
|
|
240
|
+
mode = metadata.st_mode
|
|
241
|
+
try:
|
|
242
|
+
if stat.S_ISDIR(mode):
|
|
243
|
+
row["type"] = "directory"
|
|
244
|
+
counts["directories"] += 1
|
|
245
|
+
with _open_directory(parent, name, _inode(metadata)) as opened:
|
|
246
|
+
names = sorted(os.listdir(opened.fd))
|
|
247
|
+
for child in names:
|
|
248
|
+
visit(
|
|
249
|
+
opened.fd,
|
|
250
|
+
child,
|
|
251
|
+
child if relative == "." else f"{relative}/{child}",
|
|
252
|
+
(*anchors, opened),
|
|
253
|
+
)
|
|
254
|
+
opened.check()
|
|
255
|
+
if sorted(os.listdir(opened.fd)) != names:
|
|
256
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "directory entries changed")
|
|
257
|
+
elif stat.S_ISREG(mode):
|
|
258
|
+
row.update(type="regular", size=metadata.st_size)
|
|
259
|
+
counts["regular_files"] += 1
|
|
260
|
+
counts["total_regular_file_bytes"] += metadata.st_size
|
|
261
|
+
try:
|
|
262
|
+
with _regular_fd(parent, name, identity) as fd:
|
|
263
|
+
size, digest = _transfer(fd)
|
|
264
|
+
if size != metadata.st_size:
|
|
265
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "file size changed")
|
|
266
|
+
row["content_sha256"] = digest
|
|
267
|
+
except OSError as exc:
|
|
268
|
+
counts["unreadable_regular_files"] += 1
|
|
269
|
+
errors.append(
|
|
270
|
+
{"path": relative, "code": "REGULAR_FILE_UNREADABLE", "errno": exc.errno}
|
|
271
|
+
)
|
|
272
|
+
elif stat.S_ISLNK(mode):
|
|
273
|
+
row.update(type="symlink", target=os.readlink(name, dir_fd=parent))
|
|
274
|
+
counts["symlinks"] += 1
|
|
275
|
+
elif stat.S_ISFIFO(mode):
|
|
276
|
+
row["type"] = "fifo"
|
|
277
|
+
counts["fifos"] += 1
|
|
278
|
+
else:
|
|
279
|
+
row.update(type="unsupported", file_type=stat.S_IFMT(mode))
|
|
280
|
+
counts["other_special_nodes"] += 1
|
|
281
|
+
errors.append({"path": relative, "code": "UNSUPPORTED_SPECIAL_NODE"})
|
|
282
|
+
_check_entry(parent, name, identity)
|
|
283
|
+
_check_chain(anchors)
|
|
284
|
+
except OSError as exc:
|
|
285
|
+
counts["metadata_errors"] += 1
|
|
286
|
+
errors.append({"path": relative, "code": "METADATA_UNREADABLE", "errno": exc.errno})
|
|
287
|
+
|
|
288
|
+
visit(directory.parent_fd, directory.name, ".", chain)
|
|
289
|
+
return Tree(manifest, identities, counts, errors)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _same_source(tree: Tree, expected: dict[str, Any]) -> None:
|
|
293
|
+
tree.require_complete()
|
|
294
|
+
if tree.payload() != expected:
|
|
295
|
+
raise _fail(
|
|
296
|
+
"PRESERVATION_SOURCE_CHANGED", "source content, paths or inode identities changed"
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def _same_tree(tree: Tree, source: dict[str, Any]) -> None:
|
|
301
|
+
tree.require_complete()
|
|
302
|
+
expected = [{k: v for k, v in row.items() if k != "ownership"} for row in source["manifest"]]
|
|
303
|
+
actual = [{k: v for k, v in row.items() if k != "ownership"} for row in tree.manifest]
|
|
304
|
+
if actual != expected or tree.digest != source["tree_sha256"]:
|
|
305
|
+
raise _fail("PRESERVATION_VERIFY_FAILED", "preserved paths, types, modes or content differ")
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def local_environment_roots(target: Path) -> tuple[Path, ...]:
|
|
309
|
+
"""Only physical environment roots inside this exact checkout may be excluded.
|
|
310
|
+
|
|
311
|
+
Resolving a selected symlink must never exclude an outside environment whose
|
|
312
|
+
editable mapping still refers to the source allocation.
|
|
313
|
+
"""
|
|
314
|
+
roots = []
|
|
315
|
+
for relative in (".venv", "backend/.venv"):
|
|
316
|
+
candidate = target / relative
|
|
317
|
+
if not candidate.is_symlink() and candidate.is_dir() and candidate.resolve(strict=True) == candidate:
|
|
318
|
+
roots.append(candidate)
|
|
319
|
+
return tuple(roots)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def validate_editable_references(repository, target: Path) -> None:
|
|
323
|
+
from .environment import editable_references
|
|
324
|
+
|
|
325
|
+
# Scan every managed environment, then filter only frozen physical local roots.
|
|
326
|
+
# No mutable path is passed to the scanner's resolving exclusion API.
|
|
327
|
+
with ExitStack() as stack:
|
|
328
|
+
roots = local_environment_roots(target)
|
|
329
|
+
paths = frozenset(str(root) for root in roots)
|
|
330
|
+
chains = tuple(stack.enter_context(_absolute_directory(root)) for root in roots)
|
|
331
|
+
for chain in chains:
|
|
332
|
+
_check_chain(chain)
|
|
333
|
+
references = editable_references(repository, target)
|
|
334
|
+
for chain in chains:
|
|
335
|
+
_check_chain(chain)
|
|
336
|
+
if any(reference["environment"] not in paths for reference in references):
|
|
337
|
+
raise _fail("STALE_EDITABLE_REFERENCE", "another managed environment references the checkout")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _authority(
|
|
341
|
+
repo: Path,
|
|
342
|
+
target: Path,
|
|
343
|
+
allocation: str,
|
|
344
|
+
owner: str,
|
|
345
|
+
version: int,
|
|
346
|
+
*,
|
|
347
|
+
source_relative: str = SOURCE,
|
|
348
|
+
recovery: dict[str, Any] | None = None,
|
|
349
|
+
) -> dict[str, Any]:
|
|
350
|
+
from .lifecycle import _typed_cleanup_plan_locked
|
|
351
|
+
from .semantics import managed_record as _managed_record
|
|
352
|
+
|
|
353
|
+
source_relative = source_relative_path(source_relative)
|
|
354
|
+
if tracked_paths(target, source_relative) or head_paths(target, source_relative):
|
|
355
|
+
raise _fail("TRACKED_RESOURCE", "preservation source contains tracked/staged files")
|
|
356
|
+
if not path_is_ignored(target, source_relative):
|
|
357
|
+
raise _fail("RESOURCE_NOT_IGNORED", "preservation source must be exactly Git-ignored")
|
|
358
|
+
repository = inventory(repo)
|
|
359
|
+
store = MetadataStore(repository.common_dir)
|
|
360
|
+
with store.lock(exclusive=False):
|
|
361
|
+
snapshot = store._load_unlocked()
|
|
362
|
+
item, record = _managed_record(repository, snapshot, target)
|
|
363
|
+
if record.allocation_id != allocation:
|
|
364
|
+
raise _fail(
|
|
365
|
+
"PRESERVATION_ALLOCATION_MISMATCH", "preservation belongs to another allocation"
|
|
366
|
+
)
|
|
367
|
+
plan = _typed_cleanup_plan_locked(
|
|
368
|
+
repository,
|
|
369
|
+
snapshot,
|
|
370
|
+
store,
|
|
371
|
+
item,
|
|
372
|
+
record,
|
|
373
|
+
owner=owner,
|
|
374
|
+
expected_record_version=version,
|
|
375
|
+
recovery=recovery,
|
|
376
|
+
)
|
|
377
|
+
# Copy preparation preserves only the selected ignored node. Other resource
|
|
378
|
+
# classifications stay as cleanup-not-ready evidence, never deletion authority.
|
|
379
|
+
resource_blockers = {(row["path"], row["blocker"]) for row in plan["resources"] if row["blocker"]}
|
|
380
|
+
blocked = [b for b in plan["blockers"] if (b.get("path"), b["code"]) not in resource_blockers]
|
|
381
|
+
if blocked:
|
|
382
|
+
raise _fail(blocked[0]["code"], "allocation/runtime guards block preservation")
|
|
383
|
+
validate_editable_references(repository, target)
|
|
384
|
+
return {
|
|
385
|
+
"identity": {
|
|
386
|
+
k: plan[k]
|
|
387
|
+
for k in (
|
|
388
|
+
"repository_id",
|
|
389
|
+
"allocation_id",
|
|
390
|
+
"target_path",
|
|
391
|
+
"expected_head",
|
|
392
|
+
"expected_record_version",
|
|
393
|
+
"owner",
|
|
394
|
+
"authority_key",
|
|
395
|
+
)
|
|
396
|
+
},
|
|
397
|
+
"runtime_evidence": plan["runtime_evidence"],
|
|
398
|
+
"warnings": plan["warnings"],
|
|
399
|
+
"cleanup_not_ready": [b for b in plan["blockers"] if (b.get("path"), b["code"]) in resource_blockers],
|
|
400
|
+
"worktree_paths": [str(w.path) for w in repository.worktrees],
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def evidence_root() -> Path:
|
|
405
|
+
return Path.home() / ".local/state/worktree-controller/preservations/v1"
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _destination(root: Path, authority: dict[str, Any], generation: str) -> Path:
|
|
409
|
+
identity = authority["identity"]
|
|
410
|
+
for value in (generation, identity["repository_id"], identity["allocation_id"]):
|
|
411
|
+
if str(uuid.UUID(value)) != value:
|
|
412
|
+
raise _fail("PRESERVATION_PATH_INVALID", "canonical UUID identity is required")
|
|
413
|
+
if not root.is_absolute() or ".." in root.parts:
|
|
414
|
+
raise _fail("PRESERVATION_PATH_INVALID", "state root must be absolute")
|
|
415
|
+
if any(root.is_relative_to(Path(w)) for w in authority["worktree_paths"]):
|
|
416
|
+
raise _fail("PRESERVATION_DESTINATION_INVALID", "state root must be outside every worktree")
|
|
417
|
+
return root / identity["repository_id"] / identity["allocation_id"] / generation
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _chain_identities(chain: tuple[_Directory, ...]) -> dict[str, dict[str, int]]:
|
|
421
|
+
path = Path("/")
|
|
422
|
+
result = {}
|
|
423
|
+
for directory in chain:
|
|
424
|
+
path /= directory.name
|
|
425
|
+
result[str(path)] = directory.identity
|
|
426
|
+
return result
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _destination_anchor(chain: tuple[_Directory, ...], expected: dict[str, dict[str, int]]) -> None:
|
|
430
|
+
actual = _chain_identities(chain)
|
|
431
|
+
if any(actual.get(path) != identity for path, identity in expected.items()):
|
|
432
|
+
raise _fail(
|
|
433
|
+
"PRESERVATION_DESTINATION_CHANGED", "planned destination ancestor identity changed"
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _destination_preflight(destination: Path) -> dict[str, dict[str, int]]:
|
|
438
|
+
# Inspect all existing ancestors without creating the generation during plan.
|
|
439
|
+
ancestor = destination.parent
|
|
440
|
+
while not os.path.lexists(ancestor):
|
|
441
|
+
ancestor = ancestor.parent
|
|
442
|
+
with _absolute_directory(ancestor) as chain:
|
|
443
|
+
if ancestor == destination.parent:
|
|
444
|
+
try:
|
|
445
|
+
os.stat(destination.name, dir_fd=chain[-1].fd, follow_symlinks=False)
|
|
446
|
+
except FileNotFoundError:
|
|
447
|
+
pass
|
|
448
|
+
else:
|
|
449
|
+
raise _fail("PRESERVATION_DESTINATION_EXISTS", "generation already exists")
|
|
450
|
+
return _chain_identities(chain)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def plan_preservation(
|
|
454
|
+
repo: Path,
|
|
455
|
+
target: Path,
|
|
456
|
+
allocation: str,
|
|
457
|
+
owner: str,
|
|
458
|
+
version: int,
|
|
459
|
+
*,
|
|
460
|
+
source_relative: str | None = None,
|
|
461
|
+
state_root: Path | None = None,
|
|
462
|
+
generation: str | None = None,
|
|
463
|
+
recovery: dict[str, Any] | None = None,
|
|
464
|
+
) -> dict[str, Any]:
|
|
465
|
+
if source_relative is None:
|
|
466
|
+
source_relative = "backend/.venv" if (target / "backend/.venv").exists() and not (target / SOURCE).exists() else SOURCE
|
|
467
|
+
options = {"recovery": recovery} if recovery is not None else {}
|
|
468
|
+
authority = _authority(repo, target, allocation, owner, version, source_relative=source_relative, **options)
|
|
469
|
+
root = state_root or evidence_root()
|
|
470
|
+
generation = generation or str(uuid.uuid4())
|
|
471
|
+
destination = _destination(root, authority, generation)
|
|
472
|
+
destination_anchor = _destination_preflight(destination)
|
|
473
|
+
source_relative = source_relative_path(source_relative)
|
|
474
|
+
source_path = target / source_relative
|
|
475
|
+
with _absolute_source(source_path) as chain:
|
|
476
|
+
tree = scan_tree(chain[-1], chain)
|
|
477
|
+
source_anchor = {"worktree": chain[-2].identity, "source": chain[-1].identity}
|
|
478
|
+
source_ancestors = _chain_identities(chain[:-1])
|
|
479
|
+
recovery_fields = {}
|
|
480
|
+
if recovery is not None:
|
|
481
|
+
from . import cleanup_recovery, hardlinks
|
|
482
|
+
|
|
483
|
+
store = MetadataStore(inventory(repo).common_dir)
|
|
484
|
+
_, old_plan = cleanup_recovery.original(
|
|
485
|
+
store, recovery, target=target, allocation_id=allocation
|
|
486
|
+
)
|
|
487
|
+
cleanup_recovery.validate_current_tree(tree, old_plan["source_tree"])
|
|
488
|
+
topology = hardlinks.inventory(target, cleanup_recovery.REMAINING)
|
|
489
|
+
recovery_fields = {"recovery": recovery, "cleanup_topology": topology}
|
|
490
|
+
after = _authority(repo, target, allocation, owner, version, source_relative=source_relative, **options)
|
|
491
|
+
if after["identity"] != authority["identity"]:
|
|
492
|
+
raise _fail("PRESERVATION_AUTHORITY_CHANGED", "allocation changed during planning")
|
|
493
|
+
payload = {
|
|
494
|
+
**recovery_fields,
|
|
495
|
+
"contract": CONTRACT,
|
|
496
|
+
"repository": str(repo),
|
|
497
|
+
"authority": authority,
|
|
498
|
+
"source": str(source_path),
|
|
499
|
+
"source_relative": source_relative,
|
|
500
|
+
"source_anchor": source_anchor,
|
|
501
|
+
"source_ancestor_identities": source_ancestors,
|
|
502
|
+
"source_tree": tree.payload(),
|
|
503
|
+
"generation": generation,
|
|
504
|
+
"state_root": str(root),
|
|
505
|
+
"destination": str(destination),
|
|
506
|
+
"destination_anchor": destination_anchor,
|
|
507
|
+
"runtime_checks": [authority["runtime_evidence"], after["runtime_evidence"]],
|
|
508
|
+
"ready": not tree.errors,
|
|
509
|
+
}
|
|
510
|
+
return {**payload, "plan_sha256": _sha(payload)}
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _current_authority(plan: dict[str, Any]) -> dict[str, Any]:
|
|
514
|
+
expected = plan["authority"]["identity"]
|
|
515
|
+
source_rel = plan.get("source_relative") or (
|
|
516
|
+
str(Path(plan["source"]).relative_to(Path(expected["target_path"]))) if "source" in plan else SOURCE
|
|
517
|
+
)
|
|
518
|
+
options = {"recovery": plan["recovery"]} if "recovery" in plan else {}
|
|
519
|
+
authority = _authority(
|
|
520
|
+
Path(plan["repository"]),
|
|
521
|
+
Path(expected["target_path"]),
|
|
522
|
+
expected["allocation_id"],
|
|
523
|
+
expected["owner"],
|
|
524
|
+
expected["expected_record_version"],
|
|
525
|
+
source_relative=source_rel,
|
|
526
|
+
**options,
|
|
527
|
+
)
|
|
528
|
+
if authority["identity"] != expected:
|
|
529
|
+
raise _fail(
|
|
530
|
+
"PRESERVATION_AUTHORITY_CHANGED", "allocation identity differs from preservation"
|
|
531
|
+
)
|
|
532
|
+
if "cleanup_topology" in plan:
|
|
533
|
+
from .hardlinks import verify
|
|
534
|
+
|
|
535
|
+
verify(Path(expected["target_path"]), plan["cleanup_topology"])
|
|
536
|
+
return authority
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _validate_plan(plan: dict[str, Any], state_root: Path) -> Path:
|
|
540
|
+
payload = {k: v for k, v in plan.items() if k != "plan_sha256"}
|
|
541
|
+
if (
|
|
542
|
+
plan.get("contract") != CONTRACT
|
|
543
|
+
or not plan.get("ready")
|
|
544
|
+
or _sha(payload) != plan.get("plan_sha256")
|
|
545
|
+
):
|
|
546
|
+
raise _fail("PRESERVATION_PLAN_INVALID", "a complete exact preservation plan is required")
|
|
547
|
+
expected_source = Path(plan["authority"]["identity"]["target_path"]) / source_relative_path(
|
|
548
|
+
plan.get("source_relative") or SOURCE
|
|
549
|
+
)
|
|
550
|
+
if plan["source"] != str(expected_source) or plan["state_root"] != str(state_root):
|
|
551
|
+
raise _fail("PRESERVATION_PLAN_INVALID", "source or state root differs from contract")
|
|
552
|
+
authority = _current_authority(plan)
|
|
553
|
+
destination = _destination(state_root, authority, plan["generation"])
|
|
554
|
+
if str(destination) != plan["destination"]:
|
|
555
|
+
raise _fail("PRESERVATION_PLAN_INVALID", "generation destination identity differs")
|
|
556
|
+
return destination
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
def _copy_node(
|
|
560
|
+
source: _Directory,
|
|
561
|
+
destination: _Directory,
|
|
562
|
+
tree: dict[str, Any],
|
|
563
|
+
source_chain: tuple[_Directory, ...],
|
|
564
|
+
destination_chain: tuple[_Directory, ...],
|
|
565
|
+
) -> dict[str, list[int]]:
|
|
566
|
+
created_identities: dict[str, list[int]] = {}
|
|
567
|
+
rows = {row["path"]: row for row in tree["manifest"]}
|
|
568
|
+
children: dict[str, list[str]] = {
|
|
569
|
+
path: [] for path, row in rows.items() if row["type"] == "directory"
|
|
570
|
+
}
|
|
571
|
+
for path in rows:
|
|
572
|
+
if path != ".":
|
|
573
|
+
parent, _, name = path.rpartition("/")
|
|
574
|
+
children[parent or "."].append(name)
|
|
575
|
+
|
|
576
|
+
def visit(
|
|
577
|
+
src: _Directory,
|
|
578
|
+
dst: _Directory,
|
|
579
|
+
relative: str,
|
|
580
|
+
schain: tuple[_Directory, ...],
|
|
581
|
+
dchain: tuple[_Directory, ...],
|
|
582
|
+
) -> None:
|
|
583
|
+
_check_chain(schain)
|
|
584
|
+
_check_chain(dchain)
|
|
585
|
+
_check_entry(src.parent_fd, src.name, tree["source_identities"][relative])
|
|
586
|
+
if sorted(os.listdir(src.fd)) != children[relative]:
|
|
587
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "source entries changed before copying")
|
|
588
|
+
for name in children[relative]:
|
|
589
|
+
path = name if relative == "." else f"{relative}/{name}"
|
|
590
|
+
row = rows[path]
|
|
591
|
+
expected = tree["source_identities"][path]
|
|
592
|
+
_check_chain(schain)
|
|
593
|
+
_check_chain(dchain)
|
|
594
|
+
_check_entry(src.fd, name, expected)
|
|
595
|
+
if row["type"] == "directory":
|
|
596
|
+
with _open_directory(
|
|
597
|
+
src.fd, name, _inode(os.stat(name, dir_fd=src.fd, follow_symlinks=False))
|
|
598
|
+
) as child:
|
|
599
|
+
os.mkdir(name, 0o700, dir_fd=dst.fd)
|
|
600
|
+
metadata = os.stat(name, dir_fd=dst.fd, follow_symlinks=False)
|
|
601
|
+
with _open_directory(dst.fd, name, _inode(metadata)) as copied:
|
|
602
|
+
visit(child, copied, path, (*schain, child), (*dchain, copied))
|
|
603
|
+
elif row["type"] == "regular":
|
|
604
|
+
with _regular_fd(src.fd, name, expected) as reader:
|
|
605
|
+
writer = os.open(
|
|
606
|
+
name,
|
|
607
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
608
|
+
0o600,
|
|
609
|
+
dir_fd=dst.fd,
|
|
610
|
+
)
|
|
611
|
+
try:
|
|
612
|
+
created = _inode(os.fstat(writer))
|
|
613
|
+
size, digest = _transfer(reader, writer)
|
|
614
|
+
if size != row["size"] or digest != row["content_sha256"]:
|
|
615
|
+
raise _fail(
|
|
616
|
+
"PRESERVATION_SOURCE_CHANGED",
|
|
617
|
+
"copied bytes differ from source plan",
|
|
618
|
+
)
|
|
619
|
+
os.fchmod(writer, row["mode"])
|
|
620
|
+
os.fsync(writer)
|
|
621
|
+
created_identities[path] = _identity(os.fstat(writer))
|
|
622
|
+
if _inode(os.stat(name, dir_fd=dst.fd, follow_symlinks=False)) != created:
|
|
623
|
+
raise _fail(
|
|
624
|
+
"PRESERVATION_DESTINATION_CHANGED", "destination file replaced"
|
|
625
|
+
)
|
|
626
|
+
finally:
|
|
627
|
+
os.close(writer)
|
|
628
|
+
elif row["type"] == "symlink":
|
|
629
|
+
if os.readlink(name, dir_fd=src.fd) != row["target"]:
|
|
630
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "link literal changed")
|
|
631
|
+
os.symlink(row["target"], name, dir_fd=dst.fd)
|
|
632
|
+
created_identities[path] = _identity(
|
|
633
|
+
os.stat(name, dir_fd=dst.fd, follow_symlinks=False)
|
|
634
|
+
)
|
|
635
|
+
elif row["type"] == "fifo":
|
|
636
|
+
# No open/read, payload, waiting, chown, or regular-file fallback.
|
|
637
|
+
# The command is single-threaded; restore the process umask immediately.
|
|
638
|
+
previous_umask = os.umask(0)
|
|
639
|
+
try:
|
|
640
|
+
os.mkfifo(name, row["mode"], dir_fd=dst.fd)
|
|
641
|
+
finally:
|
|
642
|
+
os.umask(previous_umask)
|
|
643
|
+
copied = os.stat(name, dir_fd=dst.fd, follow_symlinks=False)
|
|
644
|
+
if not stat.S_ISFIFO(copied.st_mode) or stat.S_IMODE(copied.st_mode) != row["mode"]:
|
|
645
|
+
raise _fail("PRESERVATION_VERIFY_FAILED", "created FIFO type/mode differs")
|
|
646
|
+
created_identities[path] = _identity(copied)
|
|
647
|
+
else:
|
|
648
|
+
raise _fail("UNSUPPORTED_SPECIAL_NODE", "node is outside the preservation contract")
|
|
649
|
+
_check_entry(src.fd, name, expected)
|
|
650
|
+
_check_entry(dst.fd, name, created_identities[path])
|
|
651
|
+
_check_chain(schain)
|
|
652
|
+
_check_chain(dchain)
|
|
653
|
+
_check_entry(src.parent_fd, src.name, tree["source_identities"][relative])
|
|
654
|
+
_check_chain(dchain)
|
|
655
|
+
os.fchmod(dst.fd, rows[relative]["mode"])
|
|
656
|
+
os.fsync(dst.fd)
|
|
657
|
+
created_identities[relative] = _identity(os.fstat(dst.fd))
|
|
658
|
+
dst.check()
|
|
659
|
+
|
|
660
|
+
visit(source, destination, ".", source_chain, destination_chain)
|
|
661
|
+
return created_identities
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _copy_leaf(source: _Directory, destination: _Directory, tree: dict[str, Any]) -> dict[str, list[int]]:
|
|
665
|
+
row = tree["manifest"][0]
|
|
666
|
+
expected = tree["source_identities"]["."]
|
|
667
|
+
_check_entry(source.parent_fd, source.name, expected)
|
|
668
|
+
if row["type"] == "regular":
|
|
669
|
+
with _regular_fd(source.parent_fd, source.name, expected) as reader:
|
|
670
|
+
writer = os.open("tree", os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
671
|
+
0o600, dir_fd=destination.fd)
|
|
672
|
+
try:
|
|
673
|
+
size, digest = _transfer(reader, writer)
|
|
674
|
+
if size != row["size"] or digest != row["content_sha256"]:
|
|
675
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "copied leaf bytes differ from plan")
|
|
676
|
+
os.fchmod(writer, row["mode"])
|
|
677
|
+
os.fsync(writer)
|
|
678
|
+
identity = _identity(os.fstat(writer))
|
|
679
|
+
_check_entry(destination.fd, "tree", identity)
|
|
680
|
+
finally:
|
|
681
|
+
os.close(writer)
|
|
682
|
+
elif row["type"] == "symlink":
|
|
683
|
+
if os.readlink(source.name, dir_fd=source.parent_fd) != row["target"]:
|
|
684
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "source link literal changed")
|
|
685
|
+
os.symlink(row["target"], "tree", dir_fd=destination.fd)
|
|
686
|
+
identity = _identity(os.stat("tree", dir_fd=destination.fd, follow_symlinks=False))
|
|
687
|
+
else:
|
|
688
|
+
raise _fail("PRESERVATION_SOURCE_INVALID", "a regular/symlink leaf is required")
|
|
689
|
+
_check_entry(source.parent_fd, source.name, expected)
|
|
690
|
+
source.check()
|
|
691
|
+
destination.check()
|
|
692
|
+
os.fsync(destination.fd)
|
|
693
|
+
return {".": identity}
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def _write_once(parent: _Directory, name: str, value: object) -> str:
|
|
697
|
+
parent.check()
|
|
698
|
+
data = _json_bytes(value)
|
|
699
|
+
fd = os.open(
|
|
700
|
+
name,
|
|
701
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC,
|
|
702
|
+
0o600,
|
|
703
|
+
dir_fd=parent.fd,
|
|
704
|
+
)
|
|
705
|
+
try:
|
|
706
|
+
expected = _inode(os.fstat(fd))
|
|
707
|
+
with os.fdopen(os.dup(fd), "wb") as stream:
|
|
708
|
+
stream.write(data)
|
|
709
|
+
stream.flush()
|
|
710
|
+
os.fchmod(fd, 0o400)
|
|
711
|
+
os.fsync(fd)
|
|
712
|
+
if _inode(os.stat(name, dir_fd=parent.fd, follow_symlinks=False)) != expected:
|
|
713
|
+
raise _fail("PRESERVATION_DESTINATION_CHANGED", "evidence file replaced")
|
|
714
|
+
parent.check()
|
|
715
|
+
os.fsync(parent.fd)
|
|
716
|
+
finally:
|
|
717
|
+
os.close(fd)
|
|
718
|
+
return hashlib.sha256(data).hexdigest()
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
def _source_anchor(chain: tuple[_Directory, ...], plan: dict[str, Any]) -> None:
|
|
722
|
+
if {"worktree": chain[-2].identity, "source": chain[-1].identity} != plan["source_anchor"]:
|
|
723
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "worktree/source directory identity changed")
|
|
724
|
+
# Old immutable generations retain their original anchor contract. New plans
|
|
725
|
+
# bind every ancestor, including the checkout root for a nested source.
|
|
726
|
+
if (
|
|
727
|
+
"source_ancestor_identities" in plan
|
|
728
|
+
and _chain_identities(chain[:-1]) != plan["source_ancestor_identities"]
|
|
729
|
+
):
|
|
730
|
+
raise _fail("PRESERVATION_SOURCE_CHANGED", "source ancestor directory identity changed")
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def copy_preservation(plan: dict[str, Any], *, state_root: Path | None = None) -> dict[str, Any]:
|
|
734
|
+
destination = _validate_plan(plan, state_root or evidence_root())
|
|
735
|
+
_destination_preflight(destination)
|
|
736
|
+
with _absolute_source(Path(plan["source"])) as source_chain:
|
|
737
|
+
_source_anchor(source_chain, plan)
|
|
738
|
+
source = source_chain[-1]
|
|
739
|
+
_same_source(scan_tree(source, source_chain), plan["source_tree"])
|
|
740
|
+
with _absolute_directory(destination.parent, create=True) as parent_chain:
|
|
741
|
+
parent = parent_chain[-1]
|
|
742
|
+
_check_chain(parent_chain)
|
|
743
|
+
_destination_anchor(parent_chain, plan["destination_anchor"])
|
|
744
|
+
os.mkdir(destination.name, 0o700, dir_fd=parent.fd)
|
|
745
|
+
os.fsync(parent.fd)
|
|
746
|
+
identity = _inode(os.stat(destination.name, dir_fd=parent.fd, follow_symlinks=False))
|
|
747
|
+
with _open_directory(parent.fd, destination.name, identity) as generation:
|
|
748
|
+
generation_chain = (*parent_chain, generation)
|
|
749
|
+
_write_once(generation, "plan.json", plan)
|
|
750
|
+
leaf = plan["source_tree"]["manifest"][0]["type"] != "directory"
|
|
751
|
+
if leaf:
|
|
752
|
+
created_identities = _copy_leaf(source, generation, plan["source_tree"])
|
|
753
|
+
else:
|
|
754
|
+
os.mkdir("tree", 0o700, dir_fd=generation.fd)
|
|
755
|
+
with _open_node(generation.fd, "tree") as copied:
|
|
756
|
+
destination_chain = (*generation_chain, copied)
|
|
757
|
+
if not leaf:
|
|
758
|
+
created_identities = _copy_node(
|
|
759
|
+
source, copied, plan["source_tree"], source_chain, destination_chain
|
|
760
|
+
)
|
|
761
|
+
preserved = scan_tree(copied, destination_chain)
|
|
762
|
+
_same_tree(preserved, plan["source_tree"])
|
|
763
|
+
if preserved.identities != created_identities:
|
|
764
|
+
raise _fail(
|
|
765
|
+
"PRESERVATION_DESTINATION_CHANGED",
|
|
766
|
+
"created destination node identities changed",
|
|
767
|
+
)
|
|
768
|
+
manifest_digest = _write_once(generation, "manifest.json", plan["source_tree"])
|
|
769
|
+
_same_source(scan_tree(source, source_chain), plan["source_tree"])
|
|
770
|
+
_check_chain(source_chain)
|
|
771
|
+
_check_chain(destination_chain)
|
|
772
|
+
source_verified_at = datetime.now(UTC).isoformat()
|
|
773
|
+
# Close our inspection FDs before procfs scanning. There is no self-PID
|
|
774
|
+
# exception: any observed reference still blocks, including our own.
|
|
775
|
+
authority = _current_authority(plan)
|
|
776
|
+
with _absolute_directory(destination) as generation_chain:
|
|
777
|
+
generation = generation_chain[-1]
|
|
778
|
+
_destination_anchor(generation_chain, plan["destination_anchor"])
|
|
779
|
+
if (
|
|
780
|
+
generation.identity != identity
|
|
781
|
+
or _read_json(generation, "manifest.json") != plan["source_tree"]
|
|
782
|
+
):
|
|
783
|
+
raise _fail("PRESERVATION_DESTINATION_CHANGED", "generation identity changed")
|
|
784
|
+
receipt = {
|
|
785
|
+
"contract": CONTRACT,
|
|
786
|
+
"result": "LEGACY_PRESERVATION_COMPLETE",
|
|
787
|
+
"receipt_id": plan["generation"],
|
|
788
|
+
"timestamp": datetime.now(UTC).isoformat(),
|
|
789
|
+
"source_verified_at": source_verified_at,
|
|
790
|
+
"allocation_source_identity": plan["authority"]["identity"],
|
|
791
|
+
"source": plan["source"],
|
|
792
|
+
"source_anchor": plan["source_anchor"],
|
|
793
|
+
"source_identity_sha256": _sha(plan["source_tree"]["source_identities"]),
|
|
794
|
+
"generation": str(destination),
|
|
795
|
+
"generation_identity": identity,
|
|
796
|
+
"destination": str(destination / "tree"),
|
|
797
|
+
"manifest_sha256": manifest_digest,
|
|
798
|
+
"source_tree_sha256": plan["source_tree"]["tree_sha256"],
|
|
799
|
+
"preserved_tree_sha256": preserved.digest,
|
|
800
|
+
"destination_identity_sha256": _sha(created_identities),
|
|
801
|
+
"inventory": preserved.inventory,
|
|
802
|
+
"verification": "PASS",
|
|
803
|
+
"plan_sha256": plan["plan_sha256"],
|
|
804
|
+
"runtime_checks": [*plan["runtime_checks"], authority["runtime_evidence"]],
|
|
805
|
+
"warnings": authority["warnings"],
|
|
806
|
+
"cleanup_authorized": False,
|
|
807
|
+
"implementation_sha256": hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
|
808
|
+
}
|
|
809
|
+
if "cleanup_topology" in plan:
|
|
810
|
+
receipt["cleanup_topology_sha256"] = plan["cleanup_topology"]["sha256"]
|
|
811
|
+
receipt["recovery_contract"] = plan["recovery"]["contract"]
|
|
812
|
+
_check_chain(generation_chain)
|
|
813
|
+
receipt_digest = _write_once(generation, "receipt.json", receipt)
|
|
814
|
+
os.fchmod(generation.fd, 0o500)
|
|
815
|
+
os.fsync(generation.fd)
|
|
816
|
+
os.fsync(generation.parent_fd)
|
|
817
|
+
return {
|
|
818
|
+
"result": receipt["result"],
|
|
819
|
+
"receipt": str(destination / "receipt.json"),
|
|
820
|
+
"receipt_sha256": receipt_digest,
|
|
821
|
+
"generation": str(destination),
|
|
822
|
+
"tree_sha256": preserved.digest,
|
|
823
|
+
"inventory": preserved.inventory,
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def _read_json(parent: _Directory, name: str) -> dict[str, Any]:
|
|
828
|
+
metadata = os.stat(name, dir_fd=parent.fd, follow_symlinks=False)
|
|
829
|
+
# Only Controller-generated JSON evidence is parsed, never preserved bytes.
|
|
830
|
+
with (
|
|
831
|
+
_regular_fd(parent.fd, name, _identity(metadata)) as fd,
|
|
832
|
+
os.fdopen(os.dup(fd), "rb") as stream,
|
|
833
|
+
):
|
|
834
|
+
result = json.load(stream)
|
|
835
|
+
parent.check()
|
|
836
|
+
return result
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def verify_preservation(plan: dict[str, Any], *, state_root: Path | None = None) -> dict[str, Any]:
|
|
840
|
+
destination = _validate_plan(plan, state_root or evidence_root())
|
|
841
|
+
with _absolute_directory(destination) as generation_chain:
|
|
842
|
+
generation = generation_chain[-1]
|
|
843
|
+
_destination_anchor(generation_chain, plan["destination_anchor"])
|
|
844
|
+
receipt = _read_json(generation, "receipt.json")
|
|
845
|
+
manifest = _read_json(generation, "manifest.json")
|
|
846
|
+
if (
|
|
847
|
+
receipt.get("contract") != CONTRACT
|
|
848
|
+
or receipt.get("verification") != "PASS"
|
|
849
|
+
or receipt.get("plan_sha256") != plan["plan_sha256"]
|
|
850
|
+
or receipt.get("allocation_source_identity") != plan["authority"]["identity"]
|
|
851
|
+
or receipt.get("generation_identity") != generation.identity
|
|
852
|
+
or receipt.get("manifest_sha256") != _sha(manifest)
|
|
853
|
+
or manifest != plan["source_tree"]
|
|
854
|
+
or receipt.get("source_tree_sha256") != manifest["tree_sha256"]
|
|
855
|
+
or receipt.get("preserved_tree_sha256") != manifest["tree_sha256"]
|
|
856
|
+
):
|
|
857
|
+
raise _fail(
|
|
858
|
+
"PRESERVATION_RECEIPT_INVALID", "receipt does not bind this exact source/generation"
|
|
859
|
+
)
|
|
860
|
+
with _absolute_source(Path(plan["source"])) as source_chain:
|
|
861
|
+
_source_anchor(source_chain, plan)
|
|
862
|
+
_same_source(scan_tree(source_chain[-1], source_chain), manifest)
|
|
863
|
+
with _open_node(generation.fd, "tree") as copied:
|
|
864
|
+
current_destination = scan_tree(copied, (*generation_chain, copied))
|
|
865
|
+
_same_tree(current_destination, manifest)
|
|
866
|
+
if _sha(current_destination.identities) != receipt.get(
|
|
867
|
+
"destination_identity_sha256"
|
|
868
|
+
):
|
|
869
|
+
raise _fail(
|
|
870
|
+
"PRESERVATION_DESTINATION_CHANGED", "preserved node identities changed"
|
|
871
|
+
)
|
|
872
|
+
_same_source(scan_tree(source_chain[-1], source_chain), manifest)
|
|
873
|
+
_current_authority(plan)
|
|
874
|
+
return {
|
|
875
|
+
"result": "LEGACY_PRESERVATION_COMPLETE",
|
|
876
|
+
"verification": "PASS",
|
|
877
|
+
"tree_sha256": manifest["tree_sha256"],
|
|
878
|
+
"receipt": str(destination / "receipt.json"),
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def main(argv: list[str] | None = None) -> int:
|
|
883
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
884
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
885
|
+
create = commands.add_parser(
|
|
886
|
+
"plan", help="read-only full source hash scan; write an exclusive plan file"
|
|
887
|
+
)
|
|
888
|
+
create.add_argument("--repo", required=True, type=Path)
|
|
889
|
+
create.add_argument("--path", required=True, type=Path)
|
|
890
|
+
create.add_argument("--allocation-id", required=True)
|
|
891
|
+
create.add_argument("--owner", required=True)
|
|
892
|
+
create.add_argument("--expected-record-version", required=True, type=int)
|
|
893
|
+
create.add_argument("--plan-file", required=True, type=Path)
|
|
894
|
+
create.add_argument("--source", default=None, help="relative source path to preserve")
|
|
895
|
+
create.add_argument("--recovery-transaction-id")
|
|
896
|
+
create.add_argument("--transaction-sha256")
|
|
897
|
+
create.add_argument("--cleanup-receipt-sha256")
|
|
898
|
+
for command in ("copy", "verify"):
|
|
899
|
+
subcommand = commands.add_parser(command)
|
|
900
|
+
subcommand.add_argument("--plan-file", required=True, type=Path)
|
|
901
|
+
if command == "verify":
|
|
902
|
+
subcommand.add_argument("--destination-only", action="store_true")
|
|
903
|
+
subcommand.add_argument("--receipt-sha256")
|
|
904
|
+
args = parser.parse_args(argv)
|
|
905
|
+
try:
|
|
906
|
+
if args.command == "plan":
|
|
907
|
+
recovery = None
|
|
908
|
+
if (
|
|
909
|
+
args.recovery_transaction_id
|
|
910
|
+
or args.transaction_sha256
|
|
911
|
+
or args.cleanup_receipt_sha256
|
|
912
|
+
):
|
|
913
|
+
from .cleanup_recovery import context
|
|
914
|
+
|
|
915
|
+
recovery = context(
|
|
916
|
+
args.recovery_transaction_id,
|
|
917
|
+
args.transaction_sha256,
|
|
918
|
+
args.cleanup_receipt_sha256,
|
|
919
|
+
)
|
|
920
|
+
plan = plan_preservation(
|
|
921
|
+
args.repo,
|
|
922
|
+
args.path,
|
|
923
|
+
args.allocation_id,
|
|
924
|
+
args.owner,
|
|
925
|
+
args.expected_record_version,
|
|
926
|
+
source_relative=args.source,
|
|
927
|
+
recovery=recovery,
|
|
928
|
+
)
|
|
929
|
+
with _absolute_directory(args.plan_file.parent) as chain:
|
|
930
|
+
_write_once(chain[-1], args.plan_file.name, plan)
|
|
931
|
+
result = {
|
|
932
|
+
"result": "PRESERVATION_PLAN_READY" if plan["ready"] else "PRESERVATION_BLOCKED",
|
|
933
|
+
"plan_file": str(args.plan_file),
|
|
934
|
+
"plan_sha256": plan["plan_sha256"],
|
|
935
|
+
"inventory": plan["source_tree"]["inventory"],
|
|
936
|
+
"errors": plan["source_tree"]["errors"],
|
|
937
|
+
"tree_sha256": plan["source_tree"]["tree_sha256"],
|
|
938
|
+
"destination": plan["destination"],
|
|
939
|
+
"runtime_evidence": plan["runtime_checks"][-1],
|
|
940
|
+
}
|
|
941
|
+
exit_code = 0 if plan["ready"] else 1
|
|
942
|
+
else:
|
|
943
|
+
with _absolute_directory(args.plan_file.parent) as chain:
|
|
944
|
+
plan = _read_json(chain[-1], args.plan_file.name)
|
|
945
|
+
if args.command == "verify" and args.destination_only:
|
|
946
|
+
from .preserved_cleanup import coverage
|
|
947
|
+
|
|
948
|
+
evidence, _ = coverage(
|
|
949
|
+
Path(plan["destination"]) / "receipt.json",
|
|
950
|
+
args.receipt_sha256,
|
|
951
|
+
verify_source=False,
|
|
952
|
+
)
|
|
953
|
+
result = {
|
|
954
|
+
"result": "PRESERVATION_DESTINATION_VERIFIED",
|
|
955
|
+
"verification": "PASS",
|
|
956
|
+
"tree_sha256": evidence["tree_sha256"],
|
|
957
|
+
"source_checked": False,
|
|
958
|
+
"receipt": evidence["receipt_path"],
|
|
959
|
+
"cleanup_authorized": False,
|
|
960
|
+
}
|
|
961
|
+
else:
|
|
962
|
+
result = (copy_preservation if args.command == "copy" else verify_preservation)(
|
|
963
|
+
plan
|
|
964
|
+
)
|
|
965
|
+
exit_code = 0
|
|
966
|
+
print(json.dumps(result, sort_keys=True))
|
|
967
|
+
return exit_code
|
|
968
|
+
except (ControllerError, OSError, ValueError, KeyError, TypeError) as exc:
|
|
969
|
+
# Never serialize client_id payloads or arbitrary exception strings.
|
|
970
|
+
print(
|
|
971
|
+
json.dumps(
|
|
972
|
+
{
|
|
973
|
+
"result": "PRESERVATION_BLOCKED",
|
|
974
|
+
"code": getattr(exc, "code", "PRESERVATION_IO_ERROR"),
|
|
975
|
+
"errno": getattr(exc, "errno", None),
|
|
976
|
+
}
|
|
977
|
+
)
|
|
978
|
+
)
|
|
979
|
+
return 1
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
if __name__ == "__main__":
|
|
983
|
+
sys.exit(main())
|