1use std::collections::BTreeMap;
33use std::path::{Path, PathBuf};
34use std::sync::atomic::{AtomicU64, Ordering};
35use std::time::Duration;
36
37use async_trait::async_trait;
38use chrono::{DateTime, Utc};
39use serde::{Deserialize, Serialize};
40use sha2::{Digest, Sha256};
41use solid_pod_rs_idp::{Invite, InviteStore, InviteStoreError};
42use tokio::sync::Mutex;
43
44const DIR: &str = ".server";
45const FILE: &str = "invites.json";
46const LOCK: &str = "invites.json.lock";
47const STALE_LOCK: Duration = Duration::from_secs(30);
49const LOCK_WAIT: Duration = Duration::from_secs(10);
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54struct Record {
55 #[serde(default, skip_serializing_if = "Option::is_none")]
58 token_sha256: Option<String>,
59 created: DateTime<Utc>,
60 max_uses: Option<u32>,
61 #[serde(default)]
62 uses: u32,
63 #[serde(default)]
64 note: String,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
66 expires_at: Option<DateTime<Utc>>,
67}
68
69fn digest(token: &str) -> String {
71 hex::encode(Sha256::digest(token.as_bytes()))
72}
73
74fn migrate(map: BTreeMap<String, Record>) -> BTreeMap<String, Record> {
77 map.into_iter()
78 .map(|(k, mut r)| match r.token_sha256.clone() {
79 Some(h) => (h, r),
80 None => {
81 let h = digest(&k);
82 r.token_sha256 = Some(h.clone());
83 (h, r)
84 }
85 })
86 .collect()
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct InviteRow {
92 pub id: String,
95 pub max_uses: Option<u32>,
97 pub uses: u32,
99 pub created: DateTime<Utc>,
101 pub expires_at: Option<DateTime<Utc>>,
103}
104
105#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum Consume {
108 Granted,
110 Unknown,
112 Expired,
114 Exhausted,
116}
117
118pub struct FileInviteStore {
120 path: PathBuf,
121 lock: Mutex<()>,
122}
123
124struct FileLock {
127 path: PathBuf,
128}
129
130impl Drop for FileLock {
131 fn drop(&mut self) {
132 let _ = std::fs::remove_file(&self.path);
133 }
134}
135
136impl FileInviteStore {
137 pub fn new(root: impl AsRef<Path>) -> Self {
139 Self {
140 path: root.as_ref().join(DIR).join(FILE),
141 lock: Mutex::new(()),
142 }
143 }
144
145 pub fn path(&self) -> &Path {
147 &self.path
148 }
149
150 fn backend(&self, e: impl std::fmt::Display) -> InviteStoreError {
151 InviteStoreError::Backend(format!("{}: {e}", self.path.display()))
152 }
153
154 async fn lock_file(&self) -> Result<FileLock, InviteStoreError> {
156 let dir = self.path.parent().expect("invites path has a parent");
157 tokio::fs::create_dir_all(dir)
158 .await
159 .map_err(|e| self.backend(e))?;
160 let path = dir.join(LOCK);
161 let started = std::time::Instant::now();
162 loop {
163 match tokio::fs::OpenOptions::new()
164 .write(true)
165 .create_new(true)
166 .open(&path)
167 .await
168 {
169 Ok(_) => return Ok(FileLock { path }),
170 Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
171 let stale = tokio::fs::metadata(&path)
172 .await
173 .ok()
174 .and_then(|m| m.modified().ok())
175 .and_then(|t| t.elapsed().ok())
176 .is_some_and(|age| age > STALE_LOCK);
177 if stale {
178 let _ = tokio::fs::remove_file(&path).await;
179 continue;
180 }
181 if started.elapsed() > LOCK_WAIT {
182 return Err(self.backend("timed out waiting for the invite store lock"));
183 }
184 tokio::time::sleep(Duration::from_millis(25)).await;
185 }
186 Err(e) => return Err(self.backend(e)),
187 }
188 }
189 }
190
191 async fn load(&self) -> Result<BTreeMap<String, Record>, InviteStoreError> {
192 match tokio::fs::read(&self.path).await {
193 Ok(bytes) => serde_json::from_slice(&bytes)
194 .map(migrate)
195 .map_err(|e| self.backend(e)),
196 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
197 Err(e) => Err(self.backend(e)),
198 }
199 }
200
201 async fn save(&self, map: &BTreeMap<String, Record>) -> Result<(), InviteStoreError> {
202 static SEQ: AtomicU64 = AtomicU64::new(0);
203 let dir = self.path.parent().expect("invites path has a parent");
204 tokio::fs::create_dir_all(dir)
205 .await
206 .map_err(|e| self.backend(e))?;
207 let body =
208 serde_json::to_vec_pretty(map).map_err(|e| InviteStoreError::Backend(e.to_string()))?;
209 let nanos = std::time::SystemTime::now()
210 .duration_since(std::time::UNIX_EPOCH)
211 .map_or(0, |d| d.as_nanos());
212 let tmp = dir.join(format!(
213 ".{FILE}.{}.{}.{nanos}.tmp",
214 std::process::id(),
215 SEQ.fetch_add(1, Ordering::Relaxed)
216 ));
217 let path = self.path.clone();
218 let tmp2 = tmp.clone();
219 tokio::task::spawn_blocking(move || -> std::io::Result<()> {
220 use std::io::Write as _;
221 let mut opts = std::fs::OpenOptions::new();
222 opts.write(true).create_new(true);
223 #[cfg(unix)]
224 {
225 use std::os::unix::fs::OpenOptionsExt as _;
226 opts.mode(0o600);
227 }
228 let r = opts
229 .open(&tmp2)
230 .and_then(|mut f| f.write_all(&body))
231 .and_then(|()| std::fs::rename(&tmp2, &path));
232 if r.is_err() {
233 let _ = std::fs::remove_file(&tmp2);
234 }
235 r
236 })
237 .await
238 .map_err(|e| InviteStoreError::Backend(e.to_string()))?
239 .map_err(|e| self.backend(e))
240 }
241
242 pub async fn list(&self) -> Result<Vec<InviteRow>, InviteStoreError> {
244 let _g = self.lock.lock().await;
245 let mut rows: Vec<InviteRow> = self
246 .load()
247 .await?
248 .into_iter()
249 .map(|(hash, r)| InviteRow {
250 id: hash.chars().take(12).collect(),
251 max_uses: r.max_uses,
252 uses: r.uses,
253 created: r.created,
254 expires_at: r.expires_at,
255 })
256 .collect();
257 rows.sort_by(|a, b| a.created.cmp(&b.created).then(a.id.cmp(&b.id)));
258 Ok(rows)
259 }
260
261 pub async fn revoke(&self, token: &str) -> Result<bool, InviteStoreError> {
265 let _g = self.lock.lock().await;
266 let _f = self.lock_file().await?;
267 let mut map = self.load().await?;
268 if map.remove(&digest(token)).is_none() {
269 let hexish = token.len() >= 8 && token.bytes().all(|b| b.is_ascii_hexdigit());
272 let prefix = token.to_ascii_lowercase();
273 let hits: Vec<String> = if hexish {
274 map.keys()
275 .filter(|k| k.starts_with(&prefix))
276 .cloned()
277 .collect()
278 } else {
279 Vec::new()
280 };
281 match hits.as_slice() {
282 [one] => {
283 map.remove(one);
284 }
285 [] => return Ok(false),
286 _ => {
287 return Err(InviteStoreError::Backend(
288 "id prefix is ambiguous, give more characters".into(),
289 ))
290 }
291 }
292 }
293 self.save(&map).await?;
294 Ok(true)
295 }
296
297 pub async fn consume(&self, token: &str) -> Result<Consume, InviteStoreError> {
316 let _g = self.lock.lock().await;
317 let _f = self.lock_file().await?;
318 let mut map = self.load().await?;
319 let Some(rec) = map.get_mut(&digest(token)) else {
320 return Ok(Consume::Unknown);
321 };
322 if rec.expires_at.is_some_and(|t| t <= Utc::now()) {
323 return Ok(Consume::Expired);
324 }
325 if rec.max_uses.is_some_and(|m| rec.uses >= m) {
326 return Ok(Consume::Exhausted);
327 }
328 rec.uses += 1;
329 self.save(&map).await?;
330 Ok(Consume::Granted)
331 }
332}
333
334#[async_trait]
335impl InviteStore for FileInviteStore {
336 async fn insert(&self, invite: Invite) -> Result<(), InviteStoreError> {
337 let _g = self.lock.lock().await;
338 let _f = self.lock_file().await?;
339 let mut map = self.load().await?;
340 let hash = digest(&invite.token);
341 if map.contains_key(&hash) {
343 return Ok(());
344 }
345 map.insert(
346 hash.clone(),
347 Record {
348 token_sha256: Some(hash),
349 created: Utc::now(),
350 max_uses: invite.max_uses,
351 uses: 0,
352 note: String::new(),
353 expires_at: invite.expires_at,
354 },
355 );
356 self.save(&map).await
357 }
358
359 async fn get(&self, token: &str) -> Result<Option<Invite>, InviteStoreError> {
360 let _g = self.lock.lock().await;
361 Ok(self.load().await?.get(&digest(token)).map(|r| Invite {
362 token: token.to_string(),
363 max_uses: r.max_uses,
364 expires_at: r.expires_at,
365 }))
366 }
367}
368
369#[cfg(test)]
370mod tests {
371 use super::*;
372
373 fn inv(t: &str, uses: Option<u32>) -> Invite {
374 Invite {
375 token: t.into(),
376 max_uses: uses,
377 expires_at: None,
378 }
379 }
380
381 #[tokio::test]
382 async fn create_list_revoke_round_trip_across_instances() {
383 let dir = tempfile::tempdir().unwrap();
384 FileInviteStore::new(dir.path())
385 .insert(inv("tok-a", Some(2)))
386 .await
387 .unwrap();
388 let s = FileInviteStore::new(dir.path());
390 s.insert(inv("tok-b", None)).await.unwrap();
391 let rows = s.list().await.unwrap();
392 assert_eq!(rows.len(), 2);
393 assert_eq!(s.get("tok-a").await.unwrap().unwrap().max_uses, Some(2));
394 assert!(s.revoke("tok-a").await.unwrap());
395 assert!(!s.revoke("tok-a").await.unwrap());
396 assert!(
397 !s.revoke("TOK-B").await.unwrap(),
398 "tokens are case-sensitive"
399 );
400 let rows = s.list().await.unwrap();
401 assert_eq!(rows.len(), 1);
402 assert_eq!(rows[0].id, digest("tok-b")[..12]);
403 assert_eq!(rows[0].max_uses, None);
404 }
405
406 #[tokio::test]
407 async fn insert_is_idempotent_and_empty_store_lists_nothing() {
408 let dir = tempfile::tempdir().unwrap();
409 let s = FileInviteStore::new(dir.path());
410 assert!(s.list().await.unwrap().is_empty());
411 s.insert(inv("t", Some(1))).await.unwrap();
412 s.insert(inv("t", Some(9))).await.unwrap();
413 assert_eq!(s.get("t").await.unwrap().unwrap().max_uses, Some(1));
414 }
415
416 #[tokio::test]
417 async fn corrupt_file_is_an_error_not_a_silent_reset() {
418 let dir = tempfile::tempdir().unwrap();
419 let s = FileInviteStore::new(dir.path());
420 std::fs::create_dir_all(dir.path().join(DIR)).unwrap();
421 std::fs::write(s.path(), "{not json").unwrap();
422 assert!(s.list().await.is_err());
423 assert!(s.insert(inv("t", None)).await.is_err());
424 }
425
426 #[cfg(unix)]
427 #[tokio::test]
428 async fn file_is_owner_only() {
429 use std::os::unix::fs::PermissionsExt as _;
430 let dir = tempfile::tempdir().unwrap();
431 let s = FileInviteStore::new(dir.path());
432 s.insert(inv("t", None)).await.unwrap();
433 let mode = std::fs::metadata(s.path()).unwrap().permissions().mode();
434 assert_eq!(mode & 0o777, 0o600);
435 }
436
437 #[tokio::test]
439 async fn consume_enforces_the_use_cap_and_expiry() {
440 let dir = tempfile::tempdir().unwrap();
441 let s = FileInviteStore::new(dir.path());
442 s.insert(inv("once", Some(1))).await.unwrap();
443 assert_eq!(s.consume("once").await.unwrap(), Consume::Granted);
444 assert_eq!(s.consume("once").await.unwrap(), Consume::Exhausted);
445 assert_eq!(s.consume("nope").await.unwrap(), Consume::Unknown);
446 assert_eq!(s.list().await.unwrap()[0].uses, 1);
447 s.insert(Invite {
448 token: "old".into(),
449 max_uses: None,
450 expires_at: Some(Utc::now() - chrono::Duration::seconds(5)),
451 })
452 .await
453 .unwrap();
454 assert_eq!(s.consume("old").await.unwrap(), Consume::Expired);
455 }
456
457 #[tokio::test]
459 async fn concurrent_consumers_cannot_both_redeem_a_single_use_invite() {
460 let dir = tempfile::tempdir().unwrap();
461 FileInviteStore::new(dir.path())
462 .insert(inv("race", Some(1)))
463 .await
464 .unwrap();
465 let root = dir.path().to_path_buf();
466 let mut tasks = Vec::new();
467 for _ in 0..8 {
468 let root = root.clone();
469 tasks.push(tokio::spawn(async move {
470 FileInviteStore::new(root).consume("race").await.unwrap()
471 }));
472 }
473 let mut granted = 0;
474 for t in tasks {
475 if t.await.unwrap() == Consume::Granted {
476 granted += 1;
477 }
478 }
479 assert_eq!(granted, 1);
480 }
481
482 #[tokio::test]
485 async fn tokens_are_hashed_at_rest() {
486 let dir = tempfile::tempdir().unwrap();
487 let s = FileInviteStore::new(dir.path());
488 s.insert(inv("super-secret-token", None)).await.unwrap();
489 let raw = std::fs::read_to_string(s.path()).unwrap();
490 assert!(!raw.contains("super-secret-token"), "{raw}");
491 assert!(raw.contains(&digest("super-secret-token")));
492 assert!(s.get("super-secret-token").await.unwrap().is_some());
493 assert!(s.revoke("super-secret-token").await.unwrap());
494 }
495
496 #[tokio::test]
499 async fn legacy_plaintext_file_is_migrated() {
500 let dir = tempfile::tempdir().unwrap();
501 let s = FileInviteStore::new(dir.path());
502 std::fs::create_dir_all(dir.path().join(DIR)).unwrap();
503 std::fs::write(
504 s.path(),
505 r#"{"legacy-token":{"created":"2025-01-01T00:00:00Z","maxUses":2,"uses":1,"note":""}}"#,
506 )
507 .unwrap();
508 assert_eq!(s.consume("legacy-token").await.unwrap(), Consume::Granted);
509 assert_eq!(s.consume("legacy-token").await.unwrap(), Consume::Exhausted);
510 let raw = std::fs::read_to_string(s.path()).unwrap();
511 assert!(!raw.contains("legacy-token"), "{raw}");
512 }
513
514 #[tokio::test]
516 async fn stale_lock_file_is_broken() {
517 let dir = tempfile::tempdir().unwrap();
518 let s = FileInviteStore::new(dir.path());
519 std::fs::create_dir_all(dir.path().join(DIR)).unwrap();
520 let lock = dir.path().join(DIR).join(LOCK);
521 std::fs::write(&lock, b"").unwrap();
522 let old = std::time::SystemTime::now() - Duration::from_secs(120);
523 std::fs::File::options()
524 .write(true)
525 .open(&lock)
526 .unwrap()
527 .set_modified(old)
528 .unwrap();
529 s.insert(inv("t", None)).await.unwrap();
530 assert!(!lock.exists(), "released after use");
531 }
532
533 #[tokio::test]
535 async fn revoke_accepts_a_unique_id_prefix() {
536 let dir = tempfile::tempdir().unwrap();
537 let s = FileInviteStore::new(dir.path());
538 s.insert(inv("lost-token", None)).await.unwrap();
539 let id = s.list().await.unwrap()[0].id.clone();
540 assert!(!s.revoke(&id[..4]).await.unwrap(), "too short to be an id");
541 assert!(s.revoke(&id).await.unwrap());
542 assert!(s.list().await.unwrap().is_empty());
543 }
544}