validate_domain_replication.py 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. #!/usr/bin/env python3
  2. """Validate a self-contained domain replication implementation package."""
  3. from __future__ import annotations
  4. import argparse
  5. import csv
  6. import hashlib
  7. import io
  8. import json
  9. import os
  10. import re
  11. import stat
  12. import sys
  13. from pathlib import Path
  14. ROOT = Path(__file__).resolve().parents[1]
  15. if str(ROOT) not in sys.path:
  16. sys.path.insert(0, str(ROOT))
  17. from app.core.governance.domain_replication import ( # noqa: E402
  18. DomainReplicationError,
  19. evaluate_replication_package,
  20. )
  21. _SHA256 = re.compile(r"^[0-9a-f]{64}$")
  22. def _json(content: bytes, label: str) -> dict:
  23. value = json.loads(content.decode("utf-8"))
  24. if not isinstance(value, dict):
  25. raise DomainReplicationError(f"JSON object required: {label}")
  26. return value
  27. def _open_directory_chain(path: Path) -> int:
  28. path = path.absolute()
  29. if not path.is_absolute():
  30. raise DomainReplicationError("package directory must be absolute")
  31. fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
  32. try:
  33. for component in path.parts[1:]:
  34. next_fd = os.open(
  35. component,
  36. os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
  37. dir_fd=fd,
  38. )
  39. os.close(fd)
  40. fd = next_fd
  41. details = os.fstat(fd)
  42. if not stat.S_ISDIR(details.st_mode) or details.st_nlink < 1:
  43. raise DomainReplicationError("package root is not a controlled directory")
  44. return fd
  45. except OSError as exc:
  46. os.close(fd)
  47. raise DomainReplicationError("package directory symlink or replacement was rejected") from exc
  48. def _relative_parts(relative: str) -> tuple[str, ...]:
  49. path = Path(relative)
  50. if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
  51. raise DomainReplicationError(f"package path escapes package directory: {relative}")
  52. return path.parts
  53. def _read_regular_once(root_fd: int, relative: str) -> bytes:
  54. parts = _relative_parts(relative)
  55. directory_fd = os.dup(root_fd)
  56. file_fd = -1
  57. try:
  58. for component in parts[:-1]:
  59. next_fd = os.open(
  60. component,
  61. os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
  62. dir_fd=directory_fd,
  63. )
  64. os.close(directory_fd)
  65. directory_fd = next_fd
  66. file_fd = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
  67. before = os.fstat(file_fd)
  68. if not stat.S_ISREG(before.st_mode):
  69. raise DomainReplicationError(f"package input must be a regular file: {relative}")
  70. if before.st_nlink != 1:
  71. raise DomainReplicationError(f"package input hardlink was rejected: {relative}")
  72. chunks: list[bytes] = []
  73. while True:
  74. chunk = os.read(file_fd, 1024 * 1024)
  75. if not chunk:
  76. break
  77. chunks.append(chunk)
  78. if sum(map(len, chunks)) > 8 * 1024 * 1024:
  79. raise DomainReplicationError(f"package input exceeds size limit: {relative}")
  80. content = b"".join(chunks)
  81. after = os.fstat(file_fd)
  82. fingerprint = (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns, before.st_ctime_ns)
  83. if fingerprint != (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns, after.st_ctime_ns) or len(content) != before.st_size:
  84. raise DomainReplicationError(f"package input changed while being read: {relative}")
  85. return content
  86. except OSError as exc:
  87. raise DomainReplicationError(f"package input symlink or replacement was rejected: {relative}") from exc
  88. finally:
  89. if file_fd >= 0:
  90. os.close(file_fd)
  91. os.close(directory_fd)
  92. def _rows(content: bytes, label: str) -> list[dict[str, str]]:
  93. try:
  94. handle = io.StringIO(content.decode("utf-8"), newline="")
  95. return list(csv.DictReader(handle))
  96. except UnicodeDecodeError as exc:
  97. raise DomainReplicationError(f"CSV must be UTF-8: {label}") from exc
  98. def validate_package(package_dir: Path, *, verify_acceptance_report: bool = True) -> dict:
  99. root_fd = _open_directory_chain(package_dir)
  100. try:
  101. blobs: dict[str, bytes] = {"manifest": _read_regular_once(root_fd, "manifest.json")}
  102. manifest = _json(blobs["manifest"], "manifest.json")
  103. files = manifest.get("files")
  104. if not isinstance(files, dict):
  105. raise DomainReplicationError("manifest.files must be an object")
  106. required_files = ("template", "evidence", "snapshot", "delta")
  107. if int(manifest.get("schema_version", 0)) == 2:
  108. required_files += ("execution_evidence",)
  109. optional_files = ("acceptance_report",)
  110. for key in (*required_files, *optional_files):
  111. binding = files.get(key)
  112. if key in optional_files and binding is None:
  113. continue
  114. if not isinstance(binding, dict):
  115. raise DomainReplicationError(f"manifest.files.{key} is required")
  116. relative = str(binding.get("path", ""))
  117. expected = str(binding.get("sha256", ""))
  118. if not _SHA256.fullmatch(expected):
  119. raise DomainReplicationError(f"manifest.files.{key}.sha256 is invalid")
  120. content = _read_regular_once(root_fd, relative)
  121. actual = hashlib.sha256(content).hexdigest()
  122. if actual != expected:
  123. raise DomainReplicationError(
  124. f"package file digest mismatch for {key}: expected={expected}, actual={actual}"
  125. )
  126. blobs[key] = content
  127. report = evaluate_replication_package(
  128. manifest,
  129. _json(blobs["evidence"], "evidence"),
  130. template=_json(blobs["template"], "template"),
  131. snapshot_rows=_rows(blobs["snapshot"], "snapshot"),
  132. delta_rows=_rows(blobs["delta"], "delta"),
  133. execution_evidence=(
  134. _json(blobs["execution_evidence"], "execution_evidence")
  135. if "execution_evidence" in blobs
  136. else None
  137. ),
  138. )
  139. if "acceptance_report" in blobs and verify_acceptance_report:
  140. rendered = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
  141. if blobs["acceptance_report"].decode("utf-8") != rendered:
  142. raise DomainReplicationError(
  143. "acceptance report does not match the deterministic package result"
  144. )
  145. return report
  146. finally:
  147. os.close(root_fd)
  148. def main(argv: list[str] | None = None) -> int:
  149. parser = argparse.ArgumentParser(description=__doc__)
  150. parser.add_argument("--package-dir", type=Path, required=True)
  151. parser.add_argument("--output", default="-")
  152. parser.add_argument("--refresh-acceptance-report", action="store_true")
  153. args = parser.parse_args(argv)
  154. report = validate_package(
  155. args.package_dir,
  156. verify_acceptance_report=not args.refresh_acceptance_report,
  157. )
  158. rendered = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n"
  159. if args.output == "-":
  160. sys.stdout.write(rendered)
  161. else:
  162. output = Path(args.output)
  163. output.parent.mkdir(parents=True, exist_ok=True)
  164. output.write_text(rendered, encoding="utf-8")
  165. return 0
  166. if __name__ == "__main__":
  167. try:
  168. raise SystemExit(main())
  169. except (DomainReplicationError, OSError, ValueError, json.JSONDecodeError) as exc:
  170. print(f"ERROR: {exc}", file=sys.stderr)
  171. raise SystemExit(2) from exc