use std::collections::BTreeMap;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Revocation {
pub reason: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct RevocationList {
#[serde(default)]
pub revocations: BTreeMap<String, Revocation>,
}
fn list_path() -> Result<PathBuf, String> {
Ok(crate::core::data_dir::lean_ctx_data_dir()?
.join("addons")
.join("revocations.json"))
}
impl RevocationList {
#[must_use]
pub fn load() -> Self {
let Ok(path) = list_path() else {
return Self::default();
};
match std::fs::read_to_string(&path) {
Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
_ => Self::default(),
}
}
pub fn save(&self) -> Result<(), String> {
let path = list_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())
}
pub fn revoke(&mut self, name: &str, reason: &str, version: Option<String>) {
self.revocations.insert(
name.to_string(),
Revocation {
reason: reason.to_string(),
version,
},
);
}
pub fn unrevoke(&mut self, name: &str) -> Option<Revocation> {
self.revocations.remove(name)
}
#[must_use]
pub fn verdict(&self, name: &str, installed_version: Option<&str>) -> Option<String> {
let entry = self.revocations.get(name)?;
match &entry.version {
None => Some(entry.reason.clone()),
Some(pinned) => match installed_version {
Some(v) if v == pinned => Some(entry.reason.clone()),
_ => None,
},
}
}
}
#[must_use]
pub fn blocked_reason(server_name: &str) -> Option<String> {
let installed_version = super::store::InstalledStore::load()
.get(server_name)
.map(|a| a.version.clone());
RevocationList::load().verdict(server_name, installed_version.as_deref())
}
#[must_use]
pub fn install_block(name: &str, version: &str) -> Option<String> {
RevocationList::load().verdict(name, Some(version))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::core::data_dir::isolated_data_dir;
#[test]
fn unpinned_revocation_blocks_all_versions() {
let mut list = RevocationList::default();
list.revoke("evil", "supply-chain compromise", None);
assert_eq!(
list.verdict("evil", Some("9.9.9")).as_deref(),
Some("supply-chain compromise")
);
assert_eq!(
list.verdict("evil", None).as_deref(),
Some("supply-chain compromise")
);
assert!(list.verdict("clean", Some("1.0.0")).is_none());
}
#[test]
fn version_pinned_revocation_blocks_only_match() {
let mut list = RevocationList::default();
list.revoke("tool", "bad release", Some("1.2.3".into()));
assert!(list.verdict("tool", Some("1.2.3")).is_some());
assert!(list.verdict("tool", Some("1.2.4")).is_none());
assert!(list.verdict("tool", None).is_none());
}
#[test]
fn round_trips_through_disk_and_unrevoke() {
let _iso = isolated_data_dir();
let mut list = RevocationList::load();
assert!(list.revocations.is_empty());
list.revoke("evil", "malware", None);
list.save().expect("save");
let reloaded = RevocationList::load();
assert!(reloaded.verdict("evil", None).is_some());
let mut reloaded = reloaded;
assert!(reloaded.unrevoke("evil").is_some());
reloaded.save().expect("save");
assert!(RevocationList::load().verdict("evil", None).is_none());
}
#[test]
fn blocked_reason_uses_installed_version() {
let _iso = isolated_data_dir();
let mut list = RevocationList::load();
list.revoke("demo", "pinned bad version", Some("1.0.0".into()));
list.save().expect("save");
let mut store = super::super::store::InstalledStore::load();
store.upsert(super::super::store::InstalledAddon {
name: "demo".into(),
version: "1.0.0".into(),
source: "registry".into(),
gateway_server: "demo".into(),
granted_capabilities: None,
content_hash: None,
install: None,
artifact: None,
});
store.save().expect("save");
assert!(
blocked_reason("demo").is_some(),
"installed 1.0.0 is revoked"
);
assert!(install_block("demo", "1.0.0").is_some());
assert!(install_block("demo", "1.0.1").is_none());
}
}