oept 0.1.1__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.
Files changed (49) hide show
  1. oept/__init__.py +32 -0
  2. oept/bundle.py +175 -0
  3. oept/canonical.py +85 -0
  4. oept/cli.py +228 -0
  5. oept/conformance.py +74 -0
  6. oept/data/__init__.py +1 -0
  7. oept/data/conformance/01-valid-unsigned/bundle.zip +0 -0
  8. oept/data/conformance/01-valid-unsigned/expected-result.json +3 -0
  9. oept/data/conformance/02-valid-signed/bundle.zip +0 -0
  10. oept/data/conformance/02-valid-signed/expected-result.json +3 -0
  11. oept/data/conformance/02-valid-signed/trust-keys.json +6 -0
  12. oept/data/conformance/03-tampered-artifact/bundle.zip +0 -0
  13. oept/data/conformance/03-tampered-artifact/expected-result.json +3 -0
  14. oept/data/conformance/04-missing-member/bundle.zip +0 -0
  15. oept/data/conformance/04-missing-member/expected-result.json +3 -0
  16. oept/data/conformance/05-unsafe-path/bundle.zip +0 -0
  17. oept/data/conformance/05-unsafe-path/expected-result.json +3 -0
  18. oept/data/conformance/06-signature-invalid/bundle.zip +0 -0
  19. oept/data/conformance/06-signature-invalid/expected-result.json +3 -0
  20. oept/data/conformance/06-signature-invalid/trust-keys.json +6 -0
  21. oept/data/conformance/07-unknown-key/bundle.zip +0 -0
  22. oept/data/conformance/07-unknown-key/expected-result.json +3 -0
  23. oept/data/conformance/08-unexpected-member/bundle.zip +0 -0
  24. oept/data/conformance/08-unexpected-member/expected-result.json +3 -0
  25. oept/data/conformance/09-unsupported-version/bundle.zip +0 -0
  26. oept/data/conformance/09-unsupported-version/expected-result.json +3 -0
  27. oept/data/conformance/10-malformed-manifest/bundle.zip +0 -0
  28. oept/data/conformance/10-malformed-manifest/expected-result.json +3 -0
  29. oept/data/schemas/__init__.py +1 -0
  30. oept/data/schemas/bundle-manifest.schema.json +27 -0
  31. oept/data/schemas/evidence-object.schema.json +15 -0
  32. oept/data/schemas/evidence-version.schema.json +21 -0
  33. oept/data/schemas/fact.schema.json +30 -0
  34. oept/data/schemas/provenance-pointer.schema.json +18 -0
  35. oept/data/schemas/rule-trace.schema.json +20 -0
  36. oept/data/schemas/verification-result.schema.json +26 -0
  37. oept/errors.py +29 -0
  38. oept/hashing.py +37 -0
  39. oept/models.py +268 -0
  40. oept/py.typed +0 -0
  41. oept/schemas.py +93 -0
  42. oept/signing.py +131 -0
  43. oept/verify.py +555 -0
  44. oept-0.1.1.dist-info/METADATA +147 -0
  45. oept-0.1.1.dist-info/RECORD +49 -0
  46. oept-0.1.1.dist-info/WHEEL +5 -0
  47. oept-0.1.1.dist-info/entry_points.txt +2 -0
  48. oept-0.1.1.dist-info/licenses/LICENSE +200 -0
  49. oept-0.1.1.dist-info/top_level.txt +1 -0
