1use std::fs;
11use std::io;
12use std::path::{Path, PathBuf};
13use std::sync::{Arc, Mutex, MutexGuard};
14
15use crate::{CertChain, Fingerprint, MeshError, RevocationLog, RevocationState, Seen, UserKey};
16
17#[derive(Debug, thiserror::Error)]
19pub enum GuardError {
20 #[error("peer revoked: agent {0}")]
22 Revoked(String),
23 #[error("cert chain rejected: {0}")]
25 BadCertChain(String),
26 #[error("revocation log unavailable: {0}")]
29 Unavailable(String),
30}
31
32pub type Result<T> = std::result::Result<T, GuardError>;
34
35#[derive(Clone, Debug)]
39pub struct RevocationGuard {
40 inner: Arc<Inner>,
41}
42
43#[derive(Debug)]
44struct Inner {
45 local_user: Fingerprint,
46 file: Option<LogFile>,
47 cache: Mutex<Option<Cached>>,
48}
49
50#[derive(Debug)]
51struct LogFile {
52 log: PathBuf,
53 seen: PathBuf,
54 lock: PathBuf,
56}
57
58#[derive(Debug)]
59struct Cached {
60 state: Arc<RevocationState>,
61 bytes: Vec<u8>,
65}
66
67impl RevocationGuard {
68 pub fn open(log: impl Into<PathBuf>, local_user: Fingerprint) -> Result<Self> {
79 let log = log.into();
80 let seen = log.with_extension("seen.json");
81 let lock = log.with_extension("seen.lock");
82 let guard = Self {
83 inner: Arc::new(Inner {
84 local_user,
85 file: Some(LogFile { log, seen, lock }),
86 cache: Mutex::new(None),
87 }),
88 };
89 guard.state()?;
90 Ok(guard)
91 }
92
93 #[must_use]
97 pub fn in_memory(user: &UserKey) -> Self {
98 let log = RevocationLog::genesis(user).expect("genesis encodes");
99 let state = log.verify().expect("a fresh genesis verifies");
100 Self {
101 inner: Arc::new(Inner {
102 local_user: user.fingerprint(),
103 file: None,
104 cache: Mutex::new(Some(Cached {
105 state: Arc::new(state),
106 bytes: Vec::new(),
107 })),
108 }),
109 }
110 }
111
112 pub fn state(&self) -> Result<Arc<RevocationState>> {
117 let mut cache = lock(&self.inner.cache);
118 let Some(file) = &self.inner.file else {
119 return Ok(cache
120 .as_ref()
121 .expect("in-memory state is set")
122 .state
123 .clone());
124 };
125 match fs::read(&file.log) {
126 Ok(now) => {
127 if let Some(cached) = cache
134 .as_ref()
135 .filter(|c| c.bytes == now && seen_is(&file.seen, &c.state))
136 {
137 return Ok(cached.state.clone());
138 }
139 }
140 Err(e) => {
141 *cache = None;
142 return Err(unavailable("read revocation log", &e));
143 }
144 }
145 *cache = None;
147 let (state, bytes) = self.load(file)?;
148 let state = Arc::new(state);
149 *cache = Some(Cached {
150 state: state.clone(),
151 bytes,
152 });
153 Ok(state)
154 }
155
156 pub fn check(&self, peer: &CertChain) -> Result<()> {
164 let state = self.state()?;
165 verify_with(peer, &state)
166 }
167
168 fn load(&self, file: &LogFile) -> Result<(RevocationState, Vec<u8>)> {
173 let _lock = fs::File::options()
174 .create(true)
175 .append(true)
176 .open(&file.lock)
177 .and_then(|f| f.lock().map(|()| f))
178 .map_err(|e| unavailable("lock seen record", &e))?;
179 let bytes = fs::read(&file.log).map_err(|e| unavailable("read revocation log", &e))?;
181 let log: RevocationLog =
182 serde_json::from_slice(&bytes).map_err(|e| unavailable("parse revocation log", &e))?;
183 let state = log
184 .verify()
185 .map_err(|e| unavailable("verify revocation log", &e))?;
186 if state.user != self.inner.local_user {
187 return Err(unavailable(
188 "revocation log",
189 &"belongs to a different user than the local identity",
190 ));
191 }
192 let advance = match fs::read(&file.seen) {
193 Ok(raw) => {
194 let seen: Seen = serde_json::from_slice(&raw)
195 .map_err(|e| unavailable("parse seen record", &e))?;
196 log.extends(&seen)
197 .map_err(|e| unavailable("revocation log", &e))?;
198 state.len > seen.len
199 }
200 Err(e) if e.kind() == io::ErrorKind::NotFound => true,
201 Err(e) => return Err(unavailable("read seen record", &e)),
202 };
203 if advance {
204 persist_seen(&file.seen, &state.seen()).map_err(|e| unavailable("persist seen", &e))?;
205 }
206 Ok((state, bytes))
207 }
208}
209
210fn seen_is(path: &Path, state: &RevocationState) -> bool {
213 fs::read(path)
214 .ok()
215 .and_then(|raw| serde_json::from_slice::<Seen>(&raw).ok())
216 .is_some_and(|seen| seen == state.seen())
217}
218
219fn unavailable(what: &str, e: &dyn std::fmt::Display) -> GuardError {
220 GuardError::Unavailable(format!("{what}: {e}"))
221}
222
223pub fn verify_with(peer: &CertChain, state: &RevocationState) -> Result<()> {
228 peer.verify_with(state).map_err(|e| match e {
229 MeshError::Revoked { agent } => GuardError::Revoked(agent.hex()),
230 other => GuardError::BadCertChain(other.to_string()),
231 })
232}
233
234fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
235 m.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
236}
237
238fn persist_seen(path: &Path, seen: &Seen) -> io::Result<()> {
241 crate::atomic::atomic_write(path, &serde_json::to_vec(seen).map_err(io::Error::other)?)
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::{AgentKey, AgentMetadata, Caveats, RevocationEntry};
248 use std::time::SystemTime;
249
250 fn agent(user: &UserKey) -> AgentKey {
251 AgentKey::issue(
252 user,
253 AgentMetadata {
254 role: "w".into(),
255 host: "h".into(),
256 capabilities: vec![],
257 issued_at: "2026-10-26T00:00:00Z".into(),
258 expires_at: None,
259 caveats: Caveats::top(),
260 },
261 )
262 }
263
264 struct Fixture {
266 user: UserKey,
267 dir: tempfile::TempDir,
268 }
269
270 impl Fixture {
271 fn new() -> Self {
272 let f = Self {
273 user: UserKey::generate(),
274 dir: tempfile::tempdir().unwrap(),
275 };
276 f.write(&RevocationLog::genesis(&f.user).unwrap());
277 f
278 }
279
280 fn path(&self) -> PathBuf {
281 self.dir.path().join("revocation.json")
282 }
283
284 fn write(&self, log: &RevocationLog) {
285 fs::write(self.path(), serde_json::to_vec(log).unwrap()).unwrap();
286 }
287
288 fn read(&self) -> RevocationLog {
289 serde_json::from_slice(&fs::read(self.path()).unwrap()).unwrap()
290 }
291
292 fn revoke(&self, k: &AgentKey) {
293 let mut log = self.read();
294 let agent = k.fingerprint();
295 log.append(&self.user, RevocationEntry::Revoke { agent })
296 .unwrap();
297 self.write(&log);
298 }
299
300 fn open(&self) -> Result<RevocationGuard> {
301 RevocationGuard::open(self.path(), self.user.fingerprint())
302 }
303 }
304
305 fn unavailable<T: std::fmt::Debug>(r: Result<T>) -> bool {
306 matches!(r, Err(GuardError::Unavailable(_)))
307 }
308
309 #[test]
310 fn a_missing_log_refuses() {
311 let user = UserKey::generate();
312 let dir = tempfile::tempdir().unwrap();
313 let r = RevocationGuard::open(dir.path().join("revocation.json"), user.fingerprint());
314 assert!(unavailable(r));
315 }
316
317 #[test]
318 fn a_garbled_log_refuses() {
319 let f = Fixture::new();
320 fs::write(f.path(), b"{not json").unwrap();
321 assert!(unavailable(f.open()));
322 }
323
324 #[test]
325 fn another_users_log_refuses() {
326 let f = Fixture::new();
327 let r = RevocationGuard::open(f.path(), UserKey::generate().fingerprint());
328 assert!(unavailable(r));
329 }
330
331 #[test]
332 fn a_valid_log_admits_its_users_agents_and_persists_seen() {
333 let f = Fixture::new();
334 let guard = f.open().unwrap();
335 guard.check(agent(&f.user).cert()).unwrap();
336 let seen: Seen =
337 serde_json::from_slice(&fs::read(f.path().with_extension("seen.json")).unwrap())
338 .unwrap();
339 assert_eq!(seen, guard.state().unwrap().seen());
340 }
341
342 #[test]
343 fn a_revoked_agent_is_refused() {
344 let f = Fixture::new();
345 let a = agent(&f.user);
346 f.revoke(&a);
347 let guard = f.open().unwrap();
348 assert!(matches!(guard.check(a.cert()), Err(GuardError::Revoked(_))));
349 }
350
351 #[test]
354 fn a_rolled_back_log_is_refused_at_startup() {
355 let f = Fixture::new();
356 let old = f.read();
357 f.revoke(&agent(&f.user));
358 f.open().unwrap();
359 f.write(&old);
360 assert!(unavailable(f.open()));
361 }
362
363 #[test]
364 fn a_forked_log_is_refused_at_startup() {
365 let f = Fixture::new();
366 let genesis = f.read();
367 f.revoke(&agent(&f.user));
368 f.open().unwrap();
369 let mut fork = genesis;
370 fork.append(
371 &f.user,
372 RevocationEntry::Revoke {
373 agent: agent(&f.user).fingerprint(),
374 },
375 )
376 .unwrap();
377 f.write(&fork);
378 assert!(unavailable(f.open()));
379 }
380
381 #[test]
382 fn a_log_that_changes_to_a_rollback_while_running_refuses_every_peer() {
383 let f = Fixture::new();
384 let (a, b) = (agent(&f.user), agent(&f.user));
385 let old = f.read();
386 f.revoke(&a);
387 let guard = f.open().unwrap();
388 guard.check(b.cert()).unwrap();
389 f.write(&old);
390 assert!(unavailable(guard.state()));
391 assert!(guard.check(b.cert()).is_err());
392 }
393
394 #[test]
395 fn an_in_memory_guard_admits_its_user_and_refuses_another() {
396 let user = UserKey::generate();
397 let guard = RevocationGuard::in_memory(&user);
398 guard.check(agent(&user).cert()).unwrap();
399 assert!(guard.check(agent(&UserKey::generate()).cert()).is_err());
400 }
401
402 fn padded(mut bytes: Vec<u8>, len: usize) -> Vec<u8> {
404 assert!(bytes.len() <= len);
405 bytes.resize(len, b' ');
406 bytes
407 }
408
409 fn set_mtime(path: &Path, t: SystemTime) {
410 fs::File::options()
411 .write(true)
412 .open(path)
413 .unwrap()
414 .set_modified(t)
415 .unwrap();
416 }
417
418 #[test]
422 fn an_equal_length_same_mtime_replacement_is_detected() {
423 let f = Fixture::new();
424 let (a, b) = (agent(&f.user), agent(&f.user));
425 let mut old = f.read();
426 old.append(
427 &f.user,
428 RevocationEntry::Revoke {
429 agent: b.fingerprint(),
430 },
431 )
432 .unwrap();
433 let mut new = old.clone();
434 new.append(
435 &f.user,
436 RevocationEntry::Revoke {
437 agent: a.fingerprint(),
438 },
439 )
440 .unwrap();
441 let (old, new) = (
442 serde_json::to_vec(&old).unwrap(),
443 serde_json::to_vec(&new).unwrap(),
444 );
445 let len = old.len().max(new.len());
446 fs::write(f.path(), padded(old, len)).unwrap();
447 let stamp = fs::metadata(f.path()).unwrap().modified().unwrap();
448 let guard = f.open().unwrap();
449 guard.check(a.cert()).unwrap();
450
451 fs::write(f.path(), padded(new, len)).unwrap();
452 set_mtime(&f.path(), stamp);
453 assert!(
454 matches!(guard.check(a.cert()), Err(GuardError::Revoked(_))),
455 "the replacement revokes a, even with identical metadata"
456 );
457 }
458
459 #[test]
461 fn an_equal_length_same_mtime_corrupt_replacement_fails_closed() {
462 let f = Fixture::new();
463 let guard = f.open().unwrap();
464 let stamp = fs::metadata(f.path()).unwrap().modified().unwrap();
465 let len = fs::metadata(f.path()).unwrap().len() as usize;
466 fs::write(f.path(), vec![b'x'; len]).unwrap();
467 set_mtime(&f.path(), stamp);
468 assert!(unavailable(guard.state()));
469 }
470
471 #[test]
475 fn opening_waits_for_the_seen_lock() {
476 let f = Fixture::new();
477 f.open().unwrap();
478 let lock = fs::File::options()
479 .create(true)
480 .append(true)
481 .open(f.path().with_extension("seen.lock"))
482 .unwrap();
483 lock.lock().unwrap();
484 let (path, fp) = (f.path(), f.user.fingerprint());
485 let opened = std::thread::spawn(move || RevocationGuard::open(path, fp).is_ok());
486 std::thread::sleep(std::time::Duration::from_millis(300));
487 assert!(!opened.is_finished(), "open must wait for the lock");
488 drop(lock);
489 assert!(opened.join().unwrap());
490 }
491
492 #[test]
495 fn seen_never_goes_backwards_across_guards() {
496 let f = Fixture::new();
497 let l1 = f.read();
498 f.revoke(&agent(&f.user));
499 let l2 = f.read();
500 f.revoke(&agent(&f.user));
501 let l3 = f.read();
502 f.write(&l1);
503 let a = f.open().unwrap();
504 f.write(&l3);
505 let b = f.open().unwrap();
506 let persisted = |f: &Fixture| fs::read(f.path().with_extension("seen.json")).unwrap();
507 let at_l3 = persisted(&f);
508 f.write(&l2);
509 assert!(unavailable(a.state()));
510 assert!(unavailable(b.state()));
511 assert_eq!(persisted(&f), at_l3, "Seen is still L3");
512 f.write(&l3);
513 a.state().unwrap();
514 assert_eq!(persisted(&f), at_l3);
515 }
516
517 #[test]
521 fn a_cached_guard_refuses_a_rollback_to_its_cached_log() {
522 let f = Fixture::new();
523 let (a, victim) = (agent(&f.user), agent(&f.user));
524 let l1 = fs::read(f.path()).unwrap();
525 let guard_a = f.open().unwrap();
526 guard_a.check(victim.cert()).unwrap();
527
528 f.revoke(&victim);
529 let _guard_b = f.open().unwrap(); fs::write(f.path(), &l1).unwrap(); assert!(
532 guard_a.check(victim.cert()).is_err(),
533 "a rollback to L1 must be refused even though A cached L1"
534 );
535 let _ = a;
536 }
537
538 #[cfg(unix)]
542 #[test]
543 fn a_planted_tmp_symlink_is_never_followed_when_seen_is_written() {
544 let f = Fixture::new();
545 let victim = f.dir.path().join("victim");
546 fs::write(&victim, "precious").unwrap();
547 let seen = f.path().with_extension("seen.json");
548 for name in [
549 format!("{}.tmp", std::process::id()),
550 "tmp".to_string(),
551 "seen.tmp".to_string(),
552 ] {
553 let planted = seen.with_extension(name);
554 let _ = std::os::unix::fs::symlink(&victim, planted);
555 }
556 f.open().unwrap();
557 assert_eq!(fs::read_to_string(&victim).unwrap(), "precious");
558 assert!(fs::metadata(&seen).is_ok(), "Seen was still written");
559 }
560}