Skip to main content

uor_addr/schema/
codemodule_signed.rs

1//! **`uor_addr::schema::codemodule_signed` — signed-code-module
2//! content-addressing** (ARCHITECTURE.md "Schema-pinned descendants"
3//! § `uor-addr-codemodule-signed`).
4//!
5//! Schema-pinned descendant of [`crate::json`] that **imports the
6//! in-toto Statement v1 attestation format** — the host-boundary
7//! parser admits only JSON-LD-style values conforming to in-toto's
8//! published Statement contract per
9//! <https://in-toto.io/Statement/v1>.
10//!
11//! # `no_std` + `no_alloc`
12//!
13//! Schema admission walks the parsed [`crate::json::JsonValue`]'s
14//! tagged bytes via [`crate::json::JsonValueRef`]. No `serde_json`,
15//! no allocator.
16//!
17//! # Authoritative sources
18//!
19//! - **in-toto Statement v1** —
20//!   <https://github.com/in-toto/attestation/blob/main/spec/v1/statement.md>.
21//! - **SLSA Provenance v1** — <https://slsa.dev/spec/v1.0/provenance>.
22//! - **sigstore signature spec** —
23//!   <https://docs.sigstore.dev/cosign/signature_specification/>.
24//!
25//! # Admission predicate
26//!
27//! 1. `_type` is `"https://in-toto.io/Statement/v1"`.
28//! 2. `subject` is a non-empty array; each element is an object with:
29//!    - `name` — non-empty string.
30//!    - `digest` — object with at least one `sha256` entry whose value
31//!      is a 64-character lowercase-hex SHA-256 digest.
32//! 3. `predicateType` — non-empty string IRI.
33//! 4. `predicate` — JSON object.
34
35use prism::pipeline::{ShapeViolation, ViolationKind};
36
37use crate::json::{JsonValue, JsonValueRef};
38
39const SCHEMA_VIOLATION: ShapeViolation = ShapeViolation {
40    shape_iri: "https://in-toto.io/Statement/v1",
41    constraint_iri: "https://in-toto.io/Statement/v1/schemaConformance",
42    property_iri: "https://in-toto.io/Statement/v1",
43    expected_range: "https://in-toto.io/Statement/v1",
44    min_count: 0,
45    max_count: 1,
46    kind: ViolationKind::ValueCheck,
47};
48
49/// in-toto Statement v1 `_type` IRI.
50pub const STATEMENT_TYPE_IRI: &[u8] = b"https://in-toto.io/Statement/v1";
51
52/// SHA-256 digest hex byte width.
53pub const SHA256_HEX_BYTES: usize = 64;
54
55pub const REQUIRED_PROPERTIES: &[&[u8]] = &[b"_type", b"subject", b"predicateType", b"predicate"];
56
57#[derive(Clone)]
58pub struct SignedCodeModuleValue {
59    inner: JsonValue,
60}
61
62impl core::fmt::Debug for SignedCodeModuleValue {
63    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
64        f.debug_struct("SignedCodeModuleValue")
65            .finish_non_exhaustive()
66    }
67}
68
69impl SignedCodeModuleValue {
70    pub fn parse(raw: &[u8]) -> Result<Self, ShapeViolation> {
71        let inner = JsonValue::parse(raw).map_err(|_| SCHEMA_VIOLATION)?;
72        let root = JsonValueRef::root(&inner);
73        if !root.is_object() {
74            return Err(SCHEMA_VIOLATION);
75        }
76        // _type must be the in-toto Statement v1 IRI.
77        let ty = root
78            .get(b"_type")
79            .and_then(|v| v.as_str())
80            .ok_or(SCHEMA_VIOLATION)?;
81        if ty != STATEMENT_TYPE_IRI {
82            return Err(SCHEMA_VIOLATION);
83        }
84        // subject — non-empty array of {name, digest} objects.
85        let subject = root.get(b"subject").ok_or(SCHEMA_VIOLATION)?;
86        let subjects = subject.iter_array().ok_or(SCHEMA_VIOLATION)?;
87        let mut subject_count = 0;
88        for s in subjects {
89            if !s.is_object() {
90                return Err(SCHEMA_VIOLATION);
91            }
92            let name = s
93                .get(b"name")
94                .and_then(|v| v.as_str())
95                .ok_or(SCHEMA_VIOLATION)?;
96            if name.is_empty() {
97                return Err(SCHEMA_VIOLATION);
98            }
99            let digest = s.get(b"digest").ok_or(SCHEMA_VIOLATION)?;
100            if !digest.is_object() {
101                return Err(SCHEMA_VIOLATION);
102            }
103            // Require at least one entry, and a sha256 entry whose
104            // value is 64 lowercase hex bytes.
105            let digest_iter = digest.iter_object().ok_or(SCHEMA_VIOLATION)?;
106            let mut digest_count = 0;
107            for (_k, _v) in digest_iter {
108                digest_count += 1;
109            }
110            if digest_count == 0 {
111                return Err(SCHEMA_VIOLATION);
112            }
113            let sha256 = digest
114                .get(b"sha256")
115                .and_then(|v| v.as_str())
116                .ok_or(SCHEMA_VIOLATION)?;
117            if sha256.len() != SHA256_HEX_BYTES
118                || !sha256
119                    .iter()
120                    .all(|&b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
121            {
122                return Err(SCHEMA_VIOLATION);
123            }
124            subject_count += 1;
125        }
126        if subject_count == 0 {
127            return Err(SCHEMA_VIOLATION);
128        }
129        // predicateType — non-empty string.
130        let pt = root
131            .get(b"predicateType")
132            .and_then(|v| v.as_str())
133            .ok_or(SCHEMA_VIOLATION)?;
134        if pt.is_empty() {
135            return Err(SCHEMA_VIOLATION);
136        }
137        // predicate — object.
138        let predicate = root.get(b"predicate").ok_or(SCHEMA_VIOLATION)?;
139        if !predicate.is_object() {
140            return Err(SCHEMA_VIOLATION);
141        }
142        Ok(Self { inner })
143    }
144
145    #[must_use]
146    pub fn tagged_bytes(&self) -> &[u8] {
147        self.inner.tagged_bytes()
148    }
149}
150
151/// Mint a κ-label over an in-toto-v1-Statement-admitted JSON value.
152pub fn address(raw: &[u8]) -> Result<crate::AddressOutcome<71>, AddressFailure> {
153    SignedCodeModuleValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
154    crate::json::address(raw).map_err(|e| match e {
155        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
156        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
157    })
158}
159
160/// As [`address`], but binds the `blake3` σ-axis ([`crate::hash`]). Schema
161/// admission is identical; only the κ-derivation hash differs.
162///
163/// # Errors
164///
165/// As [`address`].
166pub fn address_blake3(raw: &[u8]) -> Result<crate::AddressOutcome<71>, AddressFailure> {
167    SignedCodeModuleValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
168    crate::json::address_blake3(raw).map_err(|e| match e {
169        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
170        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
171    })
172}
173
174/// As [`address`], but binds the `sha3_256` σ-axis ([`crate::hash`]). Schema
175/// admission is identical; only the κ-derivation hash differs.
176///
177/// # Errors
178///
179/// As [`address`].
180pub fn address_sha3_256(raw: &[u8]) -> Result<crate::AddressOutcome<73>, AddressFailure> {
181    SignedCodeModuleValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
182    crate::json::address_sha3_256(raw).map_err(|e| match e {
183        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
184        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
185    })
186}
187
188/// As [`address`], but binds the `keccak256` σ-axis ([`crate::hash`]). Schema
189/// admission is identical; only the κ-derivation hash differs.
190///
191/// # Errors
192///
193/// As [`address`].
194pub fn address_keccak256(raw: &[u8]) -> Result<crate::AddressOutcome<74>, AddressFailure> {
195    SignedCodeModuleValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
196    crate::json::address_keccak256(raw).map_err(|e| match e {
197        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
198        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
199    })
200}
201
202/// As [`address`], but binds the `sha512` σ-axis ([`crate::hash`]).
203///
204/// # Errors
205///
206/// As [`address`].
207pub fn address_sha512(raw: &[u8]) -> Result<crate::AddressOutcome<135, 64>, AddressFailure> {
208    SignedCodeModuleValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
209    crate::json::address_sha512(raw).map_err(|e| match e {
210        crate::json::AddressFailure::InvalidJson => AddressFailure::SchemaViolation,
211        crate::json::AddressFailure::PipelineFailure => AddressFailure::PipelineFailure,
212    })
213}
214
215#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216pub enum AddressFailure {
217    SchemaViolation,
218    PipelineFailure,
219}
220
221/// **Available only under the `alloc` feature.**
222#[cfg(feature = "alloc")]
223pub fn canonicalize(raw: &[u8]) -> Result<alloc::vec::Vec<u8>, AddressFailure> {
224    extern crate alloc;
225    SignedCodeModuleValue::parse(raw).map_err(|_| AddressFailure::SchemaViolation)?;
226    crate::json::canonicalize(raw).map_err(|_| AddressFailure::PipelineFailure)
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232
233    const VALID_STATEMENT: &[u8] = br#"{
234        "_type": "https://in-toto.io/Statement/v1",
235        "subject": [
236            {
237                "name": "uor-addr-v0.1.0",
238                "digest": {
239                    "sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
240                }
241            }
242        ],
243        "predicateType": "https://slsa.dev/provenance/v1",
244        "predicate": {
245            "buildDefinition": {"buildType": "uor:test"},
246            "runDetails": {"builder": {"id": "uor:test-builder"}}
247        }
248    }"#;
249
250    #[test]
251    fn admits_valid_in_toto_statement() {
252        let s = SignedCodeModuleValue::parse(VALID_STATEMENT).expect("valid");
253        assert!(!s.tagged_bytes().is_empty());
254    }
255
256    #[test]
257    fn admits_multiple_subjects() {
258        let raw = br#"{
259            "_type": "https://in-toto.io/Statement/v1",
260            "subject": [
261                {"name": "a", "digest": {"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}},
262                {"name": "b", "digest": {"sha256": "fedcba9876543210fedcba9876543210fedcba9876543210fedcba9876543210"}}
263            ],
264            "predicateType": "https://slsa.dev/provenance/v1",
265            "predicate": {}
266        }"#;
267        SignedCodeModuleValue::parse(raw).expect("valid");
268    }
269
270    #[test]
271    fn rejects_wrong_statement_type_iri() {
272        let raw = br#"{
273            "_type": "https://example.org/CustomStatement",
274            "subject": [{"name": "x", "digest": {"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}}],
275            "predicateType": "x",
276            "predicate": {}
277        }"#;
278        let err = SignedCodeModuleValue::parse(raw).expect_err("wrong _type");
279        assert_eq!(err.constraint_iri, SCHEMA_VIOLATION.constraint_iri);
280    }
281
282    #[test]
283    fn rejects_empty_subject() {
284        let raw = br#"{
285            "_type": "https://in-toto.io/Statement/v1",
286            "subject": [],
287            "predicateType": "x",
288            "predicate": {}
289        }"#;
290        let err = SignedCodeModuleValue::parse(raw).expect_err("empty subject");
291        assert_eq!(err.constraint_iri, SCHEMA_VIOLATION.constraint_iri);
292    }
293
294    #[test]
295    fn rejects_subject_without_sha256_digest() {
296        let raw = br#"{
297            "_type": "https://in-toto.io/Statement/v1",
298            "subject": [{"name": "x", "digest": {"md5": "deadbeef"}}],
299            "predicateType": "x",
300            "predicate": {}
301        }"#;
302        let err = SignedCodeModuleValue::parse(raw).expect_err("no sha256");
303        assert_eq!(err.constraint_iri, SCHEMA_VIOLATION.constraint_iri);
304    }
305
306    #[test]
307    fn rejects_sha256_with_wrong_length() {
308        let raw = br#"{
309            "_type": "https://in-toto.io/Statement/v1",
310            "subject": [{"name": "x", "digest": {"sha256": "tooshort"}}],
311            "predicateType": "x",
312            "predicate": {}
313        }"#;
314        let err = SignedCodeModuleValue::parse(raw).expect_err("short hex");
315        assert_eq!(err.constraint_iri, SCHEMA_VIOLATION.constraint_iri);
316    }
317
318    #[test]
319    fn rejects_missing_predicate_type() {
320        let raw = br#"{
321            "_type": "https://in-toto.io/Statement/v1",
322            "subject": [{"name": "x", "digest": {"sha256": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"}}],
323            "predicate": {}
324        }"#;
325        let err = SignedCodeModuleValue::parse(raw).expect_err("missing predicateType");
326        assert_eq!(err.constraint_iri, SCHEMA_VIOLATION.constraint_iri);
327    }
328
329    #[test]
330    fn address_matches_json_realization() {
331        let from_signed = address(VALID_STATEMENT).expect("κ-label").address;
332        let from_json = crate::json::address(VALID_STATEMENT)
333            .expect("κ-label")
334            .address;
335        assert_eq!(from_signed, from_json);
336    }
337}