use std::io;
use std::path::{Path, PathBuf};
use super::Invite;
#[derive(Debug, Clone)]
pub struct InviteFile {
path: PathBuf,
}
impl InviteFile {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self { path: path.into() }
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn load(&self, now_epoch: u64) -> Vec<Invite> {
let bytes = match std::fs::read(&self.path) {
Ok(b) => b,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Vec::new(),
Err(e) => {
tracing::warn!(%e, path = %self.path.display(), "could not read the invite file; outstanding invites are lost");
return Vec::new();
}
};
match serde_json::from_slice::<Vec<Invite>>(&bytes) {
Ok(v) => v
.into_iter()
.filter(|i| i.expires_at_epoch >= now_epoch)
.collect(),
Err(e) => {
tracing::warn!(%e, path = %self.path.display(), "the invite file did not parse; outstanding invites are lost");
Vec::new()
}
}
}
pub fn store(&self, invites: &[&Invite]) -> io::Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)?;
}
static SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let seq = SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let tmp = self
.path
.with_extension(format!("tmp.{}.{}", std::process::id(), seq));
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let result = (|| -> io::Result<()> {
let bytes = serde_json::to_vec(invites)?;
let mut f = opts.open(&tmp)?;
io::Write::write_all(&mut f, &bytes)?;
f.sync_all()?;
std::fs::rename(&tmp, &self.path)
})();
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
fn invite(secret: u8, expires_at_epoch: u64) -> Invite {
Invite {
secret: [secret; 32],
inviter_id: [9u8; 32],
inviter_addr_json: "{}".into(),
nickname: "alice".into(),
services: vec!["notes".into()],
expires_at_epoch,
app_label: None,
uses_remaining: 1,
peer_nickname: Some("their-laptop".into()),
}
}
#[test]
fn an_invite_round_trips_through_the_file() {
let dir = tempfile::tempdir().unwrap();
let f = InviteFile::new(dir.path().join("invites.json"));
assert!(
f.load(100).is_empty(),
"a node that has never minted has no file, and that is not an error"
);
let a = invite(1, 1_000);
f.store(&[&a]).unwrap();
assert_eq!(f.load(100), vec![a], "survives the registry that wrote it");
}
#[test]
fn an_expired_invite_does_not_survive_a_load() {
let dir = tempfile::tempdir().unwrap();
let f = InviteFile::new(dir.path().join("invites.json"));
let live = invite(1, 1_000);
let dead = invite(2, 500);
f.store(&[&live, &dead]).unwrap();
let loaded = f.load(600);
assert_eq!(loaded, vec![live], "the expired one is dropped: {loaded:?}");
}
#[cfg(unix)]
#[test]
fn the_invite_file_is_not_readable_by_anyone_else() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let f = InviteFile::new(dir.path().join("invites.json"));
f.store(&[&invite(1, 1_000)]).unwrap();
let mode = std::fs::metadata(f.path()).unwrap().permissions().mode();
assert_eq!(
mode & 0o777,
0o600,
"invite secrets must not be group/world readable: {:o}",
mode & 0o777
);
}
#[test]
fn a_corrupt_file_degrades_instead_of_panicking() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("invites.json");
std::fs::write(&path, b"{ this is not an invite array").unwrap();
assert!(InviteFile::new(&path).load(0).is_empty());
std::fs::write(&path, br#"[{"secret":[1,1,1"#).unwrap();
assert!(InviteFile::new(&path).load(0).is_empty());
}
#[test]
fn storing_replaces_rather_than_appends() {
let dir = tempfile::tempdir().unwrap();
let f = InviteFile::new(dir.path().join("invites.json"));
let a = invite(1, 1_000);
let b = invite(2, 1_000);
f.store(&[&a, &b]).unwrap();
assert_eq!(f.load(0).len(), 2);
f.store(&[&b]).unwrap();
assert_eq!(
f.load(0),
vec![b],
"a redeemed invite must not be resurrectable by a restart"
);
}
#[test]
fn a_failed_write_leaves_no_secret_bearing_temp() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("invites.json");
std::fs::create_dir(&path).unwrap();
let f = InviteFile::new(&path);
assert!(f.store(&[&invite(1, 1_000)]).is_err());
let leftovers: Vec<_> = std::fs::read_dir(dir.path())
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.file_name().to_string_lossy().to_string())
.filter(|n| n.contains("tmp"))
.collect();
assert!(
leftovers.is_empty(),
"a failed write must not litter a world-readable temp full of bearer secrets: \
{leftovers:?}"
);
}
}