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 scratch(&self) -> PathBuf {
109 self.root.join("scratch")
110 }
111
112 pub fn tokens_path(&self) -> PathBuf {
113 self.root.join("tokens.json")
114 }
115
116 pub fn freeze_flag(&self, repo: &str) -> PathBuf {
118 self.root.join("freeze").join(repo)
119 }
120
121 pub fn load_key(&self) -> Result<KeyPair> {
122 KeyPair::load_or_create(&self.key_path(), current_uid())
123 }
124
125 pub fn next_key_path(&self) -> PathBuf {
127 self.root.join("daemon.next.key")
128 }
129
130 pub fn load_keys(&self) -> Result<(KeyPair, Option<KeyPair>)> {
134 let next = self.next_key_path();
135 if !self.key_path().exists() && next.exists() {
136 fs::rename(&next, self.key_path())?;
137 }
138 let current = self.load_key()?;
139 let next = if next.exists() {
140 Some(KeyPair::load(&next, current_uid())?)
141 } else {
142 None
143 };
144 Ok((current, next))
145 }
146
147 pub fn load_or_create_next_key(&self) -> Result<KeyPair> {
149 KeyPair::load_or_create(&self.next_key_path(), current_uid())
150 }
151
152 pub fn promote_next_key(&self) -> Result<()> {
157 fs::remove_file(self.key_path())?;
158 fs::rename(self.next_key_path(), self.key_path())?;
159 Ok(())
160 }
161}
162
163fn owned_by(root: &Path, uid: u32) -> Result<()> {
166 #[cfg(unix)]
167 {
168 use std::os::unix::fs::MetadataExt;
169 let owner = fs::metadata(root)?.uid();
170 if owner != uid {
171 return Err(Error::Invalid(format!(
172 "daemon home {} belongs to uid {owner}, not to this daemon (uid {uid}); \
173 run the daemon as its owner, or give it a home of its own",
174 root.display()
175 )));
176 }
177 }
178 let _ = (root, uid);
179 Ok(())
180}
181
182#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
186#[serde(untagged)]
187pub enum TokenEntry {
188 Agent {
192 repo: String,
193 session: String,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 expires_ms: Option<i64>,
196 },
197 Human {
200 human: String,
201 key: String,
202 expires_ms: i64,
203 },
204}
205
206#[derive(Default)]
208pub struct Tokens {
209 path: Option<PathBuf>,
210 map: BTreeMap<String, TokenEntry>,
211}
212
213impl Tokens {
214 pub fn load(path: &Path) -> Result<Self> {
215 let map = match fs::read(path) {
216 Ok(b) => serde_json::from_slice(&b)?,
217 Err(e) if e.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
218 Err(e) => return Err(e.into()),
219 };
220 Ok(Self {
221 path: Some(path.to_path_buf()),
222 map,
223 })
224 }
225
226 pub fn issue(&mut self, repo: &str, session: &str, expires_ms: i64) -> Result<String> {
228 self.insert(
229 "lonca_",
230 TokenEntry::Agent {
231 repo: repo.into(),
232 session: session.into(),
233 expires_ms: Some(expires_ms),
234 },
235 )
236 }
237
238 pub fn rotate(&mut self, old: &str) -> Result<Option<String>> {
241 let Some(entry) = self.map.remove(&sha256_hex(old.as_bytes())) else {
242 return Ok(None);
243 };
244 let prefix = match entry {
245 TokenEntry::Agent { .. } => "lonca_",
246 TokenEntry::Human { .. } => "lonca_h_",
247 };
248 self.insert(prefix, entry).map(Some)
249 }
250
251 pub fn issue_human(&mut self, human: &str, key: &str, expires_ms: i64) -> Result<String> {
252 self.insert(
253 "lonca_h_",
254 TokenEntry::Human {
255 human: human.into(),
256 key: key.into(),
257 expires_ms,
258 },
259 )
260 }
261
262 fn insert(&mut self, prefix: &str, entry: TokenEntry) -> Result<String> {
263 let token = format!("{prefix}{}", hex::encode(random_bytes::<32>()));
264 self.map.insert(sha256_hex(token.as_bytes()), entry);
265 self.save()?;
266 Ok(token)
267 }
268
269 pub fn lookup(&self, token: &str) -> Option<&TokenEntry> {
270 self.map.get(&sha256_hex(token.as_bytes()))
271 }
272
273 pub fn revoke(&mut self, token: &str) -> Result<()> {
275 if self.map.remove(&sha256_hex(token.as_bytes())).is_some() {
276 self.save()?;
277 }
278 Ok(())
279 }
280
281 pub fn revoke_sessions(&mut self, repo: &str, sessions: &[String]) -> Result<()> {
282 let before = self.map.len();
283 self.map.retain(|_, e| match e {
284 TokenEntry::Agent {
285 repo: r, session, ..
286 } => !(r == repo && sessions.contains(session)),
287 TokenEntry::Human { .. } => true,
288 });
289 if self.map.len() != before {
290 self.save()?;
291 }
292 Ok(())
293 }
294
295 pub fn sweep_expired(&mut self, now_ms: i64, live: impl Fn(&str, &str) -> bool) -> Result<()> {
299 let before = self.map.len();
300 self.map.retain(|_, e| match e {
301 TokenEntry::Agent {
302 repo,
303 session,
304 expires_ms,
305 } => expires_ms.is_none_or(|end| end > now_ms) && live(repo, session),
306 TokenEntry::Human { expires_ms, .. } => *expires_ms > now_ms,
307 });
308 if self.map.len() != before {
309 self.save()?;
310 }
311 Ok(())
312 }
313
314 fn save(&self) -> Result<()> {
315 let Some(path) = &self.path else {
316 return Ok(());
317 };
318 let tmp = path.with_extension("tmp");
319 let mut opts = fs::OpenOptions::new();
320 opts.write(true).create(true).truncate(true);
321 #[cfg(unix)]
322 {
323 use std::os::unix::fs::OpenOptionsExt;
324 opts.mode(0o600);
325 }
326 let mut f = opts.open(&tmp)?;
327 f.write_all(&serde_json::to_vec_pretty(&self.map)?)?;
328 f.sync_all()?;
329 fs::rename(&tmp, path).map_err(Error::from)
330 }
331}
332
333#[cfg(test)]
334mod tests {
335 use super::*;
336
337 #[test]
338 fn a_home_must_belong_to_the_daemon() {
339 let dir = tempfile::tempdir().unwrap();
340 let home = Home::new(dir.path().join("h"));
341 home.ensure().unwrap();
342 assert!(owned_by(&home.root, current_uid()).is_ok());
343 let e = owned_by(&home.root, current_uid() + 1)
344 .unwrap_err()
345 .to_string();
346 assert!(e.contains("belongs to uid"), "{e}");
347 #[cfg(unix)]
348 {
349 use std::os::unix::fs::PermissionsExt;
350 let mode = fs::metadata(&home.root).unwrap().permissions().mode();
351 assert_eq!(mode & 0o777, 0o700);
352 }
353 }
354
355 #[test]
356 fn tokens_are_stored_as_hashes_and_both_kinds_survive_a_reload() {
357 let dir = tempfile::tempdir().unwrap();
358 let path = dir.path().join("tokens.json");
359 std::fs::write(
361 &path,
362 format!(
363 "{{\"{}\": {{\"repo\": \"demo\", \"session\": \"s0\"}}}}",
364 sha256_hex(b"lonca_old")
365 ),
366 )
367 .unwrap();
368 let mut t = Tokens::load(&path).unwrap();
369 let a = t.issue("demo", "s1", 5_000).unwrap();
370 let h = t.issue_human("ayse", "ab", 2_000).unwrap();
371 assert!(a.starts_with("lonca_") && h.starts_with("lonca_h_"));
372 let text = std::fs::read_to_string(&path).unwrap();
373 assert!(!text.contains(&a) && !text.contains(&h), "{text}");
374 let t = Tokens::load(&path).unwrap();
375 assert_eq!(
376 t.lookup("lonca_old"),
377 Some(&TokenEntry::Agent {
378 repo: "demo".into(),
379 session: "s0".into(),
380 expires_ms: None,
381 })
382 );
383 assert_eq!(
384 t.lookup(&h),
385 Some(&TokenEntry::Human {
386 human: "ayse".into(),
387 key: "ab".into(),
388 expires_ms: 2_000
389 })
390 );
391 let mut t = t;
392 t.revoke_sessions("demo", &["s1".into()]).unwrap();
393 assert!(t.lookup(&a).is_none() && t.lookup(&h).is_some());
394 t.sweep_expired(1_999, |_, _| true).unwrap();
395 assert!(t.lookup(&h).is_some());
396 t.sweep_expired(2_000, |_, _| true).unwrap();
397 assert!(t.lookup(&h).is_none());
398 t.revoke("lonca_old").unwrap();
399 t.revoke("lonca_old").unwrap();
400 assert!(Tokens::load(&path).unwrap().lookup("lonca_old").is_none());
401 }
402
403 #[test]
406 fn a_sweep_drops_agent_tokens_that_no_longer_work() {
407 let dir = tempfile::tempdir().unwrap();
408 let path = dir.path().join("tokens.json");
409 let mut t = Tokens::load(&path).unwrap();
410 let a = t.issue("demo", "s1", 2_000).unwrap();
411 let b = t.issue("demo", "s2", 9_000).unwrap();
412 t.sweep_expired(1_999, |_, _| true).unwrap();
413 assert!(t.lookup(&a).is_some() && t.lookup(&b).is_some());
414 t.sweep_expired(2_000, |_, _| true).unwrap();
415 assert!(t.lookup(&a).is_none() && t.lookup(&b).is_some());
416 t.sweep_expired(2_000, |repo, s| repo == "demo" && s != "s2")
417 .unwrap();
418 assert!(t.lookup(&b).is_none());
419 let text = std::fs::read_to_string(&path).unwrap();
420 assert!(!text.contains("s1") && !text.contains("s2"), "{text}");
421 }
422
423 #[test]
424 fn a_rotation_replaces_the_token_in_one_write() {
425 let dir = tempfile::tempdir().unwrap();
426 let path = dir.path().join("tokens.json");
427 let mut t = Tokens::load(&path).unwrap();
428 let old = t.issue("demo", "s1", 9_000).unwrap();
429 let new = t.rotate(&old).unwrap().unwrap();
430 assert!(new.starts_with("lonca_") && !new.starts_with("lonca_h_"));
431 assert_ne!(new, old);
432 let t2 = Tokens::load(&path).unwrap();
433 assert!(t2.lookup(&old).is_none());
434 assert_eq!(t2.lookup(&new), t.lookup(&new));
435 assert!(t.rotate(&old).unwrap().is_none(), "the old one is gone");
436 let h = t.issue_human("ayse", "ab", 9_000).unwrap();
437 assert!(t.rotate(&h).unwrap().unwrap().starts_with("lonca_h_"));
438 t.revoke_sessions("demo", &["s1".into()]).unwrap();
439 assert!(
440 t.lookup(&new).is_none(),
441 "revocation reaches a rotated token"
442 );
443 }
444}