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,677 @@
|
|
|
1
|
+
"""Version 1: explicit project resources, never an ignored-directory deletion heuristic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import stat
|
|
11
|
+
import tomllib
|
|
12
|
+
from collections.abc import Iterator
|
|
13
|
+
from contextlib import ExitStack, contextmanager
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from .environment import (
|
|
19
|
+
CACHEDIR_SIGNATURE,
|
|
20
|
+
MAX_VENV_ENTRIES,
|
|
21
|
+
_sha256,
|
|
22
|
+
discover_python_project,
|
|
23
|
+
unsafe_ignored_paths,
|
|
24
|
+
)
|
|
25
|
+
from .git_backend import head_paths, ignored_paths, path_is_ignored, tracked_paths
|
|
26
|
+
from .models import ControllerError
|
|
27
|
+
|
|
28
|
+
CONTRACT = "typed-disposable-resource/v1"
|
|
29
|
+
RESOURCE_TYPES = {
|
|
30
|
+
"backend/.venv": "python-venv",
|
|
31
|
+
"frontend/node_modules": "node-modules",
|
|
32
|
+
"frontend/dist": "frontend-build-output",
|
|
33
|
+
}
|
|
34
|
+
MAX_IDENTITY_BYTES = 8 * 1_048_576
|
|
35
|
+
DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _inode(metadata: os.stat_result) -> dict[str, int]:
|
|
39
|
+
return {
|
|
40
|
+
"device": metadata.st_dev,
|
|
41
|
+
"inode": metadata.st_ino,
|
|
42
|
+
"file_type": stat.S_IFMT(metadata.st_mode),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _signature(metadata: os.stat_result) -> tuple[int, ...]:
|
|
47
|
+
return (
|
|
48
|
+
metadata.st_dev,
|
|
49
|
+
metadata.st_ino,
|
|
50
|
+
metadata.st_mode,
|
|
51
|
+
metadata.st_size,
|
|
52
|
+
metadata.st_mtime_ns,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _link_identity(metadata: os.stat_result) -> list[int]:
|
|
57
|
+
return [
|
|
58
|
+
metadata.st_dev,
|
|
59
|
+
metadata.st_ino,
|
|
60
|
+
metadata.st_mode,
|
|
61
|
+
metadata.st_size,
|
|
62
|
+
metadata.st_mtime_ns,
|
|
63
|
+
metadata.st_ctime_ns,
|
|
64
|
+
metadata.st_uid,
|
|
65
|
+
metadata.st_gid,
|
|
66
|
+
metadata.st_nlink,
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _race() -> ControllerError:
|
|
71
|
+
return _blocked("resource path or directory entry identity changed", "RESOURCE_PLAN_MISMATCH")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class _Directory:
|
|
76
|
+
parent_fd: int
|
|
77
|
+
name: str
|
|
78
|
+
fd: int
|
|
79
|
+
identity: dict[str, int]
|
|
80
|
+
|
|
81
|
+
def check(self) -> None:
|
|
82
|
+
if (
|
|
83
|
+
_inode(os.fstat(self.fd)) != self.identity
|
|
84
|
+
or _inode(os.stat(self.name, dir_fd=self.parent_fd, follow_symlinks=False))
|
|
85
|
+
!= self.identity
|
|
86
|
+
):
|
|
87
|
+
raise _race()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@contextmanager
|
|
91
|
+
def _open_directory(parent_fd: int, name: str, expected: dict[str, int]) -> Iterator[_Directory]:
|
|
92
|
+
if expected["file_type"] != stat.S_IFDIR:
|
|
93
|
+
raise _race()
|
|
94
|
+
fd = os.open(name, DIRECTORY_FLAGS, dir_fd=parent_fd)
|
|
95
|
+
try:
|
|
96
|
+
directory = _Directory(parent_fd, name, fd, expected)
|
|
97
|
+
directory.check()
|
|
98
|
+
yield directory
|
|
99
|
+
finally:
|
|
100
|
+
os.close(fd)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class _TreeEntry:
|
|
105
|
+
metadata: os.stat_result
|
|
106
|
+
children: tuple[str, ...] = ()
|
|
107
|
+
link: str | None = None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _fd_inventory(
|
|
111
|
+
directory_fd: int, *, preserved_fifo: bool = False
|
|
112
|
+
) -> tuple[dict[str, _TreeEntry], dict[str, Any]]:
|
|
113
|
+
"""Capture a no-follow inventory and reproduce the classified tree fingerprint."""
|
|
114
|
+
entries: dict[str, _TreeEntry] = {}
|
|
115
|
+
digest = hashlib.sha256()
|
|
116
|
+
size = 0
|
|
117
|
+
|
|
118
|
+
def visit(parent_fd: int, name: str, relative: str) -> None:
|
|
119
|
+
nonlocal size
|
|
120
|
+
metadata = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
121
|
+
if len(entries) >= MAX_VENV_ENTRIES:
|
|
122
|
+
raise _blocked("resource inventory exceeded its bound", "RESOURCE_SCAN_LIMIT")
|
|
123
|
+
digest.update(os.fsencode(relative) + b"\0")
|
|
124
|
+
digest.update(":".join(map(str, _signature(metadata))).encode())
|
|
125
|
+
size += metadata.st_size
|
|
126
|
+
entries[relative] = _TreeEntry(metadata)
|
|
127
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
128
|
+
with _open_directory(parent_fd, name, _inode(metadata)) as directory:
|
|
129
|
+
names = tuple(sorted(os.listdir(directory.fd)))
|
|
130
|
+
entries[relative] = _TreeEntry(metadata, names)
|
|
131
|
+
for child in names:
|
|
132
|
+
visit(directory.fd, child, child if relative == "." else f"{relative}/{child}")
|
|
133
|
+
directory.check()
|
|
134
|
+
if _signature(os.fstat(directory.fd)) != _signature(metadata):
|
|
135
|
+
raise _race()
|
|
136
|
+
elif stat.S_ISLNK(metadata.st_mode):
|
|
137
|
+
link = os.readlink(name, dir_fd=parent_fd)
|
|
138
|
+
digest.update(os.fsencode(link))
|
|
139
|
+
entries[relative] = _TreeEntry(metadata, link=link)
|
|
140
|
+
elif not (
|
|
141
|
+
stat.S_ISREG(metadata.st_mode) or (preserved_fifo and stat.S_ISFIFO(metadata.st_mode))
|
|
142
|
+
):
|
|
143
|
+
raise _race()
|
|
144
|
+
if _signature(os.stat(name, dir_fd=parent_fd, follow_symlinks=False)) != _signature(
|
|
145
|
+
metadata
|
|
146
|
+
):
|
|
147
|
+
raise _race()
|
|
148
|
+
|
|
149
|
+
visit(directory_fd, ".", ".")
|
|
150
|
+
return entries, {"tree_sha256": digest.hexdigest(), "entries": len(entries), "bytes": size}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _unlink_leaf(
|
|
154
|
+
parent: int,
|
|
155
|
+
name: str,
|
|
156
|
+
expected: list[int],
|
|
157
|
+
*,
|
|
158
|
+
link: str | None,
|
|
159
|
+
chain: tuple[_Directory, ...],
|
|
160
|
+
external: dict[str, Any] | None = None,
|
|
161
|
+
) -> dict[str, Any]:
|
|
162
|
+
from .hardlinks import pinned_content
|
|
163
|
+
|
|
164
|
+
for anchor in chain:
|
|
165
|
+
anchor.check()
|
|
166
|
+
pin = os.open(name, os.O_PATH | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
|
|
167
|
+
try:
|
|
168
|
+
metadata = os.fstat(pin)
|
|
169
|
+
if _link_identity(metadata) != expected or stat.S_ISDIR(metadata.st_mode):
|
|
170
|
+
raise _race()
|
|
171
|
+
if link is not None and os.readlink(name, dir_fd=parent) != link:
|
|
172
|
+
raise _race()
|
|
173
|
+
if external is not None and pinned_content(pin, expected) != external:
|
|
174
|
+
raise _race()
|
|
175
|
+
for anchor in chain:
|
|
176
|
+
anchor.check()
|
|
177
|
+
if _link_identity(os.stat(name, dir_fd=parent, follow_symlinks=False)) != expected:
|
|
178
|
+
raise _race()
|
|
179
|
+
os.unlink(name, dir_fd=parent)
|
|
180
|
+
after = _link_identity(os.fstat(pin))
|
|
181
|
+
wanted = list(expected)
|
|
182
|
+
wanted[8] -= 1
|
|
183
|
+
wanted[5] = after[5]
|
|
184
|
+
if after != wanted:
|
|
185
|
+
raise _race()
|
|
186
|
+
result = {"identity": after}
|
|
187
|
+
if external is not None:
|
|
188
|
+
content = pinned_content(pin, after)
|
|
189
|
+
if content != external:
|
|
190
|
+
raise _race()
|
|
191
|
+
result["content"] = content
|
|
192
|
+
for anchor in chain:
|
|
193
|
+
anchor.check()
|
|
194
|
+
try:
|
|
195
|
+
os.stat(name, dir_fd=parent, follow_symlinks=False)
|
|
196
|
+
except FileNotFoundError:
|
|
197
|
+
return result
|
|
198
|
+
raise _race()
|
|
199
|
+
finally:
|
|
200
|
+
os.close(pin)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _delete_fd_tree(
|
|
204
|
+
directory: _Directory,
|
|
205
|
+
relative: str,
|
|
206
|
+
entries: dict[str, _TreeEntry],
|
|
207
|
+
chain: tuple[_Directory, ...],
|
|
208
|
+
link_state: dict[tuple[int, int], list[int]] | None = None,
|
|
209
|
+
external_contents: dict[str, dict[str, Any]] | None = None,
|
|
210
|
+
external_survivors: dict[str, Any] | None = None,
|
|
211
|
+
) -> dict[str, Any]:
|
|
212
|
+
if external_contents is None:
|
|
213
|
+
external_contents = {}
|
|
214
|
+
if external_survivors is None:
|
|
215
|
+
external_survivors = {}
|
|
216
|
+
if link_state is None:
|
|
217
|
+
link_state = {
|
|
218
|
+
(e.metadata.st_dev, e.metadata.st_ino): _link_identity(e.metadata)
|
|
219
|
+
for e in entries.values()
|
|
220
|
+
if not stat.S_ISDIR(e.metadata.st_mode)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
def check_chain() -> None:
|
|
224
|
+
for anchor in chain:
|
|
225
|
+
anchor.check()
|
|
226
|
+
|
|
227
|
+
expected = entries[relative]
|
|
228
|
+
check_chain()
|
|
229
|
+
if tuple(sorted(os.listdir(directory.fd))) != expected.children:
|
|
230
|
+
raise _race()
|
|
231
|
+
for name in expected.children:
|
|
232
|
+
child_path = name if relative == "." else f"{relative}/{name}"
|
|
233
|
+
child = entries[child_path]
|
|
234
|
+
metadata = os.stat(name, dir_fd=directory.fd, follow_symlinks=False)
|
|
235
|
+
if _signature(metadata) != _signature(child.metadata):
|
|
236
|
+
raise _race()
|
|
237
|
+
if stat.S_ISDIR(metadata.st_mode):
|
|
238
|
+
with _open_directory(directory.fd, name, _inode(metadata)) as opened:
|
|
239
|
+
_delete_fd_tree(
|
|
240
|
+
opened,
|
|
241
|
+
child_path,
|
|
242
|
+
entries,
|
|
243
|
+
(*chain, opened),
|
|
244
|
+
link_state,
|
|
245
|
+
external_contents,
|
|
246
|
+
external_survivors,
|
|
247
|
+
)
|
|
248
|
+
else:
|
|
249
|
+
key = (metadata.st_dev, metadata.st_ino)
|
|
250
|
+
expected_identity = link_state.get(key)
|
|
251
|
+
if expected_identity is None:
|
|
252
|
+
raise _race()
|
|
253
|
+
external = external_contents.get(child_path)
|
|
254
|
+
proof = _unlink_leaf(
|
|
255
|
+
directory.fd, name, expected_identity, link=child.link, chain=chain, external=external
|
|
256
|
+
)
|
|
257
|
+
link_state[key] = proof["identity"]
|
|
258
|
+
if external is not None:
|
|
259
|
+
external_survivors[f"{key[0]}:{key[1]}"] = proof
|
|
260
|
+
try:
|
|
261
|
+
os.stat(name, dir_fd=directory.fd, follow_symlinks=False)
|
|
262
|
+
except FileNotFoundError:
|
|
263
|
+
pass
|
|
264
|
+
else:
|
|
265
|
+
raise _race()
|
|
266
|
+
check_chain()
|
|
267
|
+
if os.listdir(directory.fd):
|
|
268
|
+
raise _race()
|
|
269
|
+
os.rmdir(directory.name, dir_fd=directory.parent_fd)
|
|
270
|
+
if os.fstat(directory.fd).st_nlink != 0:
|
|
271
|
+
raise _race()
|
|
272
|
+
return external_survivors
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def delete_typed_resource(root: Path, relative: str, evidence: dict[str, Any]) -> dict[str, Any]:
|
|
276
|
+
"""Delete only through verified directory FDs; paths never authorize a destructive call."""
|
|
277
|
+
if relative not in RESOURCE_TYPES or not root.is_absolute():
|
|
278
|
+
raise _race()
|
|
279
|
+
try:
|
|
280
|
+
with ExitStack() as stack:
|
|
281
|
+
filesystem_fd = os.open("/", DIRECTORY_FLAGS)
|
|
282
|
+
stack.callback(os.close, filesystem_fd)
|
|
283
|
+
parent_fd = filesystem_fd
|
|
284
|
+
chain: list[_Directory] = []
|
|
285
|
+
# Pin even the ancestors of the worktree: opening an absolute root with
|
|
286
|
+
# O_NOFOLLOW alone would still follow a replaced intermediate directory.
|
|
287
|
+
for index, name in enumerate(root.parts[1:]):
|
|
288
|
+
expected = (
|
|
289
|
+
evidence["path_identities"]["."]
|
|
290
|
+
if index == len(root.parts) - 2
|
|
291
|
+
else _inode(os.stat(name, dir_fd=parent_fd, follow_symlinks=False))
|
|
292
|
+
)
|
|
293
|
+
opened = stack.enter_context(_open_directory(parent_fd, name, expected))
|
|
294
|
+
chain.append(opened)
|
|
295
|
+
parent_fd = opened.fd
|
|
296
|
+
for index, name in enumerate(relative.split("/")):
|
|
297
|
+
key = "/".join(relative.split("/")[: index + 1])
|
|
298
|
+
opened = stack.enter_context(
|
|
299
|
+
_open_directory(parent_fd, name, evidence["path_identities"][key])
|
|
300
|
+
)
|
|
301
|
+
chain.append(opened)
|
|
302
|
+
parent_fd = opened.fd
|
|
303
|
+
entries, tree = _fd_inventory(parent_fd)
|
|
304
|
+
if (
|
|
305
|
+
"hardlink_identities" in evidence
|
|
306
|
+
and {
|
|
307
|
+
path: _link_identity(entry.metadata)
|
|
308
|
+
for path, entry in entries.items()
|
|
309
|
+
if not stat.S_ISDIR(entry.metadata.st_mode)
|
|
310
|
+
}
|
|
311
|
+
!= evidence["hardlink_identities"]
|
|
312
|
+
):
|
|
313
|
+
raise _race()
|
|
314
|
+
if any(tree[key] != evidence[key] for key in tree):
|
|
315
|
+
raise _race()
|
|
316
|
+
return _delete_fd_tree(
|
|
317
|
+
chain[-1],
|
|
318
|
+
".",
|
|
319
|
+
entries,
|
|
320
|
+
tuple(chain),
|
|
321
|
+
external_contents=evidence.get("external_hardlink_contents"),
|
|
322
|
+
)
|
|
323
|
+
except OSError as exc:
|
|
324
|
+
if exc.errno in {errno.ENOENT, errno.ENOTDIR, errno.ELOOP, errno.ESTALE}:
|
|
325
|
+
raise _race() from exc
|
|
326
|
+
raise
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _blocked(message: str, code: str = "RESOURCE_IDENTITY_MISMATCH") -> ControllerError:
|
|
330
|
+
return ControllerError(code, message)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _regular(path: Path) -> bytes:
|
|
334
|
+
if path.is_symlink() or not path.is_file():
|
|
335
|
+
raise _blocked(f"identity must be a regular file: {path}")
|
|
336
|
+
if path.stat().st_size > MAX_IDENTITY_BYTES:
|
|
337
|
+
raise _blocked(f"identity exceeds scan bound: {path}", "RESOURCE_SCAN_LIMIT")
|
|
338
|
+
return path.read_bytes()
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _project_identity(root: Path, relative: str) -> bytes:
|
|
342
|
+
path = root / relative
|
|
343
|
+
if path.resolve(strict=True) != path:
|
|
344
|
+
raise _blocked(f"project identity escapes its path: {relative}", "RESOURCE_PATH_ESCAPE")
|
|
345
|
+
if tracked_paths(root, relative) != (relative,) or head_paths(root, relative) != (relative,):
|
|
346
|
+
raise _blocked(f"project identity must be committed and indexed: {relative}")
|
|
347
|
+
return _regular(path)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _json(payload: bytes) -> dict[str, Any]:
|
|
351
|
+
value = json.loads(payload)
|
|
352
|
+
if not isinstance(value, dict):
|
|
353
|
+
raise _blocked("project identity must be a JSON object")
|
|
354
|
+
return value
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _venv_identity(root: Path, resource: Path) -> dict[str, Any]:
|
|
358
|
+
rel_venv = resource.relative_to(root).as_posix()
|
|
359
|
+
project_rel, lock_rel = discover_python_project(root, venv_relative_path=rel_venv)
|
|
360
|
+
project = _project_identity(root, project_rel)
|
|
361
|
+
lock = _project_identity(root, lock_rel)
|
|
362
|
+
if not tomllib.loads(project.decode()).get("project", {}).get("name"):
|
|
363
|
+
raise _blocked("Python project identity is incomplete")
|
|
364
|
+
if not tomllib.loads(lock.decode()).get("version"):
|
|
365
|
+
raise _blocked("Python lock identity is incomplete")
|
|
366
|
+
marker = _regular(resource / "pyvenv.cfg")
|
|
367
|
+
values = {}
|
|
368
|
+
for line in marker.decode().splitlines():
|
|
369
|
+
key, separator, value = line.partition("=")
|
|
370
|
+
if separator:
|
|
371
|
+
values[key.strip().lower()] = value.strip()
|
|
372
|
+
version = values.get("version_info") or values.get("version", "")
|
|
373
|
+
if not re.fullmatch(r"3\.\d+\.\d+(?:\S*)?", version) or not values.get("home"):
|
|
374
|
+
raise _blocked("pyvenv.cfg requires Python version and home identity")
|
|
375
|
+
if values.get("include-system-site-packages") != "false":
|
|
376
|
+
raise _blocked("only isolated Python virtualenvs are supported")
|
|
377
|
+
tag = _regular(resource / "CACHEDIR.TAG")
|
|
378
|
+
if not tag.startswith(CACHEDIR_SIGNATURE):
|
|
379
|
+
raise _blocked("venv requires the standard CACHEDIR.TAG identity")
|
|
380
|
+
allowed = {
|
|
381
|
+
"pyvenv.cfg",
|
|
382
|
+
"CACHEDIR.TAG",
|
|
383
|
+
".gitignore",
|
|
384
|
+
".lock",
|
|
385
|
+
"bin",
|
|
386
|
+
"lib",
|
|
387
|
+
"lib64",
|
|
388
|
+
"include",
|
|
389
|
+
}
|
|
390
|
+
if any(path.name not in allowed for path in resource.iterdir()):
|
|
391
|
+
raise _blocked("venv contains unknown top-level content")
|
|
392
|
+
for directory in (resource / "bin", resource / "lib"):
|
|
393
|
+
if directory.is_symlink() or not directory.is_dir():
|
|
394
|
+
raise _blocked("venv bin/lib must be local directories", "RESOURCE_PATH_ESCAPE")
|
|
395
|
+
python = resource / "bin/python"
|
|
396
|
+
target = python.resolve(strict=True)
|
|
397
|
+
major_minor = ".".join(version.split(".")[:2])
|
|
398
|
+
interpreter_names = {"python", "python3", f"python{major_minor}"}
|
|
399
|
+
external_links = []
|
|
400
|
+
if not target.is_relative_to(resource):
|
|
401
|
+
home = Path(values["home"])
|
|
402
|
+
if not home.is_absolute() or target.parent != home.resolve(strict=True):
|
|
403
|
+
raise _blocked("external interpreter differs from pyvenv home", "RESOURCE_PATH_ESCAPE")
|
|
404
|
+
if target.name != f"python{major_minor}":
|
|
405
|
+
raise _blocked(
|
|
406
|
+
"external interpreter name differs from venv version", "RESOURCE_PATH_ESCAPE"
|
|
407
|
+
)
|
|
408
|
+
for name in sorted(interpreter_names):
|
|
409
|
+
link = resource / "bin" / name
|
|
410
|
+
if not os.path.lexists(link):
|
|
411
|
+
continue
|
|
412
|
+
if not link.is_symlink() or link.resolve(strict=True) != target:
|
|
413
|
+
raise _blocked("venv interpreter aliases disagree", "RESOURCE_PATH_ESCAPE")
|
|
414
|
+
raw = Path(os.readlink(link))
|
|
415
|
+
if not (
|
|
416
|
+
(raw.parent == Path(".") and raw.name in interpreter_names)
|
|
417
|
+
or (raw.is_absolute() and raw.parent == home and raw.name in interpreter_names)
|
|
418
|
+
):
|
|
419
|
+
raise _blocked("nonstandard interpreter link shape", "RESOURCE_PATH_ESCAPE")
|
|
420
|
+
external_links.append(link.relative_to(resource).as_posix())
|
|
421
|
+
if not python.is_file() or not os.access(python, os.X_OK):
|
|
422
|
+
raise _blocked("venv requires an executable Python identity")
|
|
423
|
+
if target.stat().st_size > 128 * 1_048_576:
|
|
424
|
+
raise _blocked("Python executable exceeds scan bound", "RESOURCE_SCAN_LIMIT")
|
|
425
|
+
# Do not execute a checkout-supplied interpreter to establish its identity.
|
|
426
|
+
with python.open("rb") as stream:
|
|
427
|
+
if stream.read(4) != b"\x7fELF":
|
|
428
|
+
raise _blocked("venv Python must be a native Linux executable")
|
|
429
|
+
if not (resource / "lib" / f"python{major_minor}" / "site-packages").is_dir():
|
|
430
|
+
raise _blocked("venv site-packages structure is incomplete")
|
|
431
|
+
return {
|
|
432
|
+
"project_sha256": hashlib.sha256(project).hexdigest(),
|
|
433
|
+
"lock_sha256": hashlib.sha256(lock).hexdigest(),
|
|
434
|
+
"pyvenv_cfg_sha256": hashlib.sha256(marker).hexdigest(),
|
|
435
|
+
"python_version": version,
|
|
436
|
+
"python_target": str(target),
|
|
437
|
+
"python_identity_sha256": _sha256(target),
|
|
438
|
+
"external_interpreter_links": external_links,
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _frontend_identity(root: Path) -> tuple[dict[str, Any], dict[str, Any], dict[str, str]]:
|
|
443
|
+
project = _project_identity(root, "frontend/package.json")
|
|
444
|
+
lock = _project_identity(root, "frontend/package-lock.json")
|
|
445
|
+
package, locked = _json(project), _json(lock)
|
|
446
|
+
packages = locked.get("packages")
|
|
447
|
+
if (
|
|
448
|
+
not package.get("name")
|
|
449
|
+
or package.get("workspaces")
|
|
450
|
+
or locked.get("lockfileVersion") not in {2, 3}
|
|
451
|
+
or not isinstance(packages, dict)
|
|
452
|
+
or not isinstance(packages.get(""), dict)
|
|
453
|
+
or packages[""].get("name") != package["name"]
|
|
454
|
+
):
|
|
455
|
+
raise _blocked("frontend requires an npm project and matching non-workspace lock")
|
|
456
|
+
for kind in ("dependencies", "devDependencies", "optionalDependencies"):
|
|
457
|
+
if package.get(kind, {}) != packages[""].get(kind, {}):
|
|
458
|
+
raise _blocked("frontend dependency declarations differ from lock")
|
|
459
|
+
return (
|
|
460
|
+
package,
|
|
461
|
+
packages,
|
|
462
|
+
{
|
|
463
|
+
"project_sha256": hashlib.sha256(project).hexdigest(),
|
|
464
|
+
"lock_sha256": hashlib.sha256(lock).hexdigest(),
|
|
465
|
+
},
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _node_identity(root: Path, resource: Path) -> dict[str, str]:
|
|
470
|
+
package, packages, evidence = _frontend_identity(root)
|
|
471
|
+
installed_bytes = _regular(resource / ".package-lock.json")
|
|
472
|
+
installed = _json(installed_bytes)
|
|
473
|
+
entries = installed.get("packages")
|
|
474
|
+
if installed.get("name") != package["name"] or not isinstance(entries, dict) or not entries:
|
|
475
|
+
raise _blocked("node_modules lacks its npm installed-tree identity")
|
|
476
|
+
allowed = {".bin", ".package-lock.json", ".vite-temp"}
|
|
477
|
+
for relative, entry in entries.items():
|
|
478
|
+
parts = relative.split("/")
|
|
479
|
+
if (
|
|
480
|
+
len(parts) < 2
|
|
481
|
+
or parts[0] != "node_modules"
|
|
482
|
+
or any(part in {"", ".", ".."} for part in parts)
|
|
483
|
+
or not isinstance(entry, dict)
|
|
484
|
+
or entry.get("link")
|
|
485
|
+
or entry != packages.get(relative)
|
|
486
|
+
):
|
|
487
|
+
raise _blocked("installed dependency is not bound to the committed npm lock")
|
|
488
|
+
manifest = _json(_regular(root / "frontend" / relative / "package.json"))
|
|
489
|
+
if not entry.get("version") or manifest.get("version") != entry["version"]:
|
|
490
|
+
raise _blocked("installed package version differs from lock")
|
|
491
|
+
allowed.add(parts[1])
|
|
492
|
+
for path in resource.iterdir():
|
|
493
|
+
if path.name not in allowed:
|
|
494
|
+
raise _blocked("node_modules contains unknown top-level content")
|
|
495
|
+
if path.name == ".vite-temp" and (not path.is_dir() or any(path.iterdir())):
|
|
496
|
+
raise _blocked("nonempty Vite temporary state is not dependency identity")
|
|
497
|
+
if path.name.startswith("@"):
|
|
498
|
+
for child in path.iterdir():
|
|
499
|
+
if f"node_modules/{path.name}/{child.name}" not in entries:
|
|
500
|
+
raise _blocked("node_modules contains an unregistered scoped package")
|
|
501
|
+
evidence["installed_lock_sha256"] = hashlib.sha256(installed_bytes).hexdigest()
|
|
502
|
+
return evidence
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _default_output_config(text: str) -> bool:
|
|
506
|
+
"""Recognize an empty or server-only exported object, not arbitrary config programs."""
|
|
507
|
+
quoted = r"\"(?:\\.|[^\"\\])*\"|'(?:\\.|[^'\\])*'"
|
|
508
|
+
plain = re.sub(quoted, "STRING", text)
|
|
509
|
+
plain = re.sub(r"/\*.*?\*/|//[^\n]*", "", plain, flags=re.DOTALL)
|
|
510
|
+
starts = list(re.finditer(r"\breturn\s*(?=\{)", plain))
|
|
511
|
+
if starts:
|
|
512
|
+
if len(starts) != 1 or not re.search(r"export\s+default\s+defineConfig\s*\(", plain):
|
|
513
|
+
return False
|
|
514
|
+
body = plain[starts[0].end() :].strip()
|
|
515
|
+
suffix = r";?\s*\}\s*\)\s*;?"
|
|
516
|
+
else:
|
|
517
|
+
match = re.search(r"export\s+default\s+defineConfig\s*\(\s*(?=\{)", plain)
|
|
518
|
+
if not match:
|
|
519
|
+
return False
|
|
520
|
+
body = plain[match.end() :].strip()
|
|
521
|
+
suffix = r"\)\s*;?"
|
|
522
|
+
# Collapse nested literal objects. Remaining top-level properties must be server only.
|
|
523
|
+
reduced = body
|
|
524
|
+
while re.search(r"\{[^{}]*\}", reduced):
|
|
525
|
+
previous = reduced
|
|
526
|
+
reduced = re.sub(r"\{[^{}]*\}", "OBJECT", reduced)
|
|
527
|
+
if not reduced.startswith("{"):
|
|
528
|
+
outer = previous[: previous.index("}") + 1]
|
|
529
|
+
return bool(
|
|
530
|
+
re.fullmatch(r"\{\s*(?:server\s*:\s*OBJECT\s*,?\s*)?\}", outer)
|
|
531
|
+
and re.fullmatch(suffix, previous[len(outer) :].strip())
|
|
532
|
+
)
|
|
533
|
+
return False
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _dist_identity(root: Path, resource: Path) -> dict[str, str]:
|
|
537
|
+
package, _, evidence = _frontend_identity(root)
|
|
538
|
+
dependencies = {**package.get("dependencies", {}), **package.get("devDependencies", {})}
|
|
539
|
+
if package.get("scripts", {}).get("build") != "vite build" or "vite" not in dependencies:
|
|
540
|
+
raise _blocked("frontend/dist is not a supported declared Vite build output")
|
|
541
|
+
configs = sorted((root / "frontend").glob("vite.config.*"))
|
|
542
|
+
if len(configs) > 1:
|
|
543
|
+
raise _blocked("ambiguous Vite configuration")
|
|
544
|
+
if configs:
|
|
545
|
+
config = _project_identity(root, configs[0].relative_to(root).as_posix())
|
|
546
|
+
text = config.decode()
|
|
547
|
+
# Only the default output with a local, statically recognizable config is supported.
|
|
548
|
+
# Overrides, imports of custom config, spreads and computed properties need review.
|
|
549
|
+
if (
|
|
550
|
+
re.search(r"\b(root|build|outDir|publicDir|extends|require)\b|\.\.\.|\[|`", text)
|
|
551
|
+
or re.search(r"\bimport\b(?!\s*\{[^}]*\}\s*from\s*['\"]vite['\"])", text)
|
|
552
|
+
or re.search(r"\bexport\s+\*|\bprocess\b", text)
|
|
553
|
+
or not _default_output_config(text)
|
|
554
|
+
):
|
|
555
|
+
raise _blocked("Vite output cannot be established without executing config")
|
|
556
|
+
evidence["build_config_sha256"] = hashlib.sha256(config).hexdigest()
|
|
557
|
+
_project_identity(root, "frontend/index.html")
|
|
558
|
+
_regular(resource / "index.html")
|
|
559
|
+
if not (resource / "assets").is_dir():
|
|
560
|
+
raise _blocked("frontend build output lacks assets")
|
|
561
|
+
if any(path.name not in {"index.html", "assets"} for path in resource.iterdir()):
|
|
562
|
+
raise _blocked("frontend build output contains unknown top-level content")
|
|
563
|
+
if any(
|
|
564
|
+
path.is_dir()
|
|
565
|
+
or path.suffix not in {".js", ".css", ".map", ".svg", ".png", ".jpg", ".woff", ".woff2"}
|
|
566
|
+
for path in (resource / "assets").iterdir()
|
|
567
|
+
):
|
|
568
|
+
raise _blocked("frontend assets contain unsupported content")
|
|
569
|
+
return evidence
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _tree_identity(
|
|
573
|
+
resource: Path,
|
|
574
|
+
*,
|
|
575
|
+
interpreter_links: tuple[str, ...] = (),
|
|
576
|
+
interpreter_target: str | None = None,
|
|
577
|
+
) -> dict[str, Any]:
|
|
578
|
+
digest = hashlib.sha256()
|
|
579
|
+
count = size = 0
|
|
580
|
+
pending = [resource]
|
|
581
|
+
while pending:
|
|
582
|
+
path = pending.pop()
|
|
583
|
+
metadata = path.lstat()
|
|
584
|
+
count += 1
|
|
585
|
+
if count > MAX_VENV_ENTRIES:
|
|
586
|
+
raise _blocked("resource inventory exceeded its bound", "RESOURCE_SCAN_LIMIT")
|
|
587
|
+
if metadata.st_uid != os.getuid():
|
|
588
|
+
raise _blocked("resource ownership is not the current user", "RESOURCE_OWNER_MISMATCH")
|
|
589
|
+
if path.name == ".git" or path.is_mount():
|
|
590
|
+
raise _blocked("resource contains Git metadata or a mount")
|
|
591
|
+
relative = path.relative_to(resource).as_posix()
|
|
592
|
+
digest.update(os.fsencode(relative) + b"\0")
|
|
593
|
+
digest.update(":".join(map(str, _signature(metadata))).encode())
|
|
594
|
+
size += metadata.st_size
|
|
595
|
+
if stat.S_ISLNK(metadata.st_mode):
|
|
596
|
+
resolved = path.resolve(strict=True)
|
|
597
|
+
if not resolved.is_relative_to(resource) and not (
|
|
598
|
+
relative in interpreter_links and str(resolved) == interpreter_target
|
|
599
|
+
):
|
|
600
|
+
raise _blocked(
|
|
601
|
+
f"resource symlink escapes its root: {relative}", "RESOURCE_PATH_ESCAPE"
|
|
602
|
+
)
|
|
603
|
+
digest.update(os.fsencode(os.readlink(path)))
|
|
604
|
+
elif stat.S_ISDIR(metadata.st_mode):
|
|
605
|
+
pending.extend(sorted(path.iterdir(), reverse=True))
|
|
606
|
+
elif not stat.S_ISREG(metadata.st_mode):
|
|
607
|
+
raise _blocked("resource contains a special filesystem object")
|
|
608
|
+
return {"tree_sha256": digest.hexdigest(), "entries": count, "bytes": size}
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def resource_evidence(root: Path, relative: str) -> dict[str, Any]:
|
|
612
|
+
if relative not in RESOURCE_TYPES:
|
|
613
|
+
raise _blocked("resource path is outside the typed contract", "UNKNOWN_IGNORED_RESOURCE")
|
|
614
|
+
resource = root / relative
|
|
615
|
+
if resource.is_symlink() or resource.resolve(strict=True) != resource or not resource.is_dir():
|
|
616
|
+
raise _blocked("resource root is not a canonical local directory", "RESOURCE_PATH_ESCAPE")
|
|
617
|
+
if tracked_paths(root, relative) or head_paths(root, relative):
|
|
618
|
+
raise _blocked("resource contains tracked or staged content", "TRACKED_RESOURCE")
|
|
619
|
+
if not path_is_ignored(root, relative):
|
|
620
|
+
raise _blocked("resource is not Git-ignored", "RESOURCE_NOT_IGNORED")
|
|
621
|
+
tree = _tree_identity(resource) if RESOURCE_TYPES[relative] != "python-venv" else None
|
|
622
|
+
identity = {
|
|
623
|
+
"python-venv": _venv_identity,
|
|
624
|
+
"node-modules": _node_identity,
|
|
625
|
+
"frontend-build-output": _dist_identity,
|
|
626
|
+
}[RESOURCE_TYPES[relative]](root, resource)
|
|
627
|
+
tree = tree or _tree_identity(
|
|
628
|
+
resource,
|
|
629
|
+
interpreter_links=tuple(identity.get("external_interpreter_links", ())),
|
|
630
|
+
interpreter_target=identity.get("python_target"),
|
|
631
|
+
)
|
|
632
|
+
path_identities = {
|
|
633
|
+
path.relative_to(root).as_posix(): _inode(path.lstat())
|
|
634
|
+
for path in (root, resource.parent, resource)
|
|
635
|
+
}
|
|
636
|
+
if any(value["file_type"] != stat.S_IFDIR for value in path_identities.values()):
|
|
637
|
+
raise _race()
|
|
638
|
+
return {**tree, **identity, "path_identities": path_identities}
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def classify_resources(root: Path) -> tuple[dict[str, Any], ...]:
|
|
642
|
+
"""Read-only inventory; legacy release caches receive no typed cleanup action."""
|
|
643
|
+
ignored = ignored_paths(root)
|
|
644
|
+
paths = {path.rstrip("/") for path in ignored}
|
|
645
|
+
paths.update(relative for relative in RESOURCE_TYPES if os.path.lexists(root / relative))
|
|
646
|
+
rows = []
|
|
647
|
+
for relative in sorted(paths):
|
|
648
|
+
if any(relative.startswith(candidate + "/") for candidate in RESOURCE_TYPES):
|
|
649
|
+
continue
|
|
650
|
+
row: dict[str, Any] = {
|
|
651
|
+
"resource_type": RESOURCE_TYPES.get(relative, "unknown"),
|
|
652
|
+
"path": relative,
|
|
653
|
+
"classification": "blocked",
|
|
654
|
+
"cleanup_action": "none",
|
|
655
|
+
"blocker": None,
|
|
656
|
+
}
|
|
657
|
+
if relative in RESOURCE_TYPES:
|
|
658
|
+
try:
|
|
659
|
+
row["evidence"] = resource_evidence(root, relative)
|
|
660
|
+
row.update(classification="disposable", cleanup_action="remove-tree")
|
|
661
|
+
except ControllerError as exc:
|
|
662
|
+
row.update(blocker=exc.code, reason=str(exc))
|
|
663
|
+
except (OSError, ValueError, RuntimeError, TypeError, AttributeError) as exc:
|
|
664
|
+
row.update(
|
|
665
|
+
blocker="RESOURCE_IDENTITY_MISMATCH",
|
|
666
|
+
reason=f"identity inspection failed: {exc}",
|
|
667
|
+
)
|
|
668
|
+
elif not unsafe_ignored_paths((relative,)):
|
|
669
|
+
row.update(
|
|
670
|
+
resource_type="legacy-release-cache", classification="existing-release-policy"
|
|
671
|
+
)
|
|
672
|
+
else:
|
|
673
|
+
row.update(
|
|
674
|
+
blocker="UNKNOWN_IGNORED_RESOURCE", reason="ignored content has no typed contract"
|
|
675
|
+
)
|
|
676
|
+
rows.append(row)
|
|
677
|
+
return tuple(rows)
|