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