1pub const MAX_OS_RELEASE_BYTES: usize = 10 * 1024;
23
24pub const OLDEST_ELIGIBLE_FEDORA_ATOMIC: u32 = 41;
31
32pub const OLDEST_SUPPORTED_UBUNTU_MAJOR: u32 = 20;
37
38pub const OLDEST_SUPPORTED_DEBIAN: u32 = 12;
41
42#[derive(Clone, Debug, PartialEq, Eq)]
48pub enum ParseError {
49 FileTooLarge,
51 Empty,
53 InvalidLine(String),
55 MismatchedQuotes(String),
58 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#[derive(Debug)]
85pub enum DetectError {
86 Io(std::io::Error),
88 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#[derive(Clone, Debug, PartialEq, Eq)]
119pub struct OsRelease {
120 pub id: String,
122 pub id_like: Vec<String>,
124 pub version_id: Option<String>,
126 pub codename: Option<String>,
128 pub variant_id: Option<String>,
130 pub pretty_name: Option<String>,
132}
133
134#[derive(Clone, Debug, PartialEq, Eq)]
136pub enum DistroId {
137 Fedora { version: u32 },
139 FedoraSilverblue { version: u32 },
141 Ubuntu { major: u32, minor: u32 },
143 UbuntuCore { major: u32, minor: u32 },
145 Debian { version: Option<u32> },
147 Other {
149 id: String,
150 version_id: Option<String>,
151 id_like: Vec<String>,
155 },
156}
157
158#[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 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 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
266pub 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 if line.is_empty() || line.starts_with('#') {
296 continue;
297 }
298
299 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 if !is_valid_key(key) {
309 return Err(ParseError::InvalidLine(line.to_string()));
310 }
311
312 if raw_value.is_empty() {
314 return Err(ParseError::InvalidLine(line.to_string()));
315 }
316
317 let value = strip_quotes(key, raw_value)?;
319
320 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 _ => {} }
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
360fn 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
370fn 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 Ok(raw[1..raw.len() - 1].to_string())
388 } else {
389 Ok(raw.to_string())
390 }
391}
392
393fn raw_split_whitespace(s: &str) -> Vec<String> {
395 s.split_ascii_whitespace().map(|t| t.to_string()).collect()
396}
397
398pub 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 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
470pub 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#[cfg(test)]
486mod tests {
487 use super::*;
488
489 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 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 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 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 let large = "A".repeat(MAX_OS_RELEASE_BYTES + 1);
904 assert_eq!(parse_os_release(&large), Err(ParseError::FileTooLarge));
905 }
906
907 #[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 #[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 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 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 #[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 #[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 #[test]
1267 fn accept_tab_in_pretty_name() {
1268 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 let input = "ID=fedora\nVERSION_ID=41\nVERSION_CODENAME=\"\"\n";
1292 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}