Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

crates/monetize-server/src/named_users.rs

//! **The names of a paid "Add users" order, BESIDE the ledger** (G13, 5.4).
//!
//! The ledger names no member: a `UsersGranted` row carries an opaque
//! `principal_id` ([`monetize::ledger::principal_id`]) and the purchase's
//! `UsersAsk` is written without its name and e-mail. A PAID order still needs
//! them between the checkout and the grant (the room is made under the name,
//! the welcome goes to the address), so they wait here, keyed by the
//! purchase's reference, and are forgotten once the order is granted or
//! refunded. Names belong to the estate: once granted they live in the
//! customer's Gunnar, not in monetize.
//!
//! `<data_dir>/named-users.json`, mode 0600, rewritten whole (temp + rename)
//! on every change. It is NOT part of the backup export: a restore loses only
//! names of orders still in flight, and such an order's grant then refuses
//! (the payment is refunded by the ordinary path) rather than add a nameless
//! member.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;

/// The file's name under the data directory.
pub const FILE: &str = "named-users.json";

/// **Who a paid order adds**: the name the room is made under, and where its
/// welcome goes.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Named {
    pub name: String,
    #[serde(default, skip_serializing_if = "String::is_empty")]
    pub email: String,
}

/// The paid orders' names, by purchase reference.
pub struct NamedUsers {
    path: Option<PathBuf>,
    book: Mutex<BTreeMap<String, Named>>,
}

impl std::fmt::Debug for NamedUsers {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never the names: how many, and where.
        f.debug_struct("NamedUsers")
            .field("path", &self.path)
            .field("orders", &self.book.lock().map(|b| b.len()).unwrap_or(0))
            .finish()
    }
}

impl NamedUsers {
    /// Read `path` (absent: nobody waiting). A file that does not parse is
    /// refused — never read as "nobody", which would drop a paid order's name.
    pub fn open(path: impl AsRef<Path>) -> Result<NamedUsers, String> {
        let path = path.as_ref().to_path_buf();
        let book = match std::fs::read(&path) {
            Ok(bytes) => {
                serde_json::from_slice(&bytes).map_err(|e| format!("{}: {e}", path.display()))?
            }
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
            Err(e) => return Err(format!("{}: {e}", path.display())),
        };
        Ok(NamedUsers {
            path: Some(path),
            book: Mutex::new(book),
        })
    }

    /// Nothing written (tests, a server with no data directory).
    pub fn in_memory() -> NamedUsers {
        NamedUsers {
            path: None,
            book: Mutex::default(),
        }
    }

    /// The names a paid order waits to add.
    pub fn get(&self, reference: &str) -> Option<Named> {
        self.book
            .lock()
            .unwrap_or_else(|e| e.into_inner())
            .get(reference)
            .cloned()
    }

    /// Keep `named` for `reference` until [`Self::forget`]; on disk before it returns.
    pub fn keep(&self, reference: &str, named: Named) -> Result<(), String> {
        let mut book = self.book.lock().unwrap_or_else(|e| e.into_inner());
        let mut next = book.clone();
        next.insert(reference.to_string(), named);
        self.write(&next)?;
        *book = next;
        Ok(())
    }

    /// Forget `reference`'s names (granted, or refunded). Nothing to forget: nothing written.
    pub fn forget(&self, reference: &str) -> Result<(), String> {
        let mut book = self.book.lock().unwrap_or_else(|e| e.into_inner());
        if !book.contains_key(reference) {
            return Ok(());
        }
        let mut next = book.clone();
        next.remove(reference);
        self.write(&next)?;
        *book = next;
        Ok(())
    }

    fn write(&self, book: &BTreeMap<String, Named>) -> Result<(), String> {
        let Some(path) = &self.path else {
            return Ok(());
        };
        use std::io::Write as _;
        let bytes = serde_json::to_vec_pretty(book).map_err(|e| e.to_string())?;
        if let Some(d) = path.parent() {
            std::fs::create_dir_all(d).map_err(|e| format!("{}: {e}", d.display()))?;
        }
        let tmp = path.with_extension("json.tmp");
        let mut opts = std::fs::OpenOptions::new();
        opts.write(true).create(true).truncate(true);
        #[cfg(unix)]
        {
            use std::os::unix::fs::OpenOptionsExt as _;
            opts.mode(0o600);
        }
        let mut f = opts
            .open(&tmp)
            .map_err(|e| format!("{}: {e}", tmp.display()))?;
        f.write_all(&bytes)
            .and_then(|_| f.sync_all())
            .map_err(|e| format!("{}: {e}", tmp.display()))?;
        std::fs::rename(&tmp, path).map_err(|e| format!("{}: {e}", path.display()))
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn dir(name: &str) -> std::path::PathBuf {
        let d = std::env::temp_dir().join(format!(
            "monetize-named-users-{}-{name}",
            std::process::id()
        ));
        let _ = std::fs::remove_dir_all(&d);
        std::fs::create_dir_all(&d).unwrap();
        d
    }

    /// Kept across a restart until the grant (or the refund) forgets it; a
    /// file it cannot read is refused, never read as "nobody".
    #[test]
    fn a_name_is_kept_across_a_restart_until_it_is_forgotten() {
        let d = dir("restart");
        let path = d.join(FILE);
        let book = NamedUsers::open(&path).unwrap();
        assert!(book.get("gunnar/acme/x").is_none());
        book.keep(
            "gunnar/acme/x",
            Named {
                name: "zed".into(),
                email: "zed@acme.test".into(),
            },
        )
        .unwrap();
        book.keep(
            "gunnar/acme/y",
            Named {
                name: "eve".into(),
                email: String::new(),
            },
        )
        .unwrap();
        let again = NamedUsers::open(&path).unwrap();
        assert_eq!(
            again.get("gunnar/acme/x"),
            Some(Named {
                name: "zed".into(),
                email: "zed@acme.test".into()
            })
        );
        again.forget("gunnar/acme/x").unwrap();
        again.forget("gunnar/acme/x").unwrap();
        let after = NamedUsers::open(&path).unwrap();
        assert!(after.get("gunnar/acme/x").is_none(), "forgotten for good");
        assert_eq!(
            after.get("gunnar/acme/y").map(|n| n.name),
            Some("eve".into())
        );
        std::fs::write(&path, b"{not json").unwrap();
        assert!(
            NamedUsers::open(&path).is_err(),
            "a file it cannot read is refused"
        );
        let mem = NamedUsers::in_memory();
        mem.keep(
            "r",
            Named {
                name: "n".into(),
                email: String::new(),
            },
        )
        .unwrap();
        assert_eq!(mem.get("r").map(|n| n.name), Some("n".into()));
    }
}

/// R5a: a panic that once held the book must not make every later paid order
/// panic. The book is copy-on-write (written whole, then swapped), so a panic
/// leaves the last book that was written.
#[cfg(test)]
mod poisoned_book_tests {
    use super::*;

    #[test]
    fn a_poisoned_book_still_keeps_gets_and_forgets() {
        let book = NamedUsers::in_memory();
        let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
            let _held = book.book.lock();
            panic!("a panic while the book is held");
        }));
        assert!(book.book.is_poisoned());
        book.keep(
            "r",
            Named {
                name: "n".into(),
                email: String::new(),
            },
        )
        .expect("kept");
        assert_eq!(book.get("r").map(|n| n.name), Some("n".into()));
        book.forget("r").expect("forgotten");
        assert!(book.get("r").is_none());
    }
}