1use serde::{Deserialize, Serialize};
27use std::collections::BTreeMap;
28use std::path::{Path, PathBuf};
29use std::sync::Mutex;
30
31pub const ALIVE: i64 = 5 * 60;
33pub const SETTLING: i64 = 90;
35pub const UNCLOSED: i64 = 24 * 3600;
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum Rule {
45 FollowsYou,
47 StaysPut,
49}
50
51#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
53pub struct Claim {
54 pub computer: String,
55 pub name: String,
57 pub since: i64,
59 pub renewed: i64,
60 pub until: i64,
61 pub active: i64,
63 pub taken: i64,
65 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub wrote: Option<(u32, u64)>,
70 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
72 pub closed: bool,
73 #[serde(default, skip_serializing_if = "String::is_empty")]
76 pub pad: String,
77}
78
79#[derive(Debug, Clone, Default, PartialEq, Eq)]
81pub struct Keeper {
82 pub computer: String,
83 pub name: String,
84 pub mine: bool,
86 pub settled: bool,
88 pub others_heard: i64,
93 pub others: usize,
96 pub since: i64,
102}
103
104pub fn started() -> i64 {
106 static STARTED: std::sync::OnceLock<i64> = std::sync::OnceLock::new();
107 *STARTED.get_or_init(|| std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs() as i64))
108}
109
110impl Keeper {
111 pub fn alone(computer: &str) -> Keeper {
113 Keeper { computer: computer.to_string(), name: host_name(), mine: true, settled: true, others_heard: 0, others: 0, since: started() }
114 }
115}
116
117pub fn host_name() -> String {
122 static NAME: std::sync::OnceLock<String> = std::sync::OnceLock::new();
123 NAME.get_or_init(|| {
124 let named = std::env::var("COMPUTERNAME").ok().or_else(|| std::fs::read_to_string("/etc/hostname").ok()).or_else(|| crate::command("hostname").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).to_string()));
125 named.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "?".into())
126 })
127 .clone()
128}
129
130fn folder_of(folder: &Path, part: &str) -> PathBuf {
131 folder.join("leases").join(part)
132}
133
134fn bound(part: &str, computer: &str) -> String {
135 format!("lease{}{part}{}{computer}", '\u{1f}', '\u{1f}')
136}
137
138pub fn claims(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
140 read(folder, key, part)
141}
142
143fn read(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
146 let mut by: BTreeMap<String, Claim> = BTreeMap::new();
147 for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
148 for claim in read_in(&dir, key, part) {
149 match by.get(&claim.computer) {
150 Some(kept) if (kept.renewed, kept.until) >= (claim.renewed, claim.until) => {}
151 _ => {
152 by.insert(claim.computer.clone(), claim);
153 }
154 }
155 }
156 }
157 by.into_values().collect()
158}
159
160fn read_in(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
161 let Ok(entries) = std::fs::read_dir(folder_of(folder, part)) else { return Vec::new() };
162 entries
163 .filter_map(Result::ok)
164 .map(|e| e.path())
165 .filter(|p| p.extension().is_some_and(|x| x == "lease"))
166 .filter_map(|path| {
167 let computer = path.file_stem()?.to_string_lossy().to_string();
168 let text = crate::share::read_small(&path)?;
170 let plain = crate::share::open(key, &bound(part, &computer), text.trim())?;
171 let claim: Claim = serde_json::from_slice(&plain).ok()?;
172 (claim.computer == computer).then_some(claim)
174 })
175 .collect()
176}
177
178fn holds(claim: &Claim, rule: Rule, now: i64) -> bool {
181 claim.until > now || (rule == Rule::StaysPut && !claim.closed && now - claim.renewed <= UNCLOSED)
182}
183
184fn choose<'a>(claims: &'a [Claim], rule: Rule, now: i64) -> Option<&'a Claim> {
187 let live = claims.iter().filter(|c| holds(c, rule, now));
188 match rule {
189 Rule::FollowsYou => live.max_by(|a, b| a.active.cmp(&b.active).then_with(|| b.computer.cmp(&a.computer))),
190 Rule::StaysPut => live.max_by(|a, b| a.taken.cmp(&b.taken).then_with(|| b.since.cmp(&a.since)).then_with(|| b.computer.cmp(&a.computer))),
192 }
193}
194
195static KEPT: Mutex<BTreeMap<String, (String, i64)>> = Mutex::new(BTreeMap::new());
197
198#[allow(clippy::too_many_arguments)]
202pub fn renew(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, active: i64, take: bool, rule: Rule, wrote: Option<(u32, u64)>) -> Result<Keeper, String> {
203 let mut claims = read(folder, key, part);
204 let old = claims.iter().find(|c| c.computer == computer).cloned();
205 let since = old.as_ref().filter(|c| c.until > now && !c.closed).map_or(now, |c| c.since);
207 let taken = if take { now } else { old.as_ref().map_or(0, |c| c.taken) };
208 let claim = Claim { computer: computer.to_string(), name: host_name(), since, renewed: now, until: now + ALIVE, active: active.max(old.as_ref().map_or(0, |c| c.active)), taken, wrote, closed: false, pad: String::new() };
209 write(folder, key, part, computer, &claim)?;
210 claims.retain(|c| c.computer != computer);
211 claims.push(claim);
212 Ok(keeper(&claims, part, computer, rule, now))
213}
214
215fn write(folder: &Path, key: &[u8; 32], part: &str, computer: &str, claim: &Claim) -> Result<(), String> {
217 let path = folder_of(folder, part).join(format!("{computer}.lease"));
218 let sealed = |pad: usize| serde_json::to_vec(&Claim { pad: ".".repeat(pad), ..claim.clone() }).map(|plain| crate::share::seal(key, &bound(part, computer), &plain)).map_err(|e| e.to_string());
219 let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
220 crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
221}
222
223pub fn look(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, rule: Rule) -> Keeper {
225 keeper(&read(folder, key, part), part, computer, rule, now)
226}
227
228fn keeper(claims: &[Claim], part: &str, computer: &str, rule: Rule, now: i64) -> Keeper {
229 let others: Vec<&Claim> = claims.iter().filter(|c| c.computer != computer && holds(c, rule, now)).collect();
230 let heard = others.iter().map(|c| c.renewed);
231 let others_heard = match rule {
232 Rule::FollowsYou => heard.max(),
233 Rule::StaysPut => heard.min(),
235 }
236 .unwrap_or(0);
237let Some(chosen) = choose(claims, rule, now) else { return Keeper::alone(computer) };
238 let mine = chosen.computer == computer;
239 let (settled, since) = match KEPT.lock() {
241 Ok(mut kept) => {
242 let entry = kept.entry(part.to_string()).or_insert_with(|| (chosen.computer.clone(), now));
243 if entry.0 != chosen.computer {
244 *entry = (chosen.computer.clone(), now);
245 }
246 (mine && (others.is_empty() || now - entry.1 >= SETTLING), entry.1)
247 }
248 Err(_) => (false, now),
249 };
250 Keeper { computer: chosen.computer.clone(), name: chosen.name.clone(), mine, settled, others_heard, others: others.len(), since }
251}
252
253pub fn close(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, wrote: Option<(u32, u64)>) -> Result<(), String> {
257 let old = read(folder, key, part).into_iter().find(|c| c.computer == computer);
258 let claim = Claim { computer: computer.to_string(), name: host_name(), since: now, renewed: now, until: now, active: old.as_ref().map_or(0, |c| c.active), taken: old.as_ref().map_or(0, |c| c.taken), wrote, closed: true, pad: String::new() };
259 write(folder, key, part, computer, &claim)?;
260 if let Ok(mut kept) = KEPT.lock() {
261 kept.remove(part);
262 }
263 Ok(())
264}
265
266#[cfg(test)]
267mod tests {
268 use super::*;
269
270 fn folder(name: &str) -> PathBuf {
271 let dir = std::env::temp_dir().join(format!("sioul-lease-{name}-{}", std::process::id()));
272 let _ = std::fs::remove_dir_all(&dir);
273 dir
274 }
275
276 #[test]
277 fn medicines_follow_you() {
278 let dir = folder("follow");
279 let key = [7u8; 32];
280 let alone = renew(&dir, &key, "health", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
282 assert!(alone.mine && alone.settled);
283 let laptop = renew(&dir, &key, "health", "laptop", 1060, 1060, false, Rule::FollowsYou, None).unwrap();
285 assert!(laptop.mine && !laptop.settled, "the desktop may not have seen it yet");
286 let desktop = renew(&dir, &key, "health", "desktop", 1070, 1000, false, Rule::FollowsYou, None).unwrap();
287 assert!(!desktop.mine, "the desktop steps back");
288 assert_eq!(desktop.others, 1);
289 let later = renew(&dir, &key, "health", "laptop", 1060 + SETTLING, 1060 + SETTLING, false, Rule::FollowsYou, None).unwrap();
290 assert!(later.mine && later.settled);
291 close(&dir, &key, "health", "laptop", 1190, Some((1, 12))).unwrap();
293 let closed = claims(&dir, &key, "health").into_iter().find(|c| c.computer == "laptop").unwrap();
294 assert!(closed.closed && closed.wrote == Some((1, 12)) && closed.until <= 1190);
295 let back = renew(&dir, &key, "health", "desktop", 1200, 1200, false, Rule::FollowsYou, None).unwrap();
296 assert!(back.mine);
297 let _ = std::fs::remove_dir_all(&dir);
298 }
299
300 #[test]
301 fn invoices_stay_put() {
302 let dir = folder("stay");
303 let key = [9u8; 32];
304 let first = renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
305 assert!(first.mine);
306 let laptop = renew(&dir, &key, "invoices", "laptop", 1100, 1100, false, Rule::StaysPut, None).unwrap();
308 assert!(!laptop.mine && laptop.name == host_name());
309 let taken = renew(&dir, &key, "invoices", "laptop", 1200, 1200, true, Rule::StaysPut, None).unwrap();
311 assert!(taken.mine && !taken.settled);
312 assert!(!look(&dir, &key, "invoices", "desktop", 1210, Rule::StaysPut).mine);
313 assert!(!look(&dir, &key, "invoices", "desktop", 1200 + ALIVE + 1, Rule::StaysPut).mine, "a lapsed claim, not closed, still holds them");
316 assert!(look(&dir, &key, "invoices", "desktop", 1200 + UNCLOSED + 1, Rule::StaysPut).mine, "a day without news: it keeps nothing");
317 assert!(look(&dir, &[1u8; 32], "invoices", "desktop", 1210, Rule::StaysPut).mine);
319 let _ = std::fs::remove_dir_all(&dir);
320 }
321
322 #[test]
328 fn invoices_stay_with_a_computer_not_heard_from_until_it_closes() {
329 let dir = folder("unclosed");
330 let key = [5u8; 32];
331 renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
332 let phone = renew(&dir, &key, "invoices", "phone", 1600, 1600, false, Rule::StaysPut, None).unwrap();
334 assert!(!phone.mine, "a computer not heard from is not a computer gone: {phone:?}");
335 let taken = renew(&dir, &key, "invoices", "phone", 1700, 1700, true, Rule::StaysPut, None).unwrap();
337 assert!(taken.mine && taken.others == 1 && taken.others_heard == 1000, "{taken:?}");
338 close(&dir, &key, "invoices", "desktop", 1800, None).unwrap();
340 let alone = renew(&dir, &key, "invoices", "phone", 1900, 1900, false, Rule::StaysPut, None).unwrap();
341 assert!(alone.mine && alone.others == 0, "{alone:?}");
342 renew(&dir, &key, "invoices", "laptop", 2000, 2000, true, Rule::StaysPut, None).unwrap();
344 assert!(!look(&dir, &key, "invoices", "phone", 2000 + UNCLOSED, Rule::StaysPut).mine);
345 assert!(look(&dir, &key, "invoices", "phone", 2001 + UNCLOSED, Rule::StaysPut).mine);
346 renew(&dir, &key, "follows", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
348 assert!(renew(&dir, &key, "follows", "phone", 1600, 1600, false, Rule::FollowsYou, None).unwrap().mine);
349 let _ = std::fs::remove_dir_all(&dir);
350 }
351}