unaltraweb 0.5.1 → 0.7.0
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.
- checksums.yaml +4 -4
- data/Makefile +13 -11
- data/_plugins/retained_documents.rb +62 -0
- data/docs/_documentation/en/40-distribution.md +73 -19
- data/docs/_documentation/en/45-retained-documents.md +138 -0
- data/docs/_documentation/en/46-live-runtime.md +89 -0
- data/docs/agents/d0-runtime-identity.md +200 -0
- data/docs/agents/mcp-contract.md +45 -2
- data/docs/agents/owner-closeout-76.md +205 -0
- data/docs/agents/owner-closeout-80.md +185 -0
- data/docs/agents/retained-letter-import.md +235 -0
- data/lib/unaltraweb.rb +1 -0
- data/requirements.txt +2 -0
- data/scripts/manual/Dockerfile +7 -4
- data/scripts/manual/build_pdf.py +28 -0
- data/scripts/manual/templates/manual.tex +1 -0
- data/scripts/test_gem_build.py +27 -0
- data/scripts/test_wheel_install.py +7 -2
- data/scripts/unaltraweb-mcp-bootstrap.sh +90 -9
- data/scripts/unaltraweb-mcp-cleanup.sh +41 -9
- data/scripts/web_captures/render.py +8 -1
- data/src/unaltraweb_mcp/__init__.py +4 -0
- data/src/unaltraweb_mcp/artifact-handoff-v1.schema.json +102 -0
- data/src/unaltraweb_mcp/artifact_handoff_v1.py +435 -0
- data/src/unaltraweb_mcp/artifact_imports.py +368 -0
- data/src/unaltraweb_mcp/component-contract.json +27 -27
- data/src/unaltraweb_mcp/distribution.py +887 -0
- data/src/unaltraweb_mcp/letter_bundle.py +223 -0
- data/src/unaltraweb_mcp/pdf_probe.py +63 -0
- metadata +16 -2
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Read-only reference verifier for the artifact handoff v1 review candidate.
|
|
3
|
+
|
|
4
|
+
No provider, Git, network, importer or cleanup operations. Python 3.10+ stdlib.
|
|
5
|
+
The adjacent schema supplies structural rules; semantic checks bind those rules
|
|
6
|
+
to exact bytes in one explicitly selected, descriptor-anchored workspace.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import contextlib
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import math
|
|
16
|
+
import os
|
|
17
|
+
import pathlib
|
|
18
|
+
import re
|
|
19
|
+
import stat
|
|
20
|
+
import sys
|
|
21
|
+
import unicodedata
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
SCHEMA_PATH = pathlib.Path(__file__).with_name("artifact-handoff-v1.schema.json")
|
|
25
|
+
MAX_MANIFEST_BYTES = 1024 * 1024
|
|
26
|
+
MAX_FILE_BYTES = 512 * 1024 * 1024
|
|
27
|
+
MAX_TOTAL_BYTES = 2 * 1024 * 1024 * 1024
|
|
28
|
+
MAX_ENTRIES = 10000
|
|
29
|
+
MAX_BUNDLES = 64
|
|
30
|
+
MAX_DEPTH = 8
|
|
31
|
+
HASH_RE = re.compile(r"[0-9a-f]{64}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class HandoffError(Exception):
|
|
35
|
+
def __init__(self, code: str, message: str, exit_code: int = 1):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.code = code
|
|
38
|
+
self.exit_code = exit_code
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def require(condition: bool, code: str, message: str) -> None:
|
|
42
|
+
if not condition:
|
|
43
|
+
raise HandoffError(code, message)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def parse_json(raw: bytes) -> dict:
|
|
47
|
+
def pairs(items: list[tuple[str, object]]) -> dict:
|
|
48
|
+
result = {}
|
|
49
|
+
for key, value in items:
|
|
50
|
+
require(key not in result, "json", "duplicate JSON key")
|
|
51
|
+
result[key] = value
|
|
52
|
+
return result
|
|
53
|
+
|
|
54
|
+
def constant(value: str) -> None:
|
|
55
|
+
raise HandoffError("json", "non-finite JSON number")
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
value = json.loads(raw.decode("utf-8"), object_pairs_hook=pairs, parse_constant=constant)
|
|
59
|
+
except (UnicodeError, ValueError, RecursionError) as exc:
|
|
60
|
+
raise HandoffError("json", "expected bounded UTF-8 JSON") from exc
|
|
61
|
+
require(isinstance(value, dict), "schema", "document must be an object")
|
|
62
|
+
pending = [(value, 0)]
|
|
63
|
+
nodes = 0
|
|
64
|
+
while pending:
|
|
65
|
+
item, depth = pending.pop()
|
|
66
|
+
nodes += 1
|
|
67
|
+
require(depth <= 32 and nodes <= 100000, "limit", "JSON exceeds depth or node limits")
|
|
68
|
+
require(not isinstance(item, float) or math.isfinite(item), "json", "non-finite JSON number")
|
|
69
|
+
if isinstance(item, dict):
|
|
70
|
+
pending.extend((child, depth + 1) for child in item.values())
|
|
71
|
+
elif isinstance(item, list):
|
|
72
|
+
pending.extend((child, depth + 1) for child in item)
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def check_shape(value: object, rule: dict, definitions: dict, location: str = "document") -> None:
|
|
77
|
+
"""Evaluate only the vocabulary used by the checked-in schema, not arbitrary schemas."""
|
|
78
|
+
if "$ref" in rule:
|
|
79
|
+
return check_shape(value, definitions[rule["$ref"].removeprefix("#/$defs/")], definitions, location)
|
|
80
|
+
types = {"object": dict, "array": list, "string": str, "integer": int}
|
|
81
|
+
if "type" in rule:
|
|
82
|
+
require(type(value) is types[rule["type"]], "schema", f"{location}: invalid type")
|
|
83
|
+
for key in ("const", "enum"):
|
|
84
|
+
if key in rule:
|
|
85
|
+
choices = [rule[key]] if key == "const" else rule[key]
|
|
86
|
+
require(any(type(value) is type(choice) and value == choice for choice in choices),
|
|
87
|
+
"schema", f"{location}: unsupported value")
|
|
88
|
+
if isinstance(value, dict):
|
|
89
|
+
properties = rule.get("properties", {})
|
|
90
|
+
require(set(rule.get("required", [])) <= value.keys(), "schema", f"{location}: missing fields")
|
|
91
|
+
if rule.get("additionalProperties") is False:
|
|
92
|
+
require(value.keys() <= properties.keys(), "schema", f"{location}: unknown fields")
|
|
93
|
+
for key, item in value.items():
|
|
94
|
+
if key in properties:
|
|
95
|
+
check_shape(item, properties[key], definitions, f"{location}.{key}")
|
|
96
|
+
if isinstance(value, list):
|
|
97
|
+
require(rule.get("minItems", 0) <= len(value) <= rule.get("maxItems", MAX_ENTRIES),
|
|
98
|
+
"schema", f"{location}: invalid array length")
|
|
99
|
+
for index, item in enumerate(value):
|
|
100
|
+
check_shape(item, rule["items"], definitions, f"{location}[{index}]")
|
|
101
|
+
if isinstance(value, str):
|
|
102
|
+
require(rule.get("minLength", 0) <= len(value) <= rule.get("maxLength", 1024),
|
|
103
|
+
"schema", f"{location}: invalid string length")
|
|
104
|
+
if "pattern" in rule:
|
|
105
|
+
require(re.fullmatch(rule["pattern"], value) is not None,
|
|
106
|
+
"schema", f"{location}: invalid string")
|
|
107
|
+
if type(value) is int:
|
|
108
|
+
require(rule.get("minimum", value) <= value <= rule.get("maximum", value),
|
|
109
|
+
"schema", f"{location}: integer outside limits")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def safe_path(value: str) -> str:
|
|
113
|
+
require(isinstance(value, str) and 0 < len(value) <= 1024, "path", "invalid relative path")
|
|
114
|
+
require(unicodedata.normalize("NFC", value) == value, "path", "paths must use NFC Unicode")
|
|
115
|
+
require(not any(unicodedata.category(char).startswith("C") or char in '\\:%$*?[]{}<>"|'
|
|
116
|
+
for char in value), "path", "path contains reserved characters")
|
|
117
|
+
for part in value.split("/"):
|
|
118
|
+
require(part not in ("", ".", "..") and part == part.strip() and not part.endswith("."),
|
|
119
|
+
"path", "path must be normalized and relative")
|
|
120
|
+
require(part.casefold() not in {".git", ".hg", ".svn"} and not part.startswith("~"),
|
|
121
|
+
"path", "reserved path component")
|
|
122
|
+
require(re.fullmatch(r"(?i)(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?", part) is None,
|
|
123
|
+
"path", "reserved device name")
|
|
124
|
+
require(len(value.split("/")) <= 64, "limit", "path depth exceeds 64")
|
|
125
|
+
return value
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def unique_paths(paths: list[str]) -> None:
|
|
129
|
+
require(len(paths) == len(set(paths)), "path", "duplicate file path")
|
|
130
|
+
aliases: dict[str, str] = {}
|
|
131
|
+
files = set(paths)
|
|
132
|
+
for path in paths:
|
|
133
|
+
safe_path(path)
|
|
134
|
+
parts = path.split("/")
|
|
135
|
+
for length in range(1, len(parts) + 1):
|
|
136
|
+
prefix = "/".join(parts[:length])
|
|
137
|
+
key = prefix.casefold()
|
|
138
|
+
require(key not in aliases or aliases[key] == prefix, "path", "case-aliased path")
|
|
139
|
+
aliases[key] = prefix
|
|
140
|
+
require(length == len(parts) or prefix not in files, "path", "file/directory collision")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def join(parent: str, child: str) -> str:
|
|
144
|
+
return f"{parent}/{child}" if parent else child
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def signature(info: os.stat_result) -> tuple:
|
|
148
|
+
return (info.st_dev, info.st_ino, info.st_mode, info.st_nlink,
|
|
149
|
+
info.st_size, info.st_mtime_ns, info.st_ctime_ns)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class Workspace:
|
|
153
|
+
def __init__(self, path: str):
|
|
154
|
+
if (not os.path.isabs(path) or path == "/" or path.startswith("//")
|
|
155
|
+
or str(pathlib.PurePosixPath(path)) != path
|
|
156
|
+
or any(unicodedata.category(char).startswith("C") for char in path)):
|
|
157
|
+
raise HandoffError("workspace", "workspace must be an explicit normalized absolute directory", 2)
|
|
158
|
+
if ".." in pathlib.PurePosixPath(path).parts:
|
|
159
|
+
raise HandoffError("workspace", "workspace must not contain traversal", 2)
|
|
160
|
+
if not hasattr(os, "O_NOFOLLOW") or not hasattr(os, "O_DIRECTORY"):
|
|
161
|
+
raise HandoffError("platform", "descriptor-relative no-follow access is required", 2)
|
|
162
|
+
self.dir_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
163
|
+
self.fd = os.open("/", self.dir_flags)
|
|
164
|
+
self.total_bytes = 0
|
|
165
|
+
self.snapshots: dict[str, tuple] = {}
|
|
166
|
+
self.directories: dict[str, tuple] = {}
|
|
167
|
+
try:
|
|
168
|
+
for part in pathlib.PurePosixPath(path).parts[1:]:
|
|
169
|
+
next_fd = os.open(part, self.dir_flags, dir_fd=self.fd)
|
|
170
|
+
os.close(self.fd)
|
|
171
|
+
self.fd = next_fd
|
|
172
|
+
except OSError:
|
|
173
|
+
os.close(self.fd)
|
|
174
|
+
raise
|
|
175
|
+
|
|
176
|
+
def close(self) -> None:
|
|
177
|
+
os.close(self.fd)
|
|
178
|
+
|
|
179
|
+
@contextlib.contextmanager
|
|
180
|
+
def directory(self, relative: str):
|
|
181
|
+
fd = os.dup(self.fd)
|
|
182
|
+
try:
|
|
183
|
+
for part in safe_path(relative).split("/") if relative else []:
|
|
184
|
+
next_fd = os.open(part, self.dir_flags, dir_fd=fd)
|
|
185
|
+
os.close(fd)
|
|
186
|
+
fd = next_fd
|
|
187
|
+
yield fd
|
|
188
|
+
finally:
|
|
189
|
+
os.close(fd)
|
|
190
|
+
|
|
191
|
+
def read(self, path: str, limit: int, *, collect: bool = False) -> tuple[str, int, bytes]:
|
|
192
|
+
safe_path(path)
|
|
193
|
+
require(path in self.snapshots or len(self.snapshots) < MAX_ENTRIES,
|
|
194
|
+
"limit", "inspection exceeds file count limit")
|
|
195
|
+
parent, _, name = path.rpartition("/")
|
|
196
|
+
with self.directory(parent) as parent_fd:
|
|
197
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC,
|
|
198
|
+
dir_fd=parent_fd)
|
|
199
|
+
try:
|
|
200
|
+
before = os.fstat(fd)
|
|
201
|
+
require(stat.S_ISREG(before.st_mode) and before.st_nlink == 1,
|
|
202
|
+
"file", "only regular, non-hardlinked files are supported")
|
|
203
|
+
require(before.st_size <= limit, "limit", "file exceeds byte limit")
|
|
204
|
+
digest = hashlib.sha256()
|
|
205
|
+
chunks = []
|
|
206
|
+
size = 0
|
|
207
|
+
while True:
|
|
208
|
+
chunk = os.read(fd, min(1024 * 1024, limit - size + 1))
|
|
209
|
+
if not chunk:
|
|
210
|
+
break
|
|
211
|
+
size += len(chunk)
|
|
212
|
+
self.total_bytes += len(chunk)
|
|
213
|
+
require(size <= limit and self.total_bytes <= MAX_TOTAL_BYTES,
|
|
214
|
+
"limit", "inspection exceeds byte limits")
|
|
215
|
+
digest.update(chunk)
|
|
216
|
+
if collect:
|
|
217
|
+
chunks.append(chunk)
|
|
218
|
+
after = os.fstat(fd)
|
|
219
|
+
linked = os.stat(name, dir_fd=parent_fd, follow_symlinks=False)
|
|
220
|
+
require(signature(before) == signature(after) == signature(linked) and size == after.st_size,
|
|
221
|
+
"changed", "file changed during inspection")
|
|
222
|
+
snapshot = signature(after)
|
|
223
|
+
require(path not in self.snapshots or self.snapshots[path] == snapshot,
|
|
224
|
+
"changed", "file changed between inspections")
|
|
225
|
+
self.snapshots[path] = snapshot
|
|
226
|
+
return digest.hexdigest(), size, b"".join(chunks)
|
|
227
|
+
finally:
|
|
228
|
+
os.close(fd)
|
|
229
|
+
|
|
230
|
+
def inventory(self, base: str) -> tuple[set[str], set[str]]:
|
|
231
|
+
files: set[str] = set()
|
|
232
|
+
directories: set[str] = set()
|
|
233
|
+
count = 0
|
|
234
|
+
|
|
235
|
+
def walk(fd: int, prefix: str, depth: int) -> None:
|
|
236
|
+
nonlocal count
|
|
237
|
+
require(depth <= 64, "limit", "directory depth exceeds 64")
|
|
238
|
+
before = signature(os.fstat(fd))
|
|
239
|
+
with os.scandir(fd) as entries:
|
|
240
|
+
for entry in entries:
|
|
241
|
+
count += 1
|
|
242
|
+
require(count <= MAX_ENTRIES, "limit", "bundle tree exceeds entry limit")
|
|
243
|
+
relative = join(prefix, entry.name)
|
|
244
|
+
safe_path(relative)
|
|
245
|
+
info = entry.stat(follow_symlinks=False)
|
|
246
|
+
if stat.S_ISDIR(info.st_mode):
|
|
247
|
+
directories.add(relative)
|
|
248
|
+
child = os.open(entry.name, self.dir_flags, dir_fd=fd)
|
|
249
|
+
try:
|
|
250
|
+
require(signature(info) == signature(os.fstat(child)),
|
|
251
|
+
"changed", "directory changed during inspection")
|
|
252
|
+
walk(child, relative, depth + 1)
|
|
253
|
+
finally:
|
|
254
|
+
os.close(child)
|
|
255
|
+
else:
|
|
256
|
+
require(stat.S_ISREG(info.st_mode) and info.st_nlink == 1,
|
|
257
|
+
"file", "bundle contains a link or special file")
|
|
258
|
+
files.add(relative)
|
|
259
|
+
require(signature(os.fstat(fd)) == before, "changed", "directory changed during inventory")
|
|
260
|
+
self.directories[join(base, prefix) if prefix else base] = before
|
|
261
|
+
|
|
262
|
+
with self.directory(base) as fd:
|
|
263
|
+
walk(fd, "", 0)
|
|
264
|
+
return files, directories
|
|
265
|
+
|
|
266
|
+
def recheck(self) -> None:
|
|
267
|
+
for path, expected in self.directories.items():
|
|
268
|
+
with self.directory(path) as fd:
|
|
269
|
+
require(signature(os.fstat(fd)) == expected,
|
|
270
|
+
"changed", "bundle directory changed before verification completed")
|
|
271
|
+
for path, expected in self.snapshots.items():
|
|
272
|
+
parent, _, name = path.rpartition("/")
|
|
273
|
+
with self.directory(parent) as fd:
|
|
274
|
+
actual = os.stat(name, dir_fd=fd, follow_symlinks=False)
|
|
275
|
+
require(signature(actual) == expected, "changed", "file changed before verification completed")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
class Verifier:
|
|
279
|
+
def __init__(self, workspace: Workspace):
|
|
280
|
+
self.workspace = workspace
|
|
281
|
+
self.definitions = parse_json(SCHEMA_PATH.read_bytes())["$defs"]
|
|
282
|
+
self.bundle_count = 0
|
|
283
|
+
|
|
284
|
+
def document(self, path: str, kind: str, expected: str | None = None) -> tuple[dict, str]:
|
|
285
|
+
digest, _, raw = self.workspace.read(path, MAX_MANIFEST_BYTES, collect=True)
|
|
286
|
+
require(expected is None or digest == expected, "hash", "manifest SHA-256 mismatch")
|
|
287
|
+
doc = parse_json(raw)
|
|
288
|
+
check_shape(doc, self.definitions[kind], self.definitions)
|
|
289
|
+
actor = doc["producer" if kind == "bundle" else "integrator"]
|
|
290
|
+
names = [runtime["name"] for runtime in actor["runtimes"]]
|
|
291
|
+
require(len(names) == len(set(names)), "schema", "duplicate runtime name")
|
|
292
|
+
return doc, digest
|
|
293
|
+
|
|
294
|
+
def bundle(self, path: str, expected: str, depth: int = 0) -> tuple[dict, dict, set[str]]:
|
|
295
|
+
safe_path(path)
|
|
296
|
+
base, _, name = path.rpartition("/")
|
|
297
|
+
require(name == "bundle.json", "path", "bundle manifest must be named bundle.json")
|
|
298
|
+
self.bundle_count += 1
|
|
299
|
+
require(depth <= MAX_DEPTH and self.bundle_count <= MAX_BUNDLES,
|
|
300
|
+
"limit", "bundle graph exceeds depth or count limits")
|
|
301
|
+
doc, _ = self.document(path, "bundle", expected)
|
|
302
|
+
files = {item["id"]: item for item in doc["files"]}
|
|
303
|
+
require(len(files) == len(doc["files"]), "schema", "duplicate file id")
|
|
304
|
+
request = files.get(doc["request"], {})
|
|
305
|
+
require(request.get("kind") == "input" and request.get("role") == "request",
|
|
306
|
+
"provenance", "request must identify a retained input with role request")
|
|
307
|
+
require(any(item["kind"] == "output" for item in files.values()),
|
|
308
|
+
"schema", "bundle requires at least one output")
|
|
309
|
+
paths = [item["path"] for item in files.values()]
|
|
310
|
+
unique_paths(paths)
|
|
311
|
+
for item in files.values():
|
|
312
|
+
require(item["path"].startswith("payload/"), "path", "bundle files must be beneath payload/")
|
|
313
|
+
if "variant_of" in item:
|
|
314
|
+
original = files.get(item["variant_of"], {})
|
|
315
|
+
require(item["ownership"] == "author" and original.get("kind") == item["kind"]
|
|
316
|
+
and original is not item and "variant_of" not in original,
|
|
317
|
+
"provenance", "variant must retain a same-kind original and be author-owned")
|
|
318
|
+
digest, size, _ = self.workspace.read(join(base, item["path"]), MAX_FILE_BYTES)
|
|
319
|
+
require((digest, size) == (item["sha256"], item["bytes"]),
|
|
320
|
+
"hash", f"file SHA-256 or size mismatch: {item['path']}")
|
|
321
|
+
|
|
322
|
+
dependencies = {}
|
|
323
|
+
flattened = {item["path"]: item for item in files.values()}
|
|
324
|
+
inventory = {"bundle.json", *flattened}
|
|
325
|
+
for dependency in doc["dependencies"]:
|
|
326
|
+
identity = dependency["id"]
|
|
327
|
+
require(identity not in dependencies, "schema", "duplicate dependency id")
|
|
328
|
+
prefix = f"dependencies/{identity}"
|
|
329
|
+
child, child_files, child_inventory = self.bundle(
|
|
330
|
+
join(base, f"{prefix}/bundle.json"), dependency["sha256"], depth + 1)
|
|
331
|
+
dependencies[identity] = {item["id"]: item for item in child["files"]}
|
|
332
|
+
flattened.update({f"{prefix}/{key}": item for key, item in child_files.items()})
|
|
333
|
+
inventory.update(f"{prefix}/{key}" for key in child_inventory)
|
|
334
|
+
used = set()
|
|
335
|
+
for item in files.values():
|
|
336
|
+
if "from" not in item:
|
|
337
|
+
continue
|
|
338
|
+
upstream = item["from"]
|
|
339
|
+
source = dependencies.get(upstream["dependency"], {}).get(upstream["file"], {})
|
|
340
|
+
require(item["kind"] == "input" and source.get("kind") == "output",
|
|
341
|
+
"provenance", "upstream reference must bind an input to a direct dependency output")
|
|
342
|
+
require(all(item[key] == source.get(key) for key in ("sha256", "bytes", "ownership")),
|
|
343
|
+
"provenance", "upstream input must retain exact bytes and ownership")
|
|
344
|
+
used.add(upstream["dependency"])
|
|
345
|
+
require(used == dependencies.keys(), "provenance", "every dependency must supply a declared input")
|
|
346
|
+
unique_paths(sorted(inventory))
|
|
347
|
+
if depth == 0:
|
|
348
|
+
actual_files, actual_dirs = self.workspace.inventory(base)
|
|
349
|
+
expected_dirs = {parent for item in inventory for parent in self.parents(item)}
|
|
350
|
+
require(actual_files == inventory and actual_dirs == expected_dirs,
|
|
351
|
+
"inventory", "bundle tree differs from its complete declared inventory")
|
|
352
|
+
return doc, flattened, inventory
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def parents(path: str) -> list[str]:
|
|
356
|
+
parts = path.split("/")
|
|
357
|
+
return ["/".join(parts[:length]) for length in range(1, len(parts))]
|
|
358
|
+
|
|
359
|
+
def integration(self, path: str, expected: str | None) -> tuple[str, int]:
|
|
360
|
+
doc, digest = self.document(path, "integration", expected)
|
|
361
|
+
bundle_path = safe_path(doc["bundle"]["path"])
|
|
362
|
+
base = bundle_path.rpartition("/")[0]
|
|
363
|
+
require(bool(base) and not path.casefold().startswith(base.casefold() + "/"),
|
|
364
|
+
"path", "integration record must be outside the sealed bundle")
|
|
365
|
+
bundle, files, inventory = self.bundle(bundle_path, doc["bundle"]["sha256"])
|
|
366
|
+
destinations = [safe_path(item["destination"]) for item in doc["mappings"]]
|
|
367
|
+
unique_paths([path, *destinations, *(join(base, item) for item in inventory)])
|
|
368
|
+
root_outputs = {item["path"] for item in bundle["files"] if item["kind"] == "output"}
|
|
369
|
+
selected = set()
|
|
370
|
+
for mapping in doc["mappings"]:
|
|
371
|
+
source_path = safe_path(mapping["source"])
|
|
372
|
+
source = files.get(source_path)
|
|
373
|
+
require(source is not None, "provenance", "mapping source is not a declared bundle file")
|
|
374
|
+
destination = mapping["destination"]
|
|
375
|
+
require(not destination.casefold().startswith(base.casefold() + "/"),
|
|
376
|
+
"path", "mapped destinations must be outside the sealed bundle")
|
|
377
|
+
current_hash, size, _ = self.workspace.read(destination, MAX_FILE_BYTES)
|
|
378
|
+
require((current_hash, size) == (source["sha256"], source["bytes"]),
|
|
379
|
+
"hash", f"integrated file differs from its source: {destination}")
|
|
380
|
+
selected.add(source_path)
|
|
381
|
+
require(bool(selected & root_outputs), "provenance", "integration must expose a root bundle output")
|
|
382
|
+
return digest, len(doc["mappings"])
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def check(workspace_path: str, action: str, path: str, expected: str | None = None) -> dict:
|
|
386
|
+
safe_path(path)
|
|
387
|
+
if expected is not None and HASH_RE.fullmatch(expected) is None:
|
|
388
|
+
raise HandoffError("argument", "expected SHA-256 must be 64 lowercase hex characters", 2)
|
|
389
|
+
if action == "bundle" and expected is None:
|
|
390
|
+
raise HandoffError("argument", "bundle verification requires the sender's SHA-256", 2)
|
|
391
|
+
if action not in {"bundle", "integration"}:
|
|
392
|
+
raise HandoffError("argument", "unknown verification action", 2)
|
|
393
|
+
with contextlib.closing(Workspace(workspace_path)) as workspace:
|
|
394
|
+
verifier = Verifier(workspace)
|
|
395
|
+
if action == "bundle":
|
|
396
|
+
verifier.bundle(path, expected)
|
|
397
|
+
digest, mappings = expected, 0
|
|
398
|
+
else:
|
|
399
|
+
digest, mappings = verifier.integration(path, expected)
|
|
400
|
+
workspace.recheck()
|
|
401
|
+
return {"schema_version": 1, "ok": True, "action": action,
|
|
402
|
+
"manifest_sha256": digest, "bundles": verifier.bundle_count,
|
|
403
|
+
"verified_files": len(workspace.snapshots), "mappings": mappings}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def main() -> int:
|
|
407
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
408
|
+
parser.add_argument("action", choices=("bundle", "integration"))
|
|
409
|
+
parser.add_argument("--workspace", required=True, help="explicit absolute workspace; no environment fallback")
|
|
410
|
+
parser.add_argument("--path", required=True, help="workspace-relative bundle.json or integration record")
|
|
411
|
+
parser.add_argument("--sha256", help="expected manifest bytes hash; required for bundle")
|
|
412
|
+
parser.add_argument("--json", action="store_true")
|
|
413
|
+
args = parser.parse_args()
|
|
414
|
+
exit_code = 0
|
|
415
|
+
try:
|
|
416
|
+
result = check(args.workspace, args.action, args.path, args.sha256)
|
|
417
|
+
except HandoffError as exc:
|
|
418
|
+
result = {"schema_version": 1, "ok": False, "action": args.action,
|
|
419
|
+
"error": {"code": exc.code, "message": str(exc)}}
|
|
420
|
+
exit_code = exc.exit_code
|
|
421
|
+
except OSError as exc:
|
|
422
|
+
result = {"schema_version": 1, "ok": False, "action": args.action,
|
|
423
|
+
"error": {"code": "inspection", "message": f"cannot safely inspect files (errno {exc.errno})"}}
|
|
424
|
+
exit_code = 2
|
|
425
|
+
if args.json:
|
|
426
|
+
print(json.dumps(result, sort_keys=True, ensure_ascii=True))
|
|
427
|
+
elif result["ok"]:
|
|
428
|
+
print(f"VERIFIED\t{result['manifest_sha256']}\t{result['bundles']} bundles\t{result['mappings']} mappings")
|
|
429
|
+
else:
|
|
430
|
+
print(f"FAILED\t{result['error']['code']}\t{result['error']['message']}")
|
|
431
|
+
return exit_code
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
if __name__ == "__main__":
|
|
435
|
+
sys.exit(main())
|