Skip to main content

sysknife_core/
distro.rs

1//! Distro detection — Phase 2a.
2//!
3//! Parses `/etc/os-release` (or a caller-supplied string) and classifies the
4//! running Linux distribution into a typed [`DistroId`].
5//!
6//! # Security note
7//!
8//! `parse_os_release` is strict-by-design: it rejects any line that does not
9//! match the `^([A-Z][A-Z0-9_]*)=(.+)$` grammar, refuses to evaluate shell
10//! escapes, and caps the accepted file size at [`MAX_OS_RELEASE_BYTES`].  The
11//! result is used to decide which package-manager backend the daemon invokes,
12//! so a corrupt or adversarially crafted `/etc/os-release` must not silently
13//! produce a plausible-looking wrong answer.
14
15// ---------------------------------------------------------------------------
16// Constants
17// ---------------------------------------------------------------------------
18
19/// Maximum number of bytes accepted from `/etc/os-release` (or a test
20/// fixture string).  Files larger than this are rejected with
21/// [`ParseError::FileTooLarge`].
22pub const MAX_OS_RELEASE_BYTES: usize = 10 * 1024;
23
24/// Oldest Fedora Atomic release SysKnife acts on.
25///
26/// Fedora Atomic is a real target: the rpm-ostree, Flatpak and toolbox action
27/// families are implemented and mutations are allowed from 41 onward. It is
28/// simply not where the effort goes, because Ubuntu has far more users, so it
29/// carries no current live-VM validation run. See `docs/distro-support.md`.
30pub const OLDEST_ELIGIBLE_FEDORA_ATOMIC: u32 = 41;
31
32/// Oldest Ubuntu major version SysKnife acts on (20 → 20.04).
33///
34/// Ubuntu encodes its version as `YY.MM`, so comparing the major alone covers
35/// every release in a year, interim ones included.
36pub const OLDEST_SUPPORTED_UBUNTU_MAJOR: u32 = 20;
37
38// ---------------------------------------------------------------------------
39// Error types
40// ---------------------------------------------------------------------------
41
42/// Errors returned by [`parse_os_release`].
43#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum ParseError {
45    /// The input exceeded [`MAX_OS_RELEASE_BYTES`].
46    FileTooLarge,
47    /// The file contained no parseable key-value pairs (empty or only comments).
48    Empty,
49    /// A non-comment, non-blank line did not match `^([A-Z][A-Z0-9_]*)=(.+)$`.
50    InvalidLine(String),
51    /// The RHS of a key had mismatched quotes (e.g. `ID="fedora` with no
52    /// closing `"`).
53    MismatchedQuotes(String),
54    /// A value contained a NUL byte or other disallowed control byte.
55    InvalidByte { key: String },
56}
57
58impl std::fmt::Display for ParseError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::FileTooLarge => write!(
62                f,
63                "os-release input exceeds the {MAX_OS_RELEASE_BYTES}-byte limit"
64            ),
65            Self::Empty => write!(f, "os-release input is empty or contains only comments"),
66            Self::InvalidLine(l) => write!(f, "invalid os-release line: {l:?}"),
67            Self::MismatchedQuotes(k) => {
68                write!(f, "mismatched quotes for key {k:?}")
69            }
70            Self::InvalidByte { key } => {
71                write!(f, "invalid byte (NUL or control) in value for key {key:?}")
72            }
73        }
74    }
75}
76
77impl std::error::Error for ParseError {}
78
79/// Errors returned by [`detect`] when reading `/etc/os-release` from disk.
80#[derive(Debug)]
81pub enum DetectError {
82    /// I/O error reading `/etc/os-release`.
83    Io(std::io::Error),
84    /// The file contents failed [`parse_os_release`].
85    Parse(ParseError),
86}
87
88impl std::fmt::Display for DetectError {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        match self {
91            Self::Io(e) => write!(f, "could not read /etc/os-release: {e}"),
92            Self::Parse(e) => write!(f, "could not parse /etc/os-release: {e}"),
93        }
94    }
95}
96
97impl std::error::Error for DetectError {
98    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99        match self {
100            Self::Io(e) => Some(e),
101            Self::Parse(e) => Some(e),
102        }
103    }
104}
105
106// ---------------------------------------------------------------------------
107// Public types
108// ---------------------------------------------------------------------------
109
110/// Parsed representation of `/etc/os-release`.
111///
112/// Only the fields relevant to distro detection are captured; unknown keys are
113/// silently ignored (they are not security-relevant here).
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct OsRelease {
116    /// `ID` — e.g. `"ubuntu"`, `"fedora"`, `"debian"`.
117    pub id: String,
118    /// `ID_LIKE` — space-separated parent distro IDs, e.g. `["debian"]`.
119    pub id_like: Vec<String>,
120    /// `VERSION_ID` — e.g. `"26.04"`, `"41"`.
121    pub version_id: Option<String>,
122    /// `VERSION_CODENAME` — e.g. `"noble"`, `"jammy"`.
123    pub codename: Option<String>,
124    /// `VARIANT_ID` — e.g. `"core"`, `"silverblue"`, `"kinoite"`.
125    pub variant_id: Option<String>,
126    /// `PRETTY_NAME` — human-readable distribution name.
127    pub pretty_name: Option<String>,
128}
129
130/// Typed distro identity derived from [`OsRelease`].
131#[derive(Clone, Debug, PartialEq, Eq)]
132pub enum DistroId {
133    /// Plain Fedora (Workstation, Server, …).
134    Fedora { version: u32 },
135    /// Fedora Silverblue, Kinoite, or Sericea (immutable desktops).
136    FedoraSilverblue { version: u32 },
137    /// Ubuntu LTS or interim.
138    Ubuntu { major: u32, minor: u32 },
139    /// Ubuntu Core (IoT).
140    UbuntuCore { major: u32, minor: u32 },
141    /// Debian.
142    Debian { version: Option<u32> },
143    /// Any other distro — fields forwarded from `OsRelease`.
144    Other {
145        id: String,
146        version_id: Option<String>,
147        /// `ID_LIKE` tokens, carried through so [`DistroId::family`] can
148        /// make a best-effort derivative-family guess (see that method's
149        /// doc comment). Empty when `ID_LIKE` was absent.
150        id_like: Vec<String>,
151    },
152}
153
154/// Broad distro family, useful for choosing a package-manager backend.
155#[derive(Clone, Debug, PartialEq, Eq)]
156pub enum DistroFamily {
157    Fedora,
158    Debian,
159    Other,
160}
161
162impl std::fmt::Display for DistroId {
163    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164        match self {
165            Self::Fedora { version } => write!(f, "Fedora {version}"),
166            Self::FedoraSilverblue { version } => write!(f, "FedoraSilverblue {version}"),
167            Self::Ubuntu { major, minor } => write!(f, "Ubuntu {major}.{minor:02}"),
168            Self::UbuntuCore { major, minor } => write!(f, "UbuntuCore {major}.{minor:02}"),
169            Self::Debian { version: Some(v) } => write!(f, "Debian {v}"),
170            Self::Debian { version: None } => write!(f, "Debian (unknown version)"),
171            Self::Other {
172                id,
173                version_id: Some(v),
174                ..
175            } => write!(f, "{id} {v}"),
176            Self::Other {
177                id,
178                version_id: None,
179                ..
180            } => write!(f, "{id}"),
181        }
182    }
183}
184
185impl DistroId {
186    /// Returns the broad family this distro belongs to.
187    ///
188    /// # `ID_LIKE` fallback for unrecognised derivatives
189    ///
190    /// When `self` is `Other` (the running distro's `ID` did not match any
191    /// distro this crate detects directly — e.g. Linux Mint `ID=linuxmint`,
192    /// Pop!_OS `ID=pop`), this consults `ID_LIKE` before giving up: if it
193    /// contains `"debian"` or `"ubuntu"` the distro routes as
194    /// [`DistroFamily::Debian`]; if it contains `"fedora"` or `"rhel"` it
195    /// routes as [`DistroFamily::Fedora`]. This is **best-effort derivative
196    /// support, not full compatibility validation** — `ID_LIKE` is a hint
197    /// the distro maintainer supplies in `/etc/os-release` (see
198    /// os-release(5)), not a guarantee the derivative's package manager,
199    /// repositories, or service names are wire-compatible with the parent.
200    /// Actions may still fail at execution time on a derivative that
201    /// diverges from its stated parent.
202    pub fn family(&self) -> DistroFamily {
203        match self {
204            Self::Fedora { .. } | Self::FedoraSilverblue { .. } => DistroFamily::Fedora,
205            Self::Ubuntu { .. } | Self::UbuntuCore { .. } | Self::Debian { .. } => {
206                DistroFamily::Debian
207            }
208            Self::Other { id, id_like, .. } => {
209                if id.contains("fedora") {
210                    DistroFamily::Fedora
211                } else if id_like.iter().any(|p| p == "debian" || p == "ubuntu") {
212                    DistroFamily::Debian
213                } else if id_like.iter().any(|p| p == "fedora" || p == "rhel") {
214                    DistroFamily::Fedora
215                } else {
216                    DistroFamily::Other
217                }
218            }
219        }
220    }
221
222    /// Returns `true` for distros and versions with a shipped action backend.
223    ///
224    /// Support policy:
225    /// - Ubuntu 20.04 and later, **every** release: LTS and interim alike.
226    ///   Ubuntu is the supported platform, so eligibility must not depend on
227    ///   the LTS cadence — the apt, snap, ufw and netplan tooling every action
228    ///   drives is present across those releases.
229    /// - Fedora Atomic variants 41+ (Silverblue, Kinoite, and siblings) are
230    ///   eligible too. The rpm-ostree action families work; the difference from
231    ///   Ubuntu is validation effort, not support, because Ubuntu has far more
232    ///   users. See `docs/distro-support.md`.
233    ///
234    /// # Eligibility is not validation coverage
235    ///
236    /// This predicate answers "may SysKnife act on this host at all" — the
237    /// daemon refuses every *mutating* action when it is false. It is not a
238    /// claim that a release has been VM-validated; `docs/distro-support.md`
239    /// tracks validation tiers separately, and a release can be supported here
240    /// while listed as smoke-tested there.
241    ///
242    /// Releases below 20.04 are excluded because they no longer receive Ubuntu
243    /// security updates. `UbuntuCore` is excluded for a structural reason
244    /// rather than an age one: it has no apt and a read-only root, so the
245    /// Debian-family action set cannot apply. Plain Fedora stays detectable so
246    /// the planner can explain the gap rather than fail opaquely.
247    pub fn is_supported(&self) -> bool {
248        match self {
249            Self::Fedora { .. } => false,
250            Self::FedoraSilverblue { version } => *version >= OLDEST_ELIGIBLE_FEDORA_ATOMIC,
251            Self::Ubuntu { major, .. } => *major >= OLDEST_SUPPORTED_UBUNTU_MAJOR,
252            Self::UbuntuCore { .. } | Self::Debian { .. } | Self::Other { .. } => false,
253        }
254    }
255}
256
257// ---------------------------------------------------------------------------
258// Parser
259// ---------------------------------------------------------------------------
260
261/// Parse the contents of an `/etc/os-release` file.
262///
263/// # Errors
264///
265/// Returns [`ParseError`] if:
266/// - The input exceeds [`MAX_OS_RELEASE_BYTES`].
267/// - Any non-comment, non-blank line fails to parse as `KEY=VALUE`.
268/// - A value has mismatched quotes.
269/// - A value contains a NUL byte or ASCII control character (except tab).
270/// - No valid key-value pair was found at all.
271pub fn parse_os_release(contents: &str) -> Result<OsRelease, ParseError> {
272    if contents.len() > MAX_OS_RELEASE_BYTES {
273        return Err(ParseError::FileTooLarge);
274    }
275
276    let mut id: Option<String> = None;
277    let mut id_like: Vec<String> = Vec::new();
278    let mut version_id: Option<String> = None;
279    let mut codename: Option<String> = None;
280    let mut variant_id: Option<String> = None;
281    let mut pretty_name: Option<String> = None;
282    let mut found_any = false;
283
284    for line in contents.lines() {
285        // Skip blank lines and comments.
286        if line.is_empty() || line.starts_with('#') {
287            continue;
288        }
289
290        // Every non-comment line MUST have an `=`.
291        let eq_pos = line
292            .find('=')
293            .ok_or_else(|| ParseError::InvalidLine(line.to_string()))?;
294
295        let key = &line[..eq_pos];
296        let raw_value = &line[eq_pos + 1..];
297
298        // Key must match `[A-Z][A-Z0-9_]*` — no lowercase allowed.
299        if !is_valid_key(key) {
300            return Err(ParseError::InvalidLine(line.to_string()));
301        }
302
303        // Value must be non-empty (RFC os-release(5): "=" is required).
304        if raw_value.is_empty() {
305            return Err(ParseError::InvalidLine(line.to_string()));
306        }
307
308        // Strip exactly one matching pair of quotes from the RHS.
309        let value = strip_quotes(key, raw_value)?;
310
311        // Reject NUL bytes and other ASCII control characters (tab is ok).
312        for ch in value.chars() {
313            if ch == '\0' || (ch.is_ascii_control() && ch != '\t') {
314                return Err(ParseError::InvalidByte {
315                    key: key.to_string(),
316                });
317            }
318        }
319
320        found_any = true;
321
322        match key {
323            "ID" => id = Some(value),
324            "ID_LIKE" => {
325                id_like = raw_split_whitespace(&value);
326            }
327            "VERSION_ID" => version_id = Some(value),
328            "VERSION_CODENAME" => codename = Some(value),
329            "VARIANT_ID" => variant_id = Some(value),
330            "PRETTY_NAME" => pretty_name = Some(value),
331            _ => {} // Unknown keys are ignored.
332        }
333    }
334
335    if !found_any {
336        return Err(ParseError::Empty);
337    }
338
339    let id = id.ok_or(ParseError::Empty)?;
340
341    Ok(OsRelease {
342        id,
343        id_like,
344        version_id,
345        codename,
346        variant_id,
347        pretty_name,
348    })
349}
350
351/// Returns `true` if `key` matches `[A-Z][A-Z0-9_]*`.
352fn is_valid_key(key: &str) -> bool {
353    let mut chars = key.chars();
354    match chars.next() {
355        Some(first) if first.is_ascii_uppercase() => {}
356        _ => return false,
357    }
358    chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
359}
360
361/// Strip exactly one matching pair of quotes (`"…"` or `'…'`).
362///
363/// Unquoted values are returned as-is.  A value that starts with a quote but
364/// does not end with the matching quote is a [`ParseError::MismatchedQuotes`].
365fn strip_quotes(key: &str, raw: &str) -> Result<String, ParseError> {
366    let bytes = raw.as_bytes();
367    if bytes.is_empty() {
368        return Ok(String::new());
369    }
370
371    let first = bytes[0];
372    if first == b'"' || first == b'\'' {
373        let last = *bytes.last().unwrap();
374        if last != first || bytes.len() < 2 {
375            return Err(ParseError::MismatchedQuotes(key.to_string()));
376        }
377        // Strip the outer quotes — do NOT evaluate any escapes.
378        Ok(raw[1..raw.len() - 1].to_string())
379    } else {
380        Ok(raw.to_string())
381    }
382}
383
384/// Split a string on ASCII whitespace, discarding empty tokens.
385fn raw_split_whitespace(s: &str) -> Vec<String> {
386    s.split_ascii_whitespace().map(|t| t.to_string()).collect()
387}
388
389// ---------------------------------------------------------------------------
390// Distro detection
391// ---------------------------------------------------------------------------
392
393/// Classify an [`OsRelease`] into a [`DistroId`].
394pub fn detect_distro(release: &OsRelease) -> DistroId {
395    match release.id.as_str() {
396        "fedora" => detect_fedora(release),
397        "ubuntu" => detect_ubuntu(release),
398        "debian" => detect_debian(release),
399        _ => DistroId::Other {
400            id: release.id.clone(),
401            version_id: release.version_id.clone(),
402            id_like: release.id_like.clone(),
403        },
404    }
405}
406
407fn detect_fedora(release: &OsRelease) -> DistroId {
408    let is_immutable = matches!(
409        release.variant_id.as_deref(),
410        Some("silverblue") | Some("kinoite") | Some("sericea")
411    );
412
413    let version = release
414        .version_id
415        .as_deref()
416        .and_then(|v| v.parse::<u32>().ok());
417
418    match version {
419        Some(v) if is_immutable => DistroId::FedoraSilverblue { version: v },
420        Some(v) => DistroId::Fedora { version: v },
421        None => DistroId::Other {
422            id: release.id.clone(),
423            version_id: release.version_id.clone(),
424            id_like: release.id_like.clone(),
425        },
426    }
427}
428
429fn detect_ubuntu(release: &OsRelease) -> DistroId {
430    let is_core = release.variant_id.as_deref() == Some("core");
431
432    // VERSION_ID for Ubuntu is "26.04", "24.04", etc.
433    let parsed = release.version_id.as_deref().and_then(parse_ubuntu_version);
434
435    match parsed {
436        Some((major, minor)) if is_core => DistroId::UbuntuCore { major, minor },
437        Some((major, minor)) => DistroId::Ubuntu { major, minor },
438        None => DistroId::Other {
439            id: release.id.clone(),
440            version_id: release.version_id.clone(),
441            id_like: release.id_like.clone(),
442        },
443    }
444}
445
446fn parse_ubuntu_version(v: &str) -> Option<(u32, u32)> {
447    let mut parts = v.splitn(2, '.');
448    let major = parts.next()?.parse::<u32>().ok()?;
449    let minor = parts.next()?.parse::<u32>().ok()?;
450    Some((major, minor))
451}
452
453fn detect_debian(release: &OsRelease) -> DistroId {
454    let version = release
455        .version_id
456        .as_deref()
457        .and_then(|v| v.parse::<u32>().ok());
458    DistroId::Debian { version }
459}
460
461// ---------------------------------------------------------------------------
462// Live detection from /etc/os-release
463// ---------------------------------------------------------------------------
464
465/// Read `/etc/os-release` from disk and return a typed [`DistroId`].
466pub fn detect() -> Result<DistroId, DetectError> {
467    let contents = std::fs::read_to_string("/etc/os-release").map_err(DetectError::Io)?;
468    let release = parse_os_release(&contents).map_err(DetectError::Parse)?;
469    Ok(detect_distro(&release))
470}
471
472// ---------------------------------------------------------------------------
473// Tests
474// ---------------------------------------------------------------------------
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    // -----------------------------------------------------------------------
481    // Fixture helpers — real-world os-release file contents
482    // -----------------------------------------------------------------------
483
484    /// Fedora 41 Workstation (from a real Fedora 41 install).
485    const FEDORA_41: &str = r#"NAME="Fedora Linux"
486VERSION="41 (Workstation Edition)"
487ID=fedora
488VERSION_ID=41
489VERSION_CODENAME=""
490PLATFORM_ID="platform:f41"
491PRETTY_NAME="Fedora Linux 41 (Workstation Edition)"
492ANSI_COLOR="0;38;2;60;110;180"
493LOGO=fedora-logo-icon
494CPE_NAME="cpe:/o:fedoraproject:fedora:41"
495DEFAULT_HOSTNAME="fedora"
496HOME_URL="https://fedoraproject.org/"
497DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/system-administrators-guide/"
498SUPPORT_URL="https://ask.fedoraproject.org/"
499BUG_REPORT_URL="https://bugzilla.redhat.com/"
500REDHAT_BUGZILLA_PRODUCT="Fedora"
501REDHAT_BUGZILLA_PRODUCT_VERSION=41
502REDHAT_SUPPORT_PRODUCT="Fedora"
503REDHAT_SUPPORT_PRODUCT_VERSION=41
504SUPPORT_END=2025-05-13
505VARIANT="Workstation Edition"
506VARIANT_ID=workstation
507"#;
508
509    /// Fedora Silverblue 41.
510    const FEDORA_SILVERBLUE_41: &str = r#"NAME="Fedora Linux"
511VERSION="41 (Silverblue)"
512ID=fedora
513VERSION_ID=41
514VERSION_CODENAME=""
515PLATFORM_ID="platform:f41"
516PRETTY_NAME="Fedora Linux 41 (Silverblue)"
517ANSI_COLOR="0;38;2;60;110;180"
518LOGO=fedora-logo-icon
519CPE_NAME="cpe:/o:fedoraproject:fedora:41"
520DEFAULT_HOSTNAME="fedora"
521HOME_URL="https://fedoraproject.org/"
522DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/"
523SUPPORT_URL="https://ask.fedoraproject.org/"
524BUG_REPORT_URL="https://bugzilla.redhat.com/"
525REDHAT_BUGZILLA_PRODUCT="Fedora"
526REDHAT_BUGZILLA_PRODUCT_VERSION=41
527REDHAT_SUPPORT_PRODUCT="Fedora"
528REDHAT_SUPPORT_PRODUCT_VERSION=41
529SUPPORT_END=2025-05-13
530VARIANT="Silverblue"
531VARIANT_ID=silverblue
532"#;
533
534    /// Fedora Kinoite 41.
535    const FEDORA_KINOITE_41: &str = r#"NAME="Fedora Linux"
536VERSION="41 (Kinoite)"
537ID=fedora
538VERSION_ID=41
539VERSION_CODENAME=""
540PLATFORM_ID="platform:f41"
541PRETTY_NAME="Fedora Linux 41 (Kinoite)"
542ANSI_COLOR="0;38;2;60;110;180"
543LOGO=fedora-logo-icon
544CPE_NAME="cpe:/o:fedoraproject:fedora:41"
545DEFAULT_HOSTNAME="fedora"
546HOME_URL="https://fedoraproject.org/"
547DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/"
548SUPPORT_URL="https://ask.fedoraproject.org/"
549BUG_REPORT_URL="https://bugzilla.redhat.com/"
550REDHAT_BUGZILLA_PRODUCT="Fedora"
551REDHAT_BUGZILLA_PRODUCT_VERSION=41
552REDHAT_SUPPORT_PRODUCT="Fedora"
553REDHAT_SUPPORT_PRODUCT_VERSION=41
554SUPPORT_END=2025-05-13
555VARIANT="Kinoite"
556VARIANT_ID=kinoite
557"#;
558
559    /// Fedora Silverblue 44, the current Atomic Desktop release at launch.
560    const FEDORA_SILVERBLUE_44: &str = r#"NAME="Fedora Linux"
561VERSION="44 (Silverblue)"
562ID=fedora
563VERSION_ID=44
564VERSION_CODENAME=""
565PLATFORM_ID="platform:f44"
566PRETTY_NAME="Fedora Linux 44 (Silverblue)"
567ANSI_COLOR="0;38;2;60;110;180"
568LOGO=fedora-logo-icon
569CPE_NAME="cpe:/o:fedoraproject:fedora:44"
570HOME_URL="https://fedoraproject.org/"
571DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f44/"
572SUPPORT_URL="https://ask.fedoraproject.org/"
573VARIANT="Silverblue"
574VARIANT_ID=silverblue
575"#;
576
577    /// Ubuntu 22.04 LTS (Jammy Jellyfish).
578    const UBUNTU_2204: &str = r#"PRETTY_NAME="Ubuntu 22.04.4 LTS"
579NAME="Ubuntu"
580VERSION_ID="22.04"
581VERSION="22.04.4 LTS (Jammy Jellyfish)"
582VERSION_CODENAME=jammy
583ID=ubuntu
584ID_LIKE=debian
585HOME_URL="https://www.ubuntu.com/"
586SUPPORT_URL="https://help.ubuntu.com/"
587BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
588PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
589UBUNTU_CODENAME=jammy
590"#;
591
592    /// Ubuntu 24.04 LTS (Noble Numbat).
593    const UBUNTU_2404: &str = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS"
594NAME="Ubuntu"
595VERSION_ID="24.04"
596VERSION="24.04.1 LTS (Noble Numbat)"
597VERSION_CODENAME=noble
598ID=ubuntu
599ID_LIKE=debian
600HOME_URL="https://www.ubuntu.com/"
601SUPPORT_URL="https://help.ubuntu.com/"
602BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
603PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
604UBUNTU_CODENAME=noble
605LOGO=ubuntu-logo
606"#;
607
608    /// Ubuntu 26.04 LTS (Resolute Raccoon — released 2026-04-23).
609    /// NOTE: This fixture is constructed from the announced series; the exact
610    /// real-world file content was not available at fixture-authoring time.
611    /// Field values match the Ubuntu pattern exactly.
612    const UBUNTU_2604: &str = r#"PRETTY_NAME="Ubuntu 26.04 LTS"
613NAME="Ubuntu"
614VERSION_ID="26.04"
615VERSION="26.04 LTS (Resolute Raccoon)"
616VERSION_CODENAME=resolute
617ID=ubuntu
618ID_LIKE=debian
619HOME_URL="https://www.ubuntu.com/"
620SUPPORT_URL="https://help.ubuntu.com/"
621BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
622PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
623UBUNTU_CODENAME=resolute
624LOGO=ubuntu-logo
625"#;
626
627    /// Ubuntu Core 24.
628    const UBUNTU_CORE_24: &str = r#"NAME="Ubuntu Core"
629VERSION="24"
630ID=ubuntu
631ID_LIKE=debian
632PRETTY_NAME="Ubuntu Core 24"
633VERSION_ID="24.04"
634HOME_URL="https://snapcraft.io/docs/ubuntu-core"
635BUG_REPORT_URL="https://bugs.launchpad.net/snappy/"
636SUPPORT_URL="https://ubuntu.com/support"
637LOGO=ubuntu-logo
638VARIANT=Core
639VARIANT_ID=core
640"#;
641
642    /// Debian 12 (Bookworm).
643    const DEBIAN_12: &str = r#"PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
644NAME="Debian GNU/Linux"
645VERSION_ID="12"
646VERSION="12 (bookworm)"
647VERSION_CODENAME=bookworm
648ID=debian
649HOME_URL="https://www.debian.org/"
650SUPPORT_URL="https://www.debian.org/support"
651BUG_REPORT_URL="https://bugs.debian.org/"
652"#;
653
654    /// Linux Mint 22 (ID=linuxmint, ID_LIKE="ubuntu debian").
655    const LINUX_MINT_22: &str = r#"NAME="Linux Mint"
656VERSION="22 (Wilma)"
657ID=linuxmint
658ID_LIKE="ubuntu debian"
659PRETTY_NAME="Linux Mint 22"
660VERSION_ID="22"
661HOME_URL="https://www.linuxmint.com/"
662SUPPORT_URL="https://forums.linuxmint.com/"
663BUG_REPORT_URL="https://github.com/linuxmint/linuxmint/issues"
664PRIVACY_POLICY_URL="https://www.linuxmint.com/privacy.php"
665VERSION_CODENAME=wilma
666UBUNTU_CODENAME=noble
667"#;
668
669    /// Pop!_OS 22.04 (System76).
670    const POP_OS_2204: &str = r#"NAME="Pop!_OS"
671VERSION="22.04 LTS"
672ID=pop
673ID_LIKE="ubuntu debian"
674PRETTY_NAME="Pop!_OS 22.04 LTS"
675VERSION_ID="22.04"
676HOME_URL="https://pop.system76.com"
677SUPPORT_URL="https://support.system76.com"
678BUG_REPORT_URL="https://github.com/pop-os/pop/issues"
679PRIVACY_POLICY_URL="https://system76.com/privacy"
680VERSION_CODENAME=jammy
681UBUNTU_CODENAME=jammy
682"#;
683
684    /// Amazon Linux 2023 (ID_LIKE="fedora").
685    const AMAZON_LINUX_2023: &str = r#"NAME="Amazon Linux"
686VERSION="2023"
687ID="amzn"
688ID_LIKE="fedora"
689VERSION_ID="2023"
690PLATFORM_ID="platform:al2023"
691PRETTY_NAME="Amazon Linux 2023.6.20241121"
692ANSI_COLOR="0;33"
693CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2023"
694HOME_URL="https://aws.amazon.com/linux/amazon-linux-2023/"
695BUG_REPORT_URL="https://github.com/amazonlinux/amazon-linux-2023"
696SUPPORT_END="2028-03-15"
697"#;
698
699    // -----------------------------------------------------------------------
700    // parse_os_release — happy paths
701    // -----------------------------------------------------------------------
702
703    #[test]
704    fn parse_fedora_41_workstation() {
705        let r = parse_os_release(FEDORA_41).unwrap();
706        assert_eq!(r.id, "fedora");
707        assert_eq!(r.version_id.as_deref(), Some("41"));
708        assert_eq!(r.variant_id.as_deref(), Some("workstation"));
709        assert!(r.id_like.is_empty());
710    }
711
712    #[test]
713    fn parse_fedora_silverblue_41() {
714        let r = parse_os_release(FEDORA_SILVERBLUE_41).unwrap();
715        assert_eq!(r.id, "fedora");
716        assert_eq!(r.version_id.as_deref(), Some("41"));
717        assert_eq!(r.variant_id.as_deref(), Some("silverblue"));
718    }
719
720    #[test]
721    fn parse_fedora_kinoite_41() {
722        let r = parse_os_release(FEDORA_KINOITE_41).unwrap();
723        assert_eq!(r.id, "fedora");
724        assert_eq!(r.version_id.as_deref(), Some("41"));
725        assert_eq!(r.variant_id.as_deref(), Some("kinoite"));
726    }
727
728    #[test]
729    fn parse_ubuntu_2204_jammy() {
730        let r = parse_os_release(UBUNTU_2204).unwrap();
731        assert_eq!(r.id, "ubuntu");
732        assert_eq!(r.version_id.as_deref(), Some("22.04"));
733        assert_eq!(r.codename.as_deref(), Some("jammy"));
734        assert_eq!(r.id_like, vec!["debian"]);
735    }
736
737    #[test]
738    fn parse_ubuntu_2404_noble() {
739        let r = parse_os_release(UBUNTU_2404).unwrap();
740        assert_eq!(r.id, "ubuntu");
741        assert_eq!(r.version_id.as_deref(), Some("24.04"));
742        assert_eq!(r.codename.as_deref(), Some("noble"));
743        assert_eq!(r.id_like, vec!["debian"]);
744    }
745
746    #[test]
747    fn parse_ubuntu_2604_resolute() {
748        let r = parse_os_release(UBUNTU_2604).unwrap();
749        assert_eq!(r.id, "ubuntu");
750        assert_eq!(r.version_id.as_deref(), Some("26.04"));
751        assert_eq!(r.codename.as_deref(), Some("resolute"));
752        assert_eq!(r.id_like, vec!["debian"]);
753    }
754
755    #[test]
756    fn parse_ubuntu_core_24() {
757        let r = parse_os_release(UBUNTU_CORE_24).unwrap();
758        assert_eq!(r.id, "ubuntu");
759        assert_eq!(r.version_id.as_deref(), Some("24.04"));
760        assert_eq!(r.variant_id.as_deref(), Some("core"));
761    }
762
763    #[test]
764    fn parse_debian_12_bookworm() {
765        let r = parse_os_release(DEBIAN_12).unwrap();
766        assert_eq!(r.id, "debian");
767        assert_eq!(r.version_id.as_deref(), Some("12"));
768        assert_eq!(r.codename.as_deref(), Some("bookworm"));
769    }
770
771    #[test]
772    fn parse_linux_mint_22() {
773        let r = parse_os_release(LINUX_MINT_22).unwrap();
774        assert_eq!(r.id, "linuxmint");
775        assert_eq!(r.version_id.as_deref(), Some("22"));
776        assert_eq!(r.id_like, vec!["ubuntu", "debian"]);
777    }
778
779    #[test]
780    fn parse_pop_os_2204() {
781        let r = parse_os_release(POP_OS_2204).unwrap();
782        assert_eq!(r.id, "pop");
783        assert_eq!(r.version_id.as_deref(), Some("22.04"));
784        assert_eq!(r.id_like, vec!["ubuntu", "debian"]);
785    }
786
787    #[test]
788    fn parse_amazon_linux_2023() {
789        let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
790        assert_eq!(r.id, "amzn");
791        assert_eq!(r.version_id.as_deref(), Some("2023"));
792        assert_eq!(r.id_like, vec!["fedora"]);
793    }
794
795    // -----------------------------------------------------------------------
796    // parse_os_release — rejection cases
797    // -----------------------------------------------------------------------
798
799    #[test]
800    fn reject_empty_file() {
801        assert_eq!(parse_os_release(""), Err(ParseError::Empty));
802    }
803
804    #[test]
805    fn reject_only_comments_and_blanks() {
806        let input = "# This is a comment\n\n# Another comment\n";
807        assert_eq!(parse_os_release(input), Err(ParseError::Empty));
808    }
809
810    #[test]
811    fn reject_line_without_equals() {
812        let input = "ID=fedora\nBAD_LINE_NO_EQUALS\n";
813        assert!(matches!(
814            parse_os_release(input),
815            Err(ParseError::InvalidLine(_))
816        ));
817    }
818
819    #[test]
820    fn reject_lowercase_key() {
821        // `id=fedora` has a lowercase key — must be rejected.
822        let input = "id=fedora\nVERSION_ID=41\n";
823        assert!(matches!(
824            parse_os_release(input),
825            Err(ParseError::InvalidLine(_))
826        ));
827    }
828
829    #[test]
830    fn reject_mixed_case_key() {
831        let input = "Id=fedora\nVERSION_ID=41\n";
832        assert!(matches!(
833            parse_os_release(input),
834            Err(ParseError::InvalidLine(_))
835        ));
836    }
837
838    #[test]
839    fn reject_mismatched_double_quotes() {
840        // Opening double-quote with no closing quote.
841        let input = "ID=\"fedora\nVERSION_ID=41\n";
842        assert!(matches!(
843            parse_os_release(input),
844            Err(ParseError::MismatchedQuotes(_))
845        ));
846    }
847
848    #[test]
849    fn reject_mismatched_single_quotes() {
850        let input = "ID='fedora\nVERSION_ID=41\n";
851        assert!(matches!(
852            parse_os_release(input),
853            Err(ParseError::MismatchedQuotes(_))
854        ));
855    }
856
857    #[test]
858    fn reject_nul_byte_in_value() {
859        // Embed a NUL in a value.
860        let input = "ID=fedora\nPRETTY_NAME=foo\x00bar\n";
861        assert!(matches!(
862            parse_os_release(input),
863            Err(ParseError::InvalidByte { .. })
864        ));
865    }
866
867    #[test]
868    fn reject_control_byte_in_value() {
869        // \x01 is a control byte (not tab).
870        let input = "ID=fedora\nPRETTY_NAME=foo\x01bar\n";
871        assert!(matches!(
872            parse_os_release(input),
873            Err(ParseError::InvalidByte { .. })
874        ));
875    }
876
877    #[test]
878    fn reject_oversized_file() {
879        // Build a string larger than MAX_OS_RELEASE_BYTES.
880        let large = "A".repeat(MAX_OS_RELEASE_BYTES + 1);
881        assert_eq!(parse_os_release(&large), Err(ParseError::FileTooLarge));
882    }
883
884    // -----------------------------------------------------------------------
885    // detect_distro cases
886    // -----------------------------------------------------------------------
887
888    #[test]
889    fn detect_fedora_41_workstation() {
890        let r = parse_os_release(FEDORA_41).unwrap();
891        assert_eq!(detect_distro(&r), DistroId::Fedora { version: 41 });
892    }
893
894    #[test]
895    fn detect_silverblue_routes_to_fedora_silverblue() {
896        let r = parse_os_release(FEDORA_SILVERBLUE_41).unwrap();
897        assert_eq!(
898            detect_distro(&r),
899            DistroId::FedoraSilverblue { version: 41 }
900        );
901    }
902
903    #[test]
904    fn detect_kinoite_routes_to_fedora_silverblue() {
905        let r = parse_os_release(FEDORA_KINOITE_41).unwrap();
906        assert_eq!(
907            detect_distro(&r),
908            DistroId::FedoraSilverblue { version: 41 }
909        );
910    }
911
912    #[test]
913    fn detect_current_silverblue_routes_to_fedora_silverblue() {
914        let r = parse_os_release(FEDORA_SILVERBLUE_44).unwrap();
915        assert_eq!(
916            detect_distro(&r),
917            DistroId::FedoraSilverblue { version: 44 }
918        );
919    }
920
921    #[test]
922    fn detect_ubuntu_core_routes_to_ubuntu_core() {
923        let r = parse_os_release(UBUNTU_CORE_24).unwrap();
924        assert_eq!(
925            detect_distro(&r),
926            DistroId::UbuntuCore {
927                major: 24,
928                minor: 4
929            }
930        );
931    }
932
933    #[test]
934    fn detect_ubuntu_2204() {
935        let r = parse_os_release(UBUNTU_2204).unwrap();
936        assert_eq!(
937            detect_distro(&r),
938            DistroId::Ubuntu {
939                major: 22,
940                minor: 4
941            }
942        );
943    }
944
945    #[test]
946    fn detect_ubuntu_2604() {
947        let r = parse_os_release(UBUNTU_2604).unwrap();
948        assert_eq!(
949            detect_distro(&r),
950            DistroId::Ubuntu {
951                major: 26,
952                minor: 4
953            }
954        );
955    }
956
957    #[test]
958    fn detect_debian_12() {
959        let r = parse_os_release(DEBIAN_12).unwrap();
960        assert_eq!(detect_distro(&r), DistroId::Debian { version: Some(12) });
961    }
962
963    #[test]
964    fn detect_mint_falls_through_to_other() {
965        let r = parse_os_release(LINUX_MINT_22).unwrap();
966        assert!(matches!(detect_distro(&r), DistroId::Other { .. }));
967        if let DistroId::Other {
968            id,
969            version_id,
970            id_like,
971        } = detect_distro(&r)
972        {
973            assert_eq!(id, "linuxmint");
974            assert_eq!(version_id.as_deref(), Some("22"));
975            assert_eq!(id_like, vec!["ubuntu", "debian"]);
976        }
977    }
978
979    #[test]
980    fn detect_amazon_linux_falls_through_to_other() {
981        let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
982        assert!(matches!(
983            detect_distro(&r),
984            DistroId::Other { ref id, .. } if id == "amzn"
985        ));
986    }
987
988    // -----------------------------------------------------------------------
989    // is_supported cases
990    // -----------------------------------------------------------------------
991
992    #[test]
993    fn plain_fedora_is_unsupported_until_dnf_actions_ship() {
994        assert!(!DistroId::Fedora { version: 44 }.is_supported());
995    }
996
997    #[test]
998    fn unsupported_fedora_39() {
999        assert!(!DistroId::Fedora { version: 39 }.is_supported());
1000    }
1001
1002    #[test]
1003    fn supported_fedora_silverblue_41() {
1004        assert!(DistroId::FedoraSilverblue { version: 41 }.is_supported());
1005    }
1006
1007    #[test]
1008    fn supported_fedora_silverblue_44() {
1009        assert!(DistroId::FedoraSilverblue { version: 44 }.is_supported());
1010    }
1011
1012    #[test]
1013    fn unsupported_fedora_silverblue_40() {
1014        assert!(!DistroId::FedoraSilverblue { version: 40 }.is_supported());
1015    }
1016
1017    #[test]
1018    fn supported_ubuntu_2204() {
1019        assert!(DistroId::Ubuntu {
1020            major: 22,
1021            minor: 4
1022        }
1023        .is_supported());
1024    }
1025
1026    #[test]
1027    fn supported_ubuntu_2404() {
1028        assert!(DistroId::Ubuntu {
1029            major: 24,
1030            minor: 4
1031        }
1032        .is_supported());
1033    }
1034
1035    #[test]
1036    fn supported_ubuntu_2604() {
1037        assert!(DistroId::Ubuntu {
1038            major: 26,
1039            minor: 4
1040        }
1041        .is_supported());
1042    }
1043
1044    #[test]
1045    fn supported_ubuntu_2004_is_the_oldest_supported_release() {
1046        assert!(DistroId::Ubuntu {
1047            major: 20,
1048            minor: 4
1049        }
1050        .is_supported());
1051    }
1052
1053    #[test]
1054    fn supported_ubuntu_interim_2610() {
1055        // Interim releases carry the same apt/snap/ufw tooling as the LTS they
1056        // follow, so support eligibility does not depend on the LTS cadence.
1057        assert!(DistroId::Ubuntu {
1058            major: 26,
1059            minor: 10
1060        }
1061        .is_supported());
1062    }
1063
1064    #[test]
1065    fn supported_ubuntu_interim_2510() {
1066        assert!(DistroId::Ubuntu {
1067            major: 25,
1068            minor: 10
1069        }
1070        .is_supported());
1071    }
1072
1073    #[test]
1074    fn unsupported_ubuntu_1804_predates_the_support_floor() {
1075        // 18.04 reached end of standard support upstream; SysKnife does not
1076        // claim releases that no longer receive Ubuntu security updates.
1077        assert!(!DistroId::Ubuntu {
1078            major: 18,
1079            minor: 4
1080        }
1081        .is_supported());
1082    }
1083
1084    #[test]
1085    fn unsupported_ubuntu_core() {
1086        assert!(!DistroId::UbuntuCore {
1087            major: 24,
1088            minor: 4
1089        }
1090        .is_supported());
1091    }
1092
1093    #[test]
1094    fn unsupported_debian() {
1095        assert!(!DistroId::Debian { version: Some(12) }.is_supported());
1096    }
1097
1098    #[test]
1099    fn unsupported_other() {
1100        assert!(!DistroId::Other {
1101            id: "arch".to_string(),
1102            version_id: None,
1103            id_like: Vec::new(),
1104        }
1105        .is_supported());
1106    }
1107
1108    // -----------------------------------------------------------------------
1109    // Roundtrip / derive tests
1110    // -----------------------------------------------------------------------
1111
1112    #[test]
1113    fn os_release_clone_eq() {
1114        let r = parse_os_release(UBUNTU_2404).unwrap();
1115        let r2 = r.clone();
1116        assert_eq!(r, r2);
1117    }
1118
1119    #[test]
1120    fn os_release_debug_contains_id() {
1121        let r = parse_os_release(FEDORA_41).unwrap();
1122        let dbg = format!("{r:?}");
1123        assert!(dbg.contains("fedora"), "debug output: {dbg}");
1124    }
1125
1126    #[test]
1127    fn distro_id_clone_eq() {
1128        let d = DistroId::Ubuntu {
1129            major: 24,
1130            minor: 4,
1131        };
1132        assert_eq!(d.clone(), d);
1133    }
1134
1135    #[test]
1136    fn distro_family_fedora() {
1137        assert_eq!(
1138            DistroId::Fedora { version: 41 }.family(),
1139            DistroFamily::Fedora
1140        );
1141        assert_eq!(
1142            DistroId::FedoraSilverblue { version: 41 }.family(),
1143            DistroFamily::Fedora
1144        );
1145    }
1146
1147    #[test]
1148    fn distro_family_debian() {
1149        assert_eq!(
1150            DistroId::Ubuntu {
1151                major: 24,
1152                minor: 4
1153            }
1154            .family(),
1155            DistroFamily::Debian
1156        );
1157        assert_eq!(
1158            DistroId::Debian { version: Some(12) }.family(),
1159            DistroFamily::Debian
1160        );
1161    }
1162
1163    // -----------------------------------------------------------------------
1164    // ID_LIKE family fallback for unrecognised derivatives
1165    // -----------------------------------------------------------------------
1166
1167    #[test]
1168    fn linux_mint_family_falls_back_to_debian_via_id_like() {
1169        let r = parse_os_release(LINUX_MINT_22).unwrap();
1170        let distro = detect_distro(&r);
1171        assert!(
1172            matches!(distro, DistroId::Other { .. }),
1173            "detect_distro must still classify Mint as Other -- only family() changes"
1174        );
1175        assert_eq!(
1176            distro.family(),
1177            DistroFamily::Debian,
1178            "Linux Mint (ID_LIKE=\"ubuntu debian\") must route to the Debian family"
1179        );
1180    }
1181
1182    #[test]
1183    fn pop_os_family_falls_back_to_debian_via_id_like() {
1184        let r = parse_os_release(POP_OS_2204).unwrap();
1185        let distro = detect_distro(&r);
1186        assert!(matches!(distro, DistroId::Other { .. }));
1187        assert_eq!(
1188            distro.family(),
1189            DistroFamily::Debian,
1190            "Pop!_OS (ID_LIKE=\"ubuntu debian\") must route to the Debian family"
1191        );
1192    }
1193
1194    #[test]
1195    fn amazon_linux_family_falls_back_to_fedora_via_id_like() {
1196        let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
1197        let distro = detect_distro(&r);
1198        assert!(matches!(distro, DistroId::Other { .. }));
1199        assert_eq!(
1200            distro.family(),
1201            DistroFamily::Fedora,
1202            "Amazon Linux (ID_LIKE=\"fedora\") must route to the Fedora family"
1203        );
1204    }
1205
1206    #[test]
1207    fn unknown_id_with_empty_id_like_stays_other() {
1208        let distro = DistroId::Other {
1209            id: "slackware".to_string(),
1210            version_id: None,
1211            id_like: Vec::new(),
1212        };
1213        assert_eq!(
1214            distro.family(),
1215            DistroFamily::Other,
1216            "an unrecognised distro with no ID_LIKE hint must stay Other"
1217        );
1218    }
1219
1220    // -----------------------------------------------------------------------
1221    // Edge-case parser tests
1222    // -----------------------------------------------------------------------
1223
1224    #[test]
1225    fn accept_tab_in_pretty_name() {
1226        // Tab is an allowed control character.
1227        let input = "ID=fedora\nVERSION_ID=41\nPRETTY_NAME=foo\tbar\n";
1228        let r = parse_os_release(input).unwrap();
1229        assert_eq!(r.pretty_name.as_deref(), Some("foo\tbar"));
1230    }
1231
1232    #[test]
1233    fn accept_single_quoted_value() {
1234        let input = "ID='fedora'\nVERSION_ID=41\n";
1235        let r = parse_os_release(input).unwrap();
1236        assert_eq!(r.id, "fedora");
1237    }
1238
1239    #[test]
1240    fn accept_unquoted_value() {
1241        let input = "ID=fedora\nVERSION_ID=41\n";
1242        let r = parse_os_release(input).unwrap();
1243        assert_eq!(r.id, "fedora");
1244    }
1245
1246    #[test]
1247    fn version_codename_empty_string_treated_as_none_equivalent() {
1248        // Fedora ships `VERSION_CODENAME=""` — empty quoted value.
1249        let input = "ID=fedora\nVERSION_ID=41\nVERSION_CODENAME=\"\"\n";
1250        // Should not error; codename field holds Some("") after parsing.
1251        // Whether callers treat "" as absent is their concern; parser is strict.
1252        let r = parse_os_release(input).unwrap();
1253        assert_eq!(r.codename.as_deref(), Some(""));
1254    }
1255
1256    #[test]
1257    fn reject_key_starting_with_digit() {
1258        let input = "1D=fedora\nVERSION_ID=41\n";
1259        assert!(matches!(
1260            parse_os_release(input),
1261            Err(ParseError::InvalidLine(_))
1262        ));
1263    }
1264
1265    #[test]
1266    fn reject_key_with_lowercase_after_valid_start() {
1267        let input = "IDENTIFier=fedora\nVERSION_ID=41\n";
1268        assert!(matches!(
1269            parse_os_release(input),
1270            Err(ParseError::InvalidLine(_))
1271        ));
1272    }
1273}