oept/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """OEPT public API.
2
+
3
+ Intentionally tiny. Everything else is internal and may change.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from .bundle import BundleBuilder, build_bundle
9
+ from .errors import (
10
+ ConfigurationError,
11
+ IntegrityError,
12
+ OeptError,
13
+ UnsupportedSpecError,
14
+ ValidationError,
15
+ )
16
+ from .models import VerificationResult
17
+ from .verify import verify_bundle, verify_bundle_bytes
18
+
19
+ __all__ = [
20
+ "BundleBuilder",
21
+ "ConfigurationError",
22
+ "IntegrityError",
23
+ "OeptError",
24
+ "UnsupportedSpecError",
25
+ "ValidationError",
26
+ "VerificationResult",
27
+ "build_bundle",
28
+ "verify_bundle",
29
+ "verify_bundle_bytes",
30
+ ]
31
+
32
+ __version__ = "0.1.1"
oept/bundle.py ADDED
@@ -0,0 +1,175 @@
1
+ """Deterministic evidence-bundle builder (oept-bundle/0.1).
2
+
3
+ A bundle is a deterministic ZIP with a canonical-JSON manifest. The manifest
4
+ lists and hashes every member except itself. Determinism inputs: sorted
5
+ member paths, fixed ZIP timestamps, fixed permission bits, DEFLATE
6
+ compression, canonical JSON payloads.
7
+
8
+ Reproducibility boundary (stated honestly): identical logical inputs
9
+ produce identical manifest bytes on any platform. Complete-ZIP-byte
10
+ identity additionally depends on the Python/zlib ZIP implementation and
11
+ is asserted only for same-interpreter round-trips in the test suite.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import io
17
+ import zipfile
18
+ from collections.abc import Mapping
19
+ from dataclasses import dataclass, field
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .canonical import canonicalize_json
24
+ from .errors import ValidationError
25
+ from .hashing import sha256_bytes
26
+ from .models import utc_now_iso
27
+
28
+ BUNDLE_SPEC_VERSION = "oept-bundle/0.1"
29
+
30
+ #: Fixed DOS timestamp used for every ZIP member (reproducibility).
31
+ FIXED_ZIP_DATE_TIME = (2024, 1, 1, 0, 0, 0)
32
+
33
+ #: Fixed unix permission bits for ZIP members (0o644 << 16).
34
+ FIXED_EXTERNAL_ATTR = 0o644 << 16
35
+
36
+ REQUIRED_MEMBERS = ("manifest.json", "facts.json", "provenance.json")
37
+
38
+ EVIDENCE_PREFIX = "evidence/"
39
+
40
+
41
+ @dataclass
42
+ class BundleFile:
43
+ """One logical bundle member: archive path + exact bytes."""
44
+
45
+ path: str
46
+ data: bytes
47
+
48
+ @property
49
+ def sha256(self) -> str:
50
+ return sha256_bytes(self.data)
51
+
52
+
53
+ def _check_member_path(path: str) -> None:
54
+ if not path or path.startswith("/") or "\\" in path or "\x00" in path:
55
+ raise ValidationError(f"unsafe bundle member path: {path!r}")
56
+ parts = path.split("/")
57
+ if any(part in ("", ".", "..") for part in parts):
58
+ raise ValidationError(f"unsafe bundle member path: {path!r}")
59
+ if len(path) > 512:
60
+ raise ValidationError(f"bundle member path too long: {path!r}")
61
+
62
+
63
+ @dataclass
64
+ class BundleBuilder:
65
+ """Accumulates members, then emits manifest + deterministic ZIP."""
66
+
67
+ bundle_id: str
68
+ created_at: str = ""
69
+ _files: dict[str, bytes] = field(default_factory=dict)
70
+ key_id: str | None = None
71
+
72
+ def __post_init__(self) -> None:
73
+ if not self.bundle_id or not self.bundle_id.strip():
74
+ raise ValidationError("bundle_id must be non-empty")
75
+ if not self.created_at:
76
+ self.created_at = utc_now_iso()
77
+
78
+ def add_file(self, path: str, data: bytes) -> BundleBuilder:
79
+ """Add one member. Duplicate paths are rejected (never overwritten)."""
80
+ _check_member_path(path)
81
+ if path in self._files:
82
+ raise ValidationError(f"duplicate bundle member path: {path!r}")
83
+ if path == "manifest.json":
84
+ raise ValidationError("manifest.json is generated by the builder")
85
+ if path == "signatures/manifest.sig":
86
+ raise ValidationError(
87
+ "signatures/manifest.sig is reserved for the signature; "
88
+ "pass signature= to build_zip_bytes instead"
89
+ )
90
+ self._files[path] = bytes(data)
91
+ return self
92
+
93
+ def add_evidence(self, name: str, data: bytes) -> BundleBuilder:
94
+ """Add raw evidence bytes under evidence/."""
95
+ return self.add_file(f"{EVIDENCE_PREFIX}{name}", data)
96
+
97
+ def manifest_dict(self) -> dict[str, Any]:
98
+ """Canonical manifest object (without signature members)."""
99
+ for required in ("facts.json", "provenance.json"):
100
+ if required not in self._files:
101
+ raise ValidationError(f"bundle is missing required member: {required}")
102
+ entries = [
103
+ {"path": path, "sha256": sha256_bytes(data), "size_bytes": len(data)}
104
+ for path, data in sorted(self._files.items())
105
+ ]
106
+ manifest: dict[str, Any] = {
107
+ "bundle_spec_version": BUNDLE_SPEC_VERSION,
108
+ "bundle_id": self.bundle_id,
109
+ "created_at": self.created_at,
110
+ "files": entries,
111
+ }
112
+ if self.key_id:
113
+ manifest["key_id"] = self.key_id
114
+ return manifest
115
+
116
+ def manifest_bytes(self) -> bytes:
117
+ """Canonical manifest bytes (the exact bytes that get signed)."""
118
+ return canonicalize_json(self.manifest_dict())
119
+
120
+ def _zip_members(self, signature: bytes | None = None) -> dict[str, bytes]:
121
+ members = dict(self._files)
122
+ if signature is not None:
123
+ if "signatures/manifest.sig" in members:
124
+ raise ValidationError("signatures/manifest.sig is reserved for the signature")
125
+ members["signatures/manifest.sig"] = signature
126
+ members["manifest.json"] = self.manifest_bytes()
127
+ return members
128
+
129
+ def build_zip_bytes(self, signature: bytes | None = None) -> bytes:
130
+ """Emit the deterministic ZIP.
131
+
132
+ Envelope rule (see spec/bundle-spec.md): signatures/manifest.sig is
133
+ written into the archive but is deliberately NOT listed in
134
+ manifest.json's file table, so signature verification never needs
135
+ the signature to check itself. signatures/key-info.json, when added
136
+ via add_file, IS listed and therefore bound to the signed payload
137
+ (its key_id stays identification-only; trust is caller-supplied).
138
+ """
139
+ members = self._zip_members(signature)
140
+ buffer = io.BytesIO()
141
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
142
+ for path in sorted(members):
143
+ info = zipfile.ZipInfo(path, date_time=FIXED_ZIP_DATE_TIME)
144
+ info.compress_type = zipfile.ZIP_DEFLATED
145
+ info.external_attr = FIXED_EXTERNAL_ATTR
146
+ info.create_system = 3
147
+ archive.writestr(info, members[path])
148
+ return buffer.getvalue()
149
+
150
+ def write_zip(self, output: str | Path, signature: bytes | None = None) -> Path:
151
+ """Write the deterministic ZIP to disk."""
152
+ target = Path(output)
153
+ target.parent.mkdir(parents=True, exist_ok=True)
154
+ target.write_bytes(self.build_zip_bytes(signature))
155
+ return target
156
+
157
+
158
+ def build_bundle(
159
+ files: Mapping[str, bytes],
160
+ bundle_id: str,
161
+ created_at: str = "",
162
+ signature: bytes | None = None,
163
+ key_id: str | None = None,
164
+ ) -> bytes:
165
+ """One-shot helper: members + id -> deterministic ZIP bytes."""
166
+ builder = BundleBuilder(bundle_id=bundle_id, created_at=created_at, key_id=key_id)
167
+ for path in sorted(files):
168
+ builder.add_file(path, files[path])
169
+ manifest = builder.manifest_dict()
170
+ if signature is not None and not manifest.get("key_id") and key_id is None:
171
+ raise ValidationError(
172
+ "signed bundles must carry a key_id label "
173
+ "(identification only; trust comes from caller-supplied keys)"
174
+ )
175
+ return builder.build_zip_bytes(signature)
oept/canonical.py ADDED
@@ -0,0 +1,85 @@
1
+ """Deterministic canonical JSON (RFC 8785 / JCS).
2
+
3
+ OEPT does not implement its own canonicalizer. Canonical bytes are produced
4
+ by the maintained ``jcs`` package (RFC 8785 JSON Canonicalization Scheme).
5
+ If ``jcs`` cannot serialize a value, OEPT fails explicitly instead of
6
+ falling back to an incompatible rendering.
7
+
8
+ Supported value domain (the only domain OEPT schemas admit):
9
+ None, bool, int, str, list, tuple (as list), dict with str keys.
10
+
11
+ Anything else — float (including NaN/Infinity), bytes, Decimal, datetime,
12
+ set, custom objects — raises CanonicalizationError. Callers serialize
13
+ Decimals as strings and datetimes as UTC ISO-8601 strings BEFORE calling
14
+ into this module; that rule is enforced, not assumed.
15
+
16
+ Known JCS limitation (failed explicitly, never silently): the ``jcs``
17
+ library routes integers through binary64 floating point. Beyond 2**53,
18
+ values round (9007199254740993 renders as ...992; 2**63-1 renders as
19
+ ...6000) and magnitudes >= 10**21 render in scientific notation, which
20
+ RFC 8785 forbids for integers. OEPT therefore rejects integers outside
21
+ ±(2**53 - 1) outright — callers needing larger magnitudes must use
22
+ decimal strings, exactly like Decimal values.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ from typing import Any
28
+
29
+ import jcs
30
+
31
+ from .errors import OeptError
32
+
33
+ #: Largest integer magnitude serializable exactly. Every integer with
34
+ #: |n| <= 2**53 - 1 is exactly representable in binary64, and the JCS
35
+ #: backend renders this range in plain decimal notation. Anything larger
36
+ #: is rejected (silent rounding or scientific notation). Enforced below,
37
+ #: not assumed.
38
+ _MAX_EXACT_JCS_INT = 2**53 - 1
39
+
40
+
41
+ class CanonicalizationError(OeptError):
42
+ """A value outside the canonical domain was supplied."""
43
+
44
+
45
+ def _check_domain(value: Any, path: str = "$") -> None:
46
+ if value is None or isinstance(value, (bool, str)):
47
+ return
48
+ if isinstance(value, int):
49
+ if abs(value) > _MAX_EXACT_JCS_INT:
50
+ raise CanonicalizationError(
51
+ f"{path}: integer magnitude exceeds the exactly "
52
+ "serializable JCS range (use a decimal string instead)"
53
+ )
54
+ return
55
+ if isinstance(value, float):
56
+ raise CanonicalizationError(
57
+ f"{path}: float is not in the canonical domain "
58
+ "(serialize Decimals as strings, reject NaN/Infinity)"
59
+ )
60
+ if isinstance(value, (list, tuple)):
61
+ for i, item in enumerate(value):
62
+ _check_domain(item, f"{path}[{i}]")
63
+ return
64
+ if isinstance(value, dict):
65
+ for key in value:
66
+ if not isinstance(key, str):
67
+ raise CanonicalizationError(f"{path}: non-string dict key {key!r} is not canonical")
68
+ for key, item in value.items():
69
+ _check_domain(item, f"{path}.{key}")
70
+ return
71
+ raise CanonicalizationError(
72
+ f"{path}: type {type(value).__name__} is not in the canonical domain"
73
+ )
74
+
75
+
76
+ def canonicalize_json(value: Any) -> bytes:
77
+ """Return RFC 8785 canonical bytes (UTF-8) for a domain value."""
78
+ _check_domain(value)
79
+ try:
80
+ result = jcs.canonicalize(value)
81
+ except Exception as exc:
82
+ raise CanonicalizationError(f"JCS serialization failed: {exc}") from exc
83
+ if isinstance(result, str):
84
+ return result.encode("utf-8")
85
+ return bytes(result)
oept/cli.py ADDED
@@ -0,0 +1,228 @@
1
+ """OEPT command line interface.
2
+
3
+ Commands:
4
+ oept bundle DIRECTORY --output BUNDLE build a bundle from a directory
5
+ oept verify BUNDLE [--json] [--trust-keys FILE]
6
+ oept conformance [--dir DIR] [--json] run the conformance corpus
7
+ oept schema (list | show NAME) print JSON Schemas
8
+
9
+ Exit codes: 0 verified/valid, 1 integrity failure, 2 usage/config error.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from collections.abc import Sequence
18
+ from pathlib import Path
19
+
20
+ from .bundle import BundleBuilder
21
+ from .errors import ConfigurationError, OeptError, ValidationError
22
+ from .models import VerificationResult
23
+ from .signing import load_trust_keys, sign_manifest
24
+ from .verify import verify_bundle
25
+
26
+ FOOTER = "Technical verification only — not a legal, compliance, or factual conclusion."
27
+
28
+
29
+ def _render_human(bundle_label: str, result: VerificationResult) -> str:
30
+ if result.status == "VALID_SIGNED":
31
+ signature = (
32
+ f"SIGNED (trusted key: {result.trusted_key_id})" if result.trusted_key_id else "SIGNED"
33
+ )
34
+ elif result.status == "VALID_UNSIGNED":
35
+ signature = "NOT PRESENT"
36
+ elif result.status == "SIGNATURE_KEY_UNKNOWN":
37
+ signature = "PRESENT (no trusted key supplied or matched)"
38
+ elif result.status in ("SIGNATURE_INVALID",):
39
+ signature = "INVALID"
40
+ else:
41
+ signature = "NOT EVALUATED"
42
+ lines = [
43
+ f"Bundle: {bundle_label}",
44
+ f"Specification: {result.bundle_spec_version or '-'}",
45
+ f"Structure: {'VALID' if result.status not in ('MALFORMED_BUNDLE', 'UNSAFE_ARCHIVE', 'UNSUPPORTED_SPEC_VERSION') else 'INVALID'}",
46
+ f"Member digests: {'VALID' if not result.mismatches and not result.missing else 'INVALID'}",
47
+ f"Signature: {signature}",
48
+ f"Result: {result.status}",
49
+ ]
50
+ for note in result.notes:
51
+ lines.append(f"Note: {note}")
52
+ if result.missing:
53
+ lines.append(f"Missing files: {len(result.missing)}")
54
+ if result.unexpected:
55
+ lines.append(f"Unexpected files: {len(result.unexpected)}")
56
+ if result.mismatches:
57
+ lines.append(f"Hash mismatches: {len(result.mismatches)}")
58
+ lines.append("")
59
+ lines.append(FOOTER)
60
+ return "\n".join(lines)
61
+
62
+
63
+ def cmd_verify(args: argparse.Namespace) -> int:
64
+ try:
65
+ trusted = load_trust_keys(args.trust_keys) if args.trust_keys else []
66
+ except ConfigurationError as exc:
67
+ print(f"oept: error: {exc}", file=sys.stderr)
68
+ return 2
69
+ target = Path(args.bundle)
70
+ if not target.exists():
71
+ print(f"oept: error: bundle not found: {target}", file=sys.stderr)
72
+ return 2
73
+ try:
74
+ result = verify_bundle(target, trusted)
75
+ except OeptError as exc:
76
+ print(f"oept: error: {exc}", file=sys.stderr)
77
+ return 2
78
+ if args.json:
79
+ print(json.dumps(result.to_dict(), indent=2, sort_keys=True))
80
+ else:
81
+ print(_render_human(str(target), result))
82
+ return 0 if result.ok else 1
83
+
84
+
85
+ def _iter_input_files(root: Path) -> dict[str, bytes]:
86
+ members: dict[str, bytes] = {}
87
+ for path in sorted(root.rglob("*")):
88
+ if path.is_dir():
89
+ continue
90
+ if path.is_symlink():
91
+ raise ValidationError(f"input contains symlink (refusing to bundle): {path}")
92
+ try:
93
+ name = path.relative_to(root).as_posix()
94
+ except ValueError as exc:
95
+ raise ValidationError(f"unresolvable input path: {path}") from exc
96
+ if name == "manifest.json" or name.startswith("signatures/"):
97
+ raise ValidationError(f"reserved bundle member in input directory: {name!r}")
98
+ members[name] = path.read_bytes()
99
+ if "facts.json" not in members or "provenance.json" not in members:
100
+ raise ValidationError("input directory must contain facts.json and provenance.json")
101
+ return members
102
+
103
+
104
+ def cmd_bundle(args: argparse.Namespace) -> int:
105
+ if args.sign and not args.key_id:
106
+ print("oept: error: --sign requires --key-id", file=sys.stderr)
107
+ return 2
108
+ source = Path(args.directory)
109
+ if not source.is_dir():
110
+ print(f"oept: error: not a directory: {source}", file=sys.stderr)
111
+ return 2
112
+ try:
113
+ members = _iter_input_files(source)
114
+ builder = BundleBuilder(
115
+ bundle_id=args.bundle_id or source.name,
116
+ created_at=args.created_at or "",
117
+ key_id=args.key_id,
118
+ )
119
+ for path in sorted(members):
120
+ builder.add_file(path, members[path])
121
+ signature = None
122
+ if args.sign:
123
+ try:
124
+ private_key = Path(args.sign).read_bytes()
125
+ except OSError as exc:
126
+ print(f"oept: error: cannot read signing key: {exc}", file=sys.stderr)
127
+ return 2
128
+ try:
129
+ signature = sign_manifest(builder.manifest_bytes(), private_key)
130
+ except ConfigurationError as exc:
131
+ print(f"oept: error: {exc}", file=sys.stderr)
132
+ return 2
133
+ output = builder.write_zip(args.output, signature)
134
+ except (ValidationError, OeptError) as exc:
135
+ print(f"oept: error: {exc}", file=sys.stderr)
136
+ return 2
137
+ print(f"wrote {output} ({len(builder.manifest_dict()['files'])} members)")
138
+ return 0
139
+
140
+
141
+ def cmd_conformance(args: argparse.Namespace) -> int:
142
+ from .conformance import run_conformance
143
+
144
+ root = Path(args.dir) if args.dir else None
145
+ try:
146
+ report = run_conformance(root)
147
+ except OeptError as exc:
148
+ print(f"oept: error: {exc}", file=sys.stderr)
149
+ return 2
150
+ if args.json:
151
+ print(json.dumps(report, indent=2, sort_keys=True))
152
+ else:
153
+ for name, status, expected, ok in report["cases"]:
154
+ mark = "ok" if ok else "MISMATCH"
155
+ print(f"{mark:8} {name:28} got={status} want={expected}")
156
+ print(f"{report['passed']}/{report['total']} conformance cases pass")
157
+ return 0 if report["failed"] == 0 else 1
158
+
159
+
160
+ def cmd_schema(args: argparse.Namespace) -> int:
161
+ from .schemas import list_schemas, load_schema
162
+
163
+ if args.action == "list":
164
+ for name in list_schemas():
165
+ print(name)
166
+ return 0
167
+ try:
168
+ schema = load_schema(args.action)
169
+ except OeptError as exc:
170
+ print(f"oept: error: {exc}", file=sys.stderr)
171
+ return 2
172
+ print(json.dumps(schema, indent=2, sort_keys=True))
173
+ return 0
174
+
175
+
176
+ def build_parser() -> argparse.ArgumentParser:
177
+ parser = argparse.ArgumentParser(prog="oept", description="Open Evidence Provenance Toolkit")
178
+ sub = parser.add_subparsers(dest="command", required=True)
179
+
180
+ p_verify = sub.add_parser("verify", help="verify a bundle offline")
181
+ p_verify.add_argument("bundle", help="bundle .zip file")
182
+ p_verify.add_argument("--json", action="store_true", help="machine-readable output")
183
+ p_verify.add_argument("--trust-keys", help="JSON trust-keys file for signatures")
184
+ p_verify.set_defaults(func=cmd_verify)
185
+
186
+ p_bundle = sub.add_parser("bundle", help="build a bundle from a directory")
187
+ p_bundle.add_argument("directory", help="evidence directory to package")
188
+ p_bundle.add_argument("--output", required=True, help="output .zip path")
189
+ p_bundle.add_argument(
190
+ "--bundle-id", default="", help="bundle identifier (default: directory name)"
191
+ )
192
+ p_bundle.add_argument("--created-at", default="", help="ISO-8601 timestamp (default: now, UTC)")
193
+ p_bundle.add_argument("--sign", default="", help="Ed25519 private key PEM file for signing")
194
+ p_bundle.add_argument(
195
+ "--key-id", default="", help="key identifier label (required with --sign)"
196
+ )
197
+ p_bundle.set_defaults(func=cmd_bundle)
198
+
199
+ p_conf = sub.add_parser("conformance", help="run the conformance corpus")
200
+ p_conf.add_argument(
201
+ "--dir", default="", help="conformance corpus directory (default: packaged corpus)"
202
+ )
203
+ p_conf.add_argument("--json", action="store_true", help="machine-readable output")
204
+ p_conf.set_defaults(func=cmd_conformance)
205
+
206
+ p_schema = sub.add_parser("schema", help="inspect JSON Schemas")
207
+ p_schema.add_argument("action", help="'list' or a schema name")
208
+ p_schema.set_defaults(func=cmd_schema)
209
+ return parser
210
+
211
+
212
+ def main(argv: Sequence[str] | None = None) -> int:
213
+ parser = build_parser()
214
+ try:
215
+ args = parser.parse_args(argv)
216
+ except SystemExit as exc:
217
+ return int(exc.code or 0)
218
+ try:
219
+ return int(args.func(args))
220
+ except BrokenPipeError:
221
+ return 1
222
+ except OeptError as exc:
223
+ print(f"oept: error: {exc}", file=sys.stderr)
224
+ return 2
225
+
226
+
227
+ if __name__ == "__main__":
228
+ raise SystemExit(main())
oept/conformance.py ADDED
@@ -0,0 +1,74 @@
1
+ """Conformance corpus runner.
2
+
3
+ Each fixture directory under the corpus root contains a ``bundle.zip``
4
+ (or ``bundle/`` directory) plus ``expected-result.json`` with at least a
5
+ ``status`` field. The runner verifies every fixture and compares the
6
+ terminal status. Trust keys for signed fixtures live in the fixture
7
+ directory (``trust-keys.json``) and are TEST ONLY material.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .errors import OeptError
17
+ from .signing import TrustedKey, load_trust_keys
18
+ from .verify import verify_bundle, verify_bundle_dir
19
+
20
+
21
+ def default_corpus_dir() -> Path:
22
+ """Packaged conformance corpus (installed data or repo checkout)."""
23
+ try:
24
+ from importlib import resources
25
+
26
+ anchor = resources.files("oept.data") / "conformance"
27
+ try:
28
+ if anchor.is_dir():
29
+ return Path(str(anchor))
30
+ except (OSError, ValueError):
31
+ pass
32
+ except (ImportError, ModuleNotFoundError, TypeError):
33
+ pass
34
+ here = Path(__file__).resolve().parent
35
+ candidates = [
36
+ here.parent.parent / "conformance" / "fixtures",
37
+ Path(__file__).resolve().parents[2] / "conformance" / "fixtures",
38
+ ]
39
+ for candidate in candidates:
40
+ if candidate.is_dir():
41
+ return candidate
42
+ raise OeptError("OEPT conformance corpus not found")
43
+
44
+
45
+ def run_conformance(corpus_dir: Path | None = None) -> dict[str, Any]:
46
+ """Run every fixture. Returns {total, passed, failed, cases}."""
47
+ root = Path(corpus_dir) if corpus_dir else default_corpus_dir()
48
+ if not root.is_dir():
49
+ raise OeptError(f"conformance corpus is not a directory: {root}")
50
+ cases: list[tuple[str, str, str, bool]] = []
51
+ passed = 0
52
+ for fixture in sorted(p for p in root.iterdir() if p.is_dir()):
53
+ expected_path = fixture / "expected-result.json"
54
+ if not expected_path.exists():
55
+ raise OeptError(f"fixture {fixture.name} lacks expected-result.json")
56
+ expected = json.loads(expected_path.read_text(encoding="utf-8"))
57
+ want = expected.get("status")
58
+ trust_file = fixture / "trust-keys.json"
59
+ keys: list[TrustedKey] = []
60
+ if trust_file.exists():
61
+ keys = load_trust_keys(trust_file)
62
+ bundle_zip = fixture / "bundle.zip"
63
+ bundle_dir = fixture / "bundle"
64
+ if bundle_zip.exists():
65
+ got = verify_bundle(bundle_zip, keys).status
66
+ elif bundle_dir.is_dir():
67
+ got = verify_bundle_dir(bundle_dir, keys).status
68
+ else:
69
+ raise OeptError(f"fixture {fixture.name} has no bundle.zip/bundle/")
70
+ ok = got == want
71
+ passed += 1 if ok else 0
72
+ cases.append((fixture.name, got, str(want), ok))
73
+ total = len(cases)
74
+ return {"total": total, "passed": passed, "failed": total - passed, "cases": cases}
oept/data/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """OEPT package data (schemas, conformance corpus)."""
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "VALID_UNSIGNED"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "VALID_SIGNED"
3
+ }
@@ -0,0 +1,6 @@
1
+ [
2
+ {
3
+ "key_id": "conformance-test-key",
4
+ "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAZHQFbcEU4SxGXn6EY0Xz6By9k70sQUa2DxU+mqfPgFc=\n-----END PUBLIC KEY-----\n"
5
+ }
6
+ ]
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "HASH_MISMATCH"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "MISSING_FILE"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "UNSAFE_ARCHIVE"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "SIGNATURE_INVALID"
3
+ }
@@ -0,0 +1,6 @@
1
+ [
2
+ {
3
+ "key_id": "conformance-test-key",
4
+ "public_key_pem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAZHQFbcEU4SxGXn6EY0Xz6By9k70sQUa2DxU+mqfPgFc=\n-----END PUBLIC KEY-----\n"
5
+ }
6
+ ]
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "SIGNATURE_KEY_UNKNOWN"
3
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "status": "UNEXPECTED_FILE"
3
+ }