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 },
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
138pub enum DistroFamily {
139 Fedora,
140 Debian,
141 Other,
142}
143
144impl std::fmt::Display for DistroId {
145 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146 match self {
147 Self::Fedora { version } => write!(f, "Fedora {version}"),
148 Self::FedoraSilverblue { version } => write!(f, "FedoraSilverblue {version}"),
149 Self::Ubuntu { major, minor } => write!(f, "Ubuntu {major}.{minor:02}"),
150 Self::UbuntuCore { major, minor } => write!(f, "UbuntuCore {major}.{minor:02}"),
151 Self::Debian { version: Some(v) } => write!(f, "Debian {v}"),
152 Self::Debian { version: None } => write!(f, "Debian (unknown version)"),
153 Self::Other {
154 id,
155 version_id: Some(v),
156 } => write!(f, "{id} {v}"),
157 Self::Other {
158 id,
159 version_id: None,
160 } => write!(f, "{id}"),
161 }
162 }
163}
164
165impl DistroId {
166 pub fn family(&self) -> DistroFamily {
168 match self {
169 Self::Fedora { .. } | Self::FedoraSilverblue { .. } => DistroFamily::Fedora,
170 Self::Ubuntu { .. } | Self::UbuntuCore { .. } | Self::Debian { .. } => {
171 DistroFamily::Debian
172 }
173 Self::Other { id, .. } => {
174 if id.contains("fedora") {
177 DistroFamily::Fedora
178 } else {
179 DistroFamily::Other
180 }
181 }
182 }
183 }
184
185 pub fn is_supported(&self) -> bool {
194 match self {
195 Self::Fedora { .. } => false,
196 Self::FedoraSilverblue { version } => *version >= 41,
197 Self::Ubuntu { major, minor } => {
198 matches!((*major, *minor), (22, 4) | (24, 4) | (26, 4))
199 }
200 Self::UbuntuCore { .. } | Self::Debian { .. } | Self::Other { .. } => false,
201 }
202 }
203}
204
205pub fn parse_os_release(contents: &str) -> Result<OsRelease, ParseError> {
220 if contents.len() > MAX_OS_RELEASE_BYTES {
221 return Err(ParseError::FileTooLarge);
222 }
223
224 let mut id: Option<String> = None;
225 let mut id_like: Vec<String> = Vec::new();
226 let mut version_id: Option<String> = None;
227 let mut codename: Option<String> = None;
228 let mut variant_id: Option<String> = None;
229 let mut pretty_name: Option<String> = None;
230 let mut found_any = false;
231
232 for line in contents.lines() {
233 if line.is_empty() || line.starts_with('#') {
235 continue;
236 }
237
238 let eq_pos = line
240 .find('=')
241 .ok_or_else(|| ParseError::InvalidLine(line.to_string()))?;
242
243 let key = &line[..eq_pos];
244 let raw_value = &line[eq_pos + 1..];
245
246 if !is_valid_key(key) {
248 return Err(ParseError::InvalidLine(line.to_string()));
249 }
250
251 if raw_value.is_empty() {
253 return Err(ParseError::InvalidLine(line.to_string()));
254 }
255
256 let value = strip_quotes(key, raw_value)?;
258
259 for ch in value.chars() {
261 if ch == '\0' || (ch.is_ascii_control() && ch != '\t') {
262 return Err(ParseError::InvalidByte {
263 key: key.to_string(),
264 });
265 }
266 }
267
268 found_any = true;
269
270 match key {
271 "ID" => id = Some(value),
272 "ID_LIKE" => {
273 id_like = raw_split_whitespace(&value);
274 }
275 "VERSION_ID" => version_id = Some(value),
276 "VERSION_CODENAME" => codename = Some(value),
277 "VARIANT_ID" => variant_id = Some(value),
278 "PRETTY_NAME" => pretty_name = Some(value),
279 _ => {} }
281 }
282
283 if !found_any {
284 return Err(ParseError::Empty);
285 }
286
287 let id = id.ok_or(ParseError::Empty)?;
288
289 Ok(OsRelease {
290 id,
291 id_like,
292 version_id,
293 codename,
294 variant_id,
295 pretty_name,
296 })
297}
298
299fn is_valid_key(key: &str) -> bool {
301 let mut chars = key.chars();
302 match chars.next() {
303 Some(first) if first.is_ascii_uppercase() => {}
304 _ => return false,
305 }
306 chars.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
307}
308
309fn strip_quotes(key: &str, raw: &str) -> Result<String, ParseError> {
314 let bytes = raw.as_bytes();
315 if bytes.is_empty() {
316 return Ok(String::new());
317 }
318
319 let first = bytes[0];
320 if first == b'"' || first == b'\'' {
321 let last = *bytes.last().unwrap();
322 if last != first || bytes.len() < 2 {
323 return Err(ParseError::MismatchedQuotes(key.to_string()));
324 }
325 Ok(raw[1..raw.len() - 1].to_string())
327 } else {
328 Ok(raw.to_string())
329 }
330}
331
332fn raw_split_whitespace(s: &str) -> Vec<String> {
334 s.split_ascii_whitespace().map(|t| t.to_string()).collect()
335}
336
337pub fn detect_distro(release: &OsRelease) -> DistroId {
343 match release.id.as_str() {
344 "fedora" => detect_fedora(release),
345 "ubuntu" => detect_ubuntu(release),
346 "debian" => detect_debian(release),
347 _ => DistroId::Other {
348 id: release.id.clone(),
349 version_id: release.version_id.clone(),
350 },
351 }
352}
353
354fn detect_fedora(release: &OsRelease) -> DistroId {
355 let is_immutable = matches!(
356 release.variant_id.as_deref(),
357 Some("silverblue") | Some("kinoite") | Some("sericea")
358 );
359
360 let version = release
361 .version_id
362 .as_deref()
363 .and_then(|v| v.parse::<u32>().ok());
364
365 match version {
366 Some(v) if is_immutable => DistroId::FedoraSilverblue { version: v },
367 Some(v) => DistroId::Fedora { version: v },
368 None => DistroId::Other {
369 id: release.id.clone(),
370 version_id: release.version_id.clone(),
371 },
372 }
373}
374
375fn detect_ubuntu(release: &OsRelease) -> DistroId {
376 let is_core = release.variant_id.as_deref() == Some("core");
377
378 let parsed = release.version_id.as_deref().and_then(parse_ubuntu_version);
380
381 match parsed {
382 Some((major, minor)) if is_core => DistroId::UbuntuCore { major, minor },
383 Some((major, minor)) => DistroId::Ubuntu { major, minor },
384 None => DistroId::Other {
385 id: release.id.clone(),
386 version_id: release.version_id.clone(),
387 },
388 }
389}
390
391fn parse_ubuntu_version(v: &str) -> Option<(u32, u32)> {
392 let mut parts = v.splitn(2, '.');
393 let major = parts.next()?.parse::<u32>().ok()?;
394 let minor = parts.next()?.parse::<u32>().ok()?;
395 Some((major, minor))
396}
397
398fn detect_debian(release: &OsRelease) -> DistroId {
399 let version = release
400 .version_id
401 .as_deref()
402 .and_then(|v| v.parse::<u32>().ok());
403 DistroId::Debian { version }
404}
405
406pub fn detect() -> Result<DistroId, DetectError> {
412 let contents = std::fs::read_to_string("/etc/os-release").map_err(DetectError::Io)?;
413 let release = parse_os_release(&contents).map_err(DetectError::Parse)?;
414 Ok(detect_distro(&release))
415}
416
417#[cfg(test)]
422mod tests {
423 use super::*;
424
425 const FEDORA_41: &str = r#"NAME="Fedora Linux"
431VERSION="41 (Workstation Edition)"
432ID=fedora
433VERSION_ID=41
434VERSION_CODENAME=""
435PLATFORM_ID="platform:f41"
436PRETTY_NAME="Fedora Linux 41 (Workstation Edition)"
437ANSI_COLOR="0;38;2;60;110;180"
438LOGO=fedora-logo-icon
439CPE_NAME="cpe:/o:fedoraproject:fedora:41"
440DEFAULT_HOSTNAME="fedora"
441HOME_URL="https://fedoraproject.org/"
442DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/system-administrators-guide/"
443SUPPORT_URL="https://ask.fedoraproject.org/"
444BUG_REPORT_URL="https://bugzilla.redhat.com/"
445REDHAT_BUGZILLA_PRODUCT="Fedora"
446REDHAT_BUGZILLA_PRODUCT_VERSION=41
447REDHAT_SUPPORT_PRODUCT="Fedora"
448REDHAT_SUPPORT_PRODUCT_VERSION=41
449SUPPORT_END=2025-05-13
450VARIANT="Workstation Edition"
451VARIANT_ID=workstation
452"#;
453
454 const FEDORA_SILVERBLUE_41: &str = r#"NAME="Fedora Linux"
456VERSION="41 (Silverblue)"
457ID=fedora
458VERSION_ID=41
459VERSION_CODENAME=""
460PLATFORM_ID="platform:f41"
461PRETTY_NAME="Fedora Linux 41 (Silverblue)"
462ANSI_COLOR="0;38;2;60;110;180"
463LOGO=fedora-logo-icon
464CPE_NAME="cpe:/o:fedoraproject:fedora:41"
465DEFAULT_HOSTNAME="fedora"
466HOME_URL="https://fedoraproject.org/"
467DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f41/"
468SUPPORT_URL="https://ask.fedoraproject.org/"
469BUG_REPORT_URL="https://bugzilla.redhat.com/"
470REDHAT_BUGZILLA_PRODUCT="Fedora"
471REDHAT_BUGZILLA_PRODUCT_VERSION=41
472REDHAT_SUPPORT_PRODUCT="Fedora"
473REDHAT_SUPPORT_PRODUCT_VERSION=41
474SUPPORT_END=2025-05-13
475VARIANT="Silverblue"
476VARIANT_ID=silverblue
477"#;
478
479 const FEDORA_KINOITE_41: &str = r#"NAME="Fedora Linux"
481VERSION="41 (Kinoite)"
482ID=fedora
483VERSION_ID=41
484VERSION_CODENAME=""
485PLATFORM_ID="platform:f41"
486PRETTY_NAME="Fedora Linux 41 (Kinoite)"
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/"
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="Kinoite"
501VARIANT_ID=kinoite
502"#;
503
504 const FEDORA_SILVERBLUE_44: &str = r#"NAME="Fedora Linux"
506VERSION="44 (Silverblue)"
507ID=fedora
508VERSION_ID=44
509VERSION_CODENAME=""
510PLATFORM_ID="platform:f44"
511PRETTY_NAME="Fedora Linux 44 (Silverblue)"
512ANSI_COLOR="0;38;2;60;110;180"
513LOGO=fedora-logo-icon
514CPE_NAME="cpe:/o:fedoraproject:fedora:44"
515HOME_URL="https://fedoraproject.org/"
516DOCUMENTATION_URL="https://docs.fedoraproject.org/en-US/fedora/f44/"
517SUPPORT_URL="https://ask.fedoraproject.org/"
518VARIANT="Silverblue"
519VARIANT_ID=silverblue
520"#;
521
522 const UBUNTU_2204: &str = r#"PRETTY_NAME="Ubuntu 22.04.4 LTS"
524NAME="Ubuntu"
525VERSION_ID="22.04"
526VERSION="22.04.4 LTS (Jammy Jellyfish)"
527VERSION_CODENAME=jammy
528ID=ubuntu
529ID_LIKE=debian
530HOME_URL="https://www.ubuntu.com/"
531SUPPORT_URL="https://help.ubuntu.com/"
532BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
533PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
534UBUNTU_CODENAME=jammy
535"#;
536
537 const UBUNTU_2404: &str = r#"PRETTY_NAME="Ubuntu 24.04.1 LTS"
539NAME="Ubuntu"
540VERSION_ID="24.04"
541VERSION="24.04.1 LTS (Noble Numbat)"
542VERSION_CODENAME=noble
543ID=ubuntu
544ID_LIKE=debian
545HOME_URL="https://www.ubuntu.com/"
546SUPPORT_URL="https://help.ubuntu.com/"
547BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
548PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
549UBUNTU_CODENAME=noble
550LOGO=ubuntu-logo
551"#;
552
553 const UBUNTU_2604: &str = r#"PRETTY_NAME="Ubuntu 26.04 LTS"
558NAME="Ubuntu"
559VERSION_ID="26.04"
560VERSION="26.04 LTS (Resolute Raccoon)"
561VERSION_CODENAME=resolute
562ID=ubuntu
563ID_LIKE=debian
564HOME_URL="https://www.ubuntu.com/"
565SUPPORT_URL="https://help.ubuntu.com/"
566BUG_REPORT_URL="https://bugs.launchpad.net/ubuntu/"
567PRIVACY_POLICY_URL="https://www.ubuntu.com/legal/terms-and-policies/privacy-policy"
568UBUNTU_CODENAME=resolute
569LOGO=ubuntu-logo
570"#;
571
572 const UBUNTU_CORE_24: &str = r#"NAME="Ubuntu Core"
574VERSION="24"
575ID=ubuntu
576ID_LIKE=debian
577PRETTY_NAME="Ubuntu Core 24"
578VERSION_ID="24.04"
579HOME_URL="https://snapcraft.io/docs/ubuntu-core"
580BUG_REPORT_URL="https://bugs.launchpad.net/snappy/"
581SUPPORT_URL="https://ubuntu.com/support"
582LOGO=ubuntu-logo
583VARIANT=Core
584VARIANT_ID=core
585"#;
586
587 const DEBIAN_12: &str = r#"PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
589NAME="Debian GNU/Linux"
590VERSION_ID="12"
591VERSION="12 (bookworm)"
592VERSION_CODENAME=bookworm
593ID=debian
594HOME_URL="https://www.debian.org/"
595SUPPORT_URL="https://www.debian.org/support"
596BUG_REPORT_URL="https://bugs.debian.org/"
597"#;
598
599 const LINUX_MINT_22: &str = r#"NAME="Linux Mint"
601VERSION="22 (Wilma)"
602ID=linuxmint
603ID_LIKE="ubuntu debian"
604PRETTY_NAME="Linux Mint 22"
605VERSION_ID="22"
606HOME_URL="https://www.linuxmint.com/"
607SUPPORT_URL="https://forums.linuxmint.com/"
608BUG_REPORT_URL="https://github.com/linuxmint/linuxmint/issues"
609PRIVACY_POLICY_URL="https://www.linuxmint.com/privacy.php"
610VERSION_CODENAME=wilma
611UBUNTU_CODENAME=noble
612"#;
613
614 const POP_OS_2204: &str = r#"NAME="Pop!_OS"
616VERSION="22.04 LTS"
617ID=pop
618ID_LIKE="ubuntu debian"
619PRETTY_NAME="Pop!_OS 22.04 LTS"
620VERSION_ID="22.04"
621HOME_URL="https://pop.system76.com"
622SUPPORT_URL="https://support.system76.com"
623BUG_REPORT_URL="https://github.com/pop-os/pop/issues"
624PRIVACY_POLICY_URL="https://system76.com/privacy"
625VERSION_CODENAME=jammy
626UBUNTU_CODENAME=jammy
627"#;
628
629 const AMAZON_LINUX_2023: &str = r#"NAME="Amazon Linux"
631VERSION="2023"
632ID="amzn"
633ID_LIKE="fedora"
634VERSION_ID="2023"
635PLATFORM_ID="platform:al2023"
636PRETTY_NAME="Amazon Linux 2023.6.20241121"
637ANSI_COLOR="0;33"
638CPE_NAME="cpe:2.3:o:amazon:amazon_linux:2023"
639HOME_URL="https://aws.amazon.com/linux/amazon-linux-2023/"
640BUG_REPORT_URL="https://github.com/amazonlinux/amazon-linux-2023"
641SUPPORT_END="2028-03-15"
642"#;
643
644 #[test]
649 fn parse_fedora_41_workstation() {
650 let r = parse_os_release(FEDORA_41).unwrap();
651 assert_eq!(r.id, "fedora");
652 assert_eq!(r.version_id.as_deref(), Some("41"));
653 assert_eq!(r.variant_id.as_deref(), Some("workstation"));
654 assert!(r.id_like.is_empty());
655 }
656
657 #[test]
658 fn parse_fedora_silverblue_41() {
659 let r = parse_os_release(FEDORA_SILVERBLUE_41).unwrap();
660 assert_eq!(r.id, "fedora");
661 assert_eq!(r.version_id.as_deref(), Some("41"));
662 assert_eq!(r.variant_id.as_deref(), Some("silverblue"));
663 }
664
665 #[test]
666 fn parse_fedora_kinoite_41() {
667 let r = parse_os_release(FEDORA_KINOITE_41).unwrap();
668 assert_eq!(r.id, "fedora");
669 assert_eq!(r.version_id.as_deref(), Some("41"));
670 assert_eq!(r.variant_id.as_deref(), Some("kinoite"));
671 }
672
673 #[test]
674 fn parse_ubuntu_2204_jammy() {
675 let r = parse_os_release(UBUNTU_2204).unwrap();
676 assert_eq!(r.id, "ubuntu");
677 assert_eq!(r.version_id.as_deref(), Some("22.04"));
678 assert_eq!(r.codename.as_deref(), Some("jammy"));
679 assert_eq!(r.id_like, vec!["debian"]);
680 }
681
682 #[test]
683 fn parse_ubuntu_2404_noble() {
684 let r = parse_os_release(UBUNTU_2404).unwrap();
685 assert_eq!(r.id, "ubuntu");
686 assert_eq!(r.version_id.as_deref(), Some("24.04"));
687 assert_eq!(r.codename.as_deref(), Some("noble"));
688 assert_eq!(r.id_like, vec!["debian"]);
689 }
690
691 #[test]
692 fn parse_ubuntu_2604_resolute() {
693 let r = parse_os_release(UBUNTU_2604).unwrap();
694 assert_eq!(r.id, "ubuntu");
695 assert_eq!(r.version_id.as_deref(), Some("26.04"));
696 assert_eq!(r.codename.as_deref(), Some("resolute"));
697 assert_eq!(r.id_like, vec!["debian"]);
698 }
699
700 #[test]
701 fn parse_ubuntu_core_24() {
702 let r = parse_os_release(UBUNTU_CORE_24).unwrap();
703 assert_eq!(r.id, "ubuntu");
704 assert_eq!(r.version_id.as_deref(), Some("24.04"));
705 assert_eq!(r.variant_id.as_deref(), Some("core"));
706 }
707
708 #[test]
709 fn parse_debian_12_bookworm() {
710 let r = parse_os_release(DEBIAN_12).unwrap();
711 assert_eq!(r.id, "debian");
712 assert_eq!(r.version_id.as_deref(), Some("12"));
713 assert_eq!(r.codename.as_deref(), Some("bookworm"));
714 }
715
716 #[test]
717 fn parse_linux_mint_22() {
718 let r = parse_os_release(LINUX_MINT_22).unwrap();
719 assert_eq!(r.id, "linuxmint");
720 assert_eq!(r.version_id.as_deref(), Some("22"));
721 assert_eq!(r.id_like, vec!["ubuntu", "debian"]);
722 }
723
724 #[test]
725 fn parse_pop_os_2204() {
726 let r = parse_os_release(POP_OS_2204).unwrap();
727 assert_eq!(r.id, "pop");
728 assert_eq!(r.version_id.as_deref(), Some("22.04"));
729 assert_eq!(r.id_like, vec!["ubuntu", "debian"]);
730 }
731
732 #[test]
733 fn parse_amazon_linux_2023() {
734 let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
735 assert_eq!(r.id, "amzn");
736 assert_eq!(r.version_id.as_deref(), Some("2023"));
737 assert_eq!(r.id_like, vec!["fedora"]);
738 }
739
740 #[test]
745 fn reject_empty_file() {
746 assert_eq!(parse_os_release(""), Err(ParseError::Empty));
747 }
748
749 #[test]
750 fn reject_only_comments_and_blanks() {
751 let input = "# This is a comment\n\n# Another comment\n";
752 assert_eq!(parse_os_release(input), Err(ParseError::Empty));
753 }
754
755 #[test]
756 fn reject_line_without_equals() {
757 let input = "ID=fedora\nBAD_LINE_NO_EQUALS\n";
758 assert!(matches!(
759 parse_os_release(input),
760 Err(ParseError::InvalidLine(_))
761 ));
762 }
763
764 #[test]
765 fn reject_lowercase_key() {
766 let input = "id=fedora\nVERSION_ID=41\n";
768 assert!(matches!(
769 parse_os_release(input),
770 Err(ParseError::InvalidLine(_))
771 ));
772 }
773
774 #[test]
775 fn reject_mixed_case_key() {
776 let input = "Id=fedora\nVERSION_ID=41\n";
777 assert!(matches!(
778 parse_os_release(input),
779 Err(ParseError::InvalidLine(_))
780 ));
781 }
782
783 #[test]
784 fn reject_mismatched_double_quotes() {
785 let input = "ID=\"fedora\nVERSION_ID=41\n";
787 assert!(matches!(
788 parse_os_release(input),
789 Err(ParseError::MismatchedQuotes(_))
790 ));
791 }
792
793 #[test]
794 fn reject_mismatched_single_quotes() {
795 let input = "ID='fedora\nVERSION_ID=41\n";
796 assert!(matches!(
797 parse_os_release(input),
798 Err(ParseError::MismatchedQuotes(_))
799 ));
800 }
801
802 #[test]
803 fn reject_nul_byte_in_value() {
804 let input = "ID=fedora\nPRETTY_NAME=foo\x00bar\n";
806 assert!(matches!(
807 parse_os_release(input),
808 Err(ParseError::InvalidByte { .. })
809 ));
810 }
811
812 #[test]
813 fn reject_control_byte_in_value() {
814 let input = "ID=fedora\nPRETTY_NAME=foo\x01bar\n";
816 assert!(matches!(
817 parse_os_release(input),
818 Err(ParseError::InvalidByte { .. })
819 ));
820 }
821
822 #[test]
823 fn reject_oversized_file() {
824 let large = "A".repeat(MAX_OS_RELEASE_BYTES + 1);
826 assert_eq!(parse_os_release(&large), Err(ParseError::FileTooLarge));
827 }
828
829 #[test]
834 fn detect_fedora_41_workstation() {
835 let r = parse_os_release(FEDORA_41).unwrap();
836 assert_eq!(detect_distro(&r), DistroId::Fedora { version: 41 });
837 }
838
839 #[test]
840 fn detect_silverblue_routes_to_fedora_silverblue() {
841 let r = parse_os_release(FEDORA_SILVERBLUE_41).unwrap();
842 assert_eq!(
843 detect_distro(&r),
844 DistroId::FedoraSilverblue { version: 41 }
845 );
846 }
847
848 #[test]
849 fn detect_kinoite_routes_to_fedora_silverblue() {
850 let r = parse_os_release(FEDORA_KINOITE_41).unwrap();
851 assert_eq!(
852 detect_distro(&r),
853 DistroId::FedoraSilverblue { version: 41 }
854 );
855 }
856
857 #[test]
858 fn detect_current_silverblue_routes_to_fedora_silverblue() {
859 let r = parse_os_release(FEDORA_SILVERBLUE_44).unwrap();
860 assert_eq!(
861 detect_distro(&r),
862 DistroId::FedoraSilverblue { version: 44 }
863 );
864 }
865
866 #[test]
867 fn detect_ubuntu_core_routes_to_ubuntu_core() {
868 let r = parse_os_release(UBUNTU_CORE_24).unwrap();
869 assert_eq!(
870 detect_distro(&r),
871 DistroId::UbuntuCore {
872 major: 24,
873 minor: 4
874 }
875 );
876 }
877
878 #[test]
879 fn detect_ubuntu_2204() {
880 let r = parse_os_release(UBUNTU_2204).unwrap();
881 assert_eq!(
882 detect_distro(&r),
883 DistroId::Ubuntu {
884 major: 22,
885 minor: 4
886 }
887 );
888 }
889
890 #[test]
891 fn detect_ubuntu_2604() {
892 let r = parse_os_release(UBUNTU_2604).unwrap();
893 assert_eq!(
894 detect_distro(&r),
895 DistroId::Ubuntu {
896 major: 26,
897 minor: 4
898 }
899 );
900 }
901
902 #[test]
903 fn detect_debian_12() {
904 let r = parse_os_release(DEBIAN_12).unwrap();
905 assert_eq!(detect_distro(&r), DistroId::Debian { version: Some(12) });
906 }
907
908 #[test]
909 fn detect_mint_falls_through_to_other() {
910 let r = parse_os_release(LINUX_MINT_22).unwrap();
911 assert!(matches!(detect_distro(&r), DistroId::Other { .. }));
912 if let DistroId::Other { id, version_id } = detect_distro(&r) {
913 assert_eq!(id, "linuxmint");
914 assert_eq!(version_id.as_deref(), Some("22"));
915 }
916 }
917
918 #[test]
919 fn detect_amazon_linux_falls_through_to_other() {
920 let r = parse_os_release(AMAZON_LINUX_2023).unwrap();
921 assert!(matches!(
922 detect_distro(&r),
923 DistroId::Other { ref id, .. } if id == "amzn"
924 ));
925 }
926
927 #[test]
932 fn plain_fedora_is_unsupported_until_dnf_actions_ship() {
933 assert!(!DistroId::Fedora { version: 44 }.is_supported());
934 }
935
936 #[test]
937 fn unsupported_fedora_39() {
938 assert!(!DistroId::Fedora { version: 39 }.is_supported());
939 }
940
941 #[test]
942 fn supported_fedora_silverblue_41() {
943 assert!(DistroId::FedoraSilverblue { version: 41 }.is_supported());
944 }
945
946 #[test]
947 fn supported_fedora_silverblue_44() {
948 assert!(DistroId::FedoraSilverblue { version: 44 }.is_supported());
949 }
950
951 #[test]
952 fn unsupported_fedora_silverblue_40() {
953 assert!(!DistroId::FedoraSilverblue { version: 40 }.is_supported());
954 }
955
956 #[test]
957 fn supported_ubuntu_2204() {
958 assert!(DistroId::Ubuntu {
959 major: 22,
960 minor: 4
961 }
962 .is_supported());
963 }
964
965 #[test]
966 fn supported_ubuntu_2404() {
967 assert!(DistroId::Ubuntu {
968 major: 24,
969 minor: 4
970 }
971 .is_supported());
972 }
973
974 #[test]
975 fn supported_ubuntu_2604() {
976 assert!(DistroId::Ubuntu {
977 major: 26,
978 minor: 4
979 }
980 .is_supported());
981 }
982
983 #[test]
984 fn unsupported_ubuntu_2004() {
985 assert!(!DistroId::Ubuntu {
986 major: 20,
987 minor: 4
988 }
989 .is_supported());
990 }
991
992 #[test]
993 fn unsupported_ubuntu_interim_2610() {
994 assert!(!DistroId::Ubuntu {
996 major: 26,
997 minor: 10
998 }
999 .is_supported());
1000 }
1001
1002 #[test]
1003 fn unsupported_ubuntu_core() {
1004 assert!(!DistroId::UbuntuCore {
1005 major: 24,
1006 minor: 4
1007 }
1008 .is_supported());
1009 }
1010
1011 #[test]
1012 fn unsupported_debian() {
1013 assert!(!DistroId::Debian { version: Some(12) }.is_supported());
1014 }
1015
1016 #[test]
1017 fn unsupported_other() {
1018 assert!(!DistroId::Other {
1019 id: "arch".to_string(),
1020 version_id: None,
1021 }
1022 .is_supported());
1023 }
1024
1025 #[test]
1030 fn os_release_clone_eq() {
1031 let r = parse_os_release(UBUNTU_2404).unwrap();
1032 let r2 = r.clone();
1033 assert_eq!(r, r2);
1034 }
1035
1036 #[test]
1037 fn os_release_debug_contains_id() {
1038 let r = parse_os_release(FEDORA_41).unwrap();
1039 let dbg = format!("{r:?}");
1040 assert!(dbg.contains("fedora"), "debug output: {dbg}");
1041 }
1042
1043 #[test]
1044 fn distro_id_clone_eq() {
1045 let d = DistroId::Ubuntu {
1046 major: 24,
1047 minor: 4,
1048 };
1049 assert_eq!(d.clone(), d);
1050 }
1051
1052 #[test]
1053 fn distro_family_fedora() {
1054 assert_eq!(
1055 DistroId::Fedora { version: 41 }.family(),
1056 DistroFamily::Fedora
1057 );
1058 assert_eq!(
1059 DistroId::FedoraSilverblue { version: 41 }.family(),
1060 DistroFamily::Fedora
1061 );
1062 }
1063
1064 #[test]
1065 fn distro_family_debian() {
1066 assert_eq!(
1067 DistroId::Ubuntu {
1068 major: 24,
1069 minor: 4
1070 }
1071 .family(),
1072 DistroFamily::Debian
1073 );
1074 assert_eq!(
1075 DistroId::Debian { version: Some(12) }.family(),
1076 DistroFamily::Debian
1077 );
1078 }
1079
1080 #[test]
1085 fn accept_tab_in_pretty_name() {
1086 let input = "ID=fedora\nVERSION_ID=41\nPRETTY_NAME=foo\tbar\n";
1088 let r = parse_os_release(input).unwrap();
1089 assert_eq!(r.pretty_name.as_deref(), Some("foo\tbar"));
1090 }
1091
1092 #[test]
1093 fn accept_single_quoted_value() {
1094 let input = "ID='fedora'\nVERSION_ID=41\n";
1095 let r = parse_os_release(input).unwrap();
1096 assert_eq!(r.id, "fedora");
1097 }
1098
1099 #[test]
1100 fn accept_unquoted_value() {
1101 let input = "ID=fedora\nVERSION_ID=41\n";
1102 let r = parse_os_release(input).unwrap();
1103 assert_eq!(r.id, "fedora");
1104 }
1105
1106 #[test]
1107 fn version_codename_empty_string_treated_as_none_equivalent() {
1108 let input = "ID=fedora\nVERSION_ID=41\nVERSION_CODENAME=\"\"\n";
1110 let r = parse_os_release(input).unwrap();
1113 assert_eq!(r.codename.as_deref(), Some(""));
1114 }
1115
1116 #[test]
1117 fn reject_key_starting_with_digit() {
1118 let input = "1D=fedora\nVERSION_ID=41\n";
1119 assert!(matches!(
1120 parse_os_release(input),
1121 Err(ParseError::InvalidLine(_))
1122 ));
1123 }
1124
1125 #[test]
1126 fn reject_key_with_lowercase_after_valid_start() {
1127 let input = "IDENTIFier=fedora\nVERSION_ID=41\n";
1128 assert!(matches!(
1129 parse_os_release(input),
1130 Err(ParseError::InvalidLine(_))
1131 ));
1132 }
1133}