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 chrono::Duration;
141
142 fn make_list(crl: u64, expires_offset_secs: i64) -> RevocationsList {
143 let now = Utc::now();
144 RevocationsList {
145 version: 1,
146 this_update: now,
147 next_update: now + Duration::hours(24),
148 expires_at: now + Duration::seconds(expires_offset_secs),
149 crl_number: crl,
150 revoked: vec![
151 RevokedEntry::Package {
152 manifest_hash: "sha256:deadbeef".into(),
153 reason: "test".into(),
154 revoked_at: now,
155 },
156 RevokedEntry::Author {
157 pubkey: "ed25519:abc123".into(),
158 reason: "test".into(),
159 revoked_at: now,
160 },
161 ],
162 }
163 }
164
165 fn serialize(list: &RevocationsList) -> Vec<u8> {
166 serde_json::to_vec(list).unwrap()
167 }
168
169 #[test]
170 fn parse_valid_list() {
171 let list = make_list(1, 3600);
172 let bytes = serialize(&list);
173 let parsed = RevocationsList::parse_and_validate(&bytes, None).unwrap();
174 assert_eq!(parsed.crl_number, 1);
175 assert_eq!(parsed.revoked.len(), 2);
176 }
177
178 #[test]
179 fn rejects_crl_rollback() {
180 let list = make_list(5, 3600);
181 let bytes = serialize(&list);
182 let err = RevocationsList::parse_and_validate(&bytes, Some(5)).unwrap_err();
184 assert!(err.to_string().contains("crl_number"));
185 let err2 = RevocationsList::parse_and_validate(&bytes, Some(6)).unwrap_err();
186 assert!(err2.to_string().contains("crl_number"));
187 }
188
189 #[test]
190 fn accepts_first_fetch() {
191 let list = make_list(1, 3600);
192 let bytes = serialize(&list);
193 RevocationsList::parse_and_validate(&bytes, None).unwrap();
195 }
196
197 #[test]
198 fn is_expired_past() {
199 let list = make_list(1, -60); assert!(list.is_expired());
201 }
202
203 #[test]
204 fn is_expired_future() {
205 let list = make_list(1, 3600);
206 assert!(!list.is_expired());
207 }
208
209 #[test]
210 fn rejects_already_expired_on_parse() {
211 let list = make_list(1, -60);
212 let bytes = serialize(&list);
213 let err = RevocationsList::parse_and_validate(&bytes, None).unwrap_err();
214 assert!(err.to_string().contains("expired"));
215 }
216
217 #[test]
218 fn package_revocation_lookup() {
219 let list = make_list(1, 3600);
220 assert!(list.is_package_revoked("sha256:deadbeef"));
221 assert!(!list.is_package_revoked("sha256:00000000"));
222 }
223
224 #[test]
225 fn author_revocation_lookup() {
226 let list = make_list(1, 3600);
227 assert!(list.is_author_revoked("ed25519:abc123"));
228 assert!(!list.is_author_revoked("ed25519:nothere"));
229 }
230
231 #[test]
232 fn cache_roundtrip() {
233 let tmp = tempfile::TempDir::new().unwrap();
234 let _env = crate::test_env::EnvGuard::set([("MUR_HOME", tmp.path())]);
235
236 let list = make_list(42, 3600);
237 list.save_cached(tmp.path()).unwrap();
238 let loaded = RevocationsList::load_cached(tmp.path()).unwrap();
239 assert_eq!(loaded.crl_number, 42);
240 assert_eq!(loaded.revoked.len(), 2);
241 }
242}