Skip to main content

cloudillo_action/
key_cache.rs

1// SPDX-FileCopyrightText: Szilárd Hajba
2// SPDX-License-Identifier: LGPL-3.0-or-later
3
4//! Federation key fetch cache
5//!
6//! Provides in-memory caching for failed key fetch attempts to prevent
7//! repeated requests to unreachable or malicious federated instances.
8
9use lru::LruCache;
10use std::num::NonZeroUsize;
11use std::sync::Arc;
12
13use crate::prelude::*;
14
15/// Limits memory for failed key fetch entries (one entry per remote instance)
16const DEFAULT_CACHE_CAPACITY: usize = 100;
17
18/// TTL for network errors (transient, may recover quickly)
19const TTL_NETWORK_ERROR_SECS: i64 = 5 * 60; // 5 minutes
20
21/// TTL for persistent errors (unlikely to change)
22const TTL_PERSISTENT_ERROR_SECS: i64 = 60 * 60; // 1 hour
23
24/// Type of failure that occurred during key fetch
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26pub enum FailureType {
27	/// Network error (connection refused, timeout, DNS failure)
28	NetworkError,
29	/// Key not found (404)
30	NotFound,
31	/// Permission denied (403)
32	Unauthorized,
33	/// Response parsing error (malformed JSON, invalid key format)
34	ParseError,
35}
36
37impl FailureType {
38	/// Get the TTL in seconds for this failure type
39	pub fn ttl_secs(self) -> i64 {
40		match self {
41			FailureType::NetworkError => TTL_NETWORK_ERROR_SECS,
42			FailureType::NotFound | FailureType::Unauthorized | FailureType::ParseError => {
43				TTL_PERSISTENT_ERROR_SECS
44			}
45		}
46	}
47
48	/// Convert from Error to FailureType
49	pub fn from_error(error: &Error) -> Self {
50		match error {
51			Error::NotFound => Self::NotFound,
52			Error::PermissionDenied | Error::Unauthorized => Self::Unauthorized,
53			Error::Parse => Self::ParseError,
54			// Default to NetworkError for unknown errors (shorter TTL)
55			_ => Self::NetworkError,
56		}
57	}
58}
59
60/// Entry in the failure cache
61#[derive(Debug, Clone)]
62pub struct FailureEntry {
63	/// When the failure occurred
64	pub failed_at: Timestamp,
65	/// Type of failure
66	pub failure_type: FailureType,
67	/// When we should retry (failed_at + TTL)
68	pub retry_after: Timestamp,
69}
70
71impl FailureEntry {
72	/// Create a new failure entry with TTL based on failure type
73	pub fn new(failure_type: FailureType) -> Self {
74		let now = Timestamp::now();
75		let ttl = failure_type.ttl_secs();
76		Self { failed_at: now, failure_type, retry_after: now.add_seconds(ttl) }
77	}
78
79	/// Check if this failure entry has expired
80	pub fn is_expired(&self) -> bool {
81		Timestamp::now() >= self.retry_after
82	}
83
84	/// Get seconds remaining until retry is allowed
85	pub fn seconds_until_retry(&self) -> i64 {
86		let now = Timestamp::now();
87		if now >= self.retry_after {
88			0
89		} else {
90			self.retry_after.0 - now.0
91		}
92	}
93}
94
95/// Cache for tracking failed key fetch attempts
96///
97/// Uses an LRU cache to limit memory usage while providing fast lookups.
98/// Entries automatically expire based on the failure type's TTL.
99pub struct KeyFetchCache {
100	/// Failed fetch attempts - keyed by "{issuer}:{key_id}"
101	failures: Arc<parking_lot::RwLock<LruCache<String, FailureEntry>>>,
102}
103
104impl KeyFetchCache {
105	/// Create a new cache with the specified maximum capacity
106	pub fn new(max_entries: usize) -> Self {
107		let capacity = NonZeroUsize::new(max_entries.max(1)).unwrap_or(NonZeroUsize::MIN);
108
109		Self { failures: Arc::new(parking_lot::RwLock::new(LruCache::new(capacity))) }
110	}
111
112	/// Create cache key from issuer and key_id
113	fn make_key(issuer: &str, key_id: &str) -> String {
114		format!("{}:{}", issuer, key_id)
115	}
116
117	/// Check if there's a cached (non-expired) failure for this issuer/key_id
118	///
119	/// Returns Some(FailureEntry) if we should NOT retry yet
120	/// Returns None if we should try fetching (no cache or expired)
121	pub fn check_failure(&self, issuer: &str, key_id: &str) -> Option<FailureEntry> {
122		let key = Self::make_key(issuer, key_id);
123		let mut cache = self.failures.write();
124
125		if let Some(entry) = cache.get(&key) {
126			if entry.is_expired() {
127				// Expired - remove from cache and allow retry
128				cache.pop(&key);
129				None
130			} else {
131				// Still valid - return the failure
132				Some(entry.clone())
133			}
134		} else {
135			None
136		}
137	}
138
139	/// Record a failed fetch attempt
140	pub fn record_failure(&self, issuer: &str, key_id: &str, error: &Error) {
141		let key = Self::make_key(issuer, key_id);
142		let failure_type = FailureType::from_error(error);
143		let entry = FailureEntry::new(failure_type);
144
145		debug!(
146			"Caching key fetch failure for {} (type: {:?}, retry in {} secs)",
147			key,
148			failure_type,
149			entry.seconds_until_retry()
150		);
151
152		let mut cache = self.failures.write();
153		cache.put(key, entry);
154	}
155
156	/// Clear a failure entry (e.g., after successful fetch)
157	pub fn clear_failure(&self, issuer: &str, key_id: &str) {
158		let key = Self::make_key(issuer, key_id);
159		let mut cache = self.failures.write();
160		cache.pop(&key);
161	}
162
163	/// Get the current number of entries in the cache
164	pub fn len(&self) -> usize {
165		self.failures.read().len()
166	}
167
168	/// Check if the cache is empty
169	pub fn is_empty(&self) -> bool {
170		self.failures.read().is_empty()
171	}
172
173	/// Clear all entries from the cache
174	pub fn clear(&self) {
175		self.failures.write().clear();
176	}
177}
178
179impl Default for KeyFetchCache {
180	fn default() -> Self {
181		Self::new(DEFAULT_CACHE_CAPACITY)
182	}
183}
184
185#[cfg(test)]
186mod tests {
187	use super::*;
188
189	#[test]
190	fn test_failure_type_ttl() {
191		assert_eq!(FailureType::NetworkError.ttl_secs(), 5 * 60);
192		assert_eq!(FailureType::NotFound.ttl_secs(), 60 * 60);
193		assert_eq!(FailureType::Unauthorized.ttl_secs(), 60 * 60);
194		assert_eq!(FailureType::ParseError.ttl_secs(), 60 * 60);
195	}
196
197	#[test]
198	fn test_failure_entry_expiration() {
199		let entry = FailureEntry::new(FailureType::NetworkError);
200		// Freshly created entry should not be expired
201		assert!(!entry.is_expired());
202		assert!(entry.seconds_until_retry() > 0);
203	}
204
205	#[test]
206	fn test_cache_operations() {
207		let cache = KeyFetchCache::new(10);
208
209		// Initially empty
210		assert!(cache.is_empty());
211		assert!(cache.check_failure("alice.example.com", "key-1").is_none());
212
213		// Record a failure
214		cache.record_failure("alice.example.com", "key-1", &Error::NotFound);
215
216		// Should now be cached
217		assert!(!cache.is_empty());
218		assert_eq!(cache.len(), 1);
219
220		let failure = cache.check_failure("alice.example.com", "key-1");
221		assert!(failure.is_some());
222		assert_eq!(failure.unwrap().failure_type, FailureType::NotFound);
223
224		// Clear the failure
225		cache.clear_failure("alice.example.com", "key-1");
226		assert!(cache.is_empty());
227	}
228
229	#[test]
230	fn test_cache_lru_eviction() {
231		let cache = KeyFetchCache::new(2);
232
233		cache.record_failure("a.com", "k1", &Error::NotFound);
234		cache.record_failure("b.com", "k2", &Error::NotFound);
235		assert_eq!(cache.len(), 2);
236
237		// Adding third entry should evict the least recently used
238		cache.record_failure("c.com", "k3", &Error::NotFound);
239		assert_eq!(cache.len(), 2);
240
241		// First entry should be evicted
242		assert!(cache.check_failure("a.com", "k1").is_none());
243		assert!(cache.check_failure("b.com", "k2").is_some());
244		assert!(cache.check_failure("c.com", "k3").is_some());
245	}
246
247	#[test]
248	fn test_failure_type_from_error() {
249		assert_eq!(
250			FailureType::from_error(&Error::NetworkError("test".into())),
251			FailureType::NetworkError
252		);
253		assert_eq!(FailureType::from_error(&Error::NotFound), FailureType::NotFound);
254		assert_eq!(FailureType::from_error(&Error::PermissionDenied), FailureType::Unauthorized);
255		assert_eq!(FailureType::from_error(&Error::Unauthorized), FailureType::Unauthorized);
256		assert_eq!(FailureType::from_error(&Error::Parse), FailureType::ParseError);
257	}
258}
259
260// vim: ts=4