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