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,1038 @@
|
|
|
1
|
+
"""Release-time inspection for ignored artifacts and Python environments."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import stat
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import asdict, dataclass
|
|
12
|
+
from pathlib import Path, PurePosixPath
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from .git_backend import head_paths, tracked_paths
|
|
16
|
+
from .models import ControllerError, RepositoryInventory, WriterHolder
|
|
17
|
+
from .writer_lease import boot_id
|
|
18
|
+
|
|
19
|
+
SAFE_CACHE_PARTS = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"__pycache__",
|
|
22
|
+
".pytest_cache",
|
|
23
|
+
".ruff_cache",
|
|
24
|
+
".mypy_cache",
|
|
25
|
+
".pyright",
|
|
26
|
+
".hypothesis",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
SAFE_CACHE_SUFFIXES = (".pyc", ".pyo")
|
|
30
|
+
REFERENCE_NAMES = frozenset({"direct_url.json"})
|
|
31
|
+
REFERENCE_SUFFIXES = (".pth",)
|
|
32
|
+
MAX_REFERENCE_FILE_BYTES = 1_048_576
|
|
33
|
+
MAX_REFERENCE_FILES = 20_000
|
|
34
|
+
MAX_VENV_ENTRIES = 500_000
|
|
35
|
+
MAX_RUNTIME_PROCESSES = 16_384
|
|
36
|
+
MAX_RUNTIME_FDS = 200_000
|
|
37
|
+
MAX_RUNTIME_MAP_BYTES = 256 * 1_048_576
|
|
38
|
+
CACHEDIR_SIGNATURE = b"Signature: 8a477f597d28d172789f06886806bc55"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def unsafe_ignored_paths(paths: tuple[str, ...]) -> tuple[str, ...]:
|
|
42
|
+
unsafe: list[str] = []
|
|
43
|
+
for raw in paths:
|
|
44
|
+
path = PurePosixPath(raw)
|
|
45
|
+
if any(part in SAFE_CACHE_PARTS for part in path.parts):
|
|
46
|
+
continue
|
|
47
|
+
if path.name.endswith(SAFE_CACHE_SUFFIXES):
|
|
48
|
+
continue
|
|
49
|
+
unsafe.append(raw)
|
|
50
|
+
return tuple(unsafe)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _candidate_reference_file(path: Path) -> bool:
|
|
54
|
+
name = path.name
|
|
55
|
+
return (
|
|
56
|
+
name in REFERENCE_NAMES
|
|
57
|
+
or name.endswith(REFERENCE_SUFFIXES)
|
|
58
|
+
or name.startswith("__editable__")
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _environment_roots(
|
|
63
|
+
repository: RepositoryInventory, excluded_roots: frozenset[Path]
|
|
64
|
+
) -> tuple[Path, ...]:
|
|
65
|
+
roots: set[Path] = set()
|
|
66
|
+
for worktree in repository.worktrees:
|
|
67
|
+
if worktree.path.exists():
|
|
68
|
+
for venv_name in (".venv", "backend/.venv"):
|
|
69
|
+
candidate = worktree.path / venv_name
|
|
70
|
+
if candidate.is_dir() and not candidate.is_symlink():
|
|
71
|
+
resolved = candidate.resolve(strict=True)
|
|
72
|
+
if resolved not in excluded_roots:
|
|
73
|
+
roots.add(resolved)
|
|
74
|
+
return tuple(sorted(roots))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def editable_references(
|
|
78
|
+
repository: RepositoryInventory,
|
|
79
|
+
target: Path,
|
|
80
|
+
*,
|
|
81
|
+
exclude_environments: tuple[Path, ...] = (),
|
|
82
|
+
) -> tuple[dict[str, str], ...]:
|
|
83
|
+
target_bytes = os.fsencode(target.resolve(strict=True))
|
|
84
|
+
excluded_roots = frozenset(
|
|
85
|
+
path.resolve(strict=True) for path in exclude_environments if path.exists()
|
|
86
|
+
)
|
|
87
|
+
matches: list[dict[str, str]] = []
|
|
88
|
+
inspected = 0
|
|
89
|
+
def scan_error(error: OSError) -> None:
|
|
90
|
+
raise ControllerError(
|
|
91
|
+
"ENVIRONMENT_SCAN_ERROR", f"cannot enumerate environment: {error.filename}"
|
|
92
|
+
) from error
|
|
93
|
+
|
|
94
|
+
for root in _environment_roots(repository, excluded_roots):
|
|
95
|
+
for directory, directories, files in os.walk(root, followlinks=False, onerror=scan_error):
|
|
96
|
+
directories[:] = [
|
|
97
|
+
name for name in directories if not (Path(directory) / name).is_symlink()
|
|
98
|
+
]
|
|
99
|
+
for name in files:
|
|
100
|
+
path = Path(directory) / name
|
|
101
|
+
if not _candidate_reference_file(path):
|
|
102
|
+
continue
|
|
103
|
+
inspected += 1
|
|
104
|
+
if inspected > MAX_REFERENCE_FILES:
|
|
105
|
+
raise ControllerError(
|
|
106
|
+
"ENVIRONMENT_SCAN_LIMIT", "editable reference scan exceeded its bound"
|
|
107
|
+
)
|
|
108
|
+
payload = _reference_payload(path)
|
|
109
|
+
if target_bytes in payload:
|
|
110
|
+
matches.append({"environment": str(root), "file": str(path)})
|
|
111
|
+
return tuple(matches)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _reference_payload(path: Path) -> bytes:
|
|
115
|
+
"""An unexamined candidate is uncertainty, never evidence of no dependency."""
|
|
116
|
+
try:
|
|
117
|
+
# Do not follow links or wait on a FIFO/device disguised as a .pth file.
|
|
118
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
119
|
+
with os.fdopen(descriptor, "rb") as stream:
|
|
120
|
+
info = os.fstat(stream.fileno())
|
|
121
|
+
if not stat.S_ISREG(info.st_mode):
|
|
122
|
+
raise ControllerError(
|
|
123
|
+
"ENVIRONMENT_SCAN_ERROR", f"environment reference is not a regular file: {path}"
|
|
124
|
+
)
|
|
125
|
+
if info.st_size > MAX_REFERENCE_FILE_BYTES:
|
|
126
|
+
raise ControllerError(
|
|
127
|
+
"ENVIRONMENT_SCAN_LIMIT", f"environment reference exceeds size bound: {path}"
|
|
128
|
+
)
|
|
129
|
+
payload = stream.read(MAX_REFERENCE_FILE_BYTES + 1)
|
|
130
|
+
if len(payload) > MAX_REFERENCE_FILE_BYTES:
|
|
131
|
+
raise ControllerError(
|
|
132
|
+
"ENVIRONMENT_SCAN_LIMIT", f"environment reference grew beyond size bound: {path}"
|
|
133
|
+
)
|
|
134
|
+
return payload
|
|
135
|
+
except OSError as exc:
|
|
136
|
+
raise ControllerError(
|
|
137
|
+
"ENVIRONMENT_SCAN_ERROR", f"cannot inspect environment reference: {path}"
|
|
138
|
+
) from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _sha256(path: Path) -> str:
|
|
142
|
+
digest = hashlib.sha256()
|
|
143
|
+
try:
|
|
144
|
+
with path.open("rb") as handle:
|
|
145
|
+
while chunk := handle.read(1_048_576):
|
|
146
|
+
digest.update(chunk)
|
|
147
|
+
except OSError as exc:
|
|
148
|
+
raise ControllerError(
|
|
149
|
+
"RESOURCE_IDENTITY_MISMATCH", f"cannot read identity file: {path}"
|
|
150
|
+
) from exc
|
|
151
|
+
return digest.hexdigest()
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _pyvenv_identity(path: Path) -> tuple[str | None, str | None]:
|
|
155
|
+
try:
|
|
156
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
157
|
+
except (OSError, UnicodeError) as exc:
|
|
158
|
+
raise ControllerError("RESOURCE_IDENTITY_MISMATCH", "pyvenv.cfg is unreadable") from exc
|
|
159
|
+
values: dict[str, str] = {}
|
|
160
|
+
for line in lines:
|
|
161
|
+
key, separator, value = line.partition("=")
|
|
162
|
+
if separator:
|
|
163
|
+
values[key.strip().lower()] = value.strip()
|
|
164
|
+
version = values.get("version") or values.get("version_info")
|
|
165
|
+
implementation = values.get("implementation")
|
|
166
|
+
return version, implementation
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _venv_inventory(root: Path) -> tuple[int, int, str]:
|
|
170
|
+
total_bytes = 0
|
|
171
|
+
entries = 0
|
|
172
|
+
identity = hashlib.sha256()
|
|
173
|
+
pending = [root]
|
|
174
|
+
while pending:
|
|
175
|
+
directory = pending.pop()
|
|
176
|
+
try:
|
|
177
|
+
with os.scandir(directory) as scan:
|
|
178
|
+
children = sorted(scan, key=lambda entry: entry.name)
|
|
179
|
+
except OSError as exc:
|
|
180
|
+
raise ControllerError(
|
|
181
|
+
"RESOURCE_IDENTITY_MISMATCH", "cannot inventory reproducible .venv"
|
|
182
|
+
) from exc
|
|
183
|
+
for child in children:
|
|
184
|
+
entries += 1
|
|
185
|
+
if entries > MAX_VENV_ENTRIES:
|
|
186
|
+
raise ControllerError(
|
|
187
|
+
"RESOURCE_SCAN_LIMIT", "reproducible .venv inventory exceeded its bound"
|
|
188
|
+
)
|
|
189
|
+
path = Path(child.path)
|
|
190
|
+
try:
|
|
191
|
+
metadata = child.stat(follow_symlinks=False)
|
|
192
|
+
except OSError as exc:
|
|
193
|
+
raise ControllerError(
|
|
194
|
+
"RESOURCE_IDENTITY_MISMATCH", "cannot stat reproducible .venv content"
|
|
195
|
+
) from exc
|
|
196
|
+
total_bytes += metadata.st_size
|
|
197
|
+
relative = path.relative_to(root).as_posix()
|
|
198
|
+
identity.update(os.fsencode(relative))
|
|
199
|
+
identity.update(b"\0")
|
|
200
|
+
identity.update(
|
|
201
|
+
f"{metadata.st_mode}:{metadata.st_size}:{metadata.st_mtime_ns}".encode("ascii")
|
|
202
|
+
)
|
|
203
|
+
identity.update(b"\0")
|
|
204
|
+
if child.is_symlink():
|
|
205
|
+
try:
|
|
206
|
+
identity.update(os.fsencode(os.readlink(path)))
|
|
207
|
+
except OSError as exc:
|
|
208
|
+
raise ControllerError(
|
|
209
|
+
"RESOURCE_IDENTITY_MISMATCH",
|
|
210
|
+
"cannot inspect reproducible .venv symlink",
|
|
211
|
+
) from exc
|
|
212
|
+
continue
|
|
213
|
+
if child.is_dir(follow_symlinks=False):
|
|
214
|
+
if path.is_mount():
|
|
215
|
+
raise ControllerError(
|
|
216
|
+
"RESOURCE_IDENTITY_MISMATCH", "reproducible .venv contains a mount"
|
|
217
|
+
)
|
|
218
|
+
pending.append(path)
|
|
219
|
+
elif not stat.S_ISREG(metadata.st_mode):
|
|
220
|
+
raise ControllerError(
|
|
221
|
+
"RESOURCE_IDENTITY_MISMATCH",
|
|
222
|
+
"reproducible .venv contains a special filesystem object",
|
|
223
|
+
)
|
|
224
|
+
return total_bytes, entries, identity.hexdigest()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def discover_python_project(
|
|
228
|
+
worktree: Path,
|
|
229
|
+
*,
|
|
230
|
+
venv_relative_path: str = ".venv",
|
|
231
|
+
) -> tuple[str, str]:
|
|
232
|
+
"""Discover and validate the python project (pyproject.toml, uv.lock) for a worktree environment.
|
|
233
|
+
|
|
234
|
+
Returns (project_path, lock_path) as POSIX repository-relative strings.
|
|
235
|
+
Fails closed if the project or lockfile is missing, ambiguous, untracked, or escapes the root.
|
|
236
|
+
"""
|
|
237
|
+
root = worktree.resolve(strict=True)
|
|
238
|
+
venv_rel = PurePosixPath(venv_relative_path)
|
|
239
|
+
if venv_rel.is_absolute() or any(part in {"..", ""} for part in venv_rel.parts):
|
|
240
|
+
raise ControllerError(
|
|
241
|
+
"RESOURCE_PATH_ESCAPE", f"invalid venv relative path: {venv_relative_path}"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
venv_parent = venv_rel.parent.as_posix()
|
|
245
|
+
|
|
246
|
+
cand_dir: str | None = None
|
|
247
|
+
if venv_parent not in {".", ""}:
|
|
248
|
+
cand_project = f"{venv_parent}/pyproject.toml"
|
|
249
|
+
cand_project_file = root / cand_project
|
|
250
|
+
if not cand_project_file.exists():
|
|
251
|
+
raise ControllerError(
|
|
252
|
+
"PROJECT_IDENTITY_MISMATCH",
|
|
253
|
+
f"reproducible teardown requires tracked {cand_project}",
|
|
254
|
+
)
|
|
255
|
+
cand_dir = venv_parent
|
|
256
|
+
else:
|
|
257
|
+
root_project_file = root / "pyproject.toml"
|
|
258
|
+
if root_project_file.exists():
|
|
259
|
+
cand_dir = ""
|
|
260
|
+
else:
|
|
261
|
+
tracked = tracked_paths(root, "*pyproject.toml")
|
|
262
|
+
candidates = sorted(
|
|
263
|
+
p
|
|
264
|
+
for p in tracked
|
|
265
|
+
if PurePosixPath(p).name == "pyproject.toml" and not PurePosixPath(p).is_absolute()
|
|
266
|
+
)
|
|
267
|
+
if len(candidates) == 0:
|
|
268
|
+
raise ControllerError(
|
|
269
|
+
"PROJECT_IDENTITY_MISMATCH",
|
|
270
|
+
"reproducible teardown requires tracked pyproject.toml",
|
|
271
|
+
)
|
|
272
|
+
if len(candidates) > 1:
|
|
273
|
+
raise ControllerError(
|
|
274
|
+
"PROJECT_IDENTITY_MISMATCH",
|
|
275
|
+
f"ambiguous python project candidates: {', '.join(candidates)}",
|
|
276
|
+
)
|
|
277
|
+
cand_dir = PurePosixPath(candidates[0]).parent.as_posix()
|
|
278
|
+
if cand_dir == ".":
|
|
279
|
+
cand_dir = ""
|
|
280
|
+
|
|
281
|
+
project_rel = f"{cand_dir}/pyproject.toml" if cand_dir else "pyproject.toml"
|
|
282
|
+
lock_rel = f"{cand_dir}/uv.lock" if cand_dir else "uv.lock"
|
|
283
|
+
|
|
284
|
+
project_path = root / project_rel
|
|
285
|
+
lock_path = root / lock_rel
|
|
286
|
+
|
|
287
|
+
for rel_path, abs_path in ((project_rel, project_path), (lock_rel, lock_path)):
|
|
288
|
+
if abs_path.is_symlink():
|
|
289
|
+
raise ControllerError("RESOURCE_PATH_ESCAPE", f"{rel_path} is a symlink")
|
|
290
|
+
try:
|
|
291
|
+
if abs_path.resolve(strict=True) != abs_path:
|
|
292
|
+
raise ControllerError("RESOURCE_PATH_ESCAPE", f"{rel_path} escapes its root")
|
|
293
|
+
except OSError as exc:
|
|
294
|
+
raise ControllerError(
|
|
295
|
+
"PROJECT_IDENTITY_MISMATCH",
|
|
296
|
+
f"reproducible teardown requires tracked {rel_path}",
|
|
297
|
+
) from exc
|
|
298
|
+
if not abs_path.is_file():
|
|
299
|
+
raise ControllerError(
|
|
300
|
+
"PROJECT_IDENTITY_MISMATCH",
|
|
301
|
+
f"reproducible teardown requires regular {rel_path}",
|
|
302
|
+
)
|
|
303
|
+
if tracked_paths(root, rel_path) != (rel_path,) or head_paths(root, rel_path) != (
|
|
304
|
+
rel_path,
|
|
305
|
+
):
|
|
306
|
+
raise ControllerError(
|
|
307
|
+
"PROJECT_IDENTITY_MISMATCH",
|
|
308
|
+
f"reproducible teardown requires tracked {rel_path}",
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
return project_rel, lock_rel
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def reproducible_venv_evidence(
|
|
315
|
+
worktree: Path,
|
|
316
|
+
*,
|
|
317
|
+
venv_relative: str = ".venv",
|
|
318
|
+
) -> dict[str, Any]:
|
|
319
|
+
"""Return bounded deletion evidence for the reproducible worktree .venv."""
|
|
320
|
+
|
|
321
|
+
root = worktree.resolve(strict=True)
|
|
322
|
+
venv = root / venv_relative
|
|
323
|
+
if venv.is_symlink() or not venv.is_dir() or venv.is_mount():
|
|
324
|
+
raise ControllerError(
|
|
325
|
+
"RESOURCE_IDENTITY_MISMATCH",
|
|
326
|
+
f"worktree {venv_relative} is missing, a symlink, or a mount",
|
|
327
|
+
)
|
|
328
|
+
if venv.resolve(strict=True) != venv:
|
|
329
|
+
raise ControllerError(
|
|
330
|
+
"RESOURCE_IDENTITY_MISMATCH", f"worktree {venv_relative} escaped its root"
|
|
331
|
+
)
|
|
332
|
+
marker = venv / "pyvenv.cfg"
|
|
333
|
+
cache_tag = venv / "CACHEDIR.TAG"
|
|
334
|
+
project_rel, lock_rel = discover_python_project(root, venv_relative_path=venv_relative)
|
|
335
|
+
lock = root / lock_rel
|
|
336
|
+
project = root / project_rel
|
|
337
|
+
required = (marker, cache_tag, lock, project)
|
|
338
|
+
if any(path.is_symlink() or not path.is_file() for path in required):
|
|
339
|
+
raise ControllerError(
|
|
340
|
+
"RESOURCE_IDENTITY_MISMATCH",
|
|
341
|
+
f"reproducible {venv_relative} requires regular pyvenv.cfg, CACHEDIR.TAG, {lock_rel}, and {project_rel}",
|
|
342
|
+
)
|
|
343
|
+
try:
|
|
344
|
+
cache_payload = cache_tag.read_bytes()
|
|
345
|
+
except OSError as exc:
|
|
346
|
+
raise ControllerError("RESOURCE_IDENTITY_MISMATCH", "CACHEDIR.TAG is unreadable") from exc
|
|
347
|
+
if CACHEDIR_SIGNATURE not in cache_payload:
|
|
348
|
+
raise ControllerError(
|
|
349
|
+
"RESOURCE_IDENTITY_MISMATCH", "CACHEDIR.TAG lacks the standard cache signature"
|
|
350
|
+
)
|
|
351
|
+
if (venv / ".git").exists() or (venv / ".git").is_symlink():
|
|
352
|
+
raise ControllerError(
|
|
353
|
+
"RESOURCE_IDENTITY_MISMATCH", f"worktree {venv_relative} contains Git metadata"
|
|
354
|
+
)
|
|
355
|
+
python = venv / "bin" / "python"
|
|
356
|
+
try:
|
|
357
|
+
python_target = python.resolve(strict=True)
|
|
358
|
+
python_stat = python_target.stat()
|
|
359
|
+
except OSError as exc:
|
|
360
|
+
raise ControllerError(
|
|
361
|
+
"RESOURCE_IDENTITY_MISMATCH",
|
|
362
|
+
f"reproducible {venv_relative} Python executable is unavailable",
|
|
363
|
+
) from exc
|
|
364
|
+
if not python_target.is_file() or not os.access(python_target, os.X_OK):
|
|
365
|
+
raise ControllerError(
|
|
366
|
+
"RESOURCE_IDENTITY_MISMATCH",
|
|
367
|
+
f"reproducible {venv_relative} Python target is not executable",
|
|
368
|
+
)
|
|
369
|
+
version, implementation = _pyvenv_identity(marker)
|
|
370
|
+
if version is None:
|
|
371
|
+
raise ControllerError("RESOURCE_IDENTITY_MISMATCH", "pyvenv.cfg has no Python version")
|
|
372
|
+
try:
|
|
373
|
+
venv_stat = venv.stat()
|
|
374
|
+
except OSError as exc:
|
|
375
|
+
raise ControllerError(
|
|
376
|
+
"RESOURCE_IDENTITY_MISMATCH", f"cannot stat reproducible {venv_relative} root"
|
|
377
|
+
) from exc
|
|
378
|
+
bytes_before, entries_before, tree_identity = _venv_inventory(venv)
|
|
379
|
+
python_identity = (
|
|
380
|
+
f"{python_target}:{python_stat.st_dev}:{python_stat.st_ino}:"
|
|
381
|
+
f"{python_stat.st_size}:{python_stat.st_mtime_ns}"
|
|
382
|
+
)
|
|
383
|
+
environment_identity = hashlib.sha256(
|
|
384
|
+
(
|
|
385
|
+
_sha256(marker)
|
|
386
|
+
+ _sha256(cache_tag)
|
|
387
|
+
+ hashlib.sha256(python_identity.encode("utf-8")).hexdigest()
|
|
388
|
+
+ tree_identity
|
|
389
|
+
).encode("ascii")
|
|
390
|
+
).hexdigest()
|
|
391
|
+
return {
|
|
392
|
+
"environment_path": venv_relative,
|
|
393
|
+
"pyvenv_cfg_sha256": _sha256(marker),
|
|
394
|
+
"cachedir_tag_sha256": _sha256(cache_tag),
|
|
395
|
+
"lock_path": lock_rel,
|
|
396
|
+
"lock_sha256": _sha256(lock),
|
|
397
|
+
"project_path": project_rel,
|
|
398
|
+
"project_sha256": _sha256(project),
|
|
399
|
+
"python_version": version,
|
|
400
|
+
"python_implementation": implementation,
|
|
401
|
+
"python_identity_sha256": hashlib.sha256(python_identity.encode("utf-8")).hexdigest(),
|
|
402
|
+
"environment_identity_sha256": environment_identity,
|
|
403
|
+
"environment_device": venv_stat.st_dev,
|
|
404
|
+
"environment_inode": venv_stat.st_ino,
|
|
405
|
+
"bytes_before": bytes_before,
|
|
406
|
+
"entries_before": entries_before,
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _path_is_within(raw: str, roots: tuple[Path, ...]) -> bool:
|
|
411
|
+
candidate = raw.removesuffix(" (deleted)")
|
|
412
|
+
if not candidate.startswith("/"):
|
|
413
|
+
return False
|
|
414
|
+
# Preserve commonpath's lexical rules (including literal '..'), without
|
|
415
|
+
# splitting every ordinary procfs path once per root and mapping entry.
|
|
416
|
+
if "//" in candidate or "/./" in candidate or candidate.endswith("/."):
|
|
417
|
+
candidate = "/" + "/".join(part for part in candidate.split("/") if part and part != ".")
|
|
418
|
+
try:
|
|
419
|
+
for root in roots:
|
|
420
|
+
prefix = str(root)
|
|
421
|
+
if not prefix.startswith("/"):
|
|
422
|
+
return False
|
|
423
|
+
if candidate.startswith(prefix) and (
|
|
424
|
+
prefix == "/" or len(candidate) == len(prefix) or candidate[len(prefix)] == "/"
|
|
425
|
+
):
|
|
426
|
+
return True
|
|
427
|
+
except (OSError, ValueError):
|
|
428
|
+
return False
|
|
429
|
+
return False
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _runtime_link(path: Path) -> str | None:
|
|
433
|
+
try:
|
|
434
|
+
return os.readlink(path)
|
|
435
|
+
except (FileNotFoundError, PermissionError):
|
|
436
|
+
return None
|
|
437
|
+
except OSError as exc:
|
|
438
|
+
raise ControllerError("RUNTIME_SCAN_ERROR", f"cannot inspect runtime link: {path}") from exc
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def runtime_references(worktree: Path, *, strict: bool = False) -> tuple[dict[str, str], ...]:
|
|
442
|
+
"""Inspect same-UID Linux processes for references into the checkout or its .venv.
|
|
443
|
+
|
|
444
|
+
The release scan shares the typed cleanup policy for permission gaps: a
|
|
445
|
+
same-UID process whose procfs entries deny access (for example a non-dumpable
|
|
446
|
+
service manager) is a host visibility gap, not a reference; every other read
|
|
447
|
+
failure fails closed. ``strict`` additionally refuses any such gap. In both
|
|
448
|
+
modes the checkout must keep one identity for the whole scan.
|
|
449
|
+
"""
|
|
450
|
+
|
|
451
|
+
if strict:
|
|
452
|
+
return _strict_runtime_references(worktree)
|
|
453
|
+
try:
|
|
454
|
+
root = worktree.resolve(strict=True)
|
|
455
|
+
before = root.stat()
|
|
456
|
+
except (OSError, RuntimeError) as exc:
|
|
457
|
+
raise ControllerError("RUNTIME_SCAN_ERROR", "cannot identify checkout for runtime scan") from exc
|
|
458
|
+
references = _lenient_runtime_references(root)
|
|
459
|
+
try:
|
|
460
|
+
after = root.stat()
|
|
461
|
+
changed = worktree.resolve(strict=True) != root or (before.st_dev, before.st_ino) != (
|
|
462
|
+
after.st_dev,
|
|
463
|
+
after.st_ino,
|
|
464
|
+
)
|
|
465
|
+
except (OSError, RuntimeError) as exc:
|
|
466
|
+
raise ControllerError(
|
|
467
|
+
"RUNTIME_SCAN_ERROR", "checkout disappeared during runtime scan"
|
|
468
|
+
) from exc
|
|
469
|
+
if changed:
|
|
470
|
+
raise ControllerError("RUNTIME_SCAN_ERROR", "checkout path/identity changed during runtime scan")
|
|
471
|
+
return references
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _lenient_runtime_references(root: Path) -> tuple[dict[str, str], ...]:
|
|
475
|
+
roots = (root, root / ".venv")
|
|
476
|
+
try:
|
|
477
|
+
processes = sorted(
|
|
478
|
+
(path for path in Path("/proc").iterdir() if path.name.isdecimal()),
|
|
479
|
+
key=lambda path: int(path.name),
|
|
480
|
+
)
|
|
481
|
+
except OSError as exc:
|
|
482
|
+
raise ControllerError("RUNTIME_SCAN_ERROR", "cannot enumerate Linux processes") from exc
|
|
483
|
+
if len(processes) > MAX_RUNTIME_PROCESSES:
|
|
484
|
+
raise ControllerError("RUNTIME_SCAN_LIMIT", "runtime process scan exceeded its bound")
|
|
485
|
+
references: list[dict[str, str]] = []
|
|
486
|
+
inspected_fds = 0
|
|
487
|
+
inspected_map_bytes = 0
|
|
488
|
+
for process in processes:
|
|
489
|
+
try:
|
|
490
|
+
if process.stat().st_uid != os.getuid():
|
|
491
|
+
continue
|
|
492
|
+
except FileNotFoundError:
|
|
493
|
+
continue
|
|
494
|
+
except OSError as exc:
|
|
495
|
+
raise ControllerError("RUNTIME_SCAN_ERROR", "cannot inspect process ownership") from exc
|
|
496
|
+
for kind in ("cwd", "exe"):
|
|
497
|
+
value = _runtime_link(process / kind)
|
|
498
|
+
if value is not None and _path_is_within(value, roots):
|
|
499
|
+
references.append({"pid": process.name, "kind": kind, "path": value})
|
|
500
|
+
try:
|
|
501
|
+
descriptors = list((process / "fd").iterdir())
|
|
502
|
+
except (FileNotFoundError, PermissionError):
|
|
503
|
+
descriptors = []
|
|
504
|
+
except OSError as exc:
|
|
505
|
+
raise ControllerError(
|
|
506
|
+
"RUNTIME_SCAN_ERROR", "cannot inspect process descriptors"
|
|
507
|
+
) from exc
|
|
508
|
+
inspected_fds += len(descriptors)
|
|
509
|
+
if inspected_fds > MAX_RUNTIME_FDS:
|
|
510
|
+
raise ControllerError(
|
|
511
|
+
"RUNTIME_SCAN_LIMIT", "runtime descriptor scan exceeded its bound"
|
|
512
|
+
)
|
|
513
|
+
for descriptor in descriptors:
|
|
514
|
+
value = _runtime_link(descriptor)
|
|
515
|
+
if value is not None and _path_is_within(value, roots):
|
|
516
|
+
references.append({"pid": process.name, "kind": "fd", "path": value})
|
|
517
|
+
try:
|
|
518
|
+
with (process / "maps").open("rb") as mappings:
|
|
519
|
+
for line in mappings:
|
|
520
|
+
inspected_map_bytes += len(line)
|
|
521
|
+
if inspected_map_bytes > MAX_RUNTIME_MAP_BYTES:
|
|
522
|
+
raise ControllerError(
|
|
523
|
+
"RUNTIME_SCAN_LIMIT", "runtime mapping scan exceeded its bound"
|
|
524
|
+
)
|
|
525
|
+
fields = line.split(maxsplit=5)
|
|
526
|
+
if len(fields) == 6:
|
|
527
|
+
value = os.fsdecode(fields[5].strip())
|
|
528
|
+
if _path_is_within(value, roots):
|
|
529
|
+
references.append({"pid": process.name, "kind": "mmap", "path": value})
|
|
530
|
+
except (FileNotFoundError, ProcessLookupError, PermissionError):
|
|
531
|
+
continue
|
|
532
|
+
except ControllerError:
|
|
533
|
+
raise
|
|
534
|
+
except OSError as exc:
|
|
535
|
+
raise ControllerError("RUNTIME_SCAN_ERROR", "cannot inspect process mappings") from exc
|
|
536
|
+
return tuple(references)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
_PROC_DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
540
|
+
_VANISHED_ERRNOS = frozenset({errno.ENOENT, errno.ESRCH})
|
|
541
|
+
_DENIED_ERRNOS = frozenset({errno.EACCES, errno.EPERM})
|
|
542
|
+
_MAX_PROCESS_ATTEMPTS = 3
|
|
543
|
+
_VANISHED_RESCAN_SECONDS = 0.05
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
@dataclass(frozen=True)
|
|
547
|
+
class _ProcessIdentity:
|
|
548
|
+
inode: tuple[int, int]
|
|
549
|
+
start_ticks: int
|
|
550
|
+
state: str
|
|
551
|
+
threads: int
|
|
552
|
+
uids: tuple[int, ...]
|
|
553
|
+
ppid: int
|
|
554
|
+
session_id: int = 0
|
|
555
|
+
|
|
556
|
+
@property
|
|
557
|
+
def terminal(self) -> bool:
|
|
558
|
+
# Linux do_exit releases mm/files/fs before EXIT_ZOMBIE. A zombie leader
|
|
559
|
+
# with other threads is NOT proof that the process has no references.
|
|
560
|
+
return self.state in {"Z", "X"} and self.threads == 1
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
@dataclass(frozen=True)
|
|
564
|
+
class _RuntimeIssue:
|
|
565
|
+
field: str
|
|
566
|
+
code: str
|
|
567
|
+
errno: int | None = None
|
|
568
|
+
|
|
569
|
+
def __str__(self) -> str:
|
|
570
|
+
suffix = f"({self.errno})" if self.errno is not None else ""
|
|
571
|
+
return f"{self.field}:{self.code}{suffix}"
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@dataclass(frozen=True)
|
|
575
|
+
class _ProcessScan:
|
|
576
|
+
path: Path
|
|
577
|
+
identity: _ProcessIdentity | None
|
|
578
|
+
references: tuple[dict[str, str], ...] = ()
|
|
579
|
+
issues: tuple[_RuntimeIssue, ...] = ()
|
|
580
|
+
|
|
581
|
+
def diagnostic(self) -> str:
|
|
582
|
+
ticks = f" start_ticks={self.identity.start_ticks}" if self.identity else ""
|
|
583
|
+
return f"pid={self.path.name}{ticks} {','.join(map(str, self.issues))}"
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _same_process(left: _ProcessIdentity, right: _ProcessIdentity | None) -> bool:
|
|
587
|
+
# A resizing thread pool is the same process; becoming single-threaded (an exec
|
|
588
|
+
# from a non-leader thread keeps the PID and start time) or multi-threaded is not.
|
|
589
|
+
return right is not None and (
|
|
590
|
+
left.inode,
|
|
591
|
+
left.start_ticks,
|
|
592
|
+
left.uids,
|
|
593
|
+
left.threads > 1,
|
|
594
|
+
left.ppid,
|
|
595
|
+
left.session_id,
|
|
596
|
+
) == (right.inode, right.start_ticks, right.uids, right.threads > 1, right.ppid, right.session_id)
|
|
597
|
+
|
|
598
|
+
|
|
599
|
+
def _proc_bytes(directory: int, name: str) -> bytes:
|
|
600
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=directory)
|
|
601
|
+
with os.fdopen(fd, "rb") as handle:
|
|
602
|
+
payload = handle.read(65_537)
|
|
603
|
+
if len(payload) > 65_536:
|
|
604
|
+
raise ValueError(f"oversized {name}")
|
|
605
|
+
return payload
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _process_identity(directory: int, pid: str) -> _ProcessIdentity:
|
|
609
|
+
raw = _proc_bytes(directory, "stat")
|
|
610
|
+
prefix, separator, tail = raw.rpartition(b") ")
|
|
611
|
+
fields = tail.split()
|
|
612
|
+
if not separator or not prefix.startswith(os.fsencode(pid) + b" (") or len(fields) < 20:
|
|
613
|
+
raise ValueError("malformed stat")
|
|
614
|
+
state = fields[0].decode("ascii")
|
|
615
|
+
threads, start_ticks = int(fields[17]), int(fields[19])
|
|
616
|
+
if state not in {"R", "S", "D", "Z", "T", "t", "X", "I", "P"} or threads < 1 or start_ticks < 0:
|
|
617
|
+
raise ValueError("malformed stat identity")
|
|
618
|
+
rows = [
|
|
619
|
+
line.split()[1:]
|
|
620
|
+
for line in _proc_bytes(directory, "status").splitlines()
|
|
621
|
+
if line.startswith(b"Uid:")
|
|
622
|
+
]
|
|
623
|
+
if len(rows) != 1 or len(rows[0]) != 4:
|
|
624
|
+
raise ValueError("malformed status Uid")
|
|
625
|
+
uids = tuple(int(value) for value in rows[0])
|
|
626
|
+
if any(value < 0 for value in uids):
|
|
627
|
+
raise ValueError("malformed status Uid")
|
|
628
|
+
metadata = os.fstat(directory)
|
|
629
|
+
ppid = int(fields[1])
|
|
630
|
+
if ppid < 0:
|
|
631
|
+
raise ValueError("malformed parent pid")
|
|
632
|
+
return _ProcessIdentity(
|
|
633
|
+
(metadata.st_dev, metadata.st_ino), start_ticks, state, threads, uids, ppid, int(fields[3])
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _current_process_identity(process: Path) -> _ProcessIdentity | None:
|
|
638
|
+
for attempt in range(_MAX_PROCESS_ATTEMPTS):
|
|
639
|
+
try:
|
|
640
|
+
directory = os.open(process, _PROC_DIRECTORY_FLAGS)
|
|
641
|
+
except OSError as exc:
|
|
642
|
+
if exc.errno in _VANISHED_ERRNOS:
|
|
643
|
+
return None
|
|
644
|
+
raise
|
|
645
|
+
try:
|
|
646
|
+
return _process_identity(directory, process.name)
|
|
647
|
+
except OSError as exc:
|
|
648
|
+
if exc.errno not in _VANISHED_ERRNOS or attempt == _MAX_PROCESS_ATTEMPTS - 1:
|
|
649
|
+
raise
|
|
650
|
+
# Exit between opening the process directory and reading stat/status:
|
|
651
|
+
# reopen the numeric PID before concluding that it vanished.
|
|
652
|
+
finally:
|
|
653
|
+
os.close(directory)
|
|
654
|
+
raise AssertionError("unreachable process identity retry")
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def verify_holder_lineage(holder: WriterHolder, process_id: int) -> None:
|
|
658
|
+
"""Prove the calling process is the recorded holder or its current descendant.
|
|
659
|
+
|
|
660
|
+
Inspect only this ancestry, using the same fd-bound proc identity reader as
|
|
661
|
+
cleanup. Recheck the whole chain to reject exit, reparenting and PID reuse.
|
|
662
|
+
A declaration copied to an unrelated process grants no host-managed access.
|
|
663
|
+
"""
|
|
664
|
+
chain: list[tuple[Path, _ProcessIdentity]] = []
|
|
665
|
+
seen: set[int] = set()
|
|
666
|
+
pid = process_id
|
|
667
|
+
try:
|
|
668
|
+
while pid > 0 and pid not in seen and len(chain) < MAX_RUNTIME_PROCESSES:
|
|
669
|
+
seen.add(pid)
|
|
670
|
+
path = Path(f"/proc/{pid}")
|
|
671
|
+
identity = _current_process_identity(path)
|
|
672
|
+
if identity is None or identity.state in {"Z", "X"}:
|
|
673
|
+
break
|
|
674
|
+
chain.append((path, identity))
|
|
675
|
+
if pid == holder.pid:
|
|
676
|
+
if holder.boot_id != boot_id() or identity.start_ticks != holder.start_ticks:
|
|
677
|
+
break
|
|
678
|
+
if all(
|
|
679
|
+
_same_process(before, _current_process_identity(item)) for item, before in chain
|
|
680
|
+
):
|
|
681
|
+
return
|
|
682
|
+
break
|
|
683
|
+
pid = identity.ppid
|
|
684
|
+
except (OSError, ValueError):
|
|
685
|
+
pass
|
|
686
|
+
raise ControllerError(
|
|
687
|
+
"WRITER_LEASE_MISMATCH", "caller is not a verified descendant of the host holder"
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def verify_runtime_transfer(source: WriterHolder, target: WriterHolder) -> None:
|
|
692
|
+
"""A host-created Linux session remains identifiable across parent exit.
|
|
693
|
+
|
|
694
|
+
The original session leader must still have its exact identity (it may be
|
|
695
|
+
unreaped). A child cannot join a foreign Linux session. This permits transfer
|
|
696
|
+
after kernel reparenting without trusting copied environment or command text.
|
|
697
|
+
"""
|
|
698
|
+
paths = [Path(f"/proc/{holder.pid}") for holder in (source, target)]
|
|
699
|
+
try:
|
|
700
|
+
before = [_current_process_identity(path) for path in paths]
|
|
701
|
+
if source.boot_id != boot_id() or target.boot_id != source.boot_id:
|
|
702
|
+
raise ValueError("boot changed")
|
|
703
|
+
if any(value is None for value in before):
|
|
704
|
+
raise ValueError("process disappeared")
|
|
705
|
+
leader, child = before
|
|
706
|
+
if (
|
|
707
|
+
leader.start_ticks != source.start_ticks
|
|
708
|
+
or child.start_ticks != target.start_ticks
|
|
709
|
+
or leader.session_id != source.pid
|
|
710
|
+
or child.session_id != source.pid
|
|
711
|
+
or leader.uids != child.uids
|
|
712
|
+
or child.terminal
|
|
713
|
+
or not all(
|
|
714
|
+
_same_process(value, _current_process_identity(path))
|
|
715
|
+
for value, path in zip(before, paths, strict=True)
|
|
716
|
+
)
|
|
717
|
+
):
|
|
718
|
+
raise ValueError("runtime session changed")
|
|
719
|
+
except (OSError, ValueError):
|
|
720
|
+
raise ControllerError(
|
|
721
|
+
"RUNTIME_TRANSFER_UNPROVEN", "background process is not in the exact host session"
|
|
722
|
+
) from None
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _runtime_issue(field: str, exc: OSError | ValueError) -> _RuntimeIssue:
|
|
726
|
+
if isinstance(exc, OSError):
|
|
727
|
+
return _RuntimeIssue(field, errno.errorcode.get(exc.errno, "OS_ERROR"), exc.errno)
|
|
728
|
+
return _RuntimeIssue(field, "MALFORMED_PROC_ENTRY")
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _strict_process_references(
|
|
732
|
+
directory: int, pid: str, roots: tuple[Path, ...], budget: list[int]
|
|
733
|
+
) -> tuple[list[dict[str, str]], list[_RuntimeIssue]]:
|
|
734
|
+
references: list[dict[str, str]] = []
|
|
735
|
+
issues: list[_RuntimeIssue] = []
|
|
736
|
+
|
|
737
|
+
def reference(kind: str, value: str) -> None:
|
|
738
|
+
if _path_is_within(value, roots):
|
|
739
|
+
references.append({"pid": pid, "kind": kind, "path": value})
|
|
740
|
+
|
|
741
|
+
for kind in ("cwd", "exe"):
|
|
742
|
+
try:
|
|
743
|
+
reference(kind, os.readlink(kind, dir_fd=directory))
|
|
744
|
+
except OSError as exc:
|
|
745
|
+
issues.append(_runtime_issue(kind, exc))
|
|
746
|
+
try:
|
|
747
|
+
descriptors = os.open("fd", _PROC_DIRECTORY_FLAGS, dir_fd=directory)
|
|
748
|
+
try:
|
|
749
|
+
names = os.listdir(descriptors)
|
|
750
|
+
budget[0] += len(names)
|
|
751
|
+
if budget[0] > MAX_RUNTIME_FDS:
|
|
752
|
+
raise ControllerError(
|
|
753
|
+
"RUNTIME_SCAN_LIMIT", "runtime descriptor scan exceeded its bound"
|
|
754
|
+
)
|
|
755
|
+
for name in names:
|
|
756
|
+
if not name.isdecimal():
|
|
757
|
+
issues.append(_RuntimeIssue("fd", "MALFORMED_PROC_ENTRY"))
|
|
758
|
+
continue
|
|
759
|
+
try:
|
|
760
|
+
reference("fd", os.readlink(name, dir_fd=descriptors))
|
|
761
|
+
except OSError as exc:
|
|
762
|
+
if exc.errno in _VANISHED_ERRNOS:
|
|
763
|
+
try:
|
|
764
|
+
os.stat(name, dir_fd=descriptors, follow_symlinks=False)
|
|
765
|
+
except OSError as confirm:
|
|
766
|
+
if confirm.errno in _VANISHED_ERRNOS:
|
|
767
|
+
continue # This descriptor closed, not an unreadable live link.
|
|
768
|
+
exc = confirm
|
|
769
|
+
issue = _runtime_issue("fd", exc)
|
|
770
|
+
if issue not in issues:
|
|
771
|
+
issues.append(issue)
|
|
772
|
+
finally:
|
|
773
|
+
os.close(descriptors)
|
|
774
|
+
except OSError as exc:
|
|
775
|
+
issues.append(_runtime_issue("fd", exc))
|
|
776
|
+
try:
|
|
777
|
+
fd = os.open("maps", os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=directory)
|
|
778
|
+
with os.fdopen(fd, "rb") as mappings:
|
|
779
|
+
for line in mappings:
|
|
780
|
+
budget[1] += len(line)
|
|
781
|
+
if budget[1] > MAX_RUNTIME_MAP_BYTES:
|
|
782
|
+
raise ControllerError(
|
|
783
|
+
"RUNTIME_SCAN_LIMIT", "runtime mapping scan exceeded its bound"
|
|
784
|
+
)
|
|
785
|
+
fields = line.split(maxsplit=5)
|
|
786
|
+
if len(fields) < 5 or not re.fullmatch(
|
|
787
|
+
rb"[0-9a-f]+-[0-9a-f]+ [r-][w-][x-][ps] [0-9a-f]+ [0-9a-f]+:[0-9a-f]+ [0-9]+",
|
|
788
|
+
b" ".join(fields[:5]),
|
|
789
|
+
):
|
|
790
|
+
raise ValueError("malformed maps")
|
|
791
|
+
if len(fields) == 6:
|
|
792
|
+
reference("mmap", os.fsdecode(fields[5].rstrip(b"\n")))
|
|
793
|
+
except (OSError, ValueError) as exc:
|
|
794
|
+
issues.append(_runtime_issue("maps", exc))
|
|
795
|
+
return references, issues
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _inspect_runtime_process(
|
|
799
|
+
process: Path, roots: tuple[Path, ...], budget: list[int], *, inspect_foreign: bool = False
|
|
800
|
+
) -> _ProcessScan:
|
|
801
|
+
last_issue = _RuntimeIssue("identity", "PID_IDENTITY_CHANGED")
|
|
802
|
+
after = None
|
|
803
|
+
for attempt in range(_MAX_PROCESS_ATTEMPTS):
|
|
804
|
+
before: _ProcessIdentity | None = None
|
|
805
|
+
references: list[dict[str, str]] = []
|
|
806
|
+
issues: list[_RuntimeIssue] = []
|
|
807
|
+
try:
|
|
808
|
+
directory = os.open(process, _PROC_DIRECTORY_FLAGS)
|
|
809
|
+
try:
|
|
810
|
+
before = _process_identity(directory, process.name)
|
|
811
|
+
if (os.getuid() in before.uids or inspect_foreign) and not before.terminal:
|
|
812
|
+
references, issues = _strict_process_references(
|
|
813
|
+
directory, process.name, roots, budget
|
|
814
|
+
)
|
|
815
|
+
finally:
|
|
816
|
+
os.close(directory)
|
|
817
|
+
except (OSError, ValueError) as exc:
|
|
818
|
+
last_issue = _runtime_issue("identity", exc)
|
|
819
|
+
issues.append(last_issue)
|
|
820
|
+
try:
|
|
821
|
+
after = _current_process_identity(process)
|
|
822
|
+
except (OSError, ValueError) as exc:
|
|
823
|
+
return _ProcessScan(
|
|
824
|
+
process,
|
|
825
|
+
before,
|
|
826
|
+
tuple(references),
|
|
827
|
+
(*issues, _runtime_issue("identity-recheck", exc)),
|
|
828
|
+
)
|
|
829
|
+
if after is None:
|
|
830
|
+
return _ProcessScan(process, None) # Numeric PID absent, not just a missing field.
|
|
831
|
+
if before is None or not _same_process(before, after):
|
|
832
|
+
if before is not None:
|
|
833
|
+
last_issue = _RuntimeIssue(
|
|
834
|
+
"identity",
|
|
835
|
+
"PID_REUSED"
|
|
836
|
+
if (before.inode, before.start_ticks) != (after.inode, after.start_ticks)
|
|
837
|
+
else "PID_IDENTITY_CHANGED",
|
|
838
|
+
)
|
|
839
|
+
continue # Scan the replacement identity immediately, with a bounded retry.
|
|
840
|
+
if after.terminal:
|
|
841
|
+
return _ProcessScan(process, after)
|
|
842
|
+
if before.terminal:
|
|
843
|
+
return _ProcessScan(
|
|
844
|
+
process, after, issues=(_RuntimeIssue("identity", "TERMINAL_STATE_CHANGED"),)
|
|
845
|
+
)
|
|
846
|
+
if (
|
|
847
|
+
issues
|
|
848
|
+
and attempt + 1 < _MAX_PROCESS_ATTEMPTS
|
|
849
|
+
and any(issue.errno in _VANISHED_ERRNOS for issue in issues)
|
|
850
|
+
and all(issue.errno in _VANISHED_ERRNOS | _DENIED_ERRNOS for issue in issues)
|
|
851
|
+
):
|
|
852
|
+
# An exiting task loses its /proc fd/cwd entries before it turns into a
|
|
853
|
+
# zombie, while its identity is still unchanged; without its mm the fd
|
|
854
|
+
# directory also denies access. Rescan after a short pause: a finished
|
|
855
|
+
# exit resolves to terminal or absent, a live process fails again.
|
|
856
|
+
time.sleep(_VANISHED_RESCAN_SECONDS)
|
|
857
|
+
continue
|
|
858
|
+
return _ProcessScan(process, after, tuple(references), tuple(issues))
|
|
859
|
+
return _ProcessScan(process, after, issues=(last_issue,))
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _runtime_process_scans(worktree: Path, budget: list[int]) -> tuple[_ProcessScan, ...]:
|
|
863
|
+
try:
|
|
864
|
+
root = worktree.resolve(strict=True)
|
|
865
|
+
metadata = root.stat()
|
|
866
|
+
identity = (metadata.st_dev, metadata.st_ino)
|
|
867
|
+
processes = sorted(
|
|
868
|
+
(path for path in Path("/proc").iterdir() if path.name.isdecimal()),
|
|
869
|
+
key=lambda path: int(path.name),
|
|
870
|
+
)
|
|
871
|
+
except (OSError, RuntimeError) as exc:
|
|
872
|
+
raise ControllerError(
|
|
873
|
+
"RUNTIME_SCAN_UNCERTAIN", "cannot identify checkout or enumerate procfs"
|
|
874
|
+
) from exc
|
|
875
|
+
if len(processes) > MAX_RUNTIME_PROCESSES:
|
|
876
|
+
raise ControllerError("RUNTIME_SCAN_LIMIT", "runtime process scan exceeded its bound")
|
|
877
|
+
scans = tuple(_inspect_runtime_process(process, (root,), budget) for process in processes)
|
|
878
|
+
try:
|
|
879
|
+
metadata = root.stat()
|
|
880
|
+
if worktree.resolve(strict=True) != root or (metadata.st_dev, metadata.st_ino) != identity:
|
|
881
|
+
raise ValueError("checkout identity changed")
|
|
882
|
+
except (OSError, RuntimeError, ValueError) as exc:
|
|
883
|
+
raise ControllerError(
|
|
884
|
+
"RUNTIME_SCAN_UNCERTAIN", "checkout path/identity changed during runtime scan"
|
|
885
|
+
) from exc
|
|
886
|
+
return scans
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def _strict_runtime_references(worktree: Path) -> tuple[dict[str, str], ...]:
|
|
890
|
+
scans = _runtime_process_scans(worktree, [0, 0])
|
|
891
|
+
references = tuple(row for scan in scans for row in scan.references)
|
|
892
|
+
if references:
|
|
893
|
+
return references
|
|
894
|
+
issues = [scan.diagnostic() for scan in scans if scan.issues]
|
|
895
|
+
if issues:
|
|
896
|
+
raise ControllerError(
|
|
897
|
+
"RUNTIME_SCAN_UNCERTAIN", "cannot exclude checkout references: " + "; ".join(issues)
|
|
898
|
+
)
|
|
899
|
+
return ()
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
RUNTIME_AUTHORITY_CONTRACT = "allocation-runtime-authority/v1"
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _managed_lineage(
|
|
906
|
+
scan: _ProcessScan,
|
|
907
|
+
processes: dict[int, _ProcessScan],
|
|
908
|
+
holders: dict[int, WriterHolder],
|
|
909
|
+
current_boot: str,
|
|
910
|
+
) -> tuple[WriterHolder | None, tuple[_ProcessScan, ...], str | None]:
|
|
911
|
+
chain: list[_ProcessScan] = []
|
|
912
|
+
current = scan
|
|
913
|
+
seen: set[int] = set()
|
|
914
|
+
while int(current.path.name) not in seen:
|
|
915
|
+
pid = int(current.path.name)
|
|
916
|
+
seen.add(pid)
|
|
917
|
+
chain.append(current)
|
|
918
|
+
holder = holders.get(pid)
|
|
919
|
+
if holder is not None:
|
|
920
|
+
if (
|
|
921
|
+
holder.boot_id != current_boot
|
|
922
|
+
or not holder.boot_id
|
|
923
|
+
or holder.boot_id == "unknown-boot"
|
|
924
|
+
):
|
|
925
|
+
return holder, tuple(chain), "HOLDER_BOOT_IDENTITY_CHANGED"
|
|
926
|
+
if current.identity is None:
|
|
927
|
+
return holder, tuple(chain), "HOLDER_IDENTITY_UNREADABLE"
|
|
928
|
+
if current.identity.start_ticks != holder.start_ticks:
|
|
929
|
+
return holder, tuple(chain), "HOLDER_PID_REUSED"
|
|
930
|
+
# Revalidate the positive ancestry proof, including parent identity and
|
|
931
|
+
# PPID edges. A changed edge must never turn a managed PID into a host gap.
|
|
932
|
+
for member in chain:
|
|
933
|
+
try:
|
|
934
|
+
observed = _current_process_identity(member.path)
|
|
935
|
+
except (OSError, ValueError):
|
|
936
|
+
return holder, tuple(chain), "LINEAGE_IDENTITY_UNREADABLE"
|
|
937
|
+
if member.identity is None or not _same_process(member.identity, observed):
|
|
938
|
+
return holder, tuple(chain), "LINEAGE_IDENTITY_CHANGED"
|
|
939
|
+
return holder, tuple(chain), None
|
|
940
|
+
if current.identity is None or current.identity.ppid == 0:
|
|
941
|
+
break
|
|
942
|
+
parent_pid = current.identity.ppid
|
|
943
|
+
parent = processes.get(parent_pid)
|
|
944
|
+
if parent is None:
|
|
945
|
+
if parent_pid in holders:
|
|
946
|
+
return holders[parent_pid], tuple(chain), "HOLDER_IDENTITY_UNAVAILABLE"
|
|
947
|
+
break
|
|
948
|
+
if parent.identity and parent.identity.start_ticks > current.identity.start_ticks:
|
|
949
|
+
# An impossible parent age indicates a mixed-generation observation.
|
|
950
|
+
# Preserve a declared parent association as uncertainty, never permission.
|
|
951
|
+
return holders.get(parent_pid), tuple(chain), "LINEAGE_PID_REUSED"
|
|
952
|
+
current = parent
|
|
953
|
+
if int(current.path.name) in seen and current.identity and current.identity.ppid in seen:
|
|
954
|
+
return None, tuple(chain), "LINEAGE_CYCLE"
|
|
955
|
+
return None, (), None
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def runtime_authority_evidence(
|
|
959
|
+
worktree: Path, *, holders: tuple[WriterHolder, ...] = ()
|
|
960
|
+
) -> dict[str, Any]:
|
|
961
|
+
"""Typed cleanup authority: positive observations block; unassociated permission gaps warn.
|
|
962
|
+
|
|
963
|
+
Holder roots must come from the allocation's formal Controller metadata, never
|
|
964
|
+
process names, cgroups, command lines, environment variables or caller guesses.
|
|
965
|
+
"""
|
|
966
|
+
budget = [0, 0]
|
|
967
|
+
scans = _runtime_process_scans(worktree, budget)
|
|
968
|
+
processes = {int(scan.path.name): scan for scan in scans}
|
|
969
|
+
declared = {holder.pid: holder for holder in holders}
|
|
970
|
+
current_boot = boot_id() if holders else ""
|
|
971
|
+
result: dict[str, Any] = {
|
|
972
|
+
"contract": RUNTIME_AUTHORITY_CONTRACT,
|
|
973
|
+
"observation_scope": "same-uid-and-managed-processes",
|
|
974
|
+
"managed_references": [],
|
|
975
|
+
"observed_references": [],
|
|
976
|
+
"managed_visibility_uncertainties": [],
|
|
977
|
+
"host_visibility_gaps": [],
|
|
978
|
+
"scan_uncertainties": [],
|
|
979
|
+
}
|
|
980
|
+
for scan in scans:
|
|
981
|
+
if scan.identity is None and not scan.issues:
|
|
982
|
+
continue # Confirmed gone.
|
|
983
|
+
if scan.identity and scan.identity.terminal and not scan.issues:
|
|
984
|
+
continue
|
|
985
|
+
holder, chain, lineage_issue = (
|
|
986
|
+
_managed_lineage(scan, processes, declared, current_boot)
|
|
987
|
+
if holders
|
|
988
|
+
else (None, (), None)
|
|
989
|
+
)
|
|
990
|
+
if holder and scan.identity and os.getuid() not in scan.identity.uids:
|
|
991
|
+
inspected = _inspect_runtime_process(
|
|
992
|
+
scan.path, (worktree.resolve(strict=True),), budget, inspect_foreign=True
|
|
993
|
+
)
|
|
994
|
+
if not _same_process(scan.identity, inspected.identity):
|
|
995
|
+
lineage_issue = "LINEAGE_IDENTITY_CHANGED"
|
|
996
|
+
scan = inspected
|
|
997
|
+
row = {
|
|
998
|
+
"pid": int(scan.path.name),
|
|
999
|
+
"start_ticks": scan.identity.start_ticks if scan.identity else None,
|
|
1000
|
+
"issues": [asdict(issue) for issue in scan.issues],
|
|
1001
|
+
}
|
|
1002
|
+
if holder:
|
|
1003
|
+
row["holder"] = asdict(holder)
|
|
1004
|
+
row["lineage"] = [
|
|
1005
|
+
{
|
|
1006
|
+
"pid": int(member.path.name),
|
|
1007
|
+
"start_ticks": member.identity.start_ticks if member.identity else None,
|
|
1008
|
+
}
|
|
1009
|
+
for member in chain
|
|
1010
|
+
]
|
|
1011
|
+
if scan.issues or lineage_issue:
|
|
1012
|
+
result["managed_visibility_uncertainties"].append(
|
|
1013
|
+
{
|
|
1014
|
+
**row,
|
|
1015
|
+
"classification": "MANAGED_VISIBILITY_UNCERTAIN",
|
|
1016
|
+
"lineage_issue": lineage_issue,
|
|
1017
|
+
}
|
|
1018
|
+
)
|
|
1019
|
+
else:
|
|
1020
|
+
result["managed_references"].append({**row, "classification": "MANAGED_REFERENCE"})
|
|
1021
|
+
elif lineage_issue or any(
|
|
1022
|
+
issue.errno not in {errno.EACCES, errno.EPERM} for issue in scan.issues
|
|
1023
|
+
):
|
|
1024
|
+
result["scan_uncertainties"].append(
|
|
1025
|
+
{**row, "classification": "SCAN_UNCERTAIN", "lineage_issue": lineage_issue}
|
|
1026
|
+
)
|
|
1027
|
+
elif scan.issues:
|
|
1028
|
+
result["host_visibility_gaps"].append(
|
|
1029
|
+
{
|
|
1030
|
+
**row,
|
|
1031
|
+
"classification": "HOST_VISIBILITY_GAP",
|
|
1032
|
+
"reason": "no formal allocation holder or verified holder lineage association; absence of references is not proven",
|
|
1033
|
+
}
|
|
1034
|
+
)
|
|
1035
|
+
result["observed_references"].extend(
|
|
1036
|
+
{**reference, "classification": "OBSERVED_REFERENCE"} for reference in scan.references
|
|
1037
|
+
)
|
|
1038
|
+
return result
|