mur_common/trust/
revocations.rs1use crate::muragent::MuragentError;
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12use std::path::Path;
13
14#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
16#[serde(tag = "kind", rename_all = "kebab-case")]
17pub enum RevokedEntry {
18 Package {
19 manifest_hash: String,
21 reason: String,
22 revoked_at: DateTime<Utc>,
23 },
24 Author {
25 pubkey: String,
27 reason: String,
28 revoked_at: DateTime<Utc>,
29 },
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
37pub struct RevocationsList {
38 pub version: u32,
39 pub this_update: DateTime<Utc>,
40 pub next_update: DateTime<Utc>,
41 pub expires_at: DateTime<Utc>,
43 pub crl_number: u64,
47 pub revoked: Vec<RevokedEntry>,
48}
49
50impl RevocationsList {
51 pub fn parse_and_validate(bytes: &[u8], known_crl: Option<u64>) -> Result<Self, MuragentError> {
56 let list: Self = serde_json::from_slice(bytes)
57 .map_err(|e| MuragentError::Other(format!("revocations parse: {e}")))?;
58
59 if list.version != 1 {
60 return Err(MuragentError::Other(format!(
61 "revocations: unsupported version {}",
62 list.version
63 )));
64 }
65
66 if let Some(n) = known_crl
68 && list.crl_number <= n
69 {
70 return Err(MuragentError::Other(format!(
71 "revocations: crl_number {} is not greater than known {}",
72 list.crl_number, n
73 )));
74 }
75
76 if list.expires_at < Utc::now() {
77 return Err(MuragentError::Other(
78 "revocations: list is already expired".into(),
79 ));
80 }
81
82 Ok(list)
85 }
86
87 pub fn is_expired(&self) -> bool {
89 Utc::now() > self.expires_at
90 }
91
92 pub fn is_package_revoked(&self, manifest_hash: &str) -> bool {
96 self.revoked.iter().any(
97 |e| matches!(e, RevokedEntry::Package { manifest_hash: h, .. } if h == manifest_hash),
98 )
99 }
100
101 pub fn is_author_revoked(&self, pubkey: &str) -> bool {
105 self.revoked
106 .iter()
107 .any(|e| matches!(e, RevokedEntry::Author { pubkey: k, .. } if k == pubkey))
108 }
109
110 pub fn load_cached(mur_home: &Path) -> Option<Self> {
116 let path = mur_home.join("trust").join("revocations.json");
117 let bytes = std::fs::read(&path).ok()?;
118 serde_json::from_slice(&bytes).ok()
119 }
120
121 pub fn save_cached(&self, mur_home: &Path) -> Result<(), MuragentError> {
125 let dir = mur_home.join("trust");
126 std::fs::create_dir_all(&dir).map_err(MuragentError::Io)?;
127 let path = dir.join("revocations.json");
128 let tmp = path.with_extension("json.tmp");
129 let json = serde_json::to_vec_pretty(self)
130 .map_err(|e| MuragentError::Other(format!("revocations serialize: {e}")))?;
131 std::fs::write(&tmp, &json).map_err(MuragentError::Io)?;
132 std::fs::rename(&tmp, &path).map_err(MuragentError::Io)?;
133 Ok(())
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use crate::trust::test_env_lock::MUR_HOME_LOCK;
141 use chrono::Duration;
142
143 fn make_list(crl: u64, expires_offset_secs: i64) -> RevocationsList {
144 let now = Utc::now();
145 RevocationsList {
146 version: 1,
147 this_update: now,
148 next_update: now + Duration::hours(24),
149 expires_at: now + Duration::seconds(expires_offset_secs),
150 crl_number: crl,
151 revoked: vec![
152 RevokedEntry::Package {
153 manifest_hash: "sha256:deadbeef".into(),
154 reason: "test".into(),
155 revoked_at: now,
156 },
157 RevokedEntry::Author {
158 pubkey: "ed25519:abc123".into(),
159 reason: "test".into(),
160 revoked_at: now,
161 },
162 ],
163 }
164 }
165
166 fn serialize(list: &RevocationsList) -> Vec<u8> {
167 serde_json::to_vec(list).unwrap()
168 }
169
170 #[test]
171 fn parse_valid_list() {
172 let list = make_list(1, 3600);
173 let bytes = serialize(&list);
174 let parsed = RevocationsList::parse_and_validate(&bytes, None).unwrap();
175 assert_eq!(parsed.crl_number, 1);
176 assert_eq!(parsed.revoked.len(), 2);
177 }
178
179 #[test]
180 fn rejects_crl_rollback() {
181 let list = make_list(5, 3600);
182 let bytes = serialize(&list);
183 let err = RevocationsList::parse_and_validate(&bytes, Some(5)).unwrap_err();
185 assert!(err.to_string().contains("crl_number"));
186 let err2 = RevocationsList::parse_and_validate(&bytes, Some(6)).unwrap_err();
187 assert!(err2.to_string().contains("crl_number"));
188 }
189
190 #[test]
191 fn accepts_first_fetch() {
192 let list = make_list(1, 3600);
193 let bytes = serialize(&list);
194 RevocationsList::parse_and_validate(&bytes, None).unwrap();
196 }
197
198 #[test]
199 fn is_expired_past() {
200 let list = make_list(1, -60); assert!(list.is_expired());
202 }
203
204 #[test]
205 fn is_expired_future() {
206 let list = make_list(1, 3600);
207 assert!(!list.is_expired());
208 }
209
210 #[test]
211 fn rejects_already_expired_on_parse() {
212 let list = make_list(1, -60);
213 let bytes = serialize(&list);
214 let err = RevocationsList::parse_and_validate(&bytes, None).unwrap_err();
215 assert!(err.to_string().contains("expired"));
216 }
217
218 #[test]
219 fn package_revocation_lookup() {
220 let list = make_list(1, 3600);
221 assert!(list.is_package_revoked("sha256:deadbeef"));
222 assert!(!list.is_package_revoked("sha256:00000000"));
223 }
224
225 #[test]
226 fn author_revocation_lookup() {
227 let list = make_list(1, 3600);
228 assert!(list.is_author_revoked("ed25519:abc123"));
229 assert!(!list.is_author_revoked("ed25519:nothere"));
230 }
231
232 #[test]
233 fn cache_roundtrip() {
234 let _guard = MUR_HOME_LOCK.lock().unwrap();
235 let tmp = tempfile::TempDir::new().unwrap();
236 let prev_home = std::env::var_os("MUR_HOME");
237 unsafe { std::env::set_var("MUR_HOME", tmp.path()) };
238
239 let list = make_list(42, 3600);
240 list.save_cached(tmp.path()).unwrap();
241 let loaded = RevocationsList::load_cached(tmp.path()).unwrap();
242 assert_eq!(loaded.crl_number, 42);
243 assert_eq!(loaded.revoked.len(), 2);
244
245 unsafe {
246 if let Some(p) = prev_home {
247 std::env::set_var("MUR_HOME", p);
248 } else {
249 std::env::remove_var("MUR_HOME");
250 }
251 }
252 }
253}