use crate::muragent::MuragentError;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::Path;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "kebab-case")]
pub enum RevokedEntry {
Package {
manifest_hash: String,
reason: String,
revoked_at: DateTime<Utc>,
},
Author {
pubkey: String,
reason: String,
revoked_at: DateTime<Utc>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RevocationsList {
pub version: u32,
pub this_update: DateTime<Utc>,
pub next_update: DateTime<Utc>,
pub expires_at: DateTime<Utc>,
pub crl_number: u64,
pub revoked: Vec<RevokedEntry>,
}
impl RevocationsList {
pub fn parse_and_validate(bytes: &[u8], known_crl: Option<u64>) -> Result<Self, MuragentError> {
let list: Self = serde_json::from_slice(bytes)
.map_err(|e| MuragentError::Other(format!("revocations parse: {e}")))?;
if list.version != 1 {
return Err(MuragentError::Other(format!(
"revocations: unsupported version {}",
list.version
)));
}
if let Some(n) = known_crl
&& list.crl_number <= n
{
return Err(MuragentError::Other(format!(
"revocations: crl_number {} is not greater than known {}",
list.crl_number, n
)));
}
if list.expires_at < Utc::now() {
return Err(MuragentError::Other(
"revocations: list is already expired".into(),
));
}
Ok(list)
}
pub fn is_expired(&self) -> bool {
Utc::now() > self.expires_at
}
pub fn is_package_revoked(&self, manifest_hash: &str) -> bool {
self.revoked.iter().any(
|e| matches!(e, RevokedEntry::Package { manifest_hash: h, .. } if h == manifest_hash),
)
}
pub fn is_author_revoked(&self, pubkey: &str) -> bool {
self.revoked
.iter()
.any(|e| matches!(e, RevokedEntry::Author { pubkey: k, .. } if k == pubkey))
}
pub fn load_cached(mur_home: &Path) -> Option<Self> {
let path = mur_home.join("trust").join("revocations.json");
let bytes = std::fs::read(&path).ok()?;
serde_json::from_slice(&bytes).ok()
}
pub fn save_cached(&self, mur_home: &Path) -> Result<(), MuragentError> {
let dir = mur_home.join("trust");
std::fs::create_dir_all(&dir).map_err(MuragentError::Io)?;
let path = dir.join("revocations.json");
let tmp = path.with_extension("json.tmp");
let json = serde_json::to_vec_pretty(self)
.map_err(|e| MuragentError::Other(format!("revocations serialize: {e}")))?;
std::fs::write(&tmp, &json).map_err(MuragentError::Io)?;
std::fs::rename(&tmp, &path).map_err(MuragentError::Io)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::trust::test_env_lock::MUR_HOME_LOCK;
use chrono::Duration;
fn make_list(crl: u64, expires_offset_secs: i64) -> RevocationsList {
let now = Utc::now();
RevocationsList {
version: 1,
this_update: now,
next_update: now + Duration::hours(24),
expires_at: now + Duration::seconds(expires_offset_secs),
crl_number: crl,
revoked: vec![
RevokedEntry::Package {
manifest_hash: "sha256:deadbeef".into(),
reason: "test".into(),
revoked_at: now,
},
RevokedEntry::Author {
pubkey: "ed25519:abc123".into(),
reason: "test".into(),
revoked_at: now,
},
],
}
}
fn serialize(list: &RevocationsList) -> Vec<u8> {
serde_json::to_vec(list).unwrap()
}
#[test]
fn parse_valid_list() {
let list = make_list(1, 3600);
let bytes = serialize(&list);
let parsed = RevocationsList::parse_and_validate(&bytes, None).unwrap();
assert_eq!(parsed.crl_number, 1);
assert_eq!(parsed.revoked.len(), 2);
}
#[test]
fn rejects_crl_rollback() {
let list = make_list(5, 3600);
let bytes = serialize(&list);
let err = RevocationsList::parse_and_validate(&bytes, Some(5)).unwrap_err();
assert!(err.to_string().contains("crl_number"));
let err2 = RevocationsList::parse_and_validate(&bytes, Some(6)).unwrap_err();
assert!(err2.to_string().contains("crl_number"));
}
#[test]
fn accepts_first_fetch() {
let list = make_list(1, 3600);
let bytes = serialize(&list);
RevocationsList::parse_and_validate(&bytes, None).unwrap();
}
#[test]
fn is_expired_past() {
let list = make_list(1, -60); assert!(list.is_expired());
}
#[test]
fn is_expired_future() {
let list = make_list(1, 3600);
assert!(!list.is_expired());
}
#[test]
fn rejects_already_expired_on_parse() {
let list = make_list(1, -60);
let bytes = serialize(&list);
let err = RevocationsList::parse_and_validate(&bytes, None).unwrap_err();
assert!(err.to_string().contains("expired"));
}
#[test]
fn package_revocation_lookup() {
let list = make_list(1, 3600);
assert!(list.is_package_revoked("sha256:deadbeef"));
assert!(!list.is_package_revoked("sha256:00000000"));
}
#[test]
fn author_revocation_lookup() {
let list = make_list(1, 3600);
assert!(list.is_author_revoked("ed25519:abc123"));
assert!(!list.is_author_revoked("ed25519:nothere"));
}
#[test]
fn cache_roundtrip() {
let _guard = MUR_HOME_LOCK.lock().unwrap();
let tmp = tempfile::TempDir::new().unwrap();
let prev_home = std::env::var_os("MUR_HOME");
unsafe { std::env::set_var("MUR_HOME", tmp.path()) };
let list = make_list(42, 3600);
list.save_cached(tmp.path()).unwrap();
let loaded = RevocationsList::load_cached(tmp.path()).unwrap();
assert_eq!(loaded.crl_number, 42);
assert_eq!(loaded.revoked.len(), 2);
unsafe {
if let Some(p) = prev_home {
std::env::set_var("MUR_HOME", p);
} else {
std::env::remove_var("MUR_HOME");
}
}
}
}