Skip to main content

mur_common/muragent/
statement.rs

1//! in-toto v1 Statement with subject hashes.
2//!
3//! Spec §6.3: the Statement binds every file in the `.muragent` tarball
4//! (except the manifest and signature files themselves) to a SHA-256 digest.
5//! The predicate carries `manifest_sha256` — the SHA-256 of `manifest.signed.json`.
6
7use serde::{Deserialize, Serialize};
8use sha2::Digest;
9
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct InTotoStatement {
12    #[serde(rename = "_type")]
13    pub type_: String,
14    pub subject: Vec<SubjectEntry>,
15    #[serde(rename = "predicateType")]
16    pub predicate_type: String,
17    pub predicate: Predicate,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SubjectEntry {
22    pub name: String,
23    pub digest: SubjectDigest,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct SubjectDigest {
28    pub sha256: String,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Predicate {
33    pub manifest_sha256: String,
34}
35
36/// Files excluded from the Statement subject list.
37const EXCLUDED_FILES: &[&str] = &["manifest.yaml", "signatures.json", "manifest.signed.json"];
38
39/// Build an in-toto Statement from a list of (path, content_bytes) for every
40/// file in the tarball.
41pub fn build_statement(
42    manifest_signed_json_bytes: &[u8],
43    tarball_files: &[(String, Vec<u8>)],
44) -> InTotoStatement {
45    let manifest_sha256 = hex::encode(sha2::Sha256::digest(manifest_signed_json_bytes));
46
47    let mut subjects: Vec<SubjectEntry> = tarball_files
48        .iter()
49        .filter(|(path, _)| !EXCLUDED_FILES.contains(&path.as_str()))
50        .map(|(path, content)| {
51            let hash = hex::encode(sha2::Sha256::digest(content));
52            SubjectEntry {
53                name: path.clone(),
54                digest: SubjectDigest { sha256: hash },
55            }
56        })
57        .collect();
58
59    subjects.sort_by(|a, b| a.name.cmp(&b.name));
60
61    InTotoStatement {
62        type_: "https://in-toto.io/Statement/v1".into(),
63        subject: subjects,
64        predicate_type: "https://mur.run/agent-manifest/v1".into(),
65        predicate: Predicate { manifest_sha256 },
66    }
67}
68
69/// Verify that every subject in the statement exists in the tarball with
70/// matching hash, and every tarball file (excluding EXCLUDED_FILES) is listed.
71pub fn verify_subjects(
72    statement: &InTotoStatement,
73    tarball_files: &[(String, Vec<u8>)],
74) -> Result<(), crate::muragent::MuragentError> {
75    let tarball_map: std::collections::BTreeMap<&str, &[u8]> = tarball_files
76        .iter()
77        .filter(|(p, _)| !EXCLUDED_FILES.contains(&p.as_str()))
78        .map(|(p, c)| (p.as_str(), c.as_slice()))
79        .collect();
80
81    for subject in &statement.subject {
82        match tarball_map.get(subject.name.as_str()) {
83            None => {
84                return Err(crate::muragent::MuragentError::MissingSubject(
85                    subject.name.clone(),
86                ));
87            }
88            Some(content) => {
89                let actual_hash = hex::encode(sha2::Sha256::digest(content));
90                if actual_hash != subject.digest.sha256 {
91                    return Err(crate::muragent::MuragentError::SubjectHashMismatch {
92                        path: subject.name.clone(),
93                        expected: subject.digest.sha256.clone(),
94                        actual: actual_hash,
95                    });
96                }
97            }
98        }
99    }
100
101    for path in tarball_map.keys() {
102        if !statement.subject.iter().any(|s| &s.name == path) {
103            return Err(crate::muragent::MuragentError::ExtraFile(format!(
104                "tarball file '{}' not listed in statement subjects",
105                path
106            )));
107        }
108    }
109
110    Ok(())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116
117    #[test]
118    fn build_statement_matches_spec_shape() {
119        let manifest_json = br#"{"schema":"mur-agent/2"}"#;
120        let files = vec![
121            ("icon/icon.png".to_string(), b"fake-png-data".to_vec()),
122            ("profile.yaml".to_string(), b"profile: content".to_vec()),
123            ("manifest.yaml".to_string(), b"should be excluded".to_vec()),
124            (
125                "signatures.json".to_string(),
126                b"should be excluded".to_vec(),
127            ),
128            (
129                "manifest.signed.json".to_string(),
130                b"should be excluded".to_vec(),
131            ),
132        ];
133        let stmt = build_statement(manifest_json, &files);
134
135        assert_eq!(stmt.type_, "https://in-toto.io/Statement/v1");
136        assert_eq!(stmt.predicate_type, "https://mur.run/agent-manifest/v1");
137        assert_eq!(stmt.subject.len(), 2);
138        assert!(stmt.subject.iter().any(|s| s.name == "icon/icon.png"));
139        assert!(stmt.subject.iter().any(|s| s.name == "profile.yaml"));
140    }
141
142    #[test]
143    fn subjects_sorted_lexicographically() {
144        let files = vec![
145            ("zzz.txt".to_string(), b"z".to_vec()),
146            ("aaa.txt".to_string(), b"a".to_vec()),
147            ("mmm.txt".to_string(), b"m".to_vec()),
148        ];
149        let stmt = build_statement(b"manifest", &files);
150        let names: Vec<&str> = stmt.subject.iter().map(|s| s.name.as_str()).collect();
151        assert_eq!(names, vec!["aaa.txt", "mmm.txt", "zzz.txt"]);
152    }
153
154    #[test]
155    fn verify_subjects_passes_for_matching() {
156        let manifest_json = br#"{}"#;
157        let files = vec![("profile.yaml".to_string(), b"hello".to_vec())];
158        let stmt = build_statement(manifest_json, &files);
159        verify_subjects(&stmt, &files).unwrap();
160    }
161
162    #[test]
163    fn verify_subjects_fails_on_mismatch() {
164        let manifest_json = br#"{}"#;
165        let files = vec![("profile.yaml".to_string(), b"hello".to_vec())];
166        let stmt = build_statement(manifest_json, &files);
167        let tampered = vec![("profile.yaml".to_string(), b"goodbye".to_vec())];
168        assert!(verify_subjects(&stmt, &tampered).is_err());
169    }
170
171    #[test]
172    fn verify_subjects_fails_on_extra_file() {
173        let manifest_json = br#"{}"#;
174        let files = vec![("profile.yaml".to_string(), b"hello".to_vec())];
175        let stmt = build_statement(manifest_json, &files);
176        let with_extra = vec![
177            ("profile.yaml".to_string(), b"hello".to_vec()),
178            ("extra.txt".to_string(), b"surprise".to_vec()),
179        ];
180        assert!(verify_subjects(&stmt, &with_extra).is_err());
181    }
182
183    #[test]
184    fn verify_subjects_fails_on_missing_subject() {
185        let manifest_json = br#"{}"#;
186        let files = vec![("profile.yaml".to_string(), b"hello".to_vec())];
187        let stmt = build_statement(manifest_json, &files);
188        let missing: Vec<(String, Vec<u8>)> = vec![];
189        assert!(verify_subjects(&stmt, &missing).is_err());
190    }
191}