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