Skip to main content

sysknife_core/
distro.rs

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