Skip to main content

solid_pod_rs_server/cli/
invite_store.rs

1//! File-backed invite store behind the `invite` CLI.
2//!
3//! JSS keeps invite codes in `<data root>/.server/invites.json`
4//! (`src/idp/invites.js` `loadInvites` / `saveInvites`) and exposes
5//! `invite create`, `invite list` and `invite revoke <code>`
6//! (`bin/jss.js` 457-540). The Rust CLI previously minted tokens into a
7//! throwaway in-memory store, so nothing survived the process; this store
8//! gives `create`, `list`, `revoke` and `consume` one shared, durable home.
9//!
10//! The file keeps the JSS field names where a counterpart exists
11//! (`created`, `maxUses`, `uses`, `note`) and adds `expiresAt`. Tokens are
12//! case-sensitive here (43-character base64url), so unlike JSS `revoke`
13//! does not upper-case its argument.
14//!
15//! # Hardening (security review L7)
16//!
17//! * **Hashed at rest.** Records are keyed by the SHA-256 hex of the token
18//!   and carry it as `tokenSha256`; the token itself is never written, so a
19//!   leaked `invites.json` redeems nothing. `invite create` prints the token
20//!   once. Files written by the earlier plaintext layout (records without
21//!   `tokenSha256`) are migrated in place on the next write.
22//! * **`max_uses` is enforced.** [`FileInviteStore::consume`] is the one
23//!   operation a redemption path must call: it checks expiry and the use cap
24//!   and increments `uses` atomically.
25//! * **Serialised across processes.** Every read-modify-write holds an
26//!   in-process mutex and an exclusive lock file (`invites.json.lock`,
27//!   `create_new`, stale locks older than 30 s are broken), so a server write
28//!   cannot resurrect an invite a CLI process just revoked.
29//! * The file is written with mode 0600 via a uniquely named temp file and an
30//!   atomic rename.
31
32use 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";
47/// A lock file older than this belongs to a dead process and is broken.
48const STALE_LOCK: Duration = Duration::from_secs(30);
49/// How long a writer waits for the lock before giving up.
50const LOCK_WAIT: Duration = Duration::from_secs(10);
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53#[serde(rename_all = "camelCase")]
54struct Record {
55    /// SHA-256 hex of the token. Absent only in the legacy plaintext layout,
56    /// where the map key is the token itself.
57    #[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
69/// SHA-256 hex digest of an invite token: the at-rest key.
70fn digest(token: &str) -> String {
71    hex::encode(Sha256::digest(token.as_bytes()))
72}
73
74/// Re-key legacy plaintext records by their digest (in memory; the file is
75/// rewritten by the next mutation).
76fn 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/// One row of `invite list`.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct InviteRow {
92    /// Short identifier (first 12 hex characters of the token's SHA-256).
93    /// The token itself is not stored, so it cannot be listed.
94    pub id: String,
95    /// Redemption cap (`None` = unlimited).
96    pub max_uses: Option<u32>,
97    /// Redemptions so far.
98    pub uses: u32,
99    /// Creation time.
100    pub created: DateTime<Utc>,
101    /// Optional expiry.
102    pub expires_at: Option<DateTime<Utc>>,
103}
104
105/// Outcome of [`FileInviteStore::consume`].
106#[derive(Debug, Clone, Copy, PartialEq, Eq)]
107pub enum Consume {
108    /// The invite was valid; one use has been recorded.
109    Granted,
110    /// No such invite (or it was revoked).
111    Unknown,
112    /// Past its expiry.
113    Expired,
114    /// `max_uses` redemptions already recorded.
115    Exhausted,
116}
117
118/// Invite store persisted at `<root>/.server/invites.json`.
119pub struct FileInviteStore {
120    path: PathBuf,
121    lock: Mutex<()>,
122}
123
124/// Exclusive cross-process lock held for one read-modify-write; the lock file
125/// is removed on drop.
126struct 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    /// Store rooted at the pod data directory `root`.
138    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    /// Location of the backing file.
146    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    /// Take the exclusive lock file, breaking one left by a dead process.
155    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    /// Every stored invite, oldest first.
243    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    /// Delete an invite by its token, or by the unique id prefix shown in
262    /// `invite list` (8 or more hex characters). `Ok(true)` if it existed
263    /// (JSS `revokeInvite`).
264    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            // The token is not stored, so `invite list` shows ids; accept a
270            // unique id prefix (8+ hex characters) for a lost token.
271            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    /// Redeem one use of `token`: checks expiry and the `max_uses` cap and
298    /// records the use, all under the store lock, so two concurrent
299    /// redemptions of a single-use invite cannot both succeed. A redemption
300    /// path must call this and proceed only on [`Consume::Granted`].
301    ///
302    /// ```no_run
303    /// use solid_pod_rs_idp::{Invite, InviteStore};
304    /// use solid_pod_rs_server::cli::{Consume, FileInviteStore};
305    ///
306    /// # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
307    /// let store = FileInviteStore::new("/var/lib/pod");
308    /// store
309    ///     .insert(Invite { token: "t".into(), max_uses: Some(1), expires_at: None })
310    ///     .await?;
311    /// assert_eq!(store.consume("t").await?, Consume::Granted);
312    /// assert_eq!(store.consume("t").await?, Consume::Exhausted);
313    /// # Ok(()) }
314    /// ```
315    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        // Idempotent on the token, as the trait requires.
342        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        // A second instance (a later CLI process) sees the first's write.
389        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    /// L7: `max_uses` is enforced by `consume`, and expiry too.
438    #[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    /// L7: two stores (two processes) racing for a single-use invite: one wins.
458    #[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    /// L7: the token never reaches disk, and a revoke by one store instance is
483    /// not undone by another instance's write.
484    #[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    /// L7: a file in the old plaintext layout is still honoured and is
497    /// rewritten hashed on the next write.
498    #[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    /// L7: a lock file left by a dead process is broken after the stale age.
515    #[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    /// L7: a lost token can still be revoked through the id `list` prints.
534    #[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}