Skip to main content

iso_probe/
sidecar.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! Operator-curated metadata that travels alongside an ISO.
4//!
5//! The rescue-TUI menu shows ISO filenames, which are dense and
6//! cryptic at 2 AM (`debian-12.5.0-amd64-netinst.iso`). A sidecar
7//! TOML at `<iso>.aegis.toml` carries human-curated `display_name`,
8//! `description`, `version`, `category`, and a "last verified" record
9//! so the menu can render `Network-install Debian 12 (verified
10//! 2026-02 on T440p, OK)` instead.
11//!
12//! Sidecars are **not signed** by default. Tampering with one can
13//! change display strings but cannot affect what boots — boot
14//! decisions still consume the sha256-attested manifest, not the
15//! sidecar. Future enhancement (`aegis-boot sign --include-sidecars`)
16//! could fold them into the signed manifest for fleet operators.
17//!
18//! # File location
19//!
20//! For an ISO at `/mnt/aegis-isos/debian.iso`, the sidecar is
21//! `/mnt/aegis-isos/debian.iso.aegis.toml`. The double-extension
22//! convention matches existing sidecars (`.iso.sha256`,
23//! `.iso.minisig`).
24//!
25//! # Schema versioning
26//!
27//! Every field is optional + has a `#[serde(default)]`. Adding new
28//! fields is semver-minor; renaming or removing fields is breaking.
29//! Tracks #246.
30
31use std::fs;
32use std::path::{Path, PathBuf};
33
34use serde::{Deserialize, Serialize};
35
36/// Operator-curated metadata for one ISO.
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
38#[serde(default)]
39pub struct IsoSidecar {
40    /// Human-readable display name (e.g. `"Network-install Debian 12"`).
41    pub display_name: Option<String>,
42    /// One-line description shown beneath the display name.
43    pub description: Option<String>,
44    /// Distro version string (e.g. `"12.5.0"`, `"24.04 LTS"`).
45    pub version: Option<String>,
46    /// Free-text category. Conventionally one of `install`, `live`,
47    /// `rescue`, `firmware`, `other` — but operators may define their
48    /// own.
49    pub category: Option<String>,
50    /// Date this ISO was last verified to boot (YYYY-MM-DD).
51    pub last_verified_at: Option<String>,
52    /// Hardware persona the ISO was last verified against (e.g.
53    /// `"lenovo-thinkpad-t440p-tpm12"`). Free-text — the sidecar is
54    /// operator-local.
55    pub last_verified_on: Option<String>,
56    /// Free-text operator notes (firmware-specific quirks, etc.).
57    pub notes: Option<String>,
58}
59
60impl IsoSidecar {
61    /// Whether the sidecar carries any populated fields.
62    #[must_use]
63    pub fn is_empty(&self) -> bool {
64        self.display_name.is_none()
65            && self.description.is_none()
66            && self.version.is_none()
67            && self.category.is_none()
68            && self.last_verified_at.is_none()
69            && self.last_verified_on.is_none()
70            && self.notes.is_none()
71    }
72}
73
74/// Compute the canonical sidecar path for an ISO.
75///
76/// `<iso>.aegis.toml` — kept consistent with the `<iso>.sha256` and
77/// `<iso>.minisig` double-extension convention.
78#[must_use]
79pub fn sidecar_path_for(iso_path: &Path) -> PathBuf {
80    let mut s = iso_path.as_os_str().to_owned();
81    s.push(".aegis.toml");
82    PathBuf::from(s)
83}
84
85/// Errors raised while loading or writing a sidecar.
86#[derive(Debug, thiserror::Error)]
87pub enum SidecarError {
88    /// I/O error reading or writing the sidecar file.
89    #[error("io: {0}")]
90    Io(#[from] std::io::Error),
91    /// TOML parser rejected the file body.
92    #[error("invalid toml in {path}: {source}")]
93    InvalidToml {
94        /// Path of the file that failed to parse.
95        path: PathBuf,
96        /// Underlying parser error.
97        #[source]
98        source: toml::de::Error,
99    },
100    /// TOML serializer rejected the in-memory struct.
101    #[error("toml serialize: {0}")]
102    SerializeToml(#[from] toml::ser::Error),
103}
104
105/// Load an ISO's sidecar metadata, if a `<iso>.aegis.toml` file
106/// exists at the canonical path.
107///
108/// Returns:
109/// - `Ok(Some(sidecar))` when the file exists and parses cleanly.
110/// - `Ok(None)` when no sidecar file is present (the common case).
111/// - `Err(SidecarError::Io)` on any I/O error other than `NotFound`.
112/// - `Err(SidecarError::InvalidToml)` when the file exists but the
113///   TOML body is malformed.
114///
115/// # Errors
116///
117/// See variants above.
118pub fn load_sidecar(iso_path: &Path) -> Result<Option<IsoSidecar>, SidecarError> {
119    let path = sidecar_path_for(iso_path);
120    let body = match fs::read_to_string(&path) {
121        Ok(s) => s,
122        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
123        Err(e) => return Err(SidecarError::Io(e)),
124    };
125    let sidecar: IsoSidecar =
126        toml::from_str(&body).map_err(|source| SidecarError::InvalidToml {
127            path: path.clone(),
128            source,
129        })?;
130    Ok(Some(sidecar))
131}
132
133/// Serialize an `IsoSidecar` to TOML. Useful for `aegis-boot add
134/// --description ...` which writes a sidecar at copy time.
135///
136/// # Errors
137///
138/// Returns `SidecarError::SerializeToml` if serde rejects the value.
139pub fn to_toml(sidecar: &IsoSidecar) -> Result<String, SidecarError> {
140    Ok(toml::to_string_pretty(sidecar)?)
141}
142
143/// Write an `IsoSidecar` to disk at the canonical sidecar path for
144/// `iso_path`. Overwrites any existing sidecar at that path.
145///
146/// # Errors
147///
148/// Returns `SidecarError::Io` on any write failure or
149/// `SidecarError::SerializeToml` if serde rejects the value.
150pub fn write_sidecar(iso_path: &Path, sidecar: &IsoSidecar) -> Result<PathBuf, SidecarError> {
151    let path = sidecar_path_for(iso_path);
152    let body = to_toml(sidecar)?;
153    fs::write(&path, body)?;
154    Ok(path)
155}
156
157#[cfg(test)]
158#[allow(clippy::unwrap_used, clippy::expect_used)]
159mod tests {
160    use super::*;
161    use std::path::PathBuf;
162    use tempfile::tempdir;
163
164    #[test]
165    fn sidecar_path_appends_double_extension() {
166        let p = sidecar_path_for(Path::new("/mnt/aegis-isos/debian.iso"));
167        assert_eq!(p, PathBuf::from("/mnt/aegis-isos/debian.iso.aegis.toml"));
168    }
169
170    #[test]
171    fn sidecar_path_works_with_no_extension() {
172        let p = sidecar_path_for(Path::new("/tmp/oddly-named-image"));
173        assert_eq!(p, PathBuf::from("/tmp/oddly-named-image.aegis.toml"));
174    }
175
176    #[test]
177    fn load_returns_none_when_no_sidecar_present() {
178        let dir = tempdir().unwrap();
179        let iso = dir.path().join("nothing.iso");
180        let result = load_sidecar(&iso).unwrap();
181        assert!(result.is_none());
182    }
183
184    #[test]
185    fn load_returns_populated_sidecar_when_present() {
186        let dir = tempdir().unwrap();
187        let iso = dir.path().join("debian.iso");
188        let sidecar_path = sidecar_path_for(&iso);
189        let body = r#"display_name = "Network-install Debian 12"
190description = "Recommended for headless servers"
191version = "12.5.0"
192category = "install"
193last_verified_at = "2026-02-18"
194last_verified_on = "lenovo-thinkpad-t440p-tpm12"
195notes = "Boots cleanly under Secure Boot via shim."
196"#;
197        fs::write(&sidecar_path, body).unwrap();
198
199        let sidecar = load_sidecar(&iso).unwrap().unwrap();
200        assert_eq!(
201            sidecar.display_name.as_deref(),
202            Some("Network-install Debian 12")
203        );
204        assert_eq!(sidecar.version.as_deref(), Some("12.5.0"));
205        assert_eq!(sidecar.category.as_deref(), Some("install"));
206        assert_eq!(sidecar.last_verified_at.as_deref(), Some("2026-02-18"));
207        assert!(!sidecar.is_empty());
208    }
209
210    #[test]
211    fn load_returns_empty_sidecar_when_file_is_blank() {
212        let dir = tempdir().unwrap();
213        let iso = dir.path().join("blank.iso");
214        fs::write(sidecar_path_for(&iso), "").unwrap();
215
216        let sidecar = load_sidecar(&iso).unwrap().unwrap();
217        assert!(sidecar.is_empty());
218    }
219
220    #[test]
221    fn load_accepts_partial_sidecar_with_serde_defaults() {
222        let dir = tempdir().unwrap();
223        let iso = dir.path().join("partial.iso");
224        let body = "display_name = \"Just a name\"\n";
225        fs::write(sidecar_path_for(&iso), body).unwrap();
226
227        let sidecar = load_sidecar(&iso).unwrap().unwrap();
228        assert_eq!(sidecar.display_name.as_deref(), Some("Just a name"));
229        assert!(sidecar.description.is_none());
230        assert!(sidecar.version.is_none());
231    }
232
233    #[test]
234    fn load_rejects_malformed_toml() {
235        let dir = tempdir().unwrap();
236        let iso = dir.path().join("bad.iso");
237        fs::write(sidecar_path_for(&iso), "this is not = valid = toml\n").unwrap();
238
239        match load_sidecar(&iso) {
240            Err(SidecarError::InvalidToml { path, .. }) => {
241                assert_eq!(path, sidecar_path_for(&iso));
242            }
243            other => panic!("expected InvalidToml, got {other:?}"),
244        }
245    }
246
247    #[test]
248    fn load_rejects_unknown_top_level_keys_with_default_serde_strict_mode() {
249        // Default serde TOML accepts unknown keys (forward-compat). This
250        // test pins that behavior — adding new fields in future versions
251        // must remain backward-compatible.
252        let dir = tempdir().unwrap();
253        let iso = dir.path().join("future.iso");
254        let body = "display_name = \"x\"\nfuture_field = 42\n";
255        fs::write(sidecar_path_for(&iso), body).unwrap();
256        let sidecar = load_sidecar(&iso).unwrap().unwrap();
257        assert_eq!(sidecar.display_name.as_deref(), Some("x"));
258    }
259
260    #[test]
261    fn write_then_load_roundtrips_full_sidecar() {
262        let dir = tempdir().unwrap();
263        let iso = dir.path().join("roundtrip.iso");
264        let original = IsoSidecar {
265            display_name: Some("Network-install Debian 12".into()),
266            description: Some("Recommended for headless servers".into()),
267            version: Some("12.5.0".into()),
268            category: Some("install".into()),
269            last_verified_at: Some("2026-02-18".into()),
270            last_verified_on: Some("framework-laptop-12gen".into()),
271            notes: Some("Boots cleanly under Secure Boot via shim.".into()),
272        };
273
274        let written_path = write_sidecar(&iso, &original).unwrap();
275        assert_eq!(written_path, sidecar_path_for(&iso));
276
277        let loaded = load_sidecar(&iso).unwrap().unwrap();
278        assert_eq!(loaded, original);
279    }
280
281    #[test]
282    fn write_then_load_roundtrips_empty_sidecar() {
283        let dir = tempdir().unwrap();
284        let iso = dir.path().join("empty.iso");
285        let original = IsoSidecar::default();
286
287        write_sidecar(&iso, &original).unwrap();
288        let loaded = load_sidecar(&iso).unwrap().unwrap();
289        assert_eq!(loaded, original);
290        assert!(loaded.is_empty());
291    }
292
293    #[test]
294    fn is_empty_default_sidecar() {
295        let s = IsoSidecar::default();
296        assert!(s.is_empty());
297    }
298
299    #[test]
300    fn is_empty_false_when_any_field_populated() {
301        let s = IsoSidecar {
302            description: Some("just one field".into()),
303            ..Default::default()
304        };
305        assert!(!s.is_empty());
306    }
307
308    #[test]
309    fn to_toml_omits_none_fields() {
310        let s = IsoSidecar {
311            display_name: Some("name".into()),
312            ..Default::default()
313        };
314        let out = to_toml(&s).unwrap();
315        assert!(out.contains("display_name = \"name\""), "got: {out}");
316        // Optional none-fields should be omitted by serde's default skip.
317        // toml-rs default doesn't skip None — we serialize as nothing
318        // because Option<String> renders as missing key when None.
319        assert!(!out.contains("description"), "got: {out}");
320        assert!(!out.contains("version"), "got: {out}");
321    }
322}