1use std::io::Read;
56use std::path::{Path, PathBuf};
57
58use crate::error::Error;
59use crate::source::{Probe, Source, SourceKind};
60use crate::sources::util::{NormalizeOutcome, classify, read_capped};
61
62macro_rules! file_source {
63 ($name:ident, $kind:expr, $default:expr, $doc:literal) => {
64 #[doc = $doc]
65 #[derive(Debug, Clone)]
66 pub struct $name {
67 path: PathBuf,
68 }
69
70 impl $name {
71 #[doc = concat!("Read from the standard path (`", $default, "`).")]
72 #[must_use]
73 pub fn new() -> Self {
74 Self {
75 path: PathBuf::from($default),
76 }
77 }
78
79 #[must_use]
82 pub fn at(path: impl Into<PathBuf>) -> Self {
83 Self { path: path.into() }
84 }
85
86 #[must_use]
88 pub fn path(&self) -> &Path {
89 &self.path
90 }
91 }
92
93 impl Default for $name {
94 fn default() -> Self {
95 Self::new()
96 }
97 }
98
99 impl Source for $name {
100 fn kind(&self) -> SourceKind {
101 $kind
102 }
103 fn probe(&self) -> Result<Option<Probe>, Error> {
104 read_machine_id_file($kind, &self.path)
105 }
106 }
107 };
108}
109
110file_source!(
111 MachineIdFile,
112 SourceKind::MachineId,
113 "/etc/machine-id",
114 "`/etc/machine-id` — the systemd-managed primary host identifier on modern Linux.\n\n\
115 # Known-duplicate filtering\n\n\
116 A non-trivial fraction of Linux installs ship or end up with machine-id\n\
117 values that are identical across many machines (Whonix's deliberate\n\
118 anti-fingerprinting constant; official container images that bake a\n\
119 single hex value into the filesystem layer; synthetic all-same-nibble\n\
120 values from broken image builds). Returning one of those would produce\n\
121 a silently non-unique identity shared by every host that inherits it,\n\
122 so this source additionally rejects, by returning `Ok(None)` with a\n\
123 `log::debug!` entry:\n\n\
124 - A curated list of public, citable shared values (`MACHINE_ID_DENYLIST`).\n\
125 - Any 32-hex-digit value whose nibbles are all the same character\n\
126 (`00…0`, `11…1`, `aa…a`, etc.). The systemd spec forbids all-zero\n\
127 machine-ids outright; the rest are only ever seen on synthetic or\n\
128 corrupt images.\n\n\
129 Anything not matching the filter passes through unchanged — the intent\n\
130 is to reject *known* garbage, not to gate on machine-id shape. A false\n\
131 positive here drops a legitimate host from identity resolution, so a\n\
132 missing entry is strictly preferable to an over-broad rule."
133);
134
135file_source!(
136 DbusMachineIdFile,
137 SourceKind::DbusMachineId,
138 "/var/lib/dbus/machine-id",
139 "`/var/lib/dbus/machine-id` — D-Bus machine ID. Often a symlink to `/etc/machine-id` \
140 but present on its own on some minimal images. Shares the same \
141 known-duplicate filter as [`MachineIdFile`]."
142);
143
144#[derive(Debug, Clone)]
167pub struct DmiProductUuid {
168 path: PathBuf,
169}
170
171impl DmiProductUuid {
172 #[must_use]
174 pub fn new() -> Self {
175 Self {
176 path: PathBuf::from("/sys/class/dmi/id/product_uuid"),
177 }
178 }
179
180 #[must_use]
183 pub fn at(path: impl Into<PathBuf>) -> Self {
184 Self { path: path.into() }
185 }
186
187 #[must_use]
189 pub fn path(&self) -> &Path {
190 &self.path
191 }
192}
193
194impl Default for DmiProductUuid {
195 fn default() -> Self {
196 Self::new()
197 }
198}
199
200impl Source for DmiProductUuid {
201 fn kind(&self) -> SourceKind {
202 SourceKind::Dmi
203 }
204 fn probe(&self) -> Result<Option<Probe>, Error> {
205 read_dmi_file(&self.path)
206 }
207}
208
209const MACHINE_ID_DENYLIST: &[&str] = &[
220 "b08dfa6083e7567a1921a715000001fb",
224 "d495c4b7bb8244639186ef65305fd685",
226 "e28a15f597cd4693bb61f1f3e8447cbd",
228 "4c010dc413ad444698de6ee4677331b9",
231 "a7570853ab864bbbbfc8c54b14eeaf8f",
233 "5b4bb40898b2416087b6224f176978fb",
235 "3948e4ca87b64871b31c9a49920b9834",
237 "835aa90928e143e3ae09efcd0c5cb118",
239];
240
241fn is_machine_id_garbage(value: &str) -> bool {
244 let lower = value.to_ascii_lowercase();
245 MACHINE_ID_DENYLIST.contains(&lower.as_str()) || is_all_same_nibble_hex32(&lower)
246}
247
248fn is_all_same_nibble_hex32(value: &str) -> bool {
257 let bytes = value.as_bytes();
258 bytes.len() == 32 && bytes[0].is_ascii_hexdigit() && bytes.iter().all(|b| *b == bytes[0])
259}
260
261fn read_machine_id_file(kind: SourceKind, path: &Path) -> Result<Option<Probe>, Error> {
262 match read_id_file(kind, path)? {
263 Some(probe) if is_machine_id_garbage(probe.value()) => {
264 log::debug!(
265 "host-identity: {kind:?} value {} matches a known-duplicate machine-id; \
266 falling through",
267 probe.value()
268 );
269 Ok(None)
270 }
271 other => Ok(other),
272 }
273}
274
275const DMI_PLACEHOLDER_UUIDS: &[&str] = &[
280 "03000200-0400-0500-0006-000700080009",
282];
283
284fn is_dmi_garbage(value: &str) -> bool {
287 let lower = value.to_ascii_lowercase();
288 if DMI_PLACEHOLDER_UUIDS.iter().any(|p| *p == lower) {
289 return true;
290 }
291 is_all_same_nibble_uuid(&lower)
292}
293
294fn is_all_same_nibble_uuid(value: &str) -> bool {
306 let mut chars = value.chars().filter(|c| *c != '-');
307 let Some(first) = chars.next() else {
308 return false;
309 };
310 if !first.is_ascii_hexdigit() {
311 return false;
312 }
313 let mut count = 1usize;
314 for c in chars {
315 if c != first {
316 return false;
317 }
318 count += 1;
319 }
320 count == 32
321}
322
323fn read_dmi_file(path: &Path) -> Result<Option<Probe>, Error> {
324 match read_id_file(SourceKind::Dmi, path)? {
325 Some(probe) if is_dmi_garbage(probe.value()) => {
326 log::debug!(
327 "host-identity: DMI product_uuid {} matches a known vendor-placeholder; \
328 falling through",
329 probe.value()
330 );
331 Ok(None)
332 }
333 other => Ok(other),
334 }
335}
336
337fn open_id_file(kind: SourceKind, path: &Path) -> Result<Option<std::fs::File>, Error> {
342 match std::fs::File::open(path) {
343 Ok(file) => Ok(Some(file)),
344 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
345 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
346 log::debug!(
347 "host-identity: permission denied reading {}",
348 path.display()
349 );
350 Ok(None)
351 }
352 Err(source) => Err(Error::Io {
353 source_kind: kind,
354 path: PathBuf::from(path),
355 source,
356 }),
357 }
358}
359
360fn read_id_file(kind: SourceKind, path: &Path) -> Result<Option<Probe>, Error> {
361 match read_capped(path) {
362 Ok(content) => match classify(&content) {
363 NormalizeOutcome::Usable(value) => Ok(Some(Probe::new(kind, value))),
364 NormalizeOutcome::Sentinel => Err(Error::Uninitialized {
365 source_kind: kind,
366 path: PathBuf::from(path),
367 }),
368 NormalizeOutcome::Empty => Ok(None),
369 },
370 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
371 Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => {
372 log::debug!(
373 "host-identity: permission denied reading {}",
374 path.display()
375 );
376 Ok(None)
377 }
378 Err(source) => Err(Error::Io {
379 source_kind: kind,
380 path: PathBuf::from(path),
381 source,
382 }),
383 }
384}
385
386#[derive(Debug, Clone)]
424pub struct LinuxHostIdFile {
425 path: PathBuf,
426}
427
428impl LinuxHostIdFile {
429 #[must_use]
431 pub fn new() -> Self {
432 Self {
433 path: PathBuf::from("/etc/hostid"),
434 }
435 }
436
437 #[must_use]
440 pub fn at(path: impl Into<PathBuf>) -> Self {
441 Self { path: path.into() }
442 }
443
444 #[must_use]
446 pub fn path(&self) -> &Path {
447 &self.path
448 }
449}
450
451impl Default for LinuxHostIdFile {
452 fn default() -> Self {
453 Self::new()
454 }
455}
456
457impl Source for LinuxHostIdFile {
458 fn kind(&self) -> SourceKind {
459 SourceKind::LinuxHostId
460 }
461 fn probe(&self) -> Result<Option<Probe>, Error> {
462 read_linux_hostid(&self.path)
463 }
464}
465
466fn read_linux_hostid(path: &Path) -> Result<Option<Probe>, Error> {
467 let Some(file) = open_id_file(SourceKind::LinuxHostId, path)? else {
468 return Ok(None);
469 };
470 let mut buf = Vec::with_capacity(5);
474 file.take(5)
475 .read_to_end(&mut buf)
476 .map_err(|source| Error::Io {
477 source_kind: SourceKind::LinuxHostId,
478 path: PathBuf::from(path),
479 source,
480 })?;
481 let Ok(bytes): Result<[u8; 4], _> = buf.as_slice().try_into() else {
482 log::debug!(
483 "host-identity: /etc/hostid at {} is {} bytes, expected 4; falling through",
484 path.display(),
485 buf.len(),
486 );
487 return Ok(None);
488 };
489 let value = u32::from_ne_bytes(bytes);
490 if value == 0 || value == u32::MAX {
491 log::debug!(
492 "host-identity: /etc/hostid at {} is {value:#010x} (unset/sentinel); falling through",
493 path.display()
494 );
495 return Ok(None);
496 }
497 Ok(Some(Probe::new(
498 SourceKind::LinuxHostId,
499 format!("{value:08x}"),
500 )))
501}
502
503#[must_use]
510pub(crate) fn in_container() -> bool {
511 const MARKERS: &[&str] = &["docker", "kubepods", "containerd", "podman", "lxc", "crio"];
512 Path::new("/.dockerenv").exists()
513 || std::fs::read_to_string("/proc/1/cgroup").is_ok_and(|cgroup| {
514 cgroup
515 .split(['/', ':', '-', '.', '_', '\n'])
516 .any(|seg| MARKERS.contains(&seg))
517 })
518}
519
520#[cfg(test)]
521mod tests {
522 use super::*;
523 use std::io::Write;
524 use tempfile::{NamedTempFile, TempDir};
525
526 #[test]
527 fn machine_id_file_rejects_uninitialized_sentinel() {
528 let mut f = NamedTempFile::new().unwrap();
529 writeln!(f, "uninitialized").unwrap();
530 let err = read_id_file(SourceKind::MachineId, f.path()).expect_err("sentinel must error");
531 match err {
532 Error::Uninitialized { path, source_kind } => {
533 assert_eq!(path, f.path());
534 assert_eq!(source_kind, SourceKind::MachineId);
535 }
536 other => panic!("expected Uninitialized, got {other:?}"),
537 }
538 }
539
540 #[test]
541 fn machine_id_file_accepts_normal_value() {
542 let mut f = NamedTempFile::new().unwrap();
543 writeln!(f, "abc123").unwrap();
544 let probe = read_id_file(SourceKind::MachineId, f.path())
545 .unwrap()
546 .unwrap();
547 assert_eq!(probe.value(), "abc123");
548 }
549
550 #[test]
551 fn machine_id_file_missing_is_none() {
552 let dir = TempDir::new().unwrap();
553 let missing = dir.path().join("definitely-not-there");
554 let probe = read_id_file(SourceKind::MachineId, &missing).unwrap();
555 assert!(probe.is_none());
556 }
557
558 #[test]
559 fn machine_id_file_empty_is_none() {
560 let f = NamedTempFile::new().unwrap();
561 let probe = read_id_file(SourceKind::MachineId, f.path()).unwrap();
562 assert!(probe.is_none());
563 }
564
565 #[test]
566 fn machine_id_file_whitespace_only_is_none() {
567 let mut f = NamedTempFile::new().unwrap();
568 write!(f, " \n\t ").unwrap();
569 let probe = read_id_file(SourceKind::MachineId, f.path()).unwrap();
570 assert!(probe.is_none());
571 }
572
573 #[test]
574 fn machine_id_file_reports_io_error_for_directory() {
575 let dir = TempDir::new().unwrap();
578 let err = read_id_file(SourceKind::MachineId, dir.path())
579 .expect_err("reading a directory must error");
580 match err {
581 Error::Io { path, .. } => assert_eq!(path, dir.path()),
582 other => panic!("expected Io, got {other:?}"),
583 }
584 }
585
586 #[cfg(unix)]
587 #[test]
588 fn machine_id_file_permission_denied_is_none() {
589 use std::os::unix::fs::PermissionsExt;
590 use std::path::{Path, PathBuf};
591
592 struct PermGuard(PathBuf);
596 impl Drop for PermGuard {
597 fn drop(&mut self) {
598 let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o600));
599 }
600 }
601
602 if nix_is_root() {
604 return;
605 }
606
607 let mut f = NamedTempFile::new().unwrap();
608 writeln!(f, "abc123").unwrap();
609 let path: &Path = f.path();
610 std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000)).unwrap();
611 let _guard = PermGuard(path.to_path_buf());
612
613 let probe = read_id_file(SourceKind::MachineId, path)
614 .expect("permission denied should be swallowed to Ok(None)");
615 assert!(probe.is_none());
616 }
617
618 fn machine_id_probe(kind: SourceKind, body: &str) -> Option<Probe> {
619 let mut f = NamedTempFile::new().unwrap();
620 write!(f, "{body}").unwrap();
621 read_machine_id_file(kind, f.path()).unwrap()
622 }
623
624 #[test]
625 fn machine_id_rejects_whonix_constant() {
626 assert!(
628 machine_id_probe(SourceKind::MachineId, "b08dfa6083e7567a1921a715000001fb\n").is_none()
629 );
630 }
631
632 #[test]
633 fn machine_id_rejects_whonix_constant_uppercase() {
634 assert!(
635 machine_id_probe(SourceKind::MachineId, "B08DFA6083E7567A1921A715000001FB\n").is_none()
636 );
637 }
638
639 #[test]
640 fn machine_id_rejects_oraclelinux_9_constant() {
641 assert!(
642 machine_id_probe(SourceKind::MachineId, "d495c4b7bb8244639186ef65305fd685\n").is_none()
643 );
644 }
645
646 #[test]
647 fn machine_id_rejects_oraclelinux_8_constant() {
648 assert!(
649 machine_id_probe(SourceKind::MachineId, "e28a15f597cd4693bb61f1f3e8447cbd\n").is_none()
650 );
651 }
652
653 #[test]
654 fn machine_id_rejects_jrei_systemd_debian_constant() {
655 assert!(
656 machine_id_probe(SourceKind::MachineId, "4c010dc413ad444698de6ee4677331b9\n").is_none()
657 );
658 }
659
660 #[test]
661 fn machine_id_rejects_jrei_systemd_ubuntu_constant() {
662 assert!(
663 machine_id_probe(SourceKind::MachineId, "a7570853ab864bbbbfc8c54b14eeaf8f\n").is_none()
664 );
665 }
666
667 #[test]
668 fn machine_id_rejects_geerlingguy_ansible_ubuntu_constant() {
669 assert!(
670 machine_id_probe(SourceKind::MachineId, "5b4bb40898b2416087b6224f176978fb\n").is_none()
671 );
672 }
673
674 #[test]
675 fn machine_id_rejects_geerlingguy_ansible_debian_constant() {
676 assert!(
677 machine_id_probe(SourceKind::MachineId, "3948e4ca87b64871b31c9a49920b9834\n").is_none()
678 );
679 }
680
681 #[test]
682 fn machine_id_rejects_geerlingguy_ansible_rocky_constant() {
683 assert!(
684 machine_id_probe(SourceKind::MachineId, "835aa90928e143e3ae09efcd0c5cb118\n").is_none()
685 );
686 }
687
688 #[test]
689 fn machine_id_rejects_all_zero_hex32() {
690 assert!(machine_id_probe(SourceKind::MachineId, &"0".repeat(32)).is_none());
691 }
692
693 #[test]
694 fn machine_id_rejects_all_same_nibble_hex32() {
695 assert!(machine_id_probe(SourceKind::MachineId, &"a".repeat(32)).is_none());
696 assert!(machine_id_probe(SourceKind::MachineId, &"F".repeat(32)).is_none());
697 }
698
699 #[test]
700 fn machine_id_accepts_plausible_real_value() {
701 let probe =
702 machine_id_probe(SourceKind::MachineId, "4c4c4544003957108052b4c04f384833\n").unwrap();
703 assert_eq!(probe.value(), "4c4c4544003957108052b4c04f384833");
704 }
705
706 #[test]
707 fn machine_id_filter_trims_whitespace_before_matching() {
708 assert!(
710 machine_id_probe(
711 SourceKind::MachineId,
712 " b08dfa6083e7567a1921a715000001fb \n\t"
713 )
714 .is_none()
715 );
716 }
717
718 #[test]
719 fn dbus_machine_id_rejects_whonix_constant() {
720 assert!(
722 machine_id_probe(
723 SourceKind::DbusMachineId,
724 "b08dfa6083e7567a1921a715000001fb\n"
725 )
726 .is_none()
727 );
728 }
729
730 #[test]
731 fn read_id_file_does_not_apply_machine_id_filter() {
732 let mut f = NamedTempFile::new().unwrap();
739 write!(f, "{}", "0".repeat(32)).unwrap();
740 let probe = read_id_file(SourceKind::MachineId, f.path())
741 .unwrap()
742 .unwrap();
743 assert_eq!(probe.value(), "0".repeat(32));
744 }
745
746 #[test]
747 fn machine_id_file_probe_applies_filter() {
748 let mut f = NamedTempFile::new().unwrap();
753 writeln!(f, "b08dfa6083e7567a1921a715000001fb").unwrap();
754 let probe = MachineIdFile::at(f.path()).probe().unwrap();
755 assert!(probe.is_none());
756 }
757
758 #[test]
759 fn dbus_machine_id_file_probe_applies_filter() {
760 let mut f = NamedTempFile::new().unwrap();
761 writeln!(f, "b08dfa6083e7567a1921a715000001fb").unwrap();
762 let probe = DbusMachineIdFile::at(f.path()).probe().unwrap();
763 assert!(probe.is_none());
764 }
765
766 #[test]
767 fn is_all_same_nibble_hex32_rejects_short_values() {
768 assert!(!is_all_same_nibble_hex32("aaa"));
770 assert!(!is_all_same_nibble_hex32(""));
771 assert!(!is_all_same_nibble_hex32(&"a".repeat(31)));
772 assert!(!is_all_same_nibble_hex32(&"a".repeat(33)));
773 }
774
775 #[test]
776 fn is_all_same_nibble_hex32_rejects_non_hex() {
777 assert!(!is_all_same_nibble_hex32(&"z".repeat(32)));
778 }
779
780 fn dmi_tempfile(body: &str) -> NamedTempFile {
781 let mut f = NamedTempFile::new().unwrap();
782 write!(f, "{body}").unwrap();
783 f
784 }
785
786 fn dmi_probe(body: &str) -> Option<Probe> {
787 let f = dmi_tempfile(body);
788 read_dmi_file(f.path()).unwrap()
789 }
790
791 #[test]
792 fn dmi_rejects_all_zero_uuid() {
793 assert!(dmi_probe("00000000-0000-0000-0000-000000000000\n").is_none());
794 }
795
796 #[test]
797 fn dmi_rejects_all_f_uuid_lower() {
798 assert!(dmi_probe("ffffffff-ffff-ffff-ffff-ffffffffffff\n").is_none());
799 }
800
801 #[test]
802 fn dmi_rejects_all_f_uuid_upper() {
803 assert!(dmi_probe("FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF\n").is_none());
804 }
805
806 #[test]
807 fn dmi_rejects_all_same_nibble_1() {
808 assert!(dmi_probe("11111111-1111-1111-1111-111111111111\n").is_none());
809 }
810
811 #[test]
812 fn dmi_rejects_all_same_nibble_a() {
813 assert!(dmi_probe("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\n").is_none());
814 }
815
816 #[test]
817 fn dmi_rejects_supermicro_ami_placeholder() {
818 assert!(dmi_probe("03000200-0400-0500-0006-000700080009\n").is_none());
821 }
822
823 #[test]
824 fn dmi_rejects_supermicro_ami_placeholder_uppercase() {
825 assert!(
826 dmi_probe(
827 "03000200-0400-0500-0006-000700080009"
828 .to_ascii_uppercase()
829 .as_str()
830 )
831 .is_none()
832 );
833 }
834
835 #[test]
836 fn dmi_rejects_garbage_with_trailing_whitespace() {
837 assert!(dmi_probe(" 00000000-0000-0000-0000-000000000000 \n\t").is_none());
839 }
840
841 #[test]
842 fn dmi_accepts_plausible_real_uuid() {
843 let probe = dmi_probe("4c4c4544-0039-5710-8052-b4c04f384833\n").unwrap();
844 assert_eq!(probe.value(), "4c4c4544-0039-5710-8052-b4c04f384833");
845 }
846
847 #[test]
848 fn dmi_accepts_non_uuid_shape() {
849 let probe = dmi_probe("abcdef\n").unwrap();
852 assert_eq!(probe.value(), "abcdef");
853 }
854
855 #[test]
856 fn machine_id_file_accepts_hyphenated_all_zero_uuid() {
857 let probe = machine_id_probe(
863 SourceKind::MachineId,
864 "00000000-0000-0000-0000-000000000000\n",
865 )
866 .unwrap();
867 assert_eq!(probe.value(), "00000000-0000-0000-0000-000000000000");
868 }
869
870 fn write_hostid(bytes: &[u8]) -> NamedTempFile {
871 let mut f = NamedTempFile::new().unwrap();
872 f.write_all(bytes).unwrap();
873 f
874 }
875
876 #[test]
877 fn linux_hostid_reads_native_endian_bytes() {
878 let file_bytes = [0x8f, 0x8f, 0x98, 0x4f];
882 let expected = format!("{:08x}", u32::from_ne_bytes(file_bytes));
883 let f = write_hostid(&file_bytes);
884 let probe = read_linux_hostid(f.path()).unwrap().unwrap();
885 assert_eq!(probe.kind(), SourceKind::LinuxHostId);
886 assert_eq!(probe.value(), expected);
887 }
888
889 #[test]
890 fn linux_hostid_pads_small_values_to_eight_hex_digits() {
891 let file_bytes = 0x0000_0042_u32.to_ne_bytes();
896 let f = write_hostid(&file_bytes);
897 let probe = read_linux_hostid(f.path()).unwrap().unwrap();
898 assert_eq!(probe.value(), "00000042");
899 }
900
901 #[test]
902 fn linux_hostid_missing_is_none() {
903 let dir = TempDir::new().unwrap();
904 let missing = dir.path().join("absent");
905 assert!(read_linux_hostid(&missing).unwrap().is_none());
906 }
907
908 #[test]
909 fn linux_hostid_wrong_size_too_small_is_none() {
910 let f = write_hostid(&[0x01, 0x02, 0x03]);
911 assert!(read_linux_hostid(f.path()).unwrap().is_none());
912 }
913
914 #[test]
915 fn linux_hostid_wrong_size_too_large_is_none() {
916 let f = write_hostid(b"4f988f8f-0000-0000-0000-000000000000\n");
920 assert!(read_linux_hostid(f.path()).unwrap().is_none());
921 }
922
923 #[test]
924 fn linux_hostid_empty_is_none() {
925 let f = write_hostid(&[]);
926 assert!(read_linux_hostid(f.path()).unwrap().is_none());
927 }
928
929 #[test]
930 fn linux_hostid_rejects_all_zero() {
931 let f = write_hostid(&[0, 0, 0, 0]);
932 assert!(read_linux_hostid(f.path()).unwrap().is_none());
933 }
934
935 #[test]
936 fn linux_hostid_rejects_all_ff() {
937 let f = write_hostid(&[0xff, 0xff, 0xff, 0xff]);
938 assert!(read_linux_hostid(f.path()).unwrap().is_none());
939 }
940
941 #[test]
942 fn linux_hostid_reports_io_error_for_directory() {
943 let dir = TempDir::new().unwrap();
944 let err = read_linux_hostid(dir.path())
945 .expect_err("reading a directory must surface as Error::Io");
946 match err {
947 Error::Io {
948 path, source_kind, ..
949 } => {
950 assert_eq!(path, dir.path());
951 assert_eq!(source_kind, SourceKind::LinuxHostId);
952 }
953 other => panic!("expected Io, got {other:?}"),
954 }
955 }
956
957 #[cfg(unix)]
958 #[test]
959 fn linux_hostid_permission_denied_is_none() {
960 use std::os::unix::fs::PermissionsExt;
961 use std::path::PathBuf;
962
963 struct PermGuard(PathBuf);
964 impl Drop for PermGuard {
965 fn drop(&mut self) {
966 let _ = std::fs::set_permissions(&self.0, std::fs::Permissions::from_mode(0o600));
967 }
968 }
969
970 if nix_is_root() {
971 return;
972 }
973 let f = write_hostid(&[0x01, 0x02, 0x03, 0x04]);
974 std::fs::set_permissions(f.path(), std::fs::Permissions::from_mode(0o000)).unwrap();
975 let _guard = PermGuard(f.path().to_path_buf());
976 assert!(read_linux_hostid(f.path()).unwrap().is_none());
977 }
978
979 #[cfg(unix)]
980 fn nix_is_root() -> bool {
981 std::fs::read_to_string("/proc/self/status")
985 .ok()
986 .is_some_and(|s| effective_uid_from_status(&s) == Some("0"))
987 }
988
989 #[cfg(unix)]
997 fn effective_uid_from_status(status: &str) -> Option<&str> {
998 status
999 .lines()
1000 .find_map(|l| l.strip_prefix("Uid:")?.split_whitespace().nth(1))
1001 }
1002
1003 #[cfg(unix)]
1004 #[test]
1005 fn effective_uid_from_status_extracts_second_field() {
1006 let status = "\
1010Name:\tbash
1011Uid:\t1000\t0\t1000\t1000
1012Gid:\t1000\t1000\t1000\t1000
1013";
1014 assert_eq!(effective_uid_from_status(status), Some("0"));
1015 }
1016
1017 #[cfg(unix)]
1018 #[test]
1019 fn effective_uid_from_status_handles_common_shapes() {
1020 assert_eq!(
1022 effective_uid_from_status("Uid:\t1000\t1000\t1000\t1000\n"),
1023 Some("1000"),
1024 );
1025 assert_eq!(effective_uid_from_status("Uid:\t0\t0\t0\t0\n"), Some("0"),);
1027 assert_eq!(effective_uid_from_status("Name:\tthing\n"), None);
1029 assert_eq!(effective_uid_from_status("Uid:\t1000\n"), None);
1031 assert_eq!(effective_uid_from_status("Uid:\n"), None);
1033 assert_eq!(effective_uid_from_status(" Uid:\t0\t0\t0\t0\n"), None);
1036 }
1037}