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());
}
}