1use std::path::Path;
29use std::time::{Duration, SystemTime};
30
31use serde::de::DeserializeOwned;
32use serde::{Deserialize, Serialize};
33
34pub fn check_sidecar_version(file: &str, got: u16, ours: u16) -> bool {
46 if got != ours {
47 tracing::warn!(
51 sidecar = file,
52 got,
53 ours,
54 "sidecar schema version mismatch; reading best-effort"
55 );
56 return false;
57 }
58 true
59}
60
61#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
68pub struct Versioned {
69 #[serde(default)]
72 pub version: u16,
73}
74
75pub fn write_durable(path: &std::path::Path, data: &[u8]) -> std::io::Result<()> {
80 use std::os::unix::fs::PermissionsExt;
81 let mode = std::fs::metadata(path)
82 .map(|m| m.permissions().mode() & 0o7777)
83 .unwrap_or(0o644);
84 write_atomic(path, data, mode)
85}
86
87pub fn write_json_atomic<T: Serialize + ?Sized>(
89 path: &std::path::Path,
90 value: &T,
91 mode: u32,
92) -> std::io::Result<()> {
93 let body = serde_json::to_vec(value).map_err(std::io::Error::other)?;
94 write_atomic(path, &body, mode)
95}
96
97pub fn write_atomic(path: &std::path::Path, data: &[u8], mode: u32) -> std::io::Result<()> {
106 use std::io::Write;
107 use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
108 let parent = path
109 .parent()
110 .filter(|p| !p.as_os_str().is_empty())
111 .unwrap_or_else(|| std::path::Path::new("."));
112 std::fs::create_dir_all(parent)?;
113 let mut suffix = [0u8; 4];
114 getrandom::fill(&mut suffix).map_err(|e| std::io::Error::other(e.to_string()))?;
115 let mut tmp_name = path.file_name().unwrap_or_default().to_os_string();
116 tmp_name.push(format!(".tmp-{}", hex::encode(suffix)));
117 let tmp = parent.join(tmp_name);
118 let mut f = std::fs::OpenOptions::new()
119 .write(true)
120 .create_new(true)
121 .mode(mode)
122 .open(&tmp)?;
123 let result = (|| {
124 f.set_permissions(std::fs::Permissions::from_mode(mode))?;
125 f.write_all(data)?;
126 f.sync_all()?;
127 std::fs::rename(&tmp, path)?;
128 std::fs::File::open(parent)?.sync_all()
129 })();
130 drop(f);
131 if result.is_err() {
132 let _ = std::fs::remove_file(&tmp);
133 }
134 result
135}
136
137#[derive(Debug, Clone, PartialEq)]
139pub enum Reading<T> {
140 Fresh(T),
142 Stale(T),
145 Absent,
147 Malformed(String),
149}
150
151impl<T> Reading<T> {
152 pub fn fresh(self) -> Option<T> {
154 match self {
155 Reading::Fresh(v) => Some(v),
156 _ => None,
157 }
158 }
159
160 pub fn is_fresh(&self) -> bool {
162 matches!(self, Reading::Fresh(_))
163 }
164}
165
166pub fn file_age(path: &Path, now: SystemTime) -> Option<Duration> {
169 let modified = std::fs::metadata(path).and_then(|m| m.modified()).ok()?;
170 now.duration_since(modified).ok()
171}
172
173pub fn read_fresh<T: DeserializeOwned>(id: &str, now: SystemTime) -> Reading<T> {
177 let Some(row) = crate::paths::sidecar(id) else {
178 return Reading::Malformed(format!("sidecar {id:?} is not registered"));
179 };
180 read_fresh_at(&crate::paths::run_path(id), row.max_age(), now)
181}
182
183pub fn read_fresh_at<T: DeserializeOwned>(
187 path: &Path,
188 max_age: Option<Duration>,
189 now: SystemTime,
190) -> Reading<T> {
191 let bytes = match std::fs::read(path) {
192 Ok(b) => b,
193 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Reading::Absent,
194 Err(e) => return Reading::Malformed(e.to_string()),
195 };
196 let value: T = match serde_json::from_slice(&bytes) {
197 Ok(v) => v,
198 Err(e) => return Reading::Malformed(e.to_string()),
199 };
200 let Some(window) = max_age else {
201 return Reading::Fresh(value);
202 };
203 match file_age(path, now) {
204 Some(age) if age <= window => Reading::Fresh(value),
205 _ => Reading::Stale(value),
206 }
207}
208
209#[cfg(test)]
210mod tests {
211 use super::*;
212
213 fn write_aged(path: &Path, body: &str) -> SystemTime {
214 std::fs::write(path, body).unwrap();
215 std::fs::metadata(path).unwrap().modified().unwrap()
216 }
217
218 #[test]
219 fn read_fresh_within_and_past_the_window() {
220 let dir = tempfile::tempdir().unwrap();
221 let p = dir.path().join("s.json");
222 let written = write_aged(&p, r#"{"a":1}"#);
223 let win = Some(Duration::from_secs(10));
224 let at = |s: u64| -> Reading<serde_json::Value> {
225 read_fresh_at(&p, win, written + Duration::from_secs(s))
226 };
227 assert_eq!(at(1), Reading::Fresh(serde_json::json!({"a": 1})));
228 assert_eq!(at(11), Reading::Stale(serde_json::json!({"a": 1})));
229 let r: Reading<serde_json::Value> =
231 read_fresh_at(&p, None, written + Duration::from_secs(3600));
232 assert!(r.is_fresh());
233 }
234
235 #[test]
236 fn read_fresh_future_mtime_is_stale() {
237 let dir = tempfile::tempdir().unwrap();
238 let p = dir.path().join("s.json");
239 let written = write_aged(&p, r#"{"a":1}"#);
240 let earlier = written - Duration::from_secs(60);
242 let r: Reading<serde_json::Value> =
243 read_fresh_at(&p, Some(Duration::from_secs(10)), earlier);
244 assert_eq!(r, Reading::Stale(serde_json::json!({"a": 1})));
245 assert!(file_age(&p, earlier).is_none());
246 }
247
248 #[test]
249 fn absent() {
250 let dir = tempfile::tempdir().unwrap();
251 let r: Reading<serde_json::Value> =
252 read_fresh_at(&dir.path().join("none.json"), None, SystemTime::now());
253 assert_eq!(r, Reading::Absent);
254 assert!(file_age(&dir.path().join("none.json"), SystemTime::now()).is_none());
255 }
256
257 #[test]
258 fn malformed() {
259 let dir = tempfile::tempdir().unwrap();
260 let p = dir.path().join("s.json");
261 std::fs::write(&p, b"{not json").unwrap();
262 let r: Reading<serde_json::Value> = read_fresh_at(&p, None, SystemTime::now());
263 assert!(matches!(r, Reading::Malformed(_)));
264 std::fs::write(&p, b"[1,2]").unwrap();
266 let r: Reading<serde_json::Map<String, serde_json::Value>> =
267 read_fresh_at(&p, None, SystemTime::now());
268 assert!(matches!(r, Reading::Malformed(_)));
269 }
270
271 #[test]
272 fn read_fresh_rejects_an_unregistered_id() {
273 let r: Reading<serde_json::Value> = read_fresh("no-such-sidecar", SystemTime::now());
274 assert!(matches!(r, Reading::Malformed(_)));
275 }
276
277 #[test]
278 fn registry_resolves_sidecar_and_socket_ids() {
279 assert!(crate::paths::run_path("wfb-stats").ends_with(crate::paths::WFB_STATS_JSON));
280 assert!(crate::paths::run_path("hid-cmd").ends_with(crate::paths::HID_CMD_SOCK));
281 assert_eq!(
282 crate::paths::max_age("wfb-stats"),
283 Some(Duration::from_millis(crate::paths::WFB_STATS_MAX_AGE_MS))
284 );
285 assert_eq!(
287 crate::paths::run_path("ground-station-input"),
288 std::path::PathBuf::from("/etc/ados/ground-station-input.json")
289 );
290 }
291
292 #[test]
293 fn matching_versions_pass() {
294 assert!(check_sidecar_version("example-sidecar", 1, 1));
295 assert!(check_sidecar_version("example-sidecar", 0, 0));
296 }
297
298 #[test]
299 fn mismatched_versions_fail() {
300 assert!(!check_sidecar_version("example-sidecar", 0, 1));
302 assert!(!check_sidecar_version("example-sidecar", 2, 1));
304 }
305
306 #[test]
307 fn versioned_defaults_to_zero_and_ignores_other_fields() {
308 let empty: Versioned = serde_json::from_str("{}").unwrap();
309 assert_eq!(empty.version, 0);
310
311 let with_extra: Versioned =
312 serde_json::from_str(r#"{"version":3,"link_state":"healthy"}"#).unwrap();
313 assert_eq!(with_extra.version, 3);
314 }
315
316 #[test]
317 fn write_durable_replaces_the_file_and_leaves_no_temp() {
318 let dir = tempfile::tempdir().unwrap();
319 let p = dir.path().join("extlinux.conf");
320 std::fs::write(&p, b"old").unwrap();
321 write_durable(&p, b"new contents").unwrap();
322 assert_eq!(std::fs::read(&p).unwrap(), b"new contents");
323 let names: Vec<_> = std::fs::read_dir(dir.path())
324 .unwrap()
325 .map(|e| e.unwrap().file_name())
326 .collect();
327 assert_eq!(names.len(), 1, "{names:?}");
328 }
329
330 #[test]
331 fn write_atomic_sets_mode_and_creates_parent() {
332 use std::os::unix::fs::PermissionsExt;
333 let dir = tempfile::tempdir().unwrap();
334 let p = dir.path().join("sub").join("key.bin");
335 write_atomic(&p, &[7u8; 8], 0o600).unwrap();
336 let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
337 assert_eq!(mode, 0o600);
338 write_durable(&p, b"x").unwrap();
340 let mode = std::fs::metadata(&p).unwrap().permissions().mode() & 0o777;
341 assert_eq!(mode, 0o600);
342 }
343
344 #[test]
345 fn concurrent_write_atomic_always_parses_and_leaves_no_temp() {
346 let dir = tempfile::tempdir().unwrap();
347 let p = std::sync::Arc::new(dir.path().join("state.json"));
348 let handles: Vec<_> = (0..8u32)
349 .map(|i| {
350 let p = p.clone();
351 std::thread::spawn(move || {
352 for j in 0..50u32 {
353 let v =
354 serde_json::json!({ "writer": i, "seq": j, "pad": "x".repeat(512) });
355 write_json_atomic(&p, &v, 0o644).unwrap();
356 let raw = std::fs::read(&*p).unwrap();
357 let _: serde_json::Value = serde_json::from_slice(&raw).unwrap();
358 }
359 })
360 })
361 .collect();
362 for h in handles {
363 h.join().unwrap();
364 }
365 let names: Vec<_> = std::fs::read_dir(dir.path())
366 .unwrap()
367 .map(|e| e.unwrap().file_name())
368 .collect();
369 assert_eq!(names.len(), 1, "{names:?}");
370 }
371}