Skip to content

Commit 61aaebc

Browse files
committed
Enforce BootReleaseSet v1 world-class fields
1 parent 54fde8c commit 61aaebc

1 file changed

Lines changed: 55 additions & 13 deletions

File tree

src/sourceos_boot/validate_boot_release_set.py

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
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

89
from __future__ import annotations
@@ -15,6 +16,7 @@
1516
from urllib.parse import urlparse
1617

1718
SHA256_RE = re.compile(r"^[a-fA-F0-9]{64}$")
19+
SHA256_PREFIX_RE = re.compile(r"^sha256:[a-fA-F0-9]{64}$")
1820
NAME_RE = re.compile(r"^[a-z0-9][a-z0-9.-]{1,62}$")
1921
VERSION_RE = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+([-.+][A-Za-z0-9.-]+)?$")
2022
PLATFORMS = {"apple-silicon", "uefi-x86_64", "uefi-aarch64", "generic-arm64"}
@@ -28,11 +30,13 @@
2830
"recovery-image",
2931
"installer-data",
3032
"signature",
33+
"attestation",
34+
"tuf-metadata",
3135
"other",
3236
}
3337
NETWORK = {"none", "enrollment-only", "restricted", "full"}
3438
DISK_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"}
3640
REPORTS = {
3741
"device-claim",
3842
"manifest-hash",
@@ -41,16 +45,18 @@
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

5157
def require(condition: bool, message: str) -> None:
5258
if not condition:
53-
fail(message)
59+
raise ValueError(message)
5460

5561

5662
def 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+
6474
def 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

7181
def 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

110152
def 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

Comments
 (0)