git_policy_cli/landing/claim_time.rs
1//! What:
2//! A reading of the clocks a claim's lease is judged by: the machine and the boot the host is in, the host's monotonic clock,
3//! and the wall clock. Two readings of one boot are compared by the monotonic clock; a reading of an earlier boot of the same
4//! machine is past at once; the wall clock judges every other pair.
5//! Why:
6//! A lease must run out when its holder stopped, not when somebody sets the date. The wall clock steps (a time sync, a person,
7//! a virtual machine resuming), and a step forward made a live holder look an hour stale while a step back kept a dead
8//! holder's claim alive. `CLOCK_MONOTONIC` is one clock for every process of a host, never steps, and does not count a
9//! suspended host's sleep, so a holder and a recoverer on one boot agree on how long a claim went unrenewed. It restarts at
10//! every boot, so a reading carries the boot ID (`/proc/sys/kernel/random/boot_id`). A restart ends the holder, so a claim of an
11//! earlier boot has run out at once, and a wall clock set back at boot cannot strand it. That holds only when both readings
12//! are of one machine: two hosts that share a work directory have unrelated boot IDs, and the other host's boot is not the
13//! end of its holder. So a reading also carries the machine ID (`/etc/machine-id`), and a different boot ID is evidence of a
14//! restart only beside an equal, known machine ID. Equal non-empty boot IDs still compare by the monotonic clock. Where either
15//! reading has no boot ID, only the wall clock is left to compare; where either has no machine ID, a different boot ID proves
16//! no restart, so the wall clock judges that pair as the fallback. Machines cloned with one machine ID cannot be told
17//! apart; their claims are judged as one machine's.
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! const stamp = { machineId: readMachineId(), bootId: readBootId(), monotonicMillis: hrtimeMillis(), wallSeconds: Date.now() / 1000 };
22//! const pastBoot = (now, then) => now.machineId !== '' && now.machineId === then.machineId
23//! && now.bootId !== '' && then.bootId !== '' && now.bootId !== then.bootId;
24//! const elapsed = (now, then) => now.bootId !== '' && now.bootId === then.bootId ? now.monotonicMillis - then.monotonicMillis : (now.wallSeconds - then.wallSeconds) * 1000;
25//! ```
26
27use crate::service_replay::capture_stamp;
28use std::path::Path;
29use std::sync::OnceLock;
30use std::time::{Duration, SystemTime, UNIX_EPOCH};
31
32/// Separates the parts of a reading when it is written into a claim's file name.
33const FIELD_SEPARATOR: char = '~';
34
35/// The length of a boot ID without its dashes, and of a machine ID.
36const BOOT_ID_LENGTH: usize = 32;
37
38/// Where a machine ID is kept, in the order they are tried (`/var/lib/dbus/machine-id` is the older place and often a link to the first).
39const MACHINE_ID_PATHS: [&str; 2] = ["/etc/machine-id", "/var/lib/dbus/machine-id"];
40
41/// The number of parts in a reading's text before the machine ID was added, and after.
42const PARTS_WITHOUT_MACHINE: usize = 3;
43const PARTS_WITH_MACHINE: usize = 4;
44
45/// What:
46/// One reading of the three clocks.
47/// Why:
48/// A claim carries the reading taken when it was last renewed; whoever judges the claim takes a reading of its own.
49///
50/// In TS you'd write (pseudocode):
51/// ```ts
52/// type ClaimTime = { bootId: string; monotonicMillis: number; wallSeconds: number };
53/// ```
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct ClaimTime {
56 /// The machine the reading was taken on; empty where the host has none or the reading predates machine IDs.
57 machine_id: String,
58 /// The boot the monotonic reading belongs to; empty where the host has no boot ID or no monotonic clock.
59 boot_id: String,
60 /// Milliseconds of `CLOCK_MONOTONIC`.
61 monotonic_millis: u64,
62 /// Seconds since the Unix epoch of the wall clock.
63 wall_seconds: u64,
64}
65
66/// Whether `text` is 32 hexadecimal digits, which is what a boot ID without dashes and a machine ID are.
67fn is_compact_hex_id(text: &str) -> bool {
68 return text.len() == BOOT_ID_LENGTH && text.bytes().all(|byte| { return byte.is_ascii_hexdigit(); });
69}
70
71/// Whether `text` is a machine ID: 32 lowercase hexadecimal digits that are not all zero. An uppercase text, an all-zero one and
72/// a fresh image's `uninitialized` are no evidence of a machine, and reading one as an ID would make hosts look like one machine.
73pub(crate) fn is_machine_id(text: &str) -> bool {
74 return text.len() == BOOT_ID_LENGTH
75 && text.bytes().all(|byte| { return byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte); })
76 && text.bytes().any(|byte| { return byte != b'0'; });
77}
78
79/// The boot ID of this host's current boot, without dashes, or nothing when it cannot be read.
80pub(crate) fn current_boot_id() -> &'static str {
81 static BOOT_ID: OnceLock<String> = OnceLock::new();
82 return BOOT_ID
83 .get_or_init(|| {
84 let Ok(text) = std::fs::read_to_string("/proc/sys/kernel/random/boot_id") else {
85 return String::new();
86 };
87 let compact: String = text.trim().chars().filter(|character| { return *character != '-'; }).collect();
88 return if is_compact_hex_id(compact.as_str()) { compact } else { String::new() };
89 })
90 .as_str();
91}
92
93/// What:
94/// The first machine ID that `paths` hold, or nothing when none holds one.
95/// Why:
96/// A file that is missing, empty or not 32 hexadecimal digits (a fresh image holds the text `uninitialized`, a container
97/// may have no file) is no evidence of a machine, and the next place may still hold one. Taking the text as an ID would
98/// make every such host look like one machine.
99fn machine_id_from(paths: &[&Path]) -> String {
100 for path in paths {
101 let Ok(text) = std::fs::read_to_string(path) else {
102 continue;
103 };
104 if is_machine_id(text.trim()) {
105 return text.trim().to_owned();
106 }
107 }
108 return String::new();
109}
110
111/// The machine ID of this host, read once, or nothing when it cannot be read.
112pub(crate) fn current_machine_id() -> &'static str {
113 #[cfg(test)]
114 if let Some(injected) = test_machine::injected() {
115 return injected;
116 }
117 static MACHINE_ID: OnceLock<String> = OnceLock::new();
118 return MACHINE_ID
119 .get_or_init(|| {
120 let paths: Vec<&Path> = MACHINE_ID_PATHS.iter().map(|path| { return Path::new(*path); }).collect();
121 return machine_id_from(paths.as_slice());
122 })
123 .as_str();
124}
125
126/// A machine ID a control gives this thread in place of the host's, so that a control does not depend on the host having one.
127#[cfg(test)]
128pub(crate) mod test_machine {
129 use std::cell::Cell;
130
131 thread_local! {
132 static INJECTED: Cell<Option<&'static str>> = const { Cell::new(None) };
133 }
134
135 /// What this thread's machine ID is: the injected text (empty for a host with none), or nothing injected.
136 pub(crate) fn injected() -> Option<&'static str> {
137 return INJECTED.with(Cell::get);
138 }
139
140 /// Gives this thread the machine ID `id` (empty for a host with none) until the thread ends.
141 pub(crate) fn inject(id: &'static str) {
142 INJECTED.with(|cell| { cell.set(Some(id)); });
143 }
144}
145
146/// Methods over a reading.
147impl ClaimTime {
148 /// A reading of the clocks now.
149 pub fn read() -> ClaimTime {
150 let monotonic_millis: u64 = capture_stamp() / 1_000_000;
151 // Without a monotonic clock the boot ID would pair with a meaningless number, so the reading has none.
152 let boot_id: String = if monotonic_millis == 0 { String::new() } else { String::from(current_boot_id()) };
153 let wall_seconds: u64 = SystemTime::now().duration_since(UNIX_EPOCH).map_or(0, |elapsed| { return elapsed.as_secs(); });
154 return ClaimTime { machine_id: String::from(current_machine_id()), boot_id, monotonic_millis, wall_seconds };
155 }
156
157 /// A reading of the given parts on a machine with no ID, which is how a test says what the clocks showed.
158 pub fn from_parts(boot_id: &str, monotonic: Duration, wall: Duration) -> ClaimTime {
159 return ClaimTime {
160 machine_id: String::new(),
161 boot_id: boot_id.to_owned(),
162 monotonic_millis: u64::try_from(monotonic.as_millis()).unwrap_or(u64::MAX),
163 wall_seconds: wall.as_secs(),
164 };
165 }
166
167 /// The reading `by` later on both clocks, which is time passing.
168 pub fn advanced(&self, by: Duration) -> ClaimTime {
169 return ClaimTime {
170 machine_id: self.machine_id.clone(),
171 boot_id: self.boot_id.clone(),
172 monotonic_millis: self.monotonic_millis.saturating_add(u64::try_from(by.as_millis()).unwrap_or(u64::MAX)),
173 wall_seconds: self.wall_seconds.saturating_add(by.as_secs()),
174 };
175 }
176
177 /// The reading `by` earlier on both clocks, which is how a test makes a claim whose holder last renewed that long ago.
178 pub fn rewound(&self, by: Duration) -> ClaimTime {
179 return ClaimTime {
180 machine_id: self.machine_id.clone(),
181 boot_id: self.boot_id.clone(),
182 monotonic_millis: self.monotonic_millis.saturating_sub(u64::try_from(by.as_millis()).unwrap_or(u64::MAX)),
183 wall_seconds: self.wall_seconds.saturating_sub(by.as_secs()),
184 };
185 }
186
187 /// The reading with the wall clock set `by` forward and the monotonic clock untouched, which is a clock step.
188 pub fn wall_stepped_forward(&self, by: Duration) -> ClaimTime {
189 return ClaimTime { wall_seconds: self.wall_seconds.saturating_add(by.as_secs()), ..self.clone() };
190 }
191
192 /// The reading with the wall clock set `by` back and the monotonic clock untouched, which is a clock step.
193 pub fn wall_stepped_back(&self, by: Duration) -> ClaimTime {
194 return ClaimTime { wall_seconds: self.wall_seconds.saturating_sub(by.as_secs()), ..self.clone() };
195 }
196
197 /// The reading in another boot, which is a restart of the host.
198 pub fn in_boot(&self, boot_id: &str) -> ClaimTime {
199 return ClaimTime { boot_id: boot_id.to_owned(), ..self.clone() };
200 }
201
202 /// The reading on the machine with the given ID, which is how a test puts two readings on one machine or on two.
203 pub fn on_machine(&self, machine_id: &str) -> ClaimTime {
204 return ClaimTime { machine_id: machine_id.to_owned(), ..self.clone() };
205 }
206
207 /// The reading a wall-clock time stands for where there is nothing else, such as the modification time of a claim an
208 /// earlier release stamped.
209 pub fn from_wall(wall: SystemTime) -> ClaimTime {
210 return ClaimTime {
211 machine_id: String::new(),
212 boot_id: String::new(),
213 monotonic_millis: 0,
214 wall_seconds: wall.duration_since(UNIX_EPOCH).map_or(0, |elapsed| { return elapsed.as_secs(); }),
215 };
216 }
217
218 /// How long ago `earlier` was, by the clock the two readings share: the monotonic clock within one boot, the wall clock
219 /// where either has no boot ID. A reading from the future of this one counts as no time at all. Readings of two different
220 /// boots share no clock that says how long the earlier holder lived, so a judge asks `is_past_the_boot_of` first.
221 pub fn elapsed_since(&self, earlier: &ClaimTime) -> Duration {
222 if !self.boot_id.is_empty() && self.boot_id == earlier.boot_id {
223 return Duration::from_millis(self.monotonic_millis.saturating_sub(earlier.monotonic_millis));
224 }
225 return Duration::from_secs(self.wall_seconds.saturating_sub(earlier.wall_seconds));
226 }
227
228 /// What:
229 /// Whether `earlier` was read in a boot this reading is not in, on the same machine, so whoever took it is gone.
230 /// Why:
231 /// A restart ends every process of the host, so the holder of an earlier boot's claim is dead and waiting out its
232 /// lease on the wall clock only strands the claim when the clock was set back at boot. A different boot ID of another
233 /// machine says nothing about the holder, which may be running there, so the answer is yes only when both machine IDs
234 /// are known and equal. Without a boot ID or a machine ID on either side there is nothing to compare, and the answer is
235 /// no: the lease decides.
236 ///
237 /// In TS you'd write (pseudocode):
238 /// ```ts
239 /// now.machineId !== '' && now.machineId === then.machineId
240 /// && now.bootId !== '' && then.bootId !== '' && now.bootId !== then.bootId
241 /// ```
242 pub fn is_past_the_boot_of(&self, earlier: &ClaimTime) -> bool {
243 let same_known_machine: bool = !self.machine_id.is_empty() && self.machine_id == earlier.machine_id;
244 return same_known_machine && !self.boot_id.is_empty() && !earlier.boot_id.is_empty() && self.boot_id != earlier.boot_id;
245 }
246
247 /// The reading as the file-name parts `boot~monotonic~wall~machine`; a reading with no machine ID leaves the last part
248 /// out, which is the form every release before machine IDs wrote and reads.
249 pub fn encode(&self) -> String {
250 let without_machine: String = format!("{}{FIELD_SEPARATOR}{}{FIELD_SEPARATOR}{}", self.boot_id, self.monotonic_millis, self.wall_seconds);
251 if self.machine_id.is_empty() {
252 return without_machine;
253 }
254 return format!("{without_machine}{FIELD_SEPARATOR}{}", self.machine_id);
255 }
256
257 /// The reading `encode` wrote, or nothing for any other text. The form without a machine ID, which releases before
258 /// machine IDs wrote into claims that may still be on disk, reads as a reading with none.
259 pub fn decode(text: &str) -> Option<ClaimTime> {
260 let parts: Vec<&str> = text.split(FIELD_SEPARATOR).collect();
261 if parts.len() != PARTS_WITHOUT_MACHINE && parts.len() != PARTS_WITH_MACHINE {
262 return None;
263 }
264 let boot_id: &str = parts[0];
265 let monotonic_millis: u64 = parts[1].parse().ok()?;
266 let wall_seconds: u64 = parts[2].parse().ok()?;
267 let machine_id: &str = parts.get(PARTS_WITH_MACHINE - 1).copied().unwrap_or("");
268 let boot_id_fits: bool = boot_id.is_empty() || is_compact_hex_id(boot_id);
269 // A stored stamp is read as it was written: an uppercase or all-zero machine ID that an earlier release accepted still decodes.
270 let machine_id_fits: bool = parts.len() == PARTS_WITHOUT_MACHINE || is_compact_hex_id(machine_id);
271 if !boot_id_fits || !machine_id_fits {
272 return None;
273 }
274 return Some(ClaimTime { machine_id: machine_id.to_owned(), boot_id: boot_id.to_owned(), monotonic_millis, wall_seconds });
275 }
276}
277
278/// Clock controls stay out of the release executable.
279#[cfg(test)]
280#[path = "claim_time_tests.rs"]
281mod tests;