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}
97
98impl Keeper {
99 pub fn alone(computer: &str) -> Keeper {
101 Keeper { computer: computer.to_string(), name: host_name(), mine: true, settled: true, others_heard: 0, others: 0 }
102 }
103}
104
105pub fn host_name() -> String {
110 static NAME: std::sync::OnceLock<String> = std::sync::OnceLock::new();
111 NAME.get_or_init(|| {
112 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()));
113 named.map(|n| n.trim().to_string()).filter(|n| !n.is_empty()).unwrap_or_else(|| "?".into())
114 })
115 .clone()
116}
117
118fn folder_of(folder: &Path, part: &str) -> PathBuf {
119 folder.join("leases").join(part)
120}
121
122fn bound(part: &str, computer: &str) -> String {
123 format!("lease{}{part}{}{computer}", '\u{1f}', '\u{1f}')
124}
125
126pub fn claims(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
128 read(folder, key, part)
129}
130
131fn read(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
134 let mut by: BTreeMap<String, Claim> = BTreeMap::new();
135 for dir in std::iter::once(folder.to_path_buf()).chain(crate::remote::overlay(folder)) {
136 for claim in read_in(&dir, key, part) {
137 match by.get(&claim.computer) {
138 Some(kept) if (kept.renewed, kept.until) >= (claim.renewed, claim.until) => {}
139 _ => {
140 by.insert(claim.computer.clone(), claim);
141 }
142 }
143 }
144 }
145 by.into_values().collect()
146}
147
148fn read_in(folder: &Path, key: &[u8; 32], part: &str) -> Vec<Claim> {
149 let Ok(entries) = std::fs::read_dir(folder_of(folder, part)) else { return Vec::new() };
150 entries
151 .filter_map(Result::ok)
152 .map(|e| e.path())
153 .filter(|p| p.extension().is_some_and(|x| x == "lease"))
154 .filter_map(|path| {
155 let computer = path.file_stem()?.to_string_lossy().to_string();
156 let text = crate::share::read_small(&path)?;
158 let plain = crate::share::open(key, &bound(part, &computer), text.trim())?;
159 let claim: Claim = serde_json::from_slice(&plain).ok()?;
160 (claim.computer == computer).then_some(claim)
162 })
163 .collect()
164}
165
166fn holds(claim: &Claim, rule: Rule, now: i64) -> bool {
169 claim.until > now || (rule == Rule::StaysPut && !claim.closed && now - claim.renewed <= UNCLOSED)
170}
171
172fn choose<'a>(claims: &'a [Claim], rule: Rule, now: i64) -> Option<&'a Claim> {
175 let live = claims.iter().filter(|c| holds(c, rule, now));
176 match rule {
177 Rule::FollowsYou => live.max_by(|a, b| a.active.cmp(&b.active).then_with(|| b.computer.cmp(&a.computer))),
178 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))),
180 }
181}
182
183static KEPT: Mutex<BTreeMap<String, (String, i64)>> = Mutex::new(BTreeMap::new());
185
186#[allow(clippy::too_many_arguments)]
190pub 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> {
191 let mut claims = read(folder, key, part);
192 let old = claims.iter().find(|c| c.computer == computer).cloned();
193 let since = old.as_ref().filter(|c| c.until > now && !c.closed).map_or(now, |c| c.since);
195 let taken = if take { now } else { old.as_ref().map_or(0, |c| c.taken) };
196 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() };
197 write(folder, key, part, computer, &claim)?;
198 claims.retain(|c| c.computer != computer);
199 claims.push(claim);
200 Ok(keeper(&claims, part, computer, rule, now))
201}
202
203fn write(folder: &Path, key: &[u8; 32], part: &str, computer: &str, claim: &Claim) -> Result<(), String> {
205 let path = folder_of(folder, part).join(format!("{computer}.lease"));
206 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());
207 let pad = crate::share::pad_for(&path, |pad| sealed(pad).map_or(0, |s| s.len() as u64));
208 crate::share::write_atomically(&path, sealed(pad)?.as_bytes())
209}
210
211pub fn look(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, rule: Rule) -> Keeper {
213 keeper(&read(folder, key, part), part, computer, rule, now)
214}
215
216fn keeper(claims: &[Claim], part: &str, computer: &str, rule: Rule, now: i64) -> Keeper {
217 let others: Vec<&Claim> = claims.iter().filter(|c| c.computer != computer && holds(c, rule, now)).collect();
218 let heard = others.iter().map(|c| c.renewed);
219 let others_heard = match rule {
220 Rule::FollowsYou => heard.max(),
221 Rule::StaysPut => heard.min(),
223 }
224 .unwrap_or(0);
225let Some(chosen) = choose(claims, rule, now) else { return Keeper::alone(computer) };
226 let mine = chosen.computer == computer;
227 let settled = match KEPT.lock() {
229 Ok(mut kept) => {
230 let entry = kept.entry(part.to_string()).or_insert_with(|| (chosen.computer.clone(), now));
231 if entry.0 != chosen.computer {
232 *entry = (chosen.computer.clone(), now);
233 }
234 mine && (others.is_empty() || now - entry.1 >= SETTLING)
235 }
236 Err(_) => false,
237 };
238 Keeper { computer: chosen.computer.clone(), name: chosen.name.clone(), mine, settled, others_heard, others: others.len() }
239}
240
241pub fn close(folder: &Path, key: &[u8; 32], part: &str, computer: &str, now: i64, wrote: Option<(u32, u64)>) -> Result<(), String> {
245 let old = read(folder, key, part).into_iter().find(|c| c.computer == computer);
246 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() };
247 write(folder, key, part, computer, &claim)?;
248 if let Ok(mut kept) = KEPT.lock() {
249 kept.remove(part);
250 }
251 Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256 use super::*;
257
258 fn folder(name: &str) -> PathBuf {
259 let dir = std::env::temp_dir().join(format!("sioul-lease-{name}-{}", std::process::id()));
260 let _ = std::fs::remove_dir_all(&dir);
261 dir
262 }
263
264 #[test]
265 fn medicines_follow_you() {
266 let dir = folder("follow");
267 let key = [7u8; 32];
268 let alone = renew(&dir, &key, "health", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
270 assert!(alone.mine && alone.settled);
271 let laptop = renew(&dir, &key, "health", "laptop", 1060, 1060, false, Rule::FollowsYou, None).unwrap();
273 assert!(laptop.mine && !laptop.settled, "the desktop may not have seen it yet");
274 let desktop = renew(&dir, &key, "health", "desktop", 1070, 1000, false, Rule::FollowsYou, None).unwrap();
275 assert!(!desktop.mine, "the desktop steps back");
276 assert_eq!(desktop.others, 1);
277 let later = renew(&dir, &key, "health", "laptop", 1060 + SETTLING, 1060 + SETTLING, false, Rule::FollowsYou, None).unwrap();
278 assert!(later.mine && later.settled);
279 close(&dir, &key, "health", "laptop", 1190, Some((1, 12))).unwrap();
281 let closed = claims(&dir, &key, "health").into_iter().find(|c| c.computer == "laptop").unwrap();
282 assert!(closed.closed && closed.wrote == Some((1, 12)) && closed.until <= 1190);
283 let back = renew(&dir, &key, "health", "desktop", 1200, 1200, false, Rule::FollowsYou, None).unwrap();
284 assert!(back.mine);
285 let _ = std::fs::remove_dir_all(&dir);
286 }
287
288 #[test]
289 fn invoices_stay_put() {
290 let dir = folder("stay");
291 let key = [9u8; 32];
292 let first = renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
293 assert!(first.mine);
294 let laptop = renew(&dir, &key, "invoices", "laptop", 1100, 1100, false, Rule::StaysPut, None).unwrap();
296 assert!(!laptop.mine && laptop.name == host_name());
297 let taken = renew(&dir, &key, "invoices", "laptop", 1200, 1200, true, Rule::StaysPut, None).unwrap();
299 assert!(taken.mine && !taken.settled);
300 assert!(!look(&dir, &key, "invoices", "desktop", 1210, Rule::StaysPut).mine);
301 assert!(!look(&dir, &key, "invoices", "desktop", 1200 + ALIVE + 1, Rule::StaysPut).mine, "a lapsed claim, not closed, still holds them");
304 assert!(look(&dir, &key, "invoices", "desktop", 1200 + UNCLOSED + 1, Rule::StaysPut).mine, "a day without news: it keeps nothing");
305 assert!(look(&dir, &[1u8; 32], "invoices", "desktop", 1210, Rule::StaysPut).mine);
307 let _ = std::fs::remove_dir_all(&dir);
308 }
309
310 #[test]
316 fn invoices_stay_with_a_computer_not_heard_from_until_it_closes() {
317 let dir = folder("unclosed");
318 let key = [5u8; 32];
319 renew(&dir, &key, "invoices", "desktop", 1000, 1000, false, Rule::StaysPut, None).unwrap();
320 let phone = renew(&dir, &key, "invoices", "phone", 1600, 1600, false, Rule::StaysPut, None).unwrap();
322 assert!(!phone.mine, "a computer not heard from is not a computer gone: {phone:?}");
323 let taken = renew(&dir, &key, "invoices", "phone", 1700, 1700, true, Rule::StaysPut, None).unwrap();
325 assert!(taken.mine && taken.others == 1 && taken.others_heard == 1000, "{taken:?}");
326 close(&dir, &key, "invoices", "desktop", 1800, None).unwrap();
328 let alone = renew(&dir, &key, "invoices", "phone", 1900, 1900, false, Rule::StaysPut, None).unwrap();
329 assert!(alone.mine && alone.others == 0, "{alone:?}");
330 renew(&dir, &key, "invoices", "laptop", 2000, 2000, true, Rule::StaysPut, None).unwrap();
332 assert!(!look(&dir, &key, "invoices", "phone", 2000 + UNCLOSED, Rule::StaysPut).mine);
333 assert!(look(&dir, &key, "invoices", "phone", 2001 + UNCLOSED, Rule::StaysPut).mine);
334 renew(&dir, &key, "follows", "desktop", 1000, 1000, false, Rule::FollowsYou, None).unwrap();
336 assert!(renew(&dir, &key, "follows", "phone", 1600, 1600, false, Rule::FollowsYou, None).unwrap().mine);
337 let _ = std::fs::remove_dir_all(&dir);
338 }
339}