actproof 0.2.0__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.
actproof/manifest.py ADDED
@@ -0,0 +1,728 @@
1
+ # SPDX-FileCopyrightText: 2026 Deyan Paroushev
2
+ # SPDX-License-Identifier: MIT
3
+ """
4
+ Canonical manifest envelope for actproof.
5
+
6
+ A manifest is the structured commitment an issuer makes. It declares which
7
+ catalogue entry it implements, who the issuer is, what is being claimed, what
8
+ evidence files back the claim, and which recipients are designated witnesses.
9
+ The manifest's canonical bytes (via RFC 8785 JCS) produce the hash that gets
10
+ anchored on the public ledger and timestamped by the QTSP. That hash is the
11
+ load-bearing artifact: identical input bytes must always produce identical
12
+ canonical bytes, on any platform, in any year, forever.
13
+
14
+ Wire shape
15
+ ----------
16
+
17
+ When canonicalized, a manifest produces JSON of this shape:
18
+
19
+ ::
20
+
21
+ {
22
+ "batching_profile": "single_attestation_anchor_v1",
23
+ "catalogue": {
24
+ "act_type_id": "op:eu.nis2.art20.management_body_approval.v1",
25
+ "entry_hash": "sha256:abc...",
26
+ "entry_version": 1,
27
+ "git_commit": "0123abcd...",
28
+ "schema_hash": "sha256:def...",
29
+ "source_uri": "https://github.com/deyan-paroushev/actproof-events"
30
+ },
31
+ "claim": {
32
+ "approving_body_name": "Board of Directors",
33
+ "decision_date": "2026-05-14",
34
+ "...": "..."
35
+ },
36
+ "evidence": [
37
+ {
38
+ "byte_size": 482991,
39
+ "filename_normalized": "minutes_2026-05-14.pdf",
40
+ "label": "signed_resolution_or_minutes",
41
+ "mime_type": "application/pdf",
42
+ "sha256": "sha256:..."
43
+ }
44
+ ],
45
+ "issued_at": "2026-05-14T08:23:11Z",
46
+ "issuer": {
47
+ "authority_label": "Management Body",
48
+ "org_name": "Sofia Tech Holdings AD"
49
+ },
50
+ "receipt_profile": "actproof-jcs-v1",
51
+ "recipients": [
52
+ {
53
+ "email_hash": "sha256:...",
54
+ "org_name": "Auditor AD",
55
+ "role": "external_auditor"
56
+ }
57
+ ],
58
+ "title": "NIS2 Article 20 management body approval, May 2026"
59
+ }
60
+
61
+ After canonicalization (RFC 8785), every object's keys are sorted in Unicode
62
+ code-point order. The example above already shows the sorted form.
63
+
64
+ Design choices
65
+ --------------
66
+
67
+ **Label-bound evidence.** Each ``Evidence`` carries its catalogue-required
68
+ ``label`` inline. This deviates from a naive design that would store evidence
69
+ hashes and labels in two separate lists and require the verifier to correlate
70
+ them. Label-bound evidence means the manifest itself states "this file
71
+ satisfies this required label," which is what a downstream verifier actually
72
+ needs to check. (Pattern carried forward from the architectural review.)
73
+
74
+ **Catalogue pinning.** The ``catalogue`` block pins five things: which
75
+ entry (``act_type_id``), which version (``entry_version``), where it lives
76
+ (``source_uri``), exactly which version of the spec repo (``git_commit``),
77
+ the SHA-256 of the entry JSON bytes (``entry_hash``), and the SHA-256 of
78
+ the schema JSON bytes (``schema_hash``). Together these make the receipt
79
+ independently re-verifiable: anyone can fetch the catalogue at the pinned
80
+ commit, recompute the entry hash, and confirm that what was used was what
81
+ the receipt claims.
82
+
83
+ **Hashed recipient emails.** The public manifest contains only
84
+ ``email_hash`` for each recipient (SHA-256 of normalised plaintext). The
85
+ plaintext email lives only in the issuer-side receipt. A public verifier
86
+ can confirm that a particular email was designated by computing the hash
87
+ and comparing; a public observer cannot enumerate recipients without
88
+ already knowing the plaintext.
89
+
90
+ **Frozen dataclasses, no Pydantic.** This module uses ``@dataclass(frozen=True)``
91
+ throughout. Frozen for immutability (a manifest's identity is its hash; mutating
92
+ fields after construction would invalidate that). No Pydantic so the library
93
+ stays lightweight; validation is a separate explicit function that callers
94
+ invoke when they want it.
95
+
96
+ API
97
+ ---
98
+
99
+ Construction:
100
+
101
+ * ``build_manifest(*, ...) -> Manifest`` - construct from keyword arguments.
102
+ * ``manifest_from_dict(d) -> Manifest`` - parse from a dict (e.g. from a receipt).
103
+
104
+ Serialisation:
105
+
106
+ * ``manifest_to_dict(m) -> dict`` - produce a dict suitable for canonicalisation.
107
+ * ``hash_manifest(m) -> bytes`` - canonicalise + SHA-256 raw digest (32 bytes).
108
+ * ``hash_manifest_hex(m) -> str`` - canonicalise + SHA-256 hex digest.
109
+
110
+ Helpers:
111
+
112
+ * ``normalize_email(email) -> str`` - lowercase + strip whitespace.
113
+ * ``hash_email(email) -> str`` - return ``"sha256:..."`` of normalised email.
114
+ * ``hash_file_bytes(content) -> str`` - return ``"sha256:..."`` of raw file bytes.
115
+ * ``hash_json_bytes(json_bytes) -> str`` - return ``"sha256:..."`` of canonical JSON bytes.
116
+
117
+ Validation:
118
+
119
+ * ``validate_manifest_shape(m) -> None`` - shape validation (sha256 formats,
120
+ ISO 8601 timestamps, non-empty required fields). Raises
121
+ ``ManifestValidationError`` on the first problem.
122
+
123
+ Catalogue conformance (does this manifest's claim match its act type's
124
+ required fields) is checked separately by ``actproof.catalogue.validate_manifest``
125
+ landing in v0.0.4.
126
+ """
127
+
128
+ from __future__ import annotations
129
+
130
+ import hashlib
131
+ import re
132
+ from dataclasses import dataclass, field
133
+ from datetime import datetime
134
+ from typing import Any, Mapping, Sequence
135
+
136
+ from actproof.canonical import canonicalize, hash_canonical
137
+
138
+ __all__ = [
139
+ "Manifest",
140
+ "CatalogueBinding",
141
+ "Issuer",
142
+ "Evidence",
143
+ "Recipient",
144
+ "build_manifest",
145
+ "manifest_to_dict",
146
+ "manifest_from_dict",
147
+ "hash_manifest",
148
+ "hash_manifest_hex",
149
+ "normalize_email",
150
+ "hash_email",
151
+ "hash_file_bytes",
152
+ "hash_json_bytes",
153
+ "validate_manifest_shape",
154
+ "ManifestValidationError",
155
+ "RECEIPT_PROFILE_V1",
156
+ "BATCHING_PROFILE_SINGLE",
157
+ ]
158
+
159
+
160
+ # ─────────────────────────────────────────────────────────────────
161
+ # CONSTANTS
162
+ # ─────────────────────────────────────────────────────────────────
163
+
164
+ RECEIPT_PROFILE_V1: str = "actproof-jcs-v1"
165
+ """The receipt profile identifier for v1 (JSON-canonical, no COSE bridge).
166
+
167
+ When the SCITT COSE_Sign1 bridge lands (post-RFC 9943 publication), v2
168
+ will be ``"actproof-scitt-cose-v2"``, and the receipt's ``receipt_profile``
169
+ field is the discriminator that lets verifiers route to the right parser.
170
+ """
171
+
172
+ BATCHING_PROFILE_SINGLE: str = "single_attestation_anchor_v1"
173
+ """The batching profile identifier for v1 (one attestation per anchor).
174
+
175
+ When Merkle batching lands in a future release, the new profile will be
176
+ ``"merkle_batch_v1"`` and the receipt's ``batching_profile`` field will
177
+ declare which is in use.
178
+ """
179
+
180
+ _SHA256_HEX_PATTERN: re.Pattern[str] = re.compile(r"^sha256:[0-9a-f]{64}$")
181
+ """Pattern for SHA-256 hash strings: ``"sha256:"`` + 64 lowercase hex chars."""
182
+
183
+ _GIT_COMMIT_PATTERN: re.Pattern[str] = re.compile(r"^[0-9a-f]{40}$")
184
+ """Pattern for git commit SHAs: 40 lowercase hex chars."""
185
+
186
+ _ISO_8601_PATTERN: re.Pattern[str] = re.compile(
187
+ r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$"
188
+ )
189
+ """Pattern for ISO 8601 UTC timestamps: ``YYYY-MM-DDTHH:MM:SS[.fff]Z``."""
190
+
191
+
192
+ # ─────────────────────────────────────────────────────────────────
193
+ # EXCEPTIONS
194
+ # ─────────────────────────────────────────────────────────────────
195
+
196
+ class ManifestValidationError(ValueError):
197
+ """Raised when a manifest's shape is structurally invalid.
198
+
199
+ Subclass of ``ValueError`` so callers can catch ``ValueError`` if they
200
+ prefer unified handling, or ``ManifestValidationError`` specifically
201
+ when they need to distinguish manifest shape errors from other value
202
+ errors.
203
+ """
204
+
205
+
206
+ # ─────────────────────────────────────────────────────────────────
207
+ # DATA CLASSES
208
+ # ─────────────────────────────────────────────────────────────────
209
+
210
+ @dataclass(frozen=True)
211
+ class CatalogueBinding:
212
+ """Pins which catalogue entry, version, and source revision this manifest implements.
213
+
214
+ Together the five fields make the receipt independently verifiable:
215
+ anyone can fetch the catalogue source at the pinned commit, locate the
216
+ entry by ``act_type_id``, recompute the entry hash, and confirm that
217
+ what was used matches what the receipt claims.
218
+
219
+ Attributes:
220
+ act_type_id: The catalogue entry's act_type_id, e.g.
221
+ ``"op:eu.nis2.art20.management_body_approval.v1"``.
222
+ entry_version: The integer version of the entry.
223
+ source_uri: URI of the catalogue source, typically a GitHub repo URL.
224
+ git_commit: 40-character git SHA-1 commit of the catalogue at issue time.
225
+ entry_hash: SHA-256 of the entry JSON file bytes, as ``"sha256:..."``.
226
+ schema_hash: SHA-256 of the catalogue schema JSON bytes, as ``"sha256:..."``.
227
+ """
228
+ act_type_id: str
229
+ entry_version: int
230
+ source_uri: str
231
+ git_commit: str
232
+ entry_hash: str
233
+ schema_hash: str
234
+
235
+
236
+ @dataclass(frozen=True)
237
+ class Issuer:
238
+ """The party making the commitment.
239
+
240
+ Public fields only. Internal identifiers (Auth0 user ID, internal user
241
+ record ID, etc.) belong in the issuer-side receipt artifact, not in
242
+ the public manifest.
243
+
244
+ Attributes:
245
+ org_name: Legal or trading name of the issuing organisation.
246
+ authority_label: Role under which the issuer acts, e.g.
247
+ ``"Management Body"`` for NIS2 Article 20, ``"Operator"`` for
248
+ EUDR, ``"Maintainer"`` for a software release.
249
+ """
250
+ org_name: str
251
+ authority_label: str
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class Evidence:
256
+ """A single evidence file bound to its catalogue-required label.
257
+
258
+ Each ``Evidence`` carries its label inline so that a verifier can read
259
+ the manifest and immediately answer: "which file satisfies which required
260
+ evidence label?" The label MUST be one of the entry's
261
+ ``required_evidence_labels`` (this is checked by
262
+ ``actproof.catalogue.validate_manifest``, not here).
263
+
264
+ Attributes:
265
+ label: The catalogue-required evidence label this file satisfies.
266
+ filename_normalized: NFC-normalised filename with no path components,
267
+ no leading dot, no shell metacharacters.
268
+ byte_size: File size in bytes. Must be positive.
269
+ mime_type: IANA media type, e.g. ``"application/pdf"``.
270
+ sha256: SHA-256 of the file bytes, as ``"sha256:..."``.
271
+ """
272
+ label: str
273
+ filename_normalized: str
274
+ byte_size: int
275
+ mime_type: str
276
+ sha256: str
277
+
278
+
279
+ @dataclass(frozen=True)
280
+ class Recipient:
281
+ """A designated recipient (witness) of the manifest.
282
+
283
+ The public manifest contains only ``email_hash``. Plaintext email
284
+ lives in the issuer-side receipt artifact, NOT here. A verifier
285
+ proves a particular email was designated by computing the hash and
286
+ comparing; a public observer cannot enumerate recipients without
287
+ already knowing the plaintext addresses.
288
+
289
+ Attributes:
290
+ role: Role from the catalogue entry's ``recommended_witness_roles``,
291
+ e.g. ``"external_auditor"``, ``"competent_authority_supervisor"``.
292
+ org_name: Legal or trading name of the recipient organisation.
293
+ email_hash: ``"sha256:..."`` of the normalised (lowercase, trimmed)
294
+ recipient email address.
295
+ """
296
+ role: str
297
+ org_name: str
298
+ email_hash: str
299
+
300
+
301
+ @dataclass(frozen=True)
302
+ class Manifest:
303
+ """The canonical envelope. Once committed, every field is immutable.
304
+
305
+ Attributes:
306
+ receipt_profile: ``"actproof-jcs-v1"`` for this release.
307
+ issued_at: ISO 8601 UTC timestamp with ``Z`` suffix.
308
+ catalogue: Catalogue binding (which entry, which version, which
309
+ source revision).
310
+ issuer: The party making the commitment.
311
+ title: Human-readable title for the commitment, e.g.
312
+ ``"NIS2 Article 20 management body approval, May 2026"``.
313
+ claim: Catalogue-typed claim fields, a mapping whose keys match
314
+ the entry's ``required_claim_fields`` plus any
315
+ ``optional_claim_fields``. Field values are JSON-serialisable
316
+ primitives (str, int, bool, None) or nested dicts/lists.
317
+ evidence: Tuple of ``Evidence`` records, one per uploaded file.
318
+ recipients: Tuple of ``Recipient`` records, one per designated witness.
319
+ batching_profile: ``"single_attestation_anchor_v1"`` for this release.
320
+ """
321
+ receipt_profile: str
322
+ issued_at: str
323
+ catalogue: CatalogueBinding
324
+ issuer: Issuer
325
+ title: str
326
+ claim: Mapping[str, Any]
327
+ evidence: tuple[Evidence, ...]
328
+ recipients: tuple[Recipient, ...]
329
+ batching_profile: str
330
+
331
+
332
+ # ─────────────────────────────────────────────────────────────────
333
+ # CONSTRUCTION
334
+ # ─────────────────────────────────────────────────────────────────
335
+
336
+ def build_manifest(
337
+ *,
338
+ act_type_id: str,
339
+ catalogue_entry_version: int,
340
+ catalogue_source_uri: str,
341
+ catalogue_git_commit: str,
342
+ catalogue_entry_hash: str,
343
+ catalogue_schema_hash: str,
344
+ issuer_org_name: str,
345
+ issuer_authority_label: str,
346
+ title: str,
347
+ claim: Mapping[str, Any],
348
+ evidence: Sequence[Evidence],
349
+ recipients: Sequence[Recipient],
350
+ issued_at: str,
351
+ receipt_profile: str = RECEIPT_PROFILE_V1,
352
+ batching_profile: str = BATCHING_PROFILE_SINGLE,
353
+ ) -> Manifest:
354
+ """Construct a Manifest from raw fields.
355
+
356
+ Use this rather than calling the ``Manifest`` constructor directly.
357
+ Builds nested dataclasses, coerces evidence and recipients to tuples
358
+ for immutability, and produces a fully populated ``Manifest`` ready
359
+ to canonicalise.
360
+
361
+ All arguments are keyword-only to prevent positional misuse.
362
+
363
+ Args:
364
+ act_type_id: Catalogue entry act_type_id.
365
+ catalogue_entry_version: Integer entry version.
366
+ catalogue_source_uri: URI of the catalogue source.
367
+ catalogue_git_commit: 40-char git SHA-1 of the catalogue source.
368
+ catalogue_entry_hash: ``"sha256:..."`` of the entry JSON bytes.
369
+ catalogue_schema_hash: ``"sha256:..."`` of the catalogue schema JSON.
370
+ issuer_org_name: Issuing organisation name.
371
+ issuer_authority_label: Role under which issuer acts.
372
+ title: Human-readable manifest title.
373
+ claim: Catalogue-typed claim fields.
374
+ evidence: Sequence of ``Evidence`` records.
375
+ recipients: Sequence of ``Recipient`` records.
376
+ issued_at: ISO 8601 UTC timestamp with ``Z`` suffix.
377
+ receipt_profile: Receipt profile discriminator. Defaults to v1.
378
+ batching_profile: Batching profile discriminator. Defaults to single.
379
+
380
+ Returns:
381
+ A fully populated, immutable ``Manifest`` instance.
382
+ """
383
+ return Manifest(
384
+ receipt_profile=receipt_profile,
385
+ issued_at=issued_at,
386
+ catalogue=CatalogueBinding(
387
+ act_type_id=act_type_id,
388
+ entry_version=catalogue_entry_version,
389
+ source_uri=catalogue_source_uri,
390
+ git_commit=catalogue_git_commit,
391
+ entry_hash=catalogue_entry_hash,
392
+ schema_hash=catalogue_schema_hash,
393
+ ),
394
+ issuer=Issuer(
395
+ org_name=issuer_org_name,
396
+ authority_label=issuer_authority_label,
397
+ ),
398
+ title=title,
399
+ claim=dict(claim), # defensive copy
400
+ evidence=tuple(evidence),
401
+ recipients=tuple(recipients),
402
+ batching_profile=batching_profile,
403
+ )
404
+
405
+
406
+ # ─────────────────────────────────────────────────────────────────
407
+ # SERIALISATION
408
+ # ─────────────────────────────────────────────────────────────────
409
+
410
+ def manifest_to_dict(m: Manifest) -> dict[str, Any]:
411
+ """Convert a ``Manifest`` to a plain dict suitable for canonicalisation.
412
+
413
+ The output is what gets passed to ``canonicalize()`` to produce the
414
+ canonical bytes that are hashed and anchored.
415
+
416
+ Args:
417
+ m: The Manifest to serialise.
418
+
419
+ Returns:
420
+ A nested dict with all manifest fields.
421
+ """
422
+ return {
423
+ "receipt_profile": m.receipt_profile,
424
+ "issued_at": m.issued_at,
425
+ "catalogue": {
426
+ "act_type_id": m.catalogue.act_type_id,
427
+ "entry_version": m.catalogue.entry_version,
428
+ "source_uri": m.catalogue.source_uri,
429
+ "git_commit": m.catalogue.git_commit,
430
+ "entry_hash": m.catalogue.entry_hash,
431
+ "schema_hash": m.catalogue.schema_hash,
432
+ },
433
+ "issuer": {
434
+ "org_name": m.issuer.org_name,
435
+ "authority_label": m.issuer.authority_label,
436
+ },
437
+ "title": m.title,
438
+ "claim": dict(m.claim),
439
+ "evidence": [
440
+ {
441
+ "label": e.label,
442
+ "filename_normalized": e.filename_normalized,
443
+ "byte_size": e.byte_size,
444
+ "mime_type": e.mime_type,
445
+ "sha256": e.sha256,
446
+ }
447
+ for e in m.evidence
448
+ ],
449
+ "recipients": [
450
+ {
451
+ "role": r.role,
452
+ "org_name": r.org_name,
453
+ "email_hash": r.email_hash,
454
+ }
455
+ for r in m.recipients
456
+ ],
457
+ "batching_profile": m.batching_profile,
458
+ }
459
+
460
+
461
+ def manifest_from_dict(d: Mapping[str, Any]) -> Manifest:
462
+ """Parse a dict back into a ``Manifest``.
463
+
464
+ Used primarily by the verifier, which receives a receipt with the
465
+ manifest as a nested dict and needs to reconstruct the typed object
466
+ for inspection.
467
+
468
+ Args:
469
+ d: A dict matching the canonical manifest shape.
470
+
471
+ Returns:
472
+ A reconstructed ``Manifest`` instance.
473
+
474
+ Raises:
475
+ ManifestValidationError: If a required field is missing or has
476
+ the wrong type.
477
+ """
478
+ try:
479
+ catalogue_dict = d["catalogue"]
480
+ issuer_dict = d["issuer"]
481
+
482
+ return Manifest(
483
+ receipt_profile=d["receipt_profile"],
484
+ issued_at=d["issued_at"],
485
+ catalogue=CatalogueBinding(
486
+ act_type_id=catalogue_dict["act_type_id"],
487
+ entry_version=int(catalogue_dict["entry_version"]),
488
+ source_uri=catalogue_dict["source_uri"],
489
+ git_commit=catalogue_dict["git_commit"],
490
+ entry_hash=catalogue_dict["entry_hash"],
491
+ schema_hash=catalogue_dict["schema_hash"],
492
+ ),
493
+ issuer=Issuer(
494
+ org_name=issuer_dict["org_name"],
495
+ authority_label=issuer_dict["authority_label"],
496
+ ),
497
+ title=d["title"],
498
+ claim=dict(d["claim"]),
499
+ evidence=tuple(
500
+ Evidence(
501
+ label=e["label"],
502
+ filename_normalized=e["filename_normalized"],
503
+ byte_size=int(e["byte_size"]),
504
+ mime_type=e["mime_type"],
505
+ sha256=e["sha256"],
506
+ )
507
+ for e in d.get("evidence", [])
508
+ ),
509
+ recipients=tuple(
510
+ Recipient(
511
+ role=r["role"],
512
+ org_name=r["org_name"],
513
+ email_hash=r["email_hash"],
514
+ )
515
+ for r in d.get("recipients", [])
516
+ ),
517
+ batching_profile=d["batching_profile"],
518
+ )
519
+ except (KeyError, TypeError) as exc:
520
+ raise ManifestValidationError(
521
+ f"Cannot parse manifest dict: {exc}"
522
+ ) from exc
523
+
524
+
525
+ # ─────────────────────────────────────────────────────────────────
526
+ # HASHING
527
+ # ─────────────────────────────────────────────────────────────────
528
+
529
+ def hash_manifest(m: Manifest) -> bytes:
530
+ """Canonicalise a manifest and return the SHA-256 raw digest (32 bytes).
531
+
532
+ This is the hash that gets anchored to the public ledger inside the
533
+ ARC-2 disclosed-mode note.
534
+
535
+ Args:
536
+ m: The manifest to hash.
537
+
538
+ Returns:
539
+ The 32-byte SHA-256 digest of the canonical manifest bytes.
540
+ """
541
+ return hash_canonical(manifest_to_dict(m))
542
+
543
+
544
+ def hash_manifest_hex(m: Manifest) -> str:
545
+ """Canonicalise a manifest and return the SHA-256 hex digest.
546
+
547
+ Args:
548
+ m: The manifest to hash.
549
+
550
+ Returns:
551
+ 64-character lowercase hex string.
552
+ """
553
+ return hash_manifest(m).hex()
554
+
555
+
556
+ # ─────────────────────────────────────────────────────────────────
557
+ # HELPERS
558
+ # ─────────────────────────────────────────────────────────────────
559
+
560
+ def normalize_email(email: str) -> str:
561
+ """Normalise an email address for hashing.
562
+
563
+ Lowercase, strip leading/trailing whitespace. Does NOT validate the
564
+ address format, does NOT canonicalise plus-addressing, does NOT do
565
+ any provider-specific normalisation (like Gmail's dot insensitivity).
566
+ The goal is reproducibility, not deliverability validation.
567
+
568
+ Args:
569
+ email: The plaintext email address.
570
+
571
+ Returns:
572
+ Lowercased, stripped email string.
573
+ """
574
+ return email.strip().lower()
575
+
576
+
577
+ def hash_email(email: str) -> str:
578
+ """Return the ``"sha256:..."`` hash of a normalised email.
579
+
580
+ Args:
581
+ email: The plaintext email address.
582
+
583
+ Returns:
584
+ ``"sha256:"`` followed by 64 lowercase hex characters.
585
+ """
586
+ normalized = normalize_email(email)
587
+ digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
588
+ return f"sha256:{digest}"
589
+
590
+
591
+ def hash_file_bytes(content: bytes) -> str:
592
+ """Return the ``"sha256:..."`` hash of raw file bytes.
593
+
594
+ Args:
595
+ content: The file's raw byte content.
596
+
597
+ Returns:
598
+ ``"sha256:"`` followed by 64 lowercase hex characters.
599
+ """
600
+ digest = hashlib.sha256(content).hexdigest()
601
+ return f"sha256:{digest}"
602
+
603
+
604
+ def hash_json_bytes(json_bytes: bytes) -> str:
605
+ """Return the ``"sha256:..."`` hash of (canonical) JSON bytes.
606
+
607
+ Useful for computing ``catalogue_entry_hash`` and ``catalogue_schema_hash``
608
+ from the on-disk JSON files in the actproof-events repository.
609
+
610
+ Args:
611
+ json_bytes: The JSON file's raw bytes (typically the contents of
612
+ a catalogue entry .json file).
613
+
614
+ Returns:
615
+ ``"sha256:"`` followed by 64 lowercase hex characters.
616
+ """
617
+ digest = hashlib.sha256(json_bytes).hexdigest()
618
+ return f"sha256:{digest}"
619
+
620
+
621
+ # ─────────────────────────────────────────────────────────────────
622
+ # VALIDATION
623
+ # ─────────────────────────────────────────────────────────────────
624
+
625
+ def validate_manifest_shape(m: Manifest) -> None:
626
+ """Validate that a manifest is structurally well-formed.
627
+
628
+ Checks formats of sha256 strings, git commit SHAs, ISO 8601 timestamps,
629
+ byte sizes, and non-emptiness of required strings. Does NOT check
630
+ whether the claim fields satisfy a particular catalogue entry's
631
+ requirements (that is ``actproof.catalogue.validate_manifest``,
632
+ landing in v0.0.4).
633
+
634
+ Args:
635
+ m: The manifest to validate.
636
+
637
+ Raises:
638
+ ManifestValidationError: On the first shape problem found.
639
+ """
640
+ # Receipt profile and batching profile.
641
+ if not m.receipt_profile:
642
+ raise ManifestValidationError("receipt_profile is empty")
643
+ if not m.batching_profile:
644
+ raise ManifestValidationError("batching_profile is empty")
645
+
646
+ # Issued-at timestamp.
647
+ if not _ISO_8601_PATTERN.match(m.issued_at):
648
+ raise ManifestValidationError(
649
+ f"issued_at {m.issued_at!r} is not ISO 8601 UTC "
650
+ f"(expected YYYY-MM-DDTHH:MM:SS[.fff]Z)"
651
+ )
652
+ # Also verify it actually parses; the regex catches most shapes but
653
+ # leaves room for impossible dates like 2026-13-32.
654
+ try:
655
+ # Python 3.11+ accepts the Z suffix directly; for older versions
656
+ # we replace it explicitly to avoid surprises.
657
+ datetime.fromisoformat(m.issued_at.replace("Z", "+00:00"))
658
+ except ValueError as exc:
659
+ raise ManifestValidationError(
660
+ f"issued_at {m.issued_at!r} is not a valid datetime: {exc}"
661
+ ) from exc
662
+
663
+ # Catalogue binding.
664
+ if not m.catalogue.act_type_id:
665
+ raise ManifestValidationError("catalogue.act_type_id is empty")
666
+ if m.catalogue.entry_version < 1:
667
+ raise ManifestValidationError(
668
+ f"catalogue.entry_version {m.catalogue.entry_version} must be >= 1"
669
+ )
670
+ if not m.catalogue.source_uri:
671
+ raise ManifestValidationError("catalogue.source_uri is empty")
672
+ if not _GIT_COMMIT_PATTERN.match(m.catalogue.git_commit):
673
+ raise ManifestValidationError(
674
+ f"catalogue.git_commit {m.catalogue.git_commit!r} is not "
675
+ f"40 lowercase hex characters"
676
+ )
677
+ if not _SHA256_HEX_PATTERN.match(m.catalogue.entry_hash):
678
+ raise ManifestValidationError(
679
+ f"catalogue.entry_hash {m.catalogue.entry_hash!r} is not in "
680
+ f"'sha256:<64 lowercase hex>' format"
681
+ )
682
+ if not _SHA256_HEX_PATTERN.match(m.catalogue.schema_hash):
683
+ raise ManifestValidationError(
684
+ f"catalogue.schema_hash {m.catalogue.schema_hash!r} is not in "
685
+ f"'sha256:<64 lowercase hex>' format"
686
+ )
687
+
688
+ # Issuer.
689
+ if not m.issuer.org_name:
690
+ raise ManifestValidationError("issuer.org_name is empty")
691
+ if not m.issuer.authority_label:
692
+ raise ManifestValidationError("issuer.authority_label is empty")
693
+
694
+ # Title.
695
+ if not m.title:
696
+ raise ManifestValidationError("title is empty")
697
+
698
+ # Evidence.
699
+ for i, e in enumerate(m.evidence):
700
+ if not e.label:
701
+ raise ManifestValidationError(f"evidence[{i}].label is empty")
702
+ if not e.filename_normalized:
703
+ raise ManifestValidationError(
704
+ f"evidence[{i}].filename_normalized is empty"
705
+ )
706
+ if e.byte_size <= 0:
707
+ raise ManifestValidationError(
708
+ f"evidence[{i}].byte_size {e.byte_size} must be > 0"
709
+ )
710
+ if not e.mime_type:
711
+ raise ManifestValidationError(f"evidence[{i}].mime_type is empty")
712
+ if not _SHA256_HEX_PATTERN.match(e.sha256):
713
+ raise ManifestValidationError(
714
+ f"evidence[{i}].sha256 {e.sha256!r} is not in "
715
+ f"'sha256:<64 lowercase hex>' format"
716
+ )
717
+
718
+ # Recipients.
719
+ for i, r in enumerate(m.recipients):
720
+ if not r.role:
721
+ raise ManifestValidationError(f"recipients[{i}].role is empty")
722
+ if not r.org_name:
723
+ raise ManifestValidationError(f"recipients[{i}].org_name is empty")
724
+ if not _SHA256_HEX_PATTERN.match(r.email_hash):
725
+ raise ManifestValidationError(
726
+ f"recipients[{i}].email_hash {r.email_hash!r} is not in "
727
+ f"'sha256:<64 lowercase hex>' format"
728
+ )