use std::collections::BTreeMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use lonca_core::canonical::{random_bytes, sha256_hex};
use lonca_core::keys::KeyPair;
use crate::sys::current_uid;
use lonca_core::{Error, Result};
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug)]
pub struct Home {
pub root: PathBuf,
backups: Option<PathBuf>,
}
impl Home {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
backups: None,
}
}
pub fn from_env() -> Self {
let home = match std::env::var_os("LONCA_HOME") {
Some(p) => Self::new(p),
None => {
let home = std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."));
Self::new(home.join(".lonca"))
}
};
match std::env::var_os("LONCA_BACKUPS") {
Some(p) => home.with_backups(p),
None => home,
}
}
pub fn with_backups(mut self, dir: impl Into<PathBuf>) -> Self {
self.backups = Some(dir.into());
self
}
pub fn backups(&self) -> PathBuf {
self.backups
.clone()
.unwrap_or_else(|| self.root.join("backups"))
}
pub fn ensure(&self) -> Result<()> {
fs::create_dir_all(self.repos())?;
owned_by(&self.root, current_uid())?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
pub fn socket(&self) -> PathBuf {
std::env::var_os("LONCA_SOCKET")
.map(PathBuf::from)
.unwrap_or_else(|| self.root.join("lonca.sock"))
}
pub fn key_path(&self) -> PathBuf {
self.root.join("daemon.key")
}
pub fn repos(&self) -> PathBuf {
self.root.join("repos")
}
pub fn repo_path(&self, name: &str) -> PathBuf {
self.repos().join(format!("{name}.git"))
}
pub fn imports(&self) -> PathBuf {
self.root.join("imports")
}
pub fn scratch(&self) -> PathBuf {
self.root.join("scratch")
}
pub fn tokens_path(&self) -> PathBuf {
self.root.join("tokens.json")
}
pub fn freeze_flag(&self, repo: &str) -> PathBuf {
self.root.join("freeze").join(repo)
}
pub fn load_key(&self) -> Result<KeyPair> {
KeyPair::load_or_create(&self.key_path(), current_uid())
}
pub fn next_key_path(&self) -> PathBuf {
self.root.join("daemon.next.key")
}
pub fn load_keys(&self) -> Result<(KeyPair, Option<KeyPair>)> {
let next = self.next_key_path();
if !self.key_path().exists() && next.exists() {
fs::rename(&next, self.key_path())?;
}
let current = self.load_key()?;
let next = if next.exists() {
Some(KeyPair::load(&next, current_uid())?)
} else {
None
};
Ok((current, next))
}
pub fn load_or_create_next_key(&self) -> Result<KeyPair> {
KeyPair::load_or_create(&self.next_key_path(), current_uid())
}
pub fn promote_next_key(&self) -> Result<()> {
fs::remove_file(self.key_path())?;
fs::rename(self.next_key_path(), self.key_path())?;
Ok(())
}
}
fn owned_by(root: &Path, uid: u32) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let owner = fs::metadata(root)?.uid();
if owner != uid {
return Err(Error::Invalid(format!(
"daemon home {} belongs to uid {owner}, not to this daemon (uid {uid}); \
run the daemon as its owner, or give it a home of its own",
root.display()
)));
}
}
let _ = (root, uid);
Ok(())
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(untagged)]
pub enum TokenEntry {
Agent {
repo: String,
session: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
expires_ms: Option<i64>,
},
Human {
human: String,
key: String,
expires_ms: i64,
},
}
#[derive(Default)]
pub struct Tokens {
path: Option<PathBuf>,
map: BTreeMap<String, TokenEntry>,
}
impl Tokens {
pub fn load(path: &Path) -> Result<Self> {
let map = match fs::read(path) {
Ok(b) => serde_json::from_slice(&b)?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
Err(e) => return Err(e.into()),
};
Ok(Self {
path: Some(path.to_path_buf()),
map,
})
}
pub fn issue(&mut self, repo: &str, session: &str, expires_ms: i64) -> Result<String> {
self.insert(
"lonca_",
TokenEntry::Agent {
repo: repo.into(),
session: session.into(),
expires_ms: Some(expires_ms),
},
)
}
pub fn rotate(&mut self, old: &str) -> Result<Option<String>> {
let Some(entry) = self.map.remove(&sha256_hex(old.as_bytes())) else {
return Ok(None);
};
let prefix = match entry {
TokenEntry::Agent { .. } => "lonca_",
TokenEntry::Human { .. } => "lonca_h_",
};
self.insert(prefix, entry).map(Some)
}
pub fn issue_human(&mut self, human: &str, key: &str, expires_ms: i64) -> Result<String> {
self.insert(
"lonca_h_",
TokenEntry::Human {
human: human.into(),
key: key.into(),
expires_ms,
},
)
}
fn insert(&mut self, prefix: &str, entry: TokenEntry) -> Result<String> {
let token = format!("{prefix}{}", hex::encode(random_bytes::<32>()));
self.map.insert(sha256_hex(token.as_bytes()), entry);
self.save()?;
Ok(token)
}
pub fn lookup(&self, token: &str) -> Option<&TokenEntry> {
self.map.get(&sha256_hex(token.as_bytes()))
}
pub fn revoke(&mut self, token: &str) -> Result<()> {
if self.map.remove(&sha256_hex(token.as_bytes())).is_some() {
self.save()?;
}
Ok(())
}
pub fn revoke_sessions(&mut self, repo: &str, sessions: &[String]) -> Result<()> {
let before = self.map.len();
self.map.retain(|_, e| match e {
TokenEntry::Agent {
repo: r, session, ..
} => !(r == repo && sessions.contains(session)),
TokenEntry::Human { .. } => true,
});
if self.map.len() != before {
self.save()?;
}
Ok(())
}
pub fn sweep_expired(&mut self, now_ms: i64, live: impl Fn(&str, &str) -> bool) -> Result<()> {
let before = self.map.len();
self.map.retain(|_, e| match e {
TokenEntry::Agent {
repo,
session,
expires_ms,
} => expires_ms.is_none_or(|end| end > now_ms) && live(repo, session),
TokenEntry::Human { expires_ms, .. } => *expires_ms > now_ms,
});
if self.map.len() != before {
self.save()?;
}
Ok(())
}
fn save(&self) -> Result<()> {
let Some(path) = &self.path else {
return Ok(());
};
let tmp = path.with_extension("tmp");
let mut opts = fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&tmp)?;
f.write_all(&serde_json::to_vec_pretty(&self.map)?)?;
f.sync_all()?;
fs::rename(&tmp, path).map_err(Error::from)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_home_must_belong_to_the_daemon() {
let dir = tempfile::tempdir().unwrap();
let home = Home::new(dir.path().join("h"));
home.ensure().unwrap();
assert!(owned_by(&home.root, current_uid()).is_ok());
let e = owned_by(&home.root, current_uid() + 1)
.unwrap_err()
.to_string();
assert!(e.contains("belongs to uid"), "{e}");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&home.root).unwrap().permissions().mode();
assert_eq!(mode & 0o777, 0o700);
}
}
#[test]
fn tokens_are_stored_as_hashes_and_both_kinds_survive_a_reload() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tokens.json");
std::fs::write(
&path,
format!(
"{{\"{}\": {{\"repo\": \"demo\", \"session\": \"s0\"}}}}",
sha256_hex(b"lonca_old")
),
)
.unwrap();
let mut t = Tokens::load(&path).unwrap();
let a = t.issue("demo", "s1", 5_000).unwrap();
let h = t.issue_human("ayse", "ab", 2_000).unwrap();
assert!(a.starts_with("lonca_") && h.starts_with("lonca_h_"));
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains(&a) && !text.contains(&h), "{text}");
let t = Tokens::load(&path).unwrap();
assert_eq!(
t.lookup("lonca_old"),
Some(&TokenEntry::Agent {
repo: "demo".into(),
session: "s0".into(),
expires_ms: None,
})
);
assert_eq!(
t.lookup(&h),
Some(&TokenEntry::Human {
human: "ayse".into(),
key: "ab".into(),
expires_ms: 2_000
})
);
let mut t = t;
t.revoke_sessions("demo", &["s1".into()]).unwrap();
assert!(t.lookup(&a).is_none() && t.lookup(&h).is_some());
t.sweep_expired(1_999, |_, _| true).unwrap();
assert!(t.lookup(&h).is_some());
t.sweep_expired(2_000, |_, _| true).unwrap();
assert!(t.lookup(&h).is_none());
t.revoke("lonca_old").unwrap();
t.revoke("lonca_old").unwrap();
assert!(Tokens::load(&path).unwrap().lookup("lonca_old").is_none());
}
#[test]
fn a_sweep_drops_agent_tokens_that_no_longer_work() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tokens.json");
let mut t = Tokens::load(&path).unwrap();
let a = t.issue("demo", "s1", 2_000).unwrap();
let b = t.issue("demo", "s2", 9_000).unwrap();
t.sweep_expired(1_999, |_, _| true).unwrap();
assert!(t.lookup(&a).is_some() && t.lookup(&b).is_some());
t.sweep_expired(2_000, |_, _| true).unwrap();
assert!(t.lookup(&a).is_none() && t.lookup(&b).is_some());
t.sweep_expired(2_000, |repo, s| repo == "demo" && s != "s2")
.unwrap();
assert!(t.lookup(&b).is_none());
let text = std::fs::read_to_string(&path).unwrap();
assert!(!text.contains("s1") && !text.contains("s2"), "{text}");
}
#[test]
fn a_rotation_replaces_the_token_in_one_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("tokens.json");
let mut t = Tokens::load(&path).unwrap();
let old = t.issue("demo", "s1", 9_000).unwrap();
let new = t.rotate(&old).unwrap().unwrap();
assert!(new.starts_with("lonca_") && !new.starts_with("lonca_h_"));
assert_ne!(new, old);
let t2 = Tokens::load(&path).unwrap();
assert!(t2.lookup(&old).is_none());
assert_eq!(t2.lookup(&new), t.lookup(&new));
assert!(t.rotate(&old).unwrap().is_none(), "the old one is gone");
let h = t.issue_human("ayse", "ab", 9_000).unwrap();
assert!(t.rotate(&h).unwrap().unwrap().starts_with("lonca_h_"));
t.revoke_sessions("demo", &["s1".into()]).unwrap();
assert!(
t.lookup(&new).is_none(),
"revocation reaches a rotated token"
);
}
}