1use std::collections::BTreeMap;
20use std::fs;
21use std::io::Write;
22use std::path::{Path, PathBuf};
23
24use lonca_core::canonical::{random_bytes, sha256_hex};
25use lonca_core::keys::KeyPair;
26
27use crate::sys::current_uid;
28use lonca_core::{Error, Result};
29use serde::{Deserialize, Serialize};
30
31#[derive(Clone, Debug)]
32pub struct Home {
33 pub root: PathBuf,
34 backups: Option<PathBuf>,
35}
36
37impl Home {
38 pub fn new(root: impl Into<PathBuf>) -> Self {
39 Self {
40 root: root.into(),
41 backups: None,
42 }
43 }
44
45 pub fn from_env() -> Self {
47 let home = match std::env::var_os("LONCA_HOME") {
48 Some(p) => Self::new(p),
49 None => {
50 let home = std::env::var_os("HOME")
51 .map(PathBuf::from)
52 .unwrap_or_else(|| PathBuf::from("."));
53 Self::new(home.join(".lonca"))
54 }
55 };
56 match std::env::var_os("LONCA_BACKUPS") {
57 Some(p) => home.with_backups(p),
58 None => home,
59 }
60 }
61
62 pub fn with_backups(mut self, dir: impl Into<PathBuf>) -> Self {
65 self.backups = Some(dir.into());
66 self
67 }
68
69 pub fn backups(&self) -> PathBuf {
70 self.backups
71 .clone()
72 .unwrap_or_else(|| self.root.join("backups"))
73 }
74
75 pub fn ensure(&self) -> Result<()> {
78 fs::create_dir_all(self.repos())?;
79 owned_by(&self.root, current_uid())?;
80 #[cfg(unix)]
81 {
82 use std::os::unix::fs::PermissionsExt;
83 fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700))?;
84 }
85 Ok(())
86 }
87
88 pub fn socket(&self) -> PathBuf {
91 std::env::var_os("LONCA_SOCKET")
92 .map(PathBuf::from)
93 .unwrap_or_else(|| self.root.join("lonca.sock"))
94 }
95
96 pub fn key_path(&self) -> PathBuf {
97 self.root.join("daemon.key")
98 }
99
100 pub fn repos(&self) -> PathBuf {
101 self.root.join("repos")
102 }
103
104 pub fn repo_path(&self, name: &str) -> PathBuf {
105 self.repos().join(format!("{name}.git"))
106 }
107
108 pub fn imports(&self) -> PathBuf {
111 self.root.join("imports")
112 }
113
114 pub fn scratch(&self) -> PathBuf {
115 self.root.join("scratch")
116 }
117
118 pub fn tokens_path(&self) -> PathBuf {
119 self.root.join("tokens.json")
120 }
121
122 pub fn freeze_flag(&self, repo: &str) -> PathBuf {
124 self.root.join("freeze").join(repo)
125 }
126
127 pub fn load_key(&self) -> Result<KeyPair> {
128 KeyPair::load_or_create(&self.key_path(), current_uid())
129 }
130
131 pub fn next_key_path(&self) -> PathBuf {
133 self.root.join("daemon.next.key")
134 }
135
136 pub fn load_keys(&self) -> Result<(KeyPair, Option<KeyPair>)> {
140 let next = self.next_key_path();
141 if !self.key_path().exists() && next.exists() {
142 fs::rename(&next, self.key_path())?;
143 }
144 let current = self.load_key()?;
145 let next = if next.exists() {
146 Some(KeyPair::load(&next, current_uid())?)
147 } else {
148 None
149 };
150 Ok((current, next))
151 }
152
153 pub fn load_or_create_next_key(&self) -> Result<KeyPair> {
155 KeyPair::load_or_create(&self.next_key_path(), current_uid())
156 }
157
158 pub fn promote_next_key(&self) -> Result<()> {
163 fs::remove_file(self.key_path())?;
164 fs::rename(self.next_key_path(), self.key_path())?;
165 Ok(())
166 }
167}
168
169fn owned_by(root: &Path, uid: u32) -> Result<()> {
172 #[cfg(unix)]
173 {
174 use std::os::unix::fs::MetadataExt;
175 let owner = fs::metadata(root)?.uid();
176 if owner != uid {
177 return Err(Error::Invalid(format!(
178 "daemon home {} belongs to uid {owner}, not to this daemon (uid {uid}); \
179 run the daemon as its owner, or give it a home of its own",
180 root.display()
181 )));
182 }
183 }
184 let _ = (root, uid);
185 Ok(())
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
192#[serde(untagged)]
193pub enum TokenEntry {
194 Agent {
198 repo: String,
199 session: String,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
201 expires_ms: Option<i64>,
202 },
203 Human {
206 human: String,
207 key: String,
208 expires_ms: i64,
209 },
210}
211
212#[derive(Default)]
214pub struct Tokens {
215 path: Option<PathBuf>,
216 map: BTreeMap<String, TokenEntry>,
217}
218
219impl Tokens {
220 pub fn load(path: &Path) -> Result<Self> {
221 let map = match fs::read(path) {
222 Ok(b) => serde_json::from_slice(&b)?,
223 Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
224 Err(e) => return Err(e.into()),
225 };
226 Ok(Self {
227 path: Some(path.to_path_buf()),
228 map,
229 })
230 }
231
232 pub fn issue(&mut self, repo: &str, session: &str, expires_ms: i64) -> Result<String> {
234 self.insert(
235 "lonca_",
236 TokenEntry::Agent {
237 repo: repo.into(),
238 session: session.into(),
239 expires_ms: Some(expires_ms),
240 },
241 )
242 }
243
244 pub fn rotate(&mut self, old: &str) -> Result<Option<String>> {
247 let Some(entry) = self.map.remove(&sha256_hex(old.as_bytes())) else {
248 return Ok(None);
249 };
250 let prefix = match entry {
251 TokenEntry::Agent { .. } => "lonca_",
252 TokenEntry::Human { .. } => "lonca_h_",
253 };
254 self.insert(prefix, entry).map(Some)
255 }
256
257 pub fn issue_human(&mut self, human: &str, key: &str, expires_ms: i64) -> Result<String> {
258 self.insert(
259 "lonca_h_",
260 TokenEntry::Human {
261 human: human.into(),
262 key: key.into(),
263 expires_ms,
264 },
265 )
266 }
267
268 fn insert(&mut self, prefix: &str, entry: TokenEntry) -> Result<String> {
269 let token = format!("{prefix}{}", hex::encode(random_bytes::<32>()));
270 self.map.insert(sha256_hex(token.as_bytes()), entry);
271 self.save()?;
272 Ok(token)
273 }
274
275 pub fn lookup(&self, token: &str) -> Option<&TokenEntry> {
276 self.map.get(&sha256_hex(token.as_bytes()))
277 }
278
279 pub fn revoke(&mut self, token: &str) -> Result<()> {
281 if self.map.remove(&sha256_hex(token.as_bytes())).is_some() {
282 self.save()?;
283 }
284 Ok(())
285 }
286
287 pub fn revoke_sessions(&mut self, repo: &str, sessions: &[String]) -> Result<()> {
288 let before = self.map.len();
289 self.map.retain(|_, e| match e {
290 TokenEntry::Agent {
291 repo: r, session, ..
292 } => !(r == repo && sessions.contains(session)),
293 TokenEntry::Human { .. } => true,
294 });
295 if self.map.len() != before {
296 self.save()?;
297 }
298 Ok(())
299 }
300
301 pub fn sweep_expired(&mut self, now_ms: i64, live: impl Fn(&str, &str) -> bool) -> Result<()> {
305 let before = self.map.len();
306 self.map.retain(|_, e| match e {
307 TokenEntry::Agent {
308 repo,
309 session,
310 expires_ms,
311 } => expires_ms.is_none_or(|end| end > now_ms) && live(repo, session),
312 TokenEntry::Human { expires_ms, .. } => *expires_ms > now_ms,
313 });
314 if self.map.len() != before {
315 self.save()?;
316 }
317 Ok(())
318 }
319
320 fn save(&self) -> Result<()> {
321 let Some(path) = &self.path else {
322 return Ok(());
323 };
324 let tmp = path.with_extension("tmp");
325 let mut opts = fs::OpenOptions::new();
326 opts.write(true).create(true).truncate(true);
327 #[cfg(unix)]
328 {
329 use std::os::unix::fs::OpenOptionsExt;
330 opts.mode(0o600);
331 }
332 let mut f = opts.open(&tmp)?;
333 f.write_all(&serde_json::to_vec_pretty(&self.map)?)?;
334 f.sync_all()?;
335 fs::rename(&tmp, path).map_err(Error::from)
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn a_home_must_belong_to_the_daemon() {
345 let dir = tempfile::tempdir().unwrap();
346 let home = Home::new(dir.path().join("h"));
347 home.ensure().unwrap();
348 assert!(owned_by(&home.root, current_uid()).is_ok());
349 let e = owned_by(&home.root, current_uid() + 1)
350 .unwrap_err()
351 .to_string();
352 assert!(e.contains("belongs to uid"), "{e}");
353 #[cfg(unix)]
354 {
355 use std::os::unix::fs::PermissionsExt;
356 let mode = fs::metadata(&home.root).unwrap().permissions().mode();
357 assert_eq!(mode & 0o777, 0o700);
358 }
359 }
360
361 #[test]
362 fn tokens_are_stored_as_hashes_and_both_kinds_survive_a_reload() {
363 let dir = tempfile::tempdir().unwrap();
364 let path = dir.path().join("tokens.json");
365 std::fs::write(
367 &path,
368 format!(
369 "{{\"{}\": {{\"repo\": \"demo\", \"session\": \"s0\"}}}}",
370 sha256_hex(b"lonca_old")
371 ),
372 )
373 .unwrap();
374 let mut t = Tokens::load(&path).unwrap();
375 let a = t.issue("demo", "s1", 5_000).unwrap();
376 let h = t.issue_human("ayse", "ab", 2_000).unwrap();
377 assert!(a.starts_with("lonca_") && h.starts_with("lonca_h_"));
378 let text = std::fs::read_to_string(&path).unwrap();
379 assert!(!text.contains(&a) && !text.contains(&h), "{text}");
380 let t = Tokens::load(&path).unwrap();
381 assert_eq!(
382 t.lookup("lonca_old"),
383 Some(&TokenEntry::Agent {
384 repo: "demo".into(),
385 session: "s0".into(),
386 expires_ms: None,
387 })
388 );
389 assert_eq!(
390 t.lookup(&h),
391 Some(&TokenEntry::Human {
392 human: "ayse".into(),
393 key: "ab".into(),
394 expires_ms: 2_000
395 })
396 );
397 let mut t = t;
398 t.revoke_sessions("demo", &["s1".into()]).unwrap();
399 assert!(t.lookup(&a).is_none() && t.lookup(&h).is_some());
400 t.sweep_expired(1_999, |_, _| true).unwrap();
401 assert!(t.lookup(&h).is_some());
402 t.sweep_expired(2_000, |_, _| true).unwrap();
403 assert!(t.lookup(&h).is_none());
404 t.revoke("lonca_old").unwrap();
405 t.revoke("lonca_old").unwrap();
406 assert!(Tokens::load(&path).unwrap().lookup("lonca_old").is_none());
407 }
408
409 #[test]
412 fn a_sweep_drops_agent_tokens_that_no_longer_work() {
413 let dir = tempfile::tempdir().unwrap();
414 let path = dir.path().join("tokens.json");
415 let mut t = Tokens::load(&path).unwrap();
416 let a = t.issue("demo", "s1", 2_000).unwrap();
417 let b = t.issue("demo", "s2", 9_000).unwrap();
418 t.sweep_expired(1_999, |_, _| true).unwrap();
419 assert!(t.lookup(&a).is_some() && t.lookup(&b).is_some());
420 t.sweep_expired(2_000, |_, _| true).unwrap();
421 assert!(t.lookup(&a).is_none() && t.lookup(&b).is_some());
422 t.sweep_expired(2_000, |repo, s| repo == "demo" && s != "s2")
423 .unwrap();
424 assert!(t.lookup(&b).is_none());
425 let text = std::fs::read_to_string(&path).unwrap();
426 assert!(!text.contains("s1") && !text.contains("s2"), "{text}");
427 }
428
429 #[test]
430 fn a_rotation_replaces_the_token_in_one_write() {
431 let dir = tempfile::tempdir().unwrap();
432 let path = dir.path().join("tokens.json");
433 let mut t = Tokens::load(&path).unwrap();
434 let old = t.issue("demo", "s1", 9_000).unwrap();
435 let new = t.rotate(&old).unwrap().unwrap();
436 assert!(new.starts_with("lonca_") && !new.starts_with("lonca_h_"));
437 assert_ne!(new, old);
438 let t2 = Tokens::load(&path).unwrap();
439 assert!(t2.lookup(&old).is_none());
440 assert_eq!(t2.lookup(&new), t.lookup(&new));
441 assert!(t.rotate(&old).unwrap().is_none(), "the old one is gone");
442 let h = t.issue_human("ayse", "ab", 9_000).unwrap();
443 assert!(t.rotate(&h).unwrap().unwrap().starts_with("lonca_h_"));
444 t.revoke_sessions("demo", &["s1".into()]).unwrap();
445 assert!(
446 t.lookup(&new).is_none(),
447 "revocation reaches a rotated token"
448 );
449 }
450}