lean_ctx/core/addons/
revocation.rs1use std::collections::BTreeMap;
26use std::path::PathBuf;
27
28use serde::{Deserialize, Serialize};
29
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
32pub struct Revocation {
33 pub reason: String,
35 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub version: Option<String>,
39}
40
41#[derive(Debug, Clone, Default, Serialize, Deserialize)]
43pub struct RevocationList {
44 #[serde(default)]
45 pub revocations: BTreeMap<String, Revocation>,
46}
47
48fn list_path() -> Result<PathBuf, String> {
49 Ok(crate::core::data_dir::lean_ctx_data_dir()?
50 .join("addons")
51 .join("revocations.json"))
52}
53
54impl RevocationList {
55 #[must_use]
57 pub fn load() -> Self {
58 let Ok(path) = list_path() else {
59 return Self::default();
60 };
61 match std::fs::read_to_string(&path) {
62 Ok(raw) if !raw.trim().is_empty() => serde_json::from_str(&raw).unwrap_or_default(),
63 _ => Self::default(),
64 }
65 }
66
67 pub fn save(&self) -> Result<(), String> {
69 let path = list_path()?;
70 if let Some(parent) = path.parent() {
71 std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
72 }
73 let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
74 std::fs::write(&path, json).map_err(|e| e.to_string())
75 }
76
77 pub fn revoke(&mut self, name: &str, reason: &str, version: Option<String>) {
79 self.revocations.insert(
80 name.to_string(),
81 Revocation {
82 reason: reason.to_string(),
83 version,
84 },
85 );
86 }
87
88 pub fn unrevoke(&mut self, name: &str) -> Option<Revocation> {
90 self.revocations.remove(name)
91 }
92
93 #[must_use]
97 pub fn verdict(&self, name: &str, installed_version: Option<&str>) -> Option<String> {
98 let entry = self.revocations.get(name)?;
99 match &entry.version {
100 None => Some(entry.reason.clone()),
101 Some(pinned) => match installed_version {
102 Some(v) if v == pinned => Some(entry.reason.clone()),
103 _ => None,
104 },
105 }
106 }
107}
108
109#[must_use]
112pub fn blocked_reason(server_name: &str) -> Option<String> {
113 let installed_version = super::store::InstalledStore::load()
114 .get(server_name)
115 .map(|a| a.version.clone());
116 RevocationList::load().verdict(server_name, installed_version.as_deref())
117}
118
119#[must_use]
121pub fn install_block(name: &str, version: &str) -> Option<String> {
122 RevocationList::load().verdict(name, Some(version))
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::core::data_dir::isolated_data_dir;
129
130 #[test]
131 fn unpinned_revocation_blocks_all_versions() {
132 let mut list = RevocationList::default();
133 list.revoke("evil", "supply-chain compromise", None);
134 assert_eq!(
135 list.verdict("evil", Some("9.9.9")).as_deref(),
136 Some("supply-chain compromise")
137 );
138 assert_eq!(
139 list.verdict("evil", None).as_deref(),
140 Some("supply-chain compromise")
141 );
142 assert!(list.verdict("clean", Some("1.0.0")).is_none());
143 }
144
145 #[test]
146 fn version_pinned_revocation_blocks_only_match() {
147 let mut list = RevocationList::default();
148 list.revoke("tool", "bad release", Some("1.2.3".into()));
149 assert!(list.verdict("tool", Some("1.2.3")).is_some());
150 assert!(list.verdict("tool", Some("1.2.4")).is_none());
151 assert!(list.verdict("tool", None).is_none());
152 }
153
154 #[test]
155 fn round_trips_through_disk_and_unrevoke() {
156 let _iso = isolated_data_dir();
157 let mut list = RevocationList::load();
158 assert!(list.revocations.is_empty());
159 list.revoke("evil", "malware", None);
160 list.save().expect("save");
161
162 let reloaded = RevocationList::load();
163 assert!(reloaded.verdict("evil", None).is_some());
164
165 let mut reloaded = reloaded;
166 assert!(reloaded.unrevoke("evil").is_some());
167 reloaded.save().expect("save");
168 assert!(RevocationList::load().verdict("evil", None).is_none());
169 }
170
171 #[test]
172 fn blocked_reason_uses_installed_version() {
173 let _iso = isolated_data_dir();
174 let mut list = RevocationList::load();
176 list.revoke("demo", "pinned bad version", Some("1.0.0".into()));
177 list.save().expect("save");
178
179 let mut store = super::super::store::InstalledStore::load();
180 store.upsert(super::super::store::InstalledAddon {
181 name: "demo".into(),
182 version: "1.0.0".into(),
183 source: "registry".into(),
184 gateway_server: "demo".into(),
185 granted_capabilities: None,
186 content_hash: None,
187 install: None,
188 artifact: None,
189 });
190 store.save().expect("save");
191
192 assert!(
193 blocked_reason("demo").is_some(),
194 "installed 1.0.0 is revoked"
195 );
196 assert!(install_block("demo", "1.0.0").is_some());
197 assert!(install_block("demo", "1.0.1").is_none());
198 }
199}