use anyhow::{Context, Result};
use rand::Rng;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub const TOKEN_TTL: Duration = Duration::from_secs(5 * 60);
struct PendingToken {
name: Option<String>,
ports: Vec<u16>,
expires: Instant,
}
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct Claimed {
pub name: Option<String>,
pub ports: Vec<u16>,
}
#[derive(Default)]
pub struct Tokens {
pending: Mutex<HashMap<String, PendingToken>>,
}
impl Tokens {
pub fn mint(&self, name: Option<String>, ports: Vec<u16>) -> String {
self.mint_with_ttl(name, ports, TOKEN_TTL)
}
fn mint_with_ttl(&self, name: Option<String>, ports: Vec<u16>, ttl: Duration) -> String {
let bytes: [u8; 32] = rand::rng().random();
let token: String = bytes.iter().map(|b| format!("{:02x}", b)).collect();
self.pending.lock().unwrap().insert(
token.clone(),
PendingToken {
name,
ports,
expires: Instant::now() + ttl,
},
);
token
}
pub fn claim(&self, token: &str) -> Option<Claimed> {
let mut pending = self.pending.lock().unwrap();
let now = Instant::now();
pending.retain(|_, t| t.expires > now);
pending.remove(token).map(|t| Claimed {
name: t.name,
ports: t.ports,
})
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pin {
pub key: String,
#[serde(default)]
pub label: Option<String>,
}
pub struct Pins {
path: PathBuf,
}
impl Pins {
pub fn new(path: PathBuf) -> Self {
Self { path }
}
pub fn load(&self) -> Result<Vec<Pin>> {
if !self.path.exists() {
return Ok(Vec::new());
}
let data = std::fs::read(&self.path)
.with_context(|| format!("failed to read {}", self.path.display()))?;
serde_json::from_slice(&data)
.with_context(|| format!("failed to parse {}", self.path.display()))
}
pub fn add(&self, key: &str, label: Option<&str>) -> Result<()> {
let mut pins = self.load()?;
pins.retain(|p| p.key != key);
pins.push(Pin {
key: key.to_string(),
label: label.map(|l| l.to_string()),
});
self.save(&pins)
}
pub fn remove(&self, key: &str) -> Result<()> {
let mut pins = self.load()?;
pins.retain(|p| p.key != key);
self.save(&pins)
}
fn save(&self, pins: &[Pin]) -> Result<()> {
if let Some(parent) = self.path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}
let data = serde_json::to_vec_pretty(pins)?;
std::fs::write(&self.path, data)
.with_context(|| format!("failed to write {}", self.path.display()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_is_single_use() {
let tokens = Tokens::default();
let token = tokens.mint(Some("rustdev".to_string()), vec![]);
assert_eq!(
tokens.claim(&token),
Some(Claimed {
name: Some("rustdev".to_string()),
ports: vec![],
})
);
assert_eq!(tokens.claim(&token), None);
}
#[test]
fn unknown_token_fails() {
let tokens = Tokens::default();
assert_eq!(tokens.claim("nope"), None);
}
#[test]
fn expired_token_fails() {
let tokens = Tokens::default();
let token = tokens.mint_with_ttl(Some("rustdev".to_string()), vec![], Duration::ZERO);
assert_eq!(tokens.claim(&token), None);
}
#[test]
fn pins_roundtrip() {
let dir = std::env::temp_dir().join(format!("pai-sho-pins-test-{}", std::process::id()));
let pins = Pins::new(dir.join("peers.json"));
assert!(pins.load().unwrap().is_empty());
pins.add("k1", Some("rustdev")).unwrap();
pins.add("k2", Some("webdev")).unwrap();
pins.add("k1", Some("rustdev2")).unwrap();
let loaded = pins.load().unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(
loaded.iter().find(|p| p.key == "k1").unwrap().label,
Some("rustdev2".to_string())
);
pins.remove("k2").unwrap();
assert_eq!(pins.load().unwrap().len(), 1);
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn a_token_carries_its_ports() {
let tokens = Tokens::default();
let token = tokens.mint(None, vec![8080]);
assert_eq!(
tokens.claim(&token),
Some(Claimed {
name: None,
ports: vec![8080],
})
);
}
}