#!/usr/bin/env python3 """Inspect one module's published files without loading its CAD runtime.""" import argparse from datetime import datetime, timezone import hashlib import json from pathlib import Path import time from urllib.parse import unquote, urlsplit ROOT = Path(__file__).resolve().parents[1] def inspect(module: str, root: Path = ROOT) -> dict: started = time.perf_counter() public = (root / "web/public").resolve() manifest = json.loads((public / "modules/manifest.json").read_text(encoding="utf-8")) descriptor = manifest.get("modules", {}).get(module) if not descriptor: raise ValueError(f"module not published: {module}") files = {} for worker, artifact in descriptor.get("workers", {}).items(): for kind, field in (("runtime", "runtimeUrl"), ("wasm", "wasmUrl"), ("data", "dataUrl"), ("pthreadWorker", "pthreadWorkerUrl")): url = artifact.get(field) if not url and kind not in {"runtime", "wasm"}: continue if not isinstance(url, str): raise ValueError(f"missing {field}: {worker}") parts = urlsplit(url) path = (public / unquote(parts.path).lstrip("/")).resolve() if parts.scheme or parts.netloc or not path.is_relative_to(public): raise ValueError(f"artifact must be inside local public directory: {url}") expected = artifact.get("hashes", {}).get(kind) if url not in files: if not path.is_file(): files[url] = {"url": url, "present": False, "matches": False} else: with path.open("rb") as stream: checksum = hashlib.file_digest(stream, "sha256").hexdigest() files[url] = {"url": url, "path": str(path.relative_to(root)), "present": True, "bytes": path.stat().st_size, "sha256": checksum, "matches": True} files[url]["matches"] = files[url]["matches"] and isinstance(expected, str) and files[url].get("sha256") == expected passed = bool(files) and all(row["present"] and row["matches"] for row in files.values()) return {"schema": "freecad-web-module-artifact-inspection/v1", "module": module, "status": "passed" if passed else "failed", "executedAt": datetime.now(timezone.utc).isoformat(), "seconds": round(time.perf_counter() - started, 6), "files": list(files.values()), "scope": "Published file presence and hash inspection; not a CAD business acceptance result."} def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--module", required=True) parser.add_argument("--output", type=Path) args = parser.parse_args() try: result = inspect(args.module) except (OSError, ValueError) as error: print(json.dumps({"status": "failed", "error": str(error)})) return 1 if args.output: output = (ROOT / args.output).resolve() if not output.is_relative_to(ROOT): parser.error("output must remain inside the project") output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(result, indent=2) + "\n", encoding="utf-8") print(json.dumps(result)) return 0 if result["status"] == "passed" else 1 if __name__ == "__main__": raise SystemExit(main())