clawft_kernel/
revocation.rs1use std::path::PathBuf;
12use std::sync::Mutex;
13
14use serde::{Deserialize, Serialize};
15use tracing::{debug, info, warn};
16
17mod subjects;
18#[cfg(test)]
19mod subjects_tests;
20pub use subjects::{
21 AUDIT_REVOKE_KIND, AUDIT_UNREVOKE_KIND, AuditSink, MAX_PACKAGE_ID_LEN, RevocationError,
22 RevocationKind, RevokedSubject, SUBJECTS_FILE_NAME,
23};
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct RevokedHost {
28 pub host_id: String,
30 pub revoked_at: u64,
32 pub reason: String,
34}
35
36pub struct RevocationList {
41 inner: Mutex<RevocationInner>,
42 audit: std::sync::OnceLock<AuditSink>,
45 generation: std::sync::atomic::AtomicU64,
47}
48
49struct RevocationInner {
50 hosts: Vec<RevokedHost>,
51 path: PathBuf,
52 subjects: subjects::SubjectState,
53}
54
55impl RevocationList {
56 pub fn new(path: PathBuf) -> Self {
58 Self {
59 inner: Mutex::new(RevocationInner {
60 hosts: Vec::new(),
61 subjects: subjects::SubjectState::empty(&path),
62 path,
63 }),
64 audit: std::sync::OnceLock::new(),
65 generation: std::sync::atomic::AtomicU64::new(0),
66 }
67 }
68
69 pub fn load(path: PathBuf) -> Self {
72 let hosts = if path.exists() {
73 match std::fs::read_to_string(&path) {
74 Ok(data) => match serde_json::from_str::<Vec<RevokedHost>>(&data) {
75 Ok(list) => {
76 info!(count = list.len(), path = %path.display(), "loaded host revocation list");
77 list
78 }
79 Err(e) => {
80 warn!(error = %e, path = %path.display(), "malformed revocation file, starting empty");
81 Vec::new()
82 }
83 },
84 Err(e) => {
85 warn!(error = %e, path = %path.display(), "failed to read revocation file, starting empty");
86 Vec::new()
87 }
88 }
89 } else {
90 debug!(path = %path.display(), "no revocation file found, starting empty");
91 Vec::new()
92 };
93
94 Self {
95 inner: Mutex::new(RevocationInner {
96 hosts,
97 subjects: subjects::SubjectState::load(&path),
98 path,
99 }),
100 audit: std::sync::OnceLock::new(),
101 generation: std::sync::atomic::AtomicU64::new(0),
102 }
103 }
104
105 pub fn revoke_host(&self, host_id: &str, reason: &str) -> bool {
109 let mut inner = self.inner.lock().unwrap();
110 if inner.hosts.iter().any(|h| h.host_id == host_id) {
111 return false;
112 }
113
114 let entry = RevokedHost {
115 host_id: host_id.to_owned(),
116 revoked_at: std::time::SystemTime::now()
117 .duration_since(std::time::UNIX_EPOCH)
118 .unwrap_or_default()
119 .as_secs(),
120 reason: reason.to_owned(),
121 };
122 inner.hosts.push(entry);
123 Self::save_inner(&inner);
124 info!(host_id, reason, "host revoked");
125 true
126 }
127
128 pub fn is_revoked(&self, host_id: &str) -> bool {
130 let inner = self.inner.lock().unwrap();
131 inner.hosts.iter().any(|h| h.host_id == host_id)
132 }
133
134 pub fn list_revoked(&self) -> Vec<RevokedHost> {
136 let inner = self.inner.lock().unwrap();
137 inner.hosts.clone()
138 }
139
140 pub fn unrevoke_host(&self, host_id: &str) -> bool {
142 let mut inner = self.inner.lock().unwrap();
143 let before = inner.hosts.len();
144 inner.hosts.retain(|h| h.host_id != host_id);
145 let removed = inner.hosts.len() < before;
146 if removed {
147 Self::save_inner(&inner);
148 info!(host_id, "host unrevoked");
149 }
150 removed
151 }
152
153 pub fn len(&self) -> usize {
155 self.inner.lock().unwrap().hosts.len()
156 }
157
158 pub fn is_empty(&self) -> bool {
160 self.inner.lock().unwrap().hosts.is_empty()
161 }
162
163 pub fn path(&self) -> PathBuf {
165 self.inner.lock().unwrap().path.clone()
166 }
167
168 fn save_inner(inner: &RevocationInner) {
170 if let Some(parent) = inner.path.parent()
171 && let Err(e) = std::fs::create_dir_all(parent)
172 {
173 warn!(error = %e, "failed to create revocation dir");
174 return;
175 }
176 match serde_json::to_string_pretty(&inner.hosts) {
177 Ok(json) => {
178 if let Err(e) = std::fs::write(&inner.path, json) {
179 warn!(error = %e, path = %inner.path.display(), "failed to write revocation file");
180 }
181 }
182 Err(e) => {
183 warn!(error = %e, "failed to serialize revocation list");
184 }
185 }
186 }
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use std::io::Write;
193
194 #[test]
195 fn revoke_and_check() {
196 let dir = tempfile::tempdir().unwrap();
197 let path = dir.path().join("revoked.json");
198 let list = RevocationList::new(path);
199
200 assert!(!list.is_revoked("host-1"));
201 assert!(list.revoke_host("host-1", "misbehaving"));
202 assert!(list.is_revoked("host-1"));
203 assert_eq!(list.len(), 1);
204
205 assert!(!list.revoke_host("host-1", "second attempt"));
207 assert_eq!(list.len(), 1);
208 }
209
210 #[test]
211 fn unrevoke() {
212 let dir = tempfile::tempdir().unwrap();
213 let path = dir.path().join("revoked.json");
214 let list = RevocationList::new(path);
215
216 list.revoke_host("host-1", "test");
217 assert!(list.unrevoke_host("host-1"));
218 assert!(!list.is_revoked("host-1"));
219 assert!(list.is_empty());
220
221 assert!(!list.unrevoke_host("host-1"));
223 }
224
225 #[test]
226 fn persistence_roundtrip() {
227 let dir = tempfile::tempdir().unwrap();
228 let path = dir.path().join("revoked.json");
229
230 {
232 let list = RevocationList::new(path.clone());
233 list.revoke_host("host-1", "reason-a");
234 list.revoke_host("host-2", "reason-b");
235 }
236
237 let list = RevocationList::load(path);
239 assert_eq!(list.len(), 2);
240 assert!(list.is_revoked("host-1"));
241 assert!(list.is_revoked("host-2"));
242 assert!(!list.is_revoked("host-3"));
243 }
244
245 #[test]
246 fn load_missing_file() {
247 let dir = tempfile::tempdir().unwrap();
248 let path = dir.path().join("nonexistent.json");
249 let list = RevocationList::load(path);
250 assert!(list.is_empty());
251 }
252
253 #[test]
254 fn load_malformed_file() {
255 let dir = tempfile::tempdir().unwrap();
256 let path = dir.path().join("bad.json");
257 {
258 let mut f = std::fs::File::create(&path).unwrap();
259 f.write_all(b"not valid json{{{").unwrap();
260 }
261 let list = RevocationList::load(path);
262 assert!(list.is_empty());
263 }
264
265 #[test]
266 fn list_revoked() {
267 let dir = tempfile::tempdir().unwrap();
268 let path = dir.path().join("revoked.json");
269 let list = RevocationList::new(path);
270
271 list.revoke_host("a", "reason-a");
272 list.revoke_host("b", "reason-b");
273
274 let entries = list.list_revoked();
275 assert_eq!(entries.len(), 2);
276 assert_eq!(entries[0].host_id, "a");
277 assert_eq!(entries[1].host_id, "b");
278 }
279}