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