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