1use 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
36const EXCLUDED_FILES: &[&str] = &["manifest.yaml", "signatures.json", "manifest.signed.json"];
38
39pub 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
69pub 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}