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