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
38#[derive(Clone, Debug, PartialEq, Eq)]
44pub enum ParseError {
45 FileTooLarge,
47 Empty,
49 InvalidLine(String),
51 MismatchedQuotes(String),
54 InvalidByte { key: String },
56}
57
58impl std::fmt::Display for ParseError {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 Self::FileTooLarge => write!(
62 f,
63 "os-release input exceeds the {MAX_OS_RELEASE_BYTES}-byte limit"
64 ),
65 Self::Empty => write!(f, "os-release input is empty or contains only comments"),
66 Self::InvalidLine(l) => write!(f, "invalid os-release line: {l:?}"),
67 Self::MismatchedQuotes(k) => {
68 write!(f, "mismatched quotes for key {k:?}")
69 }
70 Self::InvalidByte { key } => {
71 write!(f, "invalid byte (NUL or control) in value for key {key:?}")
72 }
73 }
74 }
75}
76
77impl std::error::Error for ParseError {}
78
79#[derive(Debug)]
81pub enum DetectError {
82 Io(std::io::Error),
84 Parse(ParseError),
86}
87
88impl std::fmt::Display for DetectError {
89 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90 match self {
91 Self::Io(e) => write!(f, "could not read /etc/os-release: {e}"),
92 Self::Parse(e) => write!(f, "could not parse /etc/os-release: {e}"),
93 }
94 }
95}
96
97impl std::error::Error for DetectError {
98 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
99 match self {
100 Self::Io(e) => Some(e),
101 Self::Parse(e) => Some(e),
102 }
103 }
104}
105
106#[derive(Clone, Debug, PartialEq, Eq)]
115pub struct OsRelease {
116 pub id: String,
118 pub id_like: Vec<String>,
120 pub version_id: Option<String>,
122 pub codename: Option<String>,
124 pub variant_id: Option<String>,
126 pub pretty_name: Option<String>,
128}
129
130#[derive(Clone, Debug, PartialEq, Eq)]
132pub enum DistroId {
133 Fedora { version: u32 },
135 FedoraSilverblue { version: u32 },
137 Ubuntu { major: u32, minor: u32 },
139 UbuntuCore { major: u32, minor: u32 },
141 Debian { version: Option<u32> },
143 Other {
145 id: String,
146 version_id: Option<String>,
147 id_like: Vec<String>,
151 },
152}
153
154#[derive(Clone, Debug, PartialEq, Eq)]
156pub enum DistroFamily {
157 Fedora,
158 Debian,
159 Other,
160}
161
162impl std::fmt::Display for DistroId {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 match self {
165 Self::Fedora { version } => write!(f, "Fedora {version}"),
166 Self::FedoraSilverblue { version } => write!(f, "FedoraSilverblue {version}"),
167 Self::Ubuntu { major, minor } => write!(f, "Ubuntu {major}.{minor:02}"),
168 Self::UbuntuCore { major, minor } => write!(f, "UbuntuCore {major}.{minor:02}"),
169 Self::Debian { version: Some(v) } => write!(f, "Debian {v}"),
170 Self::Debian { version: None } => write!(f, "Debian (unknown version)"),
171 Self::Other {
172 id,
173 version_id: Some(v),
174 ..
175 } => write!(f, "{id} {v}"),
176 Self::Other {
177 id,
178 version_id: None,
179 ..
180 } => write!(f, "{id}"),
181 }
182 }
183}
184
185impl DistroId {
186 pub fn family(&self) -> DistroFamily {
203 match self {
204 Self::Fedora { .. } | Self::FedoraSilverblue { .. } => DistroFamily::Fedora,
205 Self::Ubuntu { .. } | Self::UbuntuCore { .. } | Self::Debian { .. } => {
206 DistroFamily::Debian
207 }
208 Self::Other { id, id_like, .. } => {
209 if id.contains("fedora") {
210 DistroFamily::Fedora
211 } else if id_like.iter().any(|p| p == "debian" || p == "ubuntu") {
212 DistroFamily::Debian
213 } else if id_like.iter().any(|p| p == "fedora" || p == "rhel") {
214 DistroFamily::Fedora
215 } else {
216 DistroFamily::Other
217 }
218 }
219 }
220 }
221
222 pub fn is_supported(&self) -> bool {
248 match self {
249 Self::Fedora { .. } => false,
250 Self::FedoraSilverblue { version } => *version >= OLDEST_ELIGIBLE_FEDORA_ATOMIC,
251 Self::Ubuntu { major, .. } => *major >= OLDEST_SUPPORTED_UBUNTU_MAJOR,
252 Self::UbuntuCore { .. } | Self::Debian { .. } | Self::Other { .. } => false,
253 }
254 }
255}
256
257pub fn parse_os_release(contents: &str) -> Result<OsRelease, ParseError> {
272 if contents.len() > MAX_OS_RELEASE_BYTES {
273 return Err(ParseError::FileTooLarge);
274 }
275
276 let mut id: Option<String> = None;
277 let mut id_like: Vec<String> = Vec::new();
278 let mut version_id: Option<String> = None;
279 let mut codename: Option<String> = None;
280 let mut variant_id: Option<String> = None;
281 let mut pretty_name: Option<String> = None;
282 let mut found_any = false;
283
284 for line in contents.lines() {
285 if line.is_empty() || line.starts_with('#') {
287 continue;
288 }
289
290 let eq_pos = line
292 .find('=')
293 .ok_or_else(|| ParseError::InvalidLine(line.to_string()))?;
294
295 let key = &line[..eq_pos];
296 let raw_value = &line[eq_pos + 1..];
297
298 if !is_valid_key(key) {
300 return Err(ParseError::InvalidLine(line.to_string()));
301 }
302
303 if raw_value.is_empty() {
305 return Err(ParseError::InvalidLine(line.to_string()));
306 }
307
308 let value = strip_quotes(key, raw_value)?;
310
311 for ch in value.chars() {
313 if ch == '\0' || (ch.is_ascii_control() && ch != '\t') {
314 return Err(ParseError::InvalidByte {
315 key: key.to_string(),
316 });
317 }
318 }
319
320 found_any = true;
321
322 match key {
323 "ID" => id = Some(value),
324 "ID_LIKE" => {
325 id_like = raw_split_whitespace(&value);
326 }
327 "VERSION_ID" => version_id = Some(value),
328 "VERSION_CODENAME" => codename = Some(value),
329 "VARIANT_ID" => variant_id = Some(value),
330 "PRETTY_NAME" => pretty_name = Some(value),
331 _ => {} }
333 }
334
335 if !found_any {
336 return Err(ParseError::Empty);
337 }
338
339 let id = id.ok_or(ParseError::Empty)?;
340
341 Ok(OsRelease {
342 id,
343 id_like,
344 version_id,
345 codename,
346 variant_id,
347 pretty_name,
348 })
349}
350
351fn is_valid_key(key: &str) -> bool {
353 let mut chars = key.chars();
354 match chars.next() {
355 Some(first) if first.is_ascii_uppercase() => {}
356 _ => return false,
357 }
358 chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
359}
360
361fn strip_quotes(key: &str, raw: &str) -> Result<String, ParseError> {
366 let bytes = raw.as_bytes();
367 if bytes.is_empty() {
368 return Ok(String::new());
369 }
370
371 let first = bytes[0];
372 if first == b'"' || first == b'\'' {
373 let last = *bytes.last().unwrap();
374 if last != first || bytes.len() < 2 {
375 return Err(ParseError::MismatchedQuotes(key.to_string()));
376 }
377 Ok(raw[1..raw.len() - 1].to_string())
379 } else {
380 Ok(raw.to_string())
381 }
382}
383
384fn raw_split_whitespace(s: &str) -> Vec<String> {
386 s.split_ascii_whitespace().map(|t| t.to_string()).collect()
387}
388
389pub fn detect_distro(release: &OsRelease) -> DistroId {
395 match release.id.as_str() {
396 "fedora" => detect_fedora(release),
397 "ubuntu" => detect_ubuntu(release),
398 "debian" => detect_debian(release),
399 _ => DistroId::Other {
400 id: release.id.clone(),
401 version_id: release.version_id.clone(),
402 id_like: release.id_like.clone(),
403 },
404 }
405}
406
407fn detect_fedora(release: &OsRelease) -> DistroId {
408 let is_immutable = matches!(
409 release.variant_id.as_deref(),
410 Some("silverblue") | Some("kinoite") | Some("sericea")
411 );
412
413 let version = release
414 .version_id
415 .as_deref()
416 .and_then(|v| v.parse::<u32>().ok());
417
418 match version {
419 Some(v) if is_immutable => DistroId::FedoraSilverblue { version: v },
420 Some(v) => DistroId::Fedora { version: v },
421 None => DistroId::Other {
422 id: release.id.clone(),
423 version_id: release.version_id.clone(),
424 id_like: release.id_like.clone(),
425 },
426 }
427}
428
429fn detect_ubuntu(release: &OsRelease) -> DistroId {
430 let is_core = release.variant_id.as_deref() == Some("core");
431
432 let parsed = release.version_id.as_deref().and_then(parse_ubuntu_version);
434
435 match parsed {
436 Some((major, minor)) if is_core => DistroId::UbuntuCore { major, minor },
437 Some((major, minor)) => DistroId::Ubuntu { major, minor },
438 None => DistroId::Other {
439 id: release.id.clone(),
440 version_id: release.version_id.clone(),
441 id_like: release.id_like.clone(),
442 },
443 }
444}
445
446fn parse_ubuntu_version(v: &str) -> Option<(u32, u32)> {
447 let mut parts = v.splitn(2, '.');
448 let major = parts.next()?.parse::<u32>().ok()?;
449 let minor = parts.next()?.parse::<u32>().ok()?;
450 Some((major, minor))
451}
452
453fn detect_debian(release: &OsRelease) -> DistroId {
454 let version = release
455 .version_id
456 .as_deref()
457 .and_then(|v| v.parse::<u32>().ok());
458 DistroId::Debian { version }
459}
460
461pub fn detect() -> Result<DistroId, DetectError> {
467 let contents = std::fs::read_to_string("/etc/os-release").map_err(DetectError::Io)?;
468 let release = parse_os_release(&contents).map_err(DetectError::Parse)?;
469 Ok(detect_distro(&release))
470}
471
472#[cfg(test)]
477mod tests {
478 use super::*;
479
480 const FEDORA_41: &str = r#"NAME="Fedora Linux"
486VERSION="41 (Workstation Edition)"
487ID=fedora
488VERSION_ID=41
489VERSION_CODENAME=""
490PLATFORM_ID="platform:f41"
491PRETTY_NAME="Fedora Linux 41 (Workstation Edition)"
492ANSI_COLOR="0;38;2;60;110;180"
493LOGO=fedora-logo-icon
494CPE_NAME="cpe:/o:fedoraproject:fedora:41"
495DEFAULT_HOSTNAME="fedora"
496HOME_URL="https://fedoraproject.org/"
497DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/system-administrators-guide/"
498SUPPORT_URL="https://ask.fedoraproject.org/"
499BUG_REPORT_URL="https://bugzilla.redhat.com/"
500REDHAT_BUGZILLA_PRODUCT="Fedora"
501REDHAT_BUGZILLA_PRODUCT_VERSION=41
502REDHAT_SUPPORT_PRODUCT="Fedora"
503REDHAT_SUPPORT_PRODUCT_VERSION=41
504SUPPORT_END=2025-05-13
505VARIANT="Workstation Edition"
506VARIANT_ID=workstation
507"#;
508
509 const FEDORA_SILVERBLUE_41: &str = r#"NAME="Fedora Linux"
511VERSION="41 (Silverblue)"
512ID=fedora
513VERSION_ID=41
514VERSION_CODENAME=""
515PLATFORM_ID="platform:f41"
516PRETTY_NAME="Fedora Linux 41 (Silverblue)"
517ANSI_COLOR="0;38;2;60;110;180"
518LOGO=fedora-logo-icon
519CPE_NAME="cpe:/o:fedoraproject:fedora:41"
520DEFAULT_HOSTNAME="fedora"
521HOME_URL="https://fedoraproject.org/"
522DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/"
523SUPPORT_URL="https://ask.fedoraproject.org/"
524BUG_REPORT_URL="https://bugzilla.redhat.com/"
525REDHAT_BUGZILLA_PRODUCT="Fedora"
526REDHAT_BUGZILLA_PRODUCT_VERSION=41
527REDHAT_SUPPORT_PRODUCT="Fedora"
528REDHAT_SUPPORT_PRODUCT_VERSION=41
529SUPPORT_END=2025-05-13
530VARIANT="Silverblue"
531VARIANT_ID=silverblue
532"#;
533
534 const FEDORA_KINOITE_41: &str = r#"NAME="Fedora Linux"
536VERSION="41 (Kinoite)"
537ID=fedora
538VERSION_ID=41
539VERSION_CODENAME=""
540PLATFORM_ID="platform:f41"
541PRETTY_NAME="Fedora Linux 41 (Kinoite)"
542ANSI_COLOR="0;38;2;60;110;180"
543LOGO=fedora-logo-icon
544CPE_NAME="cpe:/o:fedoraproject:fedora:41"
545DEFAULT_HOSTNAME="fedora"
546HOME_URL="https://fedoraproject.org/"
547DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/"
548SUPPORT_URL="https://ask.fedoraproject.org/"
549BUG_REPORT_URL="https://bugzilla.redhat.com/"
550REDHAT_BUGZILLA_PRODUCT="Fedora"
551REDHAT_BUGZILLA_PRODUCT_VERSION=41
552REDHAT_SUPPORT_PRODUCT="Fedora"
553REDHAT_SUPPORT_PRODUCT_VERSION=41
554SUPPORT_END=2025-05-13
555VARIANT="Kinoite"
556VARIANT_ID=kinoite
557"#;
558
559 const FEDORA_SILVERBLUE_44: &str = r#"NAME="Fedora Linux"
561VERSION="44 (Silverblue)"
562ID=fedora
563VERSION_ID=44
564VERSION_CODENAME=""
565PLATFORM_ID="platform:f44"
566PRETTY_NAME="Fedora Linux 44 (Silverblue)"
567ANSI_COLOR="0;38;2;60;110;180"
568LOGO=fedora-logo-icon
569CPE_NAME="cpe:/o:fedoraproject:fedora:44"
570HOME_URL="https://fedoraproject.org/"
571DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f44/"
572SUPPORT_URL="https://ask.fedoraproject.org/"
573VARIANT="Silverblue"
574VARIANT_ID=silverblue
575"#;
576
577 const UBUNTU_2204: &str = r#"PRETTY_NAME="Ubuntu 22.04.4 LTS"
579NAME="Ubuntu"
580VERSION_ID="22.04"
581VERSION="22.04.4 LTS (Jammy Jellyfish)"
582VERSION_CODENAME=jammy
583ID=ubuntu
584ID_LIKE=debian
585HOME_URL="https://www.ubuntu.com/"
586SUPPORT_URL="https://help.ubuntu.com/"
587BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
588PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
589UBUNTU_CODENAME=jammy
590"#;
591
592 const UBUNTU_2404: &str = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS"
594NAME="Ubuntu"
595VERSION_ID="24.04"
596VERSION="24.04.1 LTS (Noble Numbat)"
597VERSION_CODENAME=noble
598ID=ubuntu
599ID_LIKE=debian
600HOME_URL="https://www.ubuntu.com/"
601SUPPORT_URL="https://help.ubuntu.com/"
602BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
603PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
604UBUNTU_CODENAME=noble
605LOGO=ubuntu-logo
606"#;
607
608 const UBUNTU_2604: &str = r#"PRETTY_NAME="Ubuntu 26.04 LTS"
613NAME="Ubuntu"
614VERSION_ID="26.04"
615VERSION="26.04 LTS (Resolute Raccoon)"
616VERSION_CODENAME=resolute
617ID=ubuntu
618ID_LIKE=debian
619HOME_URL="https://www.ubuntu.com/"
620SUPPORT_URL="https://help.ubuntu.com/"
621BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
622PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
623UBUNTU_CODENAME=resolute
624LOGO=ubuntu-logo
625"#;
626
627 const UBUNTU_CORE_24: &str = r#"NAME="Ubuntu Core"
629VERSION="24"
630ID=ubuntu
631ID_LIKE=debian
632PRETTY_NAME="Ubuntu Core 24"
633VERSION_ID="24.04"
634HOME_URL="https://snapcraft.io/docs/ubuntu-core"
635BUG_REPORT_URL="https://bugs.launchpad.net/snappy/"
636SUPPORT_URL="https://ubuntu.com/support"
637LOGO=ubuntu-logo
638VARIANT=Core
639VARIANT_ID=core
640"#;
641
642 const DEBIAN_12: &str = r#"PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
644NAME="Debian GNU/Linux"
645VERSION_ID="12"
646VERSION="12 (bookworm)"
647VERSION_CODENAME=bookworm
648ID=debian
649HOME_URL="https://www.debian.org/"
650SUPPORT_URL="https://www.debian.org/support"
651BUG_REPORT_URL="https://bugs.debian.org/"
652"#;
653
654 const LINUX_MINT_22: &str = r#"NAME="Linux Mint"
656VERSION="22 (Wilma)"
657ID=linuxmint
658ID_LIKE="ubuntu debian"
659PRETTY_NAME="Linux Mint 22"
660VERSION_ID="22"
661HOME_URL="https://www.linuxmint.com/"
662SUPPORT_URL="https://forums.linuxmint.com/"
663BUG_REPORT_URL="https://github.com/linuxmint/linuxmint/issues"
664PRIVACY_POLICY_URL="https://www.linuxmint.com/privacy.php"
665VERSION_CODENAME=wilma
666UBUNTU_CODENAME=noble
667"#;
668
669 const POP_OS_2204: &str = r#"NAME="Pop!_OS"
671VERSION="22.04 LTS"
672ID=pop
673ID_LIKE="ubuntu debian"
674PRETTY_NAME="Pop!_OS 22.04 LTS"
675VERSION_ID="22.04"
676HOME_URL="https://pop.system76.com"
677SUPPORT_URL="https://support.system76.com"
678BUG_REPORT_URL="https://github.com/pop-os/pop/issues"
679PRIVACY_POLICY_URL="https://system76.com/privacy"
680VERSION_CODENAME=jammy
681UBUNTU_CODENAME=jammy
682"#;
683
684 const AMAZON_LINUX_2023: &str = r#"NAME="Amazon Linux"
686VERSION="2023"
687ID="amzn"
688ID_LIKE="fedora"
689VERSION_ID="2023"
690PLATFORM_ID="platform:al2023"
691PRETTY_NAME="Amazon Linux 2023.6.20241121"
692ANSI_COLOR="0;33"
693CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2023"
694HOME_URL="https://aws.amazon.com/linux/amazon-linux-2023/"
695BUG_REPORT_URL="https://github.com/amazonlinux/amazon-linux-2023"
696SUPPORT_END="2028-03-15"
697"#;
698
699 #[test]
704 fn parse_fedora_41_workstation() {
705 let r = parse_os_release(FEDORA_41).unwrap();
706 assert_eq!(r.id, "fedora");
707 assert_eq!(r.version_id.as_deref(), Some("41"));
708 assert_eq!(r.variant_id.as_deref(), Some("workstation"));
709 assert!(r.id_like.is_empty());
710 }
711
712 #[test]
713 fn parse_fedora_silverblue_41() {
714 let r = parse_os_release(FEDORA_SILVERBLUE_41).unwrap();
715 assert_eq!(r.id, "fedora");
716 assert_eq!(r.version_id.as_deref(), Some("41"));
717 assert_eq!(r.variant_id.as_deref(), Some("silverblue"));
718 }
719
720 #[test]
721 fn parse_fedora_kinoite_41() {
722 let r = parse_os_release(FEDORA_KINOITE_41).unwrap();
723 assert_eq!(r.id, "fedora");
724 assert_eq!(r.version_id.as_deref(), Some("41"));
725 assert_eq!(r.variant_id.as_deref(), Some("kinoite"));
726 }
727
728 #[test]
729 fn parse_ubuntu_2204_jammy() {
730 let r = parse_os_release(UBUNTU_2204).unwrap();
731 assert_eq!(r.id, "ubuntu");
732 assert_eq!(r.version_id.as_deref(), Some("22.04"));
733 assert_eq!(r.codename.as_deref(), Some("jammy"));
734 assert_eq!(r.id_like, vec!["debian"]);
735 }
736
737 #[test]
738 fn parse_ubuntu_2404_noble() {
739 let r = parse_os_release(UBUNTU_2404).unwrap();
740 assert_eq!(r.id, "ubuntu");
741 assert_eq!(r.version_id.as_deref(), Some("24.04"));
742 assert_eq!(r.codename.as_deref(), Some("noble"));
743 assert_eq!(r.id_like, vec!["debian"]);
744 }
745
746 #[test]
747 fn parse_ubuntu_2604_resolute() {
748 let r = parse_os_release(UBUNTU_2604).unwrap();
749 assert_eq!(r.id, "ubuntu");
750 assert_eq!(r.version_id.as_deref(), Some("26.04"));
751 assert_eq!(r.codename.as_deref(), Some("resolute"));
752 assert_eq!(r.id_like, vec!["debian"]);
753 }
754
755 #[test]
756 fn parse_ubuntu_core_24() {
757 let r = parse_os_release(UBUNTU_CORE_24).unwrap();
758 assert_eq!(r.id, "ubuntu");
759 assert_eq!(r.version_id.as_deref(), Some("24.04"));
760 assert_eq!(r.variant_id.as_deref(), Some("core"));
761 }
762
763 #[test]
764 fn parse_debian_12_bookworm() {
765 let r = parse_os_release(DEBIAN_12).unwrap();
766 assert_eq!(r.id, "debian");
767 assert_eq!(r.version_id.as_deref(), Some("12"));
768 assert_eq!(r.codename.as_deref(), Some("bookworm"));
769 }
770
771 #[test]
772 fn parse_linux_mint_22() {
773 let r = parse_os_release(LINUX_MINT_22).unwrap();
774 assert_eq!(r.id, "linuxmint");
775 assert_eq!(r.version_id.as_deref(), Some("22"));
776 assert_eq!(r.id_like, vec!["ubuntu", "debian"]);
777 }
778
779 #[test]
780 fn parse_pop_os_2204() {
781 let r = parse_os_release(POP_OS_2204).unwrap();
782 assert_eq!(r.id, "pop");
783 assert_eq!(r.version_id.as_deref(), Some("22.04"));
784 assert_eq!(r.id_like, vec!["ubuntu", "debian"]);
785 }
786
787 #[test]
788 fn parse_amazon_linux_2023() {
789 let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
790 assert_eq!(r.id, "amzn");
791 assert_eq!(r.version_id.as_deref(), Some("2023"));
792 assert_eq!(r.id_like, vec!["fedora"]);
793 }
794
795 #[test]
800 fn reject_empty_file() {
801 assert_eq!(parse_os_release(""), Err(ParseError::Empty));
802 }
803
804 #[test]
805 fn reject_only_comments_and_blanks() {
806 let input = "# This is a comment\n\n# Another comment\n";
807 assert_eq!(parse_os_release(input), Err(ParseError::Empty));
808 }
809
810 #[test]
811 fn reject_line_without_equals() {
812 let input = "ID=fedora\nBAD_LINE_NO_EQUALS\n";
813 assert!(matches!(
814 parse_os_release(input),
815 Err(ParseError::InvalidLine(_))
816 ));
817 }
818
819 #[test]
820 fn reject_lowercase_key() {
821 let input = "id=fedora\nVERSION_ID=41\n";
823 assert!(matches!(
824 parse_os_release(input),
825 Err(ParseError::InvalidLine(_))
826 ));
827 }
828
829 #[test]
830 fn reject_mixed_case_key() {
831 let input = "Id=fedora\nVERSION_ID=41\n";
832 assert!(matches!(
833 parse_os_release(input),
834 Err(ParseError::InvalidLine(_))
835 ));
836 }
837
838 #[test]
839 fn reject_mismatched_double_quotes() {
840 let input = "ID=\"fedora\nVERSION_ID=41\n";
842 assert!(matches!(
843 parse_os_release(input),
844 Err(ParseError::MismatchedQuotes(_))
845 ));
846 }
847
848 #[test]
849 fn reject_mismatched_single_quotes() {
850 let input = "ID='fedora\nVERSION_ID=41\n";
851 assert!(matches!(
852 parse_os_release(input),
853 Err(ParseError::MismatchedQuotes(_))
854 ));
855 }
856
857 #[test]
858 fn reject_nul_byte_in_value() {
859 let input = "ID=fedora\nPRETTY_NAME=foo\x00bar\n";
861 assert!(matches!(
862 parse_os_release(input),
863 Err(ParseError::InvalidByte { .. })
864 ));
865 }
866
867 #[test]
868 fn reject_control_byte_in_value() {
869 let input = "ID=fedora\nPRETTY_NAME=foo\x01bar\n";
871 assert!(matches!(
872 parse_os_release(input),
873 Err(ParseError::InvalidByte { .. })
874 ));
875 }
876
877 #[test]
878 fn reject_oversized_file() {
879 let large = "A".repeat(MAX_OS_RELEASE_BYTES + 1);
881 assert_eq!(parse_os_release(&large), Err(ParseError::FileTooLarge));
882 }
883
884 #[test]
889 fn detect_fedora_41_workstation() {
890 let r = parse_os_release(FEDORA_41).unwrap();
891 assert_eq!(detect_distro(&r), DistroId::Fedora { version: 41 });
892 }
893
894 #[test]
895 fn detect_silverblue_routes_to_fedora_silverblue() {
896 let r = parse_os_release(FEDORA_SILVERBLUE_41).unwrap();
897 assert_eq!(
898 detect_distro(&r),
899 DistroId::FedoraSilverblue { version: 41 }
900 );
901 }
902
903 #[test]
904 fn detect_kinoite_routes_to_fedora_silverblue() {
905 let r = parse_os_release(FEDORA_KINOITE_41).unwrap();
906 assert_eq!(
907 detect_distro(&r),
908 DistroId::FedoraSilverblue { version: 41 }
909 );
910 }
911
912 #[test]
913 fn detect_current_silverblue_routes_to_fedora_silverblue() {
914 let r = parse_os_release(FEDORA_SILVERBLUE_44).unwrap();
915 assert_eq!(
916 detect_distro(&r),
917 DistroId::FedoraSilverblue { version: 44 }
918 );
919 }
920
921 #[test]
922 fn detect_ubuntu_core_routes_to_ubuntu_core() {
923 let r = parse_os_release(UBUNTU_CORE_24).unwrap();
924 assert_eq!(
925 detect_distro(&r),
926 DistroId::UbuntuCore {
927 major: 24,
928 minor: 4
929 }
930 );
931 }
932
933 #[test]
934 fn detect_ubuntu_2204() {
935 let r = parse_os_release(UBUNTU_2204).unwrap();
936 assert_eq!(
937 detect_distro(&r),
938 DistroId::Ubuntu {
939 major: 22,
940 minor: 4
941 }
942 );
943 }
944
945 #[test]
946 fn detect_ubuntu_2604() {
947 let r = parse_os_release(UBUNTU_2604).unwrap();
948 assert_eq!(
949 detect_distro(&r),
950 DistroId::Ubuntu {
951 major: 26,
952 minor: 4
953 }
954 );
955 }
956
957 #[test]
958 fn detect_debian_12() {
959 let r = parse_os_release(DEBIAN_12).unwrap();
960 assert_eq!(detect_distro(&r), DistroId::Debian { version: Some(12) });
961 }
962
963 #[test]
964 fn detect_mint_falls_through_to_other() {
965 let r = parse_os_release(LINUX_MINT_22).unwrap();
966 assert!(matches!(detect_distro(&r), DistroId::Other { .. }));
967 if let DistroId::Other {
968 id,
969 version_id,
970 id_like,
971 } = detect_distro(&r)
972 {
973 assert_eq!(id, "linuxmint");
974 assert_eq!(version_id.as_deref(), Some("22"));
975 assert_eq!(id_like, vec!["ubuntu", "debian"]);
976 }
977 }
978
979 #[test]
980 fn detect_amazon_linux_falls_through_to_other() {
981 let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
982 assert!(matches!(
983 detect_distro(&r),
984 DistroId::Other { ref id, .. } if id == "amzn"
985 ));
986 }
987
988 #[test]
993 fn plain_fedora_is_unsupported_until_dnf_actions_ship() {
994 assert!(!DistroId::Fedora { version: 44 }.is_supported());
995 }
996
997 #[test]
998 fn unsupported_fedora_39() {
999 assert!(!DistroId::Fedora { version: 39 }.is_supported());
1000 }
1001
1002 #[test]
1003 fn supported_fedora_silverblue_41() {
1004 assert!(DistroId::FedoraSilverblue { version: 41 }.is_supported());
1005 }
1006
1007 #[test]
1008 fn supported_fedora_silverblue_44() {
1009 assert!(DistroId::FedoraSilverblue { version: 44 }.is_supported());
1010 }
1011
1012 #[test]
1013 fn unsupported_fedora_silverblue_40() {
1014 assert!(!DistroId::FedoraSilverblue { version: 40 }.is_supported());
1015 }
1016
1017 #[test]
1018 fn supported_ubuntu_2204() {
1019 assert!(DistroId::Ubuntu {
1020 major: 22,
1021 minor: 4
1022 }
1023 .is_supported());
1024 }
1025
1026 #[test]
1027 fn supported_ubuntu_2404() {
1028 assert!(DistroId::Ubuntu {
1029 major: 24,
1030 minor: 4
1031 }
1032 .is_supported());
1033 }
1034
1035 #[test]
1036 fn supported_ubuntu_2604() {
1037 assert!(DistroId::Ubuntu {
1038 major: 26,
1039 minor: 4
1040 }
1041 .is_supported());
1042 }
1043
1044 #[test]
1045 fn supported_ubuntu_2004_is_the_oldest_supported_release() {
1046 assert!(DistroId::Ubuntu {
1047 major: 20,
1048 minor: 4
1049 }
1050 .is_supported());
1051 }
1052
1053 #[test]
1054 fn supported_ubuntu_interim_2610() {
1055 assert!(DistroId::Ubuntu {
1058 major: 26,
1059 minor: 10
1060 }
1061 .is_supported());
1062 }
1063
1064 #[test]
1065 fn supported_ubuntu_interim_2510() {
1066 assert!(DistroId::Ubuntu {
1067 major: 25,
1068 minor: 10
1069 }
1070 .is_supported());
1071 }
1072
1073 #[test]
1074 fn unsupported_ubuntu_1804_predates_the_support_floor() {
1075 assert!(!DistroId::Ubuntu {
1078 major: 18,
1079 minor: 4
1080 }
1081 .is_supported());
1082 }
1083
1084 #[test]
1085 fn unsupported_ubuntu_core() {
1086 assert!(!DistroId::UbuntuCore {
1087 major: 24,
1088 minor: 4
1089 }
1090 .is_supported());
1091 }
1092
1093 #[test]
1094 fn unsupported_debian() {
1095 assert!(!DistroId::Debian { version: Some(12) }.is_supported());
1096 }
1097
1098 #[test]
1099 fn unsupported_other() {
1100 assert!(!DistroId::Other {
1101 id: "arch".to_string(),
1102 version_id: None,
1103 id_like: Vec::new(),
1104 }
1105 .is_supported());
1106 }
1107
1108 #[test]
1113 fn os_release_clone_eq() {
1114 let r = parse_os_release(UBUNTU_2404).unwrap();
1115 let r2 = r.clone();
1116 assert_eq!(r, r2);
1117 }
1118
1119 #[test]
1120 fn os_release_debug_contains_id() {
1121 let r = parse_os_release(FEDORA_41).unwrap();
1122 let dbg = format!("{r:?}");
1123 assert!(dbg.contains("fedora"), "debug output: {dbg}");
1124 }
1125
1126 #[test]
1127 fn distro_id_clone_eq() {
1128 let d = DistroId::Ubuntu {
1129 major: 24,
1130 minor: 4,
1131 };
1132 assert_eq!(d.clone(), d);
1133 }
1134
1135 #[test]
1136 fn distro_family_fedora() {
1137 assert_eq!(
1138 DistroId::Fedora { version: 41 }.family(),
1139 DistroFamily::Fedora
1140 );
1141 assert_eq!(
1142 DistroId::FedoraSilverblue { version: 41 }.family(),
1143 DistroFamily::Fedora
1144 );
1145 }
1146
1147 #[test]
1148 fn distro_family_debian() {
1149 assert_eq!(
1150 DistroId::Ubuntu {
1151 major: 24,
1152 minor: 4
1153 }
1154 .family(),
1155 DistroFamily::Debian
1156 );
1157 assert_eq!(
1158 DistroId::Debian { version: Some(12) }.family(),
1159 DistroFamily::Debian
1160 );
1161 }
1162
1163 #[test]
1168 fn linux_mint_family_falls_back_to_debian_via_id_like() {
1169 let r = parse_os_release(LINUX_MINT_22).unwrap();
1170 let distro = detect_distro(&r);
1171 assert!(
1172 matches!(distro, DistroId::Other { .. }),
1173 "detect_distro must still classify Mint as Other -- only family() changes"
1174 );
1175 assert_eq!(
1176 distro.family(),
1177 DistroFamily::Debian,
1178 "Linux Mint (ID_LIKE=\"ubuntu debian\") must route to the Debian family"
1179 );
1180 }
1181
1182 #[test]
1183 fn pop_os_family_falls_back_to_debian_via_id_like() {
1184 let r = parse_os_release(POP_OS_2204).unwrap();
1185 let distro = detect_distro(&r);
1186 assert!(matches!(distro, DistroId::Other { .. }));
1187 assert_eq!(
1188 distro.family(),
1189 DistroFamily::Debian,
1190 "Pop!_OS (ID_LIKE=\"ubuntu debian\") must route to the Debian family"
1191 );
1192 }
1193
1194 #[test]
1195 fn amazon_linux_family_falls_back_to_fedora_via_id_like() {
1196 let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
1197 let distro = detect_distro(&r);
1198 assert!(matches!(distro, DistroId::Other { .. }));
1199 assert_eq!(
1200 distro.family(),
1201 DistroFamily::Fedora,
1202 "Amazon Linux (ID_LIKE=\"fedora\") must route to the Fedora family"
1203 );
1204 }
1205
1206 #[test]
1207 fn unknown_id_with_empty_id_like_stays_other() {
1208 let distro = DistroId::Other {
1209 id: "slackware".to_string(),
1210 version_id: None,
1211 id_like: Vec::new(),
1212 };
1213 assert_eq!(
1214 distro.family(),
1215 DistroFamily::Other,
1216 "an unrecognised distro with no ID_LIKE hint must stay Other"
1217 );
1218 }
1219
1220 #[test]
1225 fn accept_tab_in_pretty_name() {
1226 let input = "ID=fedora\nVERSION_ID=41\nPRETTY_NAME=foo\tbar\n";
1228 let r = parse_os_release(input).unwrap();
1229 assert_eq!(r.pretty_name.as_deref(), Some("foo\tbar"));
1230 }
1231
1232 #[test]
1233 fn accept_single_quoted_value() {
1234 let input = "ID='fedora'\nVERSION_ID=41\n";
1235 let r = parse_os_release(input).unwrap();
1236 assert_eq!(r.id, "fedora");
1237 }
1238
1239 #[test]
1240 fn accept_unquoted_value() {
1241 let input = "ID=fedora\nVERSION_ID=41\n";
1242 let r = parse_os_release(input).unwrap();
1243 assert_eq!(r.id, "fedora");
1244 }
1245
1246 #[test]
1247 fn version_codename_empty_string_treated_as_none_equivalent() {
1248 let input = "ID=fedora\nVERSION_ID=41\nVERSION_CODENAME=\"\"\n";
1250 let r = parse_os_release(input).unwrap();
1253 assert_eq!(r.codename.as_deref(), Some(""));
1254 }
1255
1256 #[test]
1257 fn reject_key_starting_with_digit() {
1258 let input = "1D=fedora\nVERSION_ID=41\n";
1259 assert!(matches!(
1260 parse_os_release(input),
1261 Err(ParseError::InvalidLine(_))
1262 ));
1263 }
1264
1265 #[test]
1266 fn reject_key_with_lowercase_after_valid_start() {
1267 let input = "IDENTIFier=fedora\nVERSION_ID=41\n";
1268 assert!(matches!(
1269 parse_os_release(input),
1270 Err(ParseError::InvalidLine(_))
1271 ));
1272 }
1273}