11#!/usr/bin/env python3
22"""Validate BootReleaseSet example documents.
33
4- This validator is intentionally dependency-light for first-pass CI. It checks the
5- schema-critical invariants we need before adding a full JSON Schema runtime.
4+ This validator intentionally remains dependency-light for CI, while enforcing the
5+ v1 world-class contract fields: provenance, trust, signature, anti-rollback, and
6+ telemetry.
67"""
78
89from __future__ import annotations
1516from urllib .parse import urlparse
1617
1718SHA256_RE = re .compile (r"^[a-fA-F0-9]{64}$" )
19+ SHA256_PREFIX_RE = re .compile (r"^sha256:[a-fA-F0-9]{64}$" )
1820NAME_RE = re .compile (r"^[a-z0-9][a-z0-9.-]{1,62}$" )
1921VERSION_RE = re .compile (r"^v?[0-9]+\.[0-9]+\.[0-9]+([-.+][A-Za-z0-9.-]+)?$" )
2022PLATFORMS = {"apple-silicon" , "uefi-x86_64" , "uefi-aarch64" , "generic-arm64" }
2830 "recovery-image" ,
2931 "installer-data" ,
3032 "signature" ,
33+ "attestation" ,
34+ "tuf-metadata" ,
3135 "other" ,
3236}
3337NETWORK = {"none" , "enrollment-only" , "restricted" , "full" }
3438DISK_WRITE = {"forbidden" , "installer-only" , "recovery-only" , "allowed" }
35- ACTIONS = {"announce" , "enroll" , "fetch" , "verify" , "kexec" , "install" , "rollback" , "repair" , "rekey" }
39+ ACTIONS = {"announce" , "enroll" , "fetch" , "verify" , "kexec" , "install" , "rollback" , "repair" , "rekey" , "attest" }
3640REPORTS = {
3741 "device-claim" ,
3842 "manifest-hash" ,
4145 "boot-mode" ,
4246 "install-result" ,
4347 "rollback-result" ,
48+ "measurement" ,
49+ "attestation" ,
4450}
45-
46-
47- def fail ( message : str ) -> None :
48- raise ValueError ( message )
51+ ATTESTATIONS = { "slsa" , "in-toto" }
52+ TRUST_MODELS = { "tuf" , "uptane" , "static-root" }
53+ SIGNATURE_TYPES = { "sigstore" , "cosign" , "minisign" , "x509" , "other" }
54+ METRICS = { "boot-duration" , "verify-duration" , "download-bytes" , "action-result" }
4955
5056
5157def require (condition : bool , message : str ) -> None :
5258 if not condition :
53- fail (message )
59+ raise ValueError (message )
5460
5561
5662def require_enum_list (values : object , allowed : set [str ], field : str ) -> None :
@@ -61,22 +67,26 @@ def require_enum_list(values: object, allowed: set[str], field: str) -> None:
6167 require (not invalid , f"{ field } contains invalid values: { invalid } " )
6268
6369
70+ def require_string (value : object , field : str ) -> None :
71+ require (isinstance (value , str ) and bool (value ), f"{ field } must be a non-empty string" )
72+
73+
6474def require_uri (value : object , field : str ) -> None :
65- require ( isinstance ( value , str ), f" { field } must be a string" )
75+ require_string ( value , field )
6676 parsed = urlparse (value )
6777 require (parsed .scheme in {"https" , "file" , "oci" }, f"{ field } must use https, file, or oci scheme" )
6878 require (bool (parsed .netloc ) or parsed .scheme in {"file" , "oci" }, f"{ field } must include a location" )
6979
7080
7181def validate_document (doc : dict ) -> None :
72- require (doc .get ("apiVersion" ) == "sourceos.dev/v0 " , "apiVersion must be sourceos.dev/v0 " )
82+ require (doc .get ("apiVersion" ) == "sourceos.dev/v1 " , "apiVersion must be sourceos.dev/v1 " )
7383 require (doc .get ("kind" ) == "BootReleaseSet" , "kind must be BootReleaseSet" )
7484
7585 metadata = doc .get ("metadata" )
7686 require (isinstance (metadata , dict ), "metadata must be an object" )
7787 require (NAME_RE .match (metadata .get ("name" , "" )) is not None , "metadata.name is invalid" )
7888 require (VERSION_RE .match (metadata .get ("version" , "" )) is not None , "metadata.version is invalid" )
79- require ( isinstance ( metadata .get ("createdAt" ), str ), "metadata.createdAt is required " )
89+ require_string ( metadata .get ("createdAt" ), "metadata.createdAt" )
8090
8191 spec = doc .get ("spec" )
8292 require (isinstance (spec , dict ), "spec must be an object" )
@@ -88,7 +98,7 @@ def validate_document(doc: dict) -> None:
8898 for index , artifact in enumerate (artifacts ):
8999 prefix = f"spec.artifacts[{ index } ]"
90100 require (isinstance (artifact , dict ), f"{ prefix } must be an object" )
91- require ( isinstance ( artifact .get ("name" ), str ) and artifact [ "name" ], f"{ prefix } .name is required " )
101+ require_string ( artifact .get ("name" ), f"{ prefix } .name" )
92102 require (artifact .get ("role" ) in ARTIFACT_ROLES , f"{ prefix } .role is invalid" )
93103 require_uri (artifact .get ("uri" ), f"{ prefix } .uri" )
94104 require (SHA256_RE .match (artifact .get ("sha256" , "" )) is not None , f"{ prefix } .sha256 must be a 64-character hex digest" )
@@ -104,8 +114,40 @@ def validate_document(doc: dict) -> None:
104114
105115 evidence = spec .get ("evidence" )
106116 require (isinstance (evidence , dict ), "spec.evidence must be an object" )
117+ require_string (evidence .get ("correlationId" ), "spec.evidence.correlationId" )
107118 require_enum_list (evidence .get ("requiredReports" ), REPORTS , "spec.evidence.requiredReports" )
108119
120+ provenance = spec .get ("provenance" )
121+ require (isinstance (provenance , dict ), "spec.provenance must be an object" )
122+ require_string (provenance .get ("builderId" ), "spec.provenance.builderId" )
123+ require (isinstance (provenance .get ("sourceRefs" ), list ) and provenance ["sourceRefs" ], "spec.provenance.sourceRefs must be a non-empty list" )
124+ for index , ref in enumerate (provenance ["sourceRefs" ]):
125+ require_string (ref , f"spec.provenance.sourceRefs[{ index } ]" )
126+ require_enum_list (provenance .get ("attestations" ), ATTESTATIONS , "spec.provenance.attestations" )
127+
128+ trust = spec .get ("trust" )
129+ require (isinstance (trust , dict ), "spec.trust must be an object" )
130+ require (trust .get ("model" ) in TRUST_MODELS , "spec.trust.model is invalid" )
131+ require_string (trust .get ("rootRef" ), "spec.trust.rootRef" )
132+ require_string (trust .get ("metadataRef" ), "spec.trust.metadataRef" )
133+ if "threshold" in trust :
134+ require (isinstance (trust ["threshold" ], int ) and trust ["threshold" ] >= 1 , "spec.trust.threshold must be >= 1" )
135+
136+ signature = spec .get ("signature" )
137+ require (isinstance (signature , dict ), "spec.signature must be an object" )
138+ require (signature .get ("type" ) in SIGNATURE_TYPES , "spec.signature.type is invalid" )
139+ require (SHA256_PREFIX_RE .match (signature .get ("digest" , "" )) is not None , "spec.signature.digest must be sha256:<64 hex chars>" )
140+
141+ anti_rollback = spec .get ("antiRollback" )
142+ require (isinstance (anti_rollback , dict ), "spec.antiRollback must be an object" )
143+ require_string (anti_rollback .get ("minimumVersion" ), "spec.antiRollback.minimumVersion" )
144+ require (isinstance (anti_rollback .get ("allowOfflineFallback" ), bool ), "spec.antiRollback.allowOfflineFallback must be a boolean" )
145+
146+ telemetry = spec .get ("telemetry" )
147+ require (isinstance (telemetry , dict ), "spec.telemetry must be an object" )
148+ require (isinstance (telemetry .get ("traceRequired" ), bool ), "spec.telemetry.traceRequired must be a boolean" )
149+ require_enum_list (telemetry .get ("metricSet" ), METRICS , "spec.telemetry.metricSet" )
150+
109151
110152def main (argv : list [str ] | None = None ) -> int :
111153 parser = argparse .ArgumentParser (description = "Validate BootReleaseSet JSON files" )
@@ -119,7 +161,7 @@ def main(argv: list[str] | None = None) -> int:
119161 doc = json .load (handle )
120162 validate_document (doc )
121163 print (f"PASS { path } " )
122- except Exception as exc : # noqa: BLE001 - CLI should report all validation errors uniformly.
164+ except Exception as exc : # noqa: BLE001
123165 failed = True
124166 print (f"FAIL { path } : { exc } " , file = sys .stderr )
125167 return 1 if failed else 0
0 commit comments