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.
@@ -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())