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