1use std::collections::{BTreeSet, HashMap};
12use std::hash::{BuildHasher, Hasher};
13use std::sync::{Arc, Mutex};
14use std::time::{Duration, SystemTime};
15
16use kaish_types::clock::{system_now, Instant};
17
18#[derive(Debug, Clone)]
20pub struct NonceScope {
21 command: String,
23 paths: BTreeSet<String>,
25}
26
27impl NonceScope {
28 pub fn command(&self) -> &str {
30 &self.command
31 }
32
33 pub fn paths(&self) -> &BTreeSet<String> {
35 &self.paths
36 }
37}
38
39#[derive(Clone, Debug)]
46pub struct NonceStore {
47 inner: Arc<Mutex<NonceStoreInner>>,
48 ttl: Duration,
49}
50
51#[derive(Debug)]
52struct NonceStoreInner {
53 nonces: HashMap<String, (Instant, NonceScope)>,
55}
56
57impl NonceStore {
58 pub fn new() -> Self {
60 Self::with_ttl(Duration::from_secs(60))
61 }
62
63 pub fn with_ttl(ttl: Duration) -> Self {
65 Self {
66 inner: Arc::new(Mutex::new(NonceStoreInner {
67 nonces: HashMap::new(),
68 })),
69 ttl,
70 }
71 }
72
73 pub fn lookup(&self, nonce: &str) -> Result<NonceScope, String> {
78 let now = Instant::now();
79 let ttl = self.ttl;
80
81 #[allow(clippy::expect_used)]
82 let inner = self.inner.lock().expect("nonce store poisoned");
83
84 match inner.nonces.get(nonce) {
85 Some((created, scope)) => {
86 if now.duration_since(*created) >= ttl {
87 Err("nonce expired".to_string())
88 } else {
89 Ok(scope.clone())
90 }
91 }
92 None => Err("invalid nonce".to_string()),
93 }
94 }
95
96 pub fn issue(&self, command: &str, paths: &[&str]) -> String {
100 let nonce = generate_nonce();
101 let now = Instant::now();
102 let ttl = self.ttl;
103
104 let scope = NonceScope {
105 command: command.to_string(),
106 paths: paths.iter().map(|p| p.to_string()).collect(),
107 };
108
109 #[allow(clippy::expect_used)]
110 let mut inner = self.inner.lock().expect("nonce store poisoned");
111
112 inner.nonces.retain(|_, (created, _)| now.duration_since(*created) < ttl);
114
115 inner.nonces.insert(nonce.clone(), (now, scope));
116 nonce
117 }
118
119 pub fn validate(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
126 let now = Instant::now();
127 let ttl = self.ttl;
128
129 #[allow(clippy::expect_used)]
130 let inner = self.inner.lock().expect("nonce store poisoned");
131
132 match inner.nonces.get(nonce) {
133 Some((created, scope)) => {
134 if now.duration_since(*created) >= ttl {
135 return Err("nonce expired".to_string());
136 }
137
138 if scope.command != command {
139 return Err(format!(
140 "nonce scope mismatch: issued for command '{}', got '{}'",
141 scope.command, command
142 ));
143 }
144
145 if let Some(unauthorized) = paths.iter().find(|p| !scope.paths.contains(**p)) {
148 return Err(format!(
149 "nonce scope mismatch: unauthorized path '{}' (authorized: {:?})",
150 unauthorized,
151 scope.paths.iter().collect::<Vec<_>>()
152 ));
153 }
154
155 Ok(())
156 }
157 None => Err("invalid nonce".to_string()),
158 }
159 }
160
161 pub fn ttl(&self) -> Duration {
163 self.ttl
164 }
165}
166
167impl Default for NonceStore {
168 fn default() -> Self {
169 Self::new()
170 }
171}
172
173fn generate_nonce() -> String {
175 let hasher_state = std::collections::hash_map::RandomState::new();
176 let mut hasher = hasher_state.build_hasher();
177
178 let now = system_now()
180 .duration_since(SystemTime::UNIX_EPOCH)
181 .unwrap_or_default();
182 hasher.write_u128(now.as_nanos());
183
184 let hasher_state2 = std::collections::hash_map::RandomState::new();
186 let mut hasher2 = hasher_state2.build_hasher();
187 hasher2.write_u64(0xdeadbeef);
188 hasher.write_u64(hasher2.finish());
189
190 format!("{:08x}", hasher.finish() as u32)
191}
192
193#[cfg(test)]
194mod tests {
195 use super::*;
196
197 #[test]
198 fn issue_and_validate() {
199 let store = NonceStore::new();
200 let nonce = store.issue("rm", &["/tmp/important"]);
201 assert_eq!(nonce.len(), 8);
202 assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
203
204 let result = store.validate(&nonce, "rm", &["/tmp/important"]);
205 assert!(result.is_ok());
206 }
207
208 #[test]
209 fn idempotent_reuse() {
210 let store = NonceStore::new();
211 let nonce = store.issue("rm", &["bigdir/"]);
212
213 let first = store.validate(&nonce, "rm", &["bigdir/"]);
214 let second = store.validate(&nonce, "rm", &["bigdir/"]);
215 assert!(first.is_ok());
216 assert!(second.is_ok());
217 }
218
219 #[test]
220 fn expired_nonce_fails() {
221 let store = NonceStore::with_ttl(Duration::from_millis(0));
222 let nonce = store.issue("rm", &["ephemeral"]);
223
224 std::thread::sleep(Duration::from_millis(1));
226 let result = store.validate(&nonce, "rm", &["ephemeral"]);
227 assert_eq!(result, Err("nonce expired".to_string()));
228 }
229
230 #[test]
231 fn invalid_nonce_fails() {
232 let store = NonceStore::new();
233 let result = store.validate("bogus123", "rm", &["anything"]);
234 assert_eq!(result, Err("invalid nonce".to_string()));
235 }
236
237 #[test]
238 fn nonces_are_unique() {
239 let store = NonceStore::new();
240 let a = store.issue("rm", &["first"]);
241 let b = store.issue("rm", &["second"]);
242 assert_ne!(a, b);
243 }
244
245 #[test]
246 fn clone_shares_state() {
247 let store = NonceStore::new();
248 let cloned = store.clone();
249 let nonce = store.issue("rm", &["/shared"]);
250
251 let result = cloned.validate(&nonce, "rm", &["/shared"]);
252 assert!(result.is_ok());
253 }
254
255 #[test]
256 fn gc_cleans_expired() {
257 let store = NonceStore::with_ttl(Duration::from_millis(10));
258 let old_nonce = store.issue("rm", &["old"]);
259
260 std::thread::sleep(Duration::from_millis(20));
261
262 let _new = store.issue("rm", &["new"]);
264
265 let result = store.validate(&old_nonce, "rm", &["old"]);
267 assert!(result.is_err());
268 }
269
270 #[test]
273 fn path_mismatch_rejected() {
274 let store = NonceStore::new();
275 let nonce = store.issue("rm", &["fileA.txt"]);
276
277 let result = store.validate(&nonce, "rm", &["fileB.txt"]);
278 assert!(result.is_err());
279 assert!(result.unwrap_err().contains("nonce scope mismatch"));
280 }
281
282 #[test]
283 fn subset_accepted() {
284 let store = NonceStore::new();
285 let nonce = store.issue("rm", &["a.txt", "b.txt", "c.txt"]);
286
287 let result = store.validate(&nonce, "rm", &["a.txt", "b.txt"]);
289 assert!(result.is_ok());
290 }
291
292 #[test]
293 fn superset_rejected() {
294 let store = NonceStore::new();
295 let nonce = store.issue("rm", &["a.txt", "b.txt"]);
296
297 let result = store.validate(&nonce, "rm", &["a.txt", "b.txt", "c.txt"]);
299 assert!(result.is_err());
300 assert!(result.unwrap_err().contains("unauthorized"));
301 }
302
303 #[test]
304 fn command_mismatch_rejected() {
305 let store = NonceStore::new();
306 let nonce = store.issue("rm", &["file.txt"]);
307
308 let result = store.validate(&nonce, "kaish-trash empty", &[]);
309 assert!(result.is_err());
310 assert!(result.unwrap_err().contains("command"));
311 }
312
313 #[test]
314 fn empty_paths_command_only() {
315 let store = NonceStore::new();
316 let nonce = store.issue("kaish-trash empty", &[]);
317
318 let result = store.validate(&nonce, "kaish-trash empty", &[]);
319 assert!(result.is_ok());
320 }
321
322 #[test]
323 fn empty_paths_rejects_nonempty() {
324 let store = NonceStore::new();
325 let nonce = store.issue("kaish-trash empty", &[]);
326
327 let result = store.validate(&nonce, "kaish-trash empty", &["sneaky.txt"]);
329 assert!(result.is_err());
330 }
331
332}