1use std::ffi::{OsStr, OsString};
18use std::path::{Path, PathBuf};
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22
23use crate::paths::AppPaths;
24
25#[must_use]
32pub fn is_elevated() -> bool {
33 sys::is_elevated()
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ElevationOutcome {
39 Exited(i32),
41 Refused,
44 Unavailable(String),
47}
48
49#[must_use]
60pub fn run_elevated(program: &Path, args: &[OsString], terminal: bool) -> ElevationOutcome {
61 sys::run_elevated(program, args, terminal)
62}
63
64#[must_use]
77pub fn can_answer_elevation_prompt(terminal: bool) -> bool {
78 prompt_answerable(sys::session_id(), terminal)
79}
80
81fn prompt_answerable(session: Option<u32>, terminal: bool) -> bool {
84 session.map_or(terminal, |session| session != 0)
85}
86
87#[must_use]
89pub fn quote_posix_argument(argument: &str) -> String {
90 format!("'{}'", argument.replace('\'', "'\\''"))
91}
92
93#[must_use]
96pub fn administrator_applescript(command: &str) -> String {
97 let escaped = command.replace('\\', "\\\\").replace('"', "\\\"");
98 format!("do shell script \"{escaped}\" with administrator privileges")
99}
100
101#[must_use]
107pub fn physical_memory_bytes() -> Option<u64> {
108 sys::physical_memory_bytes()
109}
110
111#[must_use]
116pub fn available_memory_bytes() -> Option<u64> {
117 sys::available_memory_bytes()
118}
119
120#[must_use]
122pub fn free_disk_bytes(path: &Path) -> Option<u64> {
123 sys::free_disk_bytes(path)
124}
125
126#[must_use]
131pub fn cpu_load_percent() -> Option<u32> {
132 sys::cpu_load_percent()
133}
134
135#[cfg_attr(not(windows), allow(dead_code))]
138fn busy_percent(previous: (u64, u64, u64), now: (u64, u64, u64)) -> Option<u32> {
139 let idle = now.0.checked_sub(previous.0)?;
140 let total = now
141 .1
142 .checked_sub(previous.1)?
143 .checked_add(now.2.checked_sub(previous.2)?)?;
144 if total == 0 {
145 return None;
146 }
147 let busy = total.saturating_sub(idle);
148 u32::try_from(busy.saturating_mul(100).saturating_add(total / 2) / total).ok()
149}
150
151#[cfg_attr(any(windows, target_os = "macos"), allow(dead_code))]
153fn meminfo_field(text: &str, field: &str) -> Option<u64> {
154 let line = text.lines().find(|line| line.starts_with(field))?;
155 let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
156 kib.checked_mul(1024)
157}
158
159#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
162fn available_pages_bytes(free: u64, inactive: u64, page: u64) -> Option<u64> {
163 free.checked_add(inactive)?.checked_mul(page)
164}
165
166#[cfg(windows)]
172pub fn system_directory() -> std::io::Result<PathBuf> {
173 crate::runner_root::windows_system_directory().map(PathBuf::from)
174}
175
176pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
182 use std::io::Write as _;
183 let parent = path.parent().unwrap_or_else(|| Path::new("."));
184 std::fs::create_dir_all(parent)?;
185 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
186 temporary.write_all(bytes)?;
187 temporary.as_file().sync_all()?;
188 temporary
189 .persist(path)
190 .map(|_| ())
191 .map_err(|error| error.error)
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
196pub enum Responsiveness {
197 Responds,
199 Hung,
202 ListingBlocked,
208 NotPermitted(String),
212 Failed(String),
214}
215
216type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
219
220static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
224
225#[must_use]
227pub fn probes_in_flight() -> Vec<PathBuf> {
228 PROBES_IN_FLIGHT
229 .lock()
230 .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
231 .unwrap_or_default()
232}
233
234fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
236 if listing.load(std::sync::atomic::Ordering::Acquire) {
237 Responsiveness::ListingBlocked
238 } else {
239 Responsiveness::Hung
240 }
241}
242
243#[must_use]
251pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
252 let path = directory.to_path_buf();
253 let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
256 {
257 let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
258 return Responsiveness::Failed("the probe registry is poisoned".into());
259 };
260 if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
261 return unanswered(stuck);
262 }
263 in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
264 }
265 let (sender, receiver) = std::sync::mpsc::channel();
266 let probed = path.clone();
267 let listing_started = std::sync::Arc::clone(&listing);
268 let spawned = std::thread::Builder::new()
269 .name("runner-root-probe".into())
270 .spawn(move || {
271 let existing = probed
272 .ancestors()
273 .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
274 .map(Path::to_path_buf);
275 listing_started.store(true, std::sync::atomic::Ordering::Release);
276 let refused = |error: std::io::Error| {
277 if is_not_permitted(&error) {
278 Responsiveness::NotPermitted(error.to_string())
279 } else {
280 Responsiveness::Failed(error.to_string())
281 }
282 };
283 let answer = match existing {
284 None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
285 Some(existing) => match std::fs::read_dir(&existing) {
286 Ok(mut entries) => match entries.next() {
287 Some(Err(error)) => refused(error),
288 _ => Responsiveness::Responds,
289 },
290 Err(error) => refused(error),
291 },
292 };
293 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
294 in_flight.retain(|(candidate, _)| *candidate != probed);
295 }
296 let _ = sender.send(answer);
297 });
298 if let Err(error) = spawned {
299 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
300 in_flight.retain(|(candidate, _)| *candidate != path);
301 }
302 return Responsiveness::Failed(error.to_string());
303 }
304 receiver
305 .recv_timeout(deadline)
306 .unwrap_or_else(|_| unanswered(&listing))
307}
308
309fn is_not_permitted(error: &std::io::Error) -> bool {
311 #[cfg(unix)]
312 {
313 error.raw_os_error() == Some(libc::EPERM)
314 }
315 #[cfg(not(unix))]
316 {
317 let _ = error;
318 false
319 }
320}
321
322pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
334 std::fs::create_dir_all(directory)?;
335 let unique = format!(
336 ".rm-symlink-probe-{}-{}",
337 std::process::id(),
338 uuid::Uuid::new_v4().simple()
339 );
340 let target = directory.join(format!("{unique}.target"));
341 let link = directory.join(format!("{unique}.link"));
342 std::fs::write(&target, b"")?;
343 let created = symlink_file(&target, &link);
344 let _ = std::fs::remove_file(&link);
345 let _ = std::fs::remove_file(&target);
346 Ok(created.is_ok())
347}
348
349#[cfg(windows)]
350fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
351 std::os::windows::fs::symlink_file(target, link)
352}
353
354#[cfg(unix)]
355fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
356 std::os::unix::fs::symlink(target, link)
357}
358
359#[must_use]
362pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
363 if cfg!(windows) {
364 return find_on_windows_path(tool, path, std::env::var_os("PATHEXT").as_deref());
365 }
366 if tool.is_empty() || tool.contains(['/', '\\']) {
367 return None;
368 }
369 std::env::split_paths(path)
370 .filter(|dir| !dir.as_os_str().is_empty())
371 .map(|dir| dir.join(tool))
372 .find(|candidate| is_executable(candidate))
373}
374
375#[must_use]
393pub fn find_on_windows_path(tool: &str, path: &OsStr, pathext: Option<&OsStr>) -> Option<PathBuf> {
394 const RUNNABLE: [&str; 4] = [".exe", ".com", ".cmd", ".bat"];
395 if tool.is_empty() || tool.contains(['/', '\\']) {
396 return None;
397 }
398 let pathext = pathext
399 .and_then(OsStr::to_str)
400 .filter(|value| !value.trim().is_empty())
401 .unwrap_or(".COM;.EXE;.BAT;.CMD");
402 let bare = Path::new(tool)
403 .extension()
404 .and_then(OsStr::to_str)
405 .is_some_and(|extension| {
406 RUNNABLE
407 .iter()
408 .any(|runnable| runnable[1..].eq_ignore_ascii_case(extension))
409 })
410 .then_some("");
411 let extensions: Vec<&str> = bare
412 .into_iter()
413 .chain(pathext.split(';').filter_map(|extension| {
414 RUNNABLE
415 .into_iter()
416 .find(|runnable| runnable.eq_ignore_ascii_case(extension.trim()))
417 }))
418 .collect();
419 std::env::split_paths(path)
420 .filter(|dir| dir.is_absolute())
421 .find_map(|dir| {
422 extensions.iter().find_map(|extension| {
423 let candidate = dir.join(format!("{tool}{extension}"));
424 candidate.is_file().then_some(candidate)
425 })
426 })
427}
428
429#[cfg(unix)]
430fn is_executable(path: &Path) -> bool {
431 use std::os::unix::fs::PermissionsExt as _;
432 std::fs::metadata(path)
433 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
434}
435
436#[cfg(windows)]
437fn is_executable(path: &Path) -> bool {
438 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
439}
440
441#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
447pub enum RegistryError {
448 #[error("access denied")]
451 AccessDenied,
452 #[error("{0}")]
454 Other(String),
455}
456
457pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
463 sys::read_dword(subkey, value)
464}
465
466pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
471 sys::read_string(subkey, value)
472}
473
474pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
480 sys::value_names(subkey)
481}
482
483pub fn write_registry_dword(
489 subkey: &str,
490 value: &str,
491 data: Option<u32>,
492) -> Result<(), RegistryError> {
493 sys::write_dword(subkey, value, data)
494}
495
496pub fn write_registry_string(
502 subkey: &str,
503 value: &str,
504 data: Option<&str>,
505) -> Result<(), RegistryError> {
506 sys::write_string(subkey, value, data)
507}
508
509pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
515
516pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
519
520pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
524 std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
525const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
526
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct HostUnfitRecord {
530 pub schema_version: u32,
531 pub since: DateTime<Utc>,
533 pub checked_at: DateTime<Utc>,
535 pub checks: Vec<String>,
537 #[serde(default)]
540 pub findings: Vec<UnfitFinding>,
541}
542
543#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
545pub struct UnfitFinding {
546 pub id: String,
547 pub detail: String,
548 #[serde(default)]
549 pub remedy: Option<String>,
550}
551
552impl HostUnfitRecord {
553 #[must_use]
555 pub fn describe(&self) -> String {
556 if self.findings.is_empty() {
557 return self.checks.join(", ");
558 }
559 self.findings
560 .iter()
561 .map(|finding| match &finding.remedy {
562 Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
563 None => format!("{}: {}", finding.id, finding.detail),
564 })
565 .collect::<Vec<_>>()
566 .join("; ")
567 }
568}
569
570fn host_unfit_path(paths: &AppPaths) -> PathBuf {
571 paths.state_dir().join(HOST_UNFIT_FILE)
572}
573
574pub fn record_host_unfit(
580 paths: &AppPaths,
581 findings: &[UnfitFinding],
582 at: DateTime<Utc>,
583) -> Result<(), String> {
584 let since = host_unfit(paths)
585 .ok()
586 .flatten()
587 .map_or(at, |record| record.since);
588 let record = HostUnfitRecord {
589 schema_version: HOST_UNFIT_SCHEMA_VERSION,
590 since,
591 checked_at: at,
592 checks: findings.iter().map(|finding| finding.id.clone()).collect(),
593 findings: findings.to_vec(),
594 };
595 let path = host_unfit_path(paths);
596 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
597 write_atomically(&path, &text)
598 .map_err(|error| format!("cannot write {}: {error}", path.display()))
599}
600
601pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
606 let path = host_unfit_path(paths);
607 match std::fs::remove_file(&path) {
608 Ok(()) => Ok(()),
609 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
610 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
611 }
612}
613
614#[must_use]
623pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
624 host_unfit(paths).ok().flatten().filter(|record| {
625 if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
626 return true;
627 }
628 match crate::daemon_heartbeat::liveness(paths, now) {
631 crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
632 !crate::wsl::fence::elapsed_at_least(
633 record.checked_at,
634 since,
635 HOST_UNFIT_RECORD_FRESH,
636 )
637 }
638 _ => false,
639 }
640 })
641}
642
643pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
648 let path = host_unfit_path(paths);
649 match std::fs::read_to_string(&path) {
650 Ok(text) => serde_json::from_str(&text)
651 .map(Some)
652 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
653 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
654 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
655 }
656}
657
658#[cfg(windows)]
663mod sys {
664 use std::ffi::OsString;
665 use std::os::windows::ffi::OsStrExt as _;
666 use std::path::Path;
667
668 use windows::Win32::Foundation::{
669 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
670 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
671 };
672 use windows::Win32::Security::{
673 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
674 };
675 use windows::Win32::System::Registry::{
676 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
677 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
678 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
679 RegSetValueExW,
680 };
681 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
682 use windows::Win32::System::Threading::{
683 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
684 };
685 use windows::Win32::UI::Shell::{
686 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
687 };
688 use windows::core::{PCWSTR, PWSTR};
689
690 use super::{ElevationOutcome, RegistryError};
691
692 const SW_HIDE: i32 = 0;
695 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
699
700 fn wide(text: &str) -> Vec<u16> {
701 text.encode_utf16().chain(std::iter::once(0)).collect()
702 }
703
704 fn registry_error(code: WIN32_ERROR) -> RegistryError {
705 if code == ERROR_ACCESS_DENIED {
706 RegistryError::AccessDenied
707 } else {
708 RegistryError::Other(format!("registry error {}", code.0))
709 }
710 }
711
712 pub(super) fn session_id() -> Option<u32> {
714 let mut token = HANDLE::default();
715 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
718 return None;
719 }
720 let mut session = 0u32;
721 let mut returned = 0u32;
722 let queried = unsafe {
725 GetTokenInformation(
726 token,
727 TokenSessionId,
728 Some((&raw mut session).cast()),
729 4,
730 &raw mut returned,
731 )
732 };
733 let _ = unsafe { CloseHandle(token) };
735 queried.is_ok().then_some(session)
736 }
737
738 pub(super) fn is_elevated() -> bool {
739 let mut token = HANDLE::default();
740 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
743 return false;
744 }
745 let mut elevation = TOKEN_ELEVATION::default();
746 let mut returned = 0u32;
747 let queried = unsafe {
750 GetTokenInformation(
751 token,
752 TokenElevation,
753 Some((&raw mut elevation).cast()),
754 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
755 &raw mut returned,
756 )
757 };
758 let _ = unsafe { CloseHandle(token) };
760 queried.is_ok() && elevation.TokenIsElevated != 0
761 }
762
763 fn memory_status() -> Option<MEMORYSTATUSEX> {
764 let mut status = MEMORYSTATUSEX {
765 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
766 ..MEMORYSTATUSEX::default()
767 };
768 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
771 Some(status)
772 }
773
774 pub(super) fn physical_memory_bytes() -> Option<u64> {
775 memory_status().map(|status| status.ullTotalPhys)
776 }
777
778 pub(super) fn available_memory_bytes() -> Option<u64> {
779 memory_status().map(|status| status.ullAvailPhys)
780 }
781
782 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
783 let wide: Vec<u16> = path
784 .as_os_str()
785 .encode_wide()
786 .chain(std::iter::once(0))
787 .collect();
788 let mut free = 0u64;
789 unsafe {
792 windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
793 PCWSTR(wide.as_ptr()),
794 Some(&raw mut free),
795 None,
796 None,
797 )
798 }
799 .ok()?;
800 Some(free)
801 }
802
803 static PREVIOUS_TIMES: std::sync::Mutex<Option<(u64, u64, u64)>> = std::sync::Mutex::new(None);
806
807 fn system_times() -> Option<(u64, u64, u64)> {
808 use windows::Win32::Foundation::FILETIME;
809 let (mut idle, mut kernel, mut user) = (
810 FILETIME::default(),
811 FILETIME::default(),
812 FILETIME::default(),
813 );
814 unsafe {
816 windows::Win32::System::Threading::GetSystemTimes(
817 Some(&raw mut idle),
818 Some(&raw mut kernel),
819 Some(&raw mut user),
820 )
821 }
822 .ok()?;
823 let value =
824 |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime);
825 Some((value(idle), value(kernel), value(user)))
826 }
827
828 pub(super) fn cpu_load_percent() -> Option<u32> {
829 let now = system_times()?;
830 let previous = PREVIOUS_TIMES
831 .lock()
832 .unwrap_or_else(std::sync::PoisonError::into_inner)
833 .replace(now)?;
834 super::busy_percent(previous, now)
835 }
836
837 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
838 let subkey = wide(subkey);
839 let value = wide(value);
840 let mut data = 0u32;
841 let mut size = 4u32;
842 let code = unsafe {
845 RegGetValueW(
846 HKEY_LOCAL_MACHINE,
847 PCWSTR(subkey.as_ptr()),
848 PCWSTR(value.as_ptr()),
849 RRF_RT_REG_DWORD,
850 None,
851 Some((&raw mut data).cast()),
852 Some(&raw mut size),
853 )
854 };
855 match code {
856 ERROR_SUCCESS => Ok(Some(data)),
857 ERROR_FILE_NOT_FOUND => Ok(None),
858 other => Err(registry_error(other)),
859 }
860 }
861
862 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
863 let subkey = wide(subkey);
864 let value = wide(value);
865 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
866 let mut size = 0u32;
867 let code = unsafe {
869 RegGetValueW(
870 HKEY_LOCAL_MACHINE,
871 PCWSTR(subkey.as_ptr()),
872 PCWSTR(value.as_ptr()),
873 flags,
874 None,
875 None,
876 Some(&raw mut size),
877 )
878 };
879 match code {
880 ERROR_SUCCESS => {}
881 ERROR_FILE_NOT_FOUND => return Ok(None),
882 other => return Err(registry_error(other)),
883 }
884 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
885 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
886 let code = unsafe {
889 RegGetValueW(
890 HKEY_LOCAL_MACHINE,
891 PCWSTR(subkey.as_ptr()),
892 PCWSTR(value.as_ptr()),
893 flags,
894 None,
895 Some(buffer.as_mut_ptr().cast()),
896 Some(&raw mut size),
897 )
898 };
899 match code {
900 ERROR_SUCCESS => {
901 let units = (size as usize / 2).min(buffer.len());
902 let text = String::from_utf16_lossy(&buffer[..units]);
903 Ok(Some(text.trim_end_matches('\0').to_owned()))
904 }
905 ERROR_FILE_NOT_FOUND => Ok(None),
906 other => Err(registry_error(other)),
907 }
908 }
909
910 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
911 let subkey = wide(subkey);
912 let mut key = HKEY::default();
913 let code = unsafe {
915 RegOpenKeyExW(
916 HKEY_LOCAL_MACHINE,
917 PCWSTR(subkey.as_ptr()),
918 None,
919 KEY_READ,
920 &raw mut key,
921 )
922 };
923 match code {
924 ERROR_SUCCESS => {}
925 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
926 other => return Err(registry_error(other)),
927 }
928 let mut names = Vec::new();
929 let mut index = 0u32;
930 let result = loop {
931 let mut name = vec![0u16; 16_384];
933 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
934 let code = unsafe {
937 RegEnumValueW(
938 key,
939 index,
940 Some(PWSTR(name.as_mut_ptr())),
941 &raw mut length,
942 None,
943 None,
944 None,
945 None,
946 )
947 };
948 match code {
949 ERROR_SUCCESS => {
950 names.push(String::from_utf16_lossy(&name[..length as usize]));
951 index += 1;
952 }
953 ERROR_NO_MORE_ITEMS => break Ok(names),
954 other => break Err(registry_error(other)),
955 }
956 };
957 let _ = unsafe { RegCloseKey(key) };
959 result
960 }
961
962 fn write_value(
963 subkey: &str,
964 value: &str,
965 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
966 ) -> Result<(), RegistryError> {
967 let subkey_wide = wide(subkey);
968 let value_wide = wide(value);
969 let Some((kind, bytes)) = data else {
970 let code = unsafe {
972 RegDeleteKeyValueW(
973 HKEY_LOCAL_MACHINE,
974 PCWSTR(subkey_wide.as_ptr()),
975 PCWSTR(value_wide.as_ptr()),
976 )
977 };
978 return match code {
979 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
980 other => Err(registry_error(other)),
981 };
982 };
983 let mut key = HKEY::default();
984 let code = unsafe {
987 RegCreateKeyExW(
988 HKEY_LOCAL_MACHINE,
989 PCWSTR(subkey_wide.as_ptr()),
990 None,
991 PCWSTR::null(),
992 REG_OPTION_NON_VOLATILE,
993 KEY_SET_VALUE,
994 None,
995 &raw mut key,
996 None,
997 )
998 };
999 if code != ERROR_SUCCESS {
1000 return Err(registry_error(code));
1001 }
1002 let code =
1005 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
1006 let _ = unsafe { RegCloseKey(key) };
1008 if code == ERROR_SUCCESS {
1009 Ok(())
1010 } else {
1011 Err(registry_error(code))
1012 }
1013 }
1014
1015 pub(super) fn write_dword(
1016 subkey: &str,
1017 value: &str,
1018 data: Option<u32>,
1019 ) -> Result<(), RegistryError> {
1020 write_value(
1021 subkey,
1022 value,
1023 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
1024 )
1025 }
1026
1027 pub(super) fn write_string(
1028 subkey: &str,
1029 value: &str,
1030 data: Option<&str>,
1031 ) -> Result<(), RegistryError> {
1032 write_value(
1033 subkey,
1034 value,
1035 data.map(|text| {
1036 let bytes = wide(text)
1037 .iter()
1038 .flat_map(|unit| unit.to_le_bytes())
1039 .collect();
1040 (REG_SZ, bytes)
1041 }),
1042 )
1043 }
1044
1045 pub(super) fn run_elevated(
1046 program: &Path,
1047 args: &[OsString],
1048 _terminal: bool,
1049 ) -> ElevationOutcome {
1050 let file: Vec<u16> = program
1051 .as_os_str()
1052 .encode_wide()
1053 .chain(std::iter::once(0))
1054 .collect();
1055 let parameters = args
1056 .iter()
1057 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
1058 .collect::<Vec<_>>()
1059 .join(" ");
1060 let parameters = wide(¶meters);
1061 let verb = wide("runas");
1062 let mut info = SHELLEXECUTEINFOW {
1063 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
1064 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
1065 lpVerb: PCWSTR(verb.as_ptr()),
1066 lpFile: PCWSTR(file.as_ptr()),
1067 lpParameters: PCWSTR(parameters.as_ptr()),
1068 nShow: SW_HIDE,
1069 ..SHELLEXECUTEINFOW::default()
1070 };
1071 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
1074 #[allow(clippy::cast_sign_loss)]
1075 let code = (error.code().0 as u32) & 0xffff;
1076 return if code == ERROR_CANCELLED.0 {
1077 ElevationOutcome::Refused
1078 } else {
1079 ElevationOutcome::Unavailable(format!(
1080 "Windows could not show the administrator prompt: {}",
1081 error.message()
1082 ))
1083 };
1084 }
1085 if info.hProcess.is_invalid() {
1086 return ElevationOutcome::Unavailable(
1087 "Windows started no elevated process to wait for".into(),
1088 );
1089 }
1090 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
1093 let mut code = 1u32;
1094 let outcome = if waited == WAIT_OBJECT_0 {
1095 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
1097 #[allow(clippy::cast_possible_wrap)]
1098 Ok(()) => ElevationOutcome::Exited(code as i32),
1099 Err(error) => ElevationOutcome::Unavailable(format!(
1100 "the elevated process's exit code could not be read: {}",
1101 error.message()
1102 )),
1103 }
1104 } else {
1105 ElevationOutcome::Unavailable(
1106 "the elevated process did not finish within 15 minutes".into(),
1107 )
1108 };
1109 let _ = unsafe { CloseHandle(info.hProcess) };
1111 outcome
1112 }
1113}
1114
1115#[cfg(unix)]
1120mod sys {
1121 use std::ffi::OsString;
1122 use std::path::Path;
1123 use std::process::Command;
1124
1125 pub(super) const fn session_id() -> Option<u32> {
1127 None
1128 }
1129
1130 use super::{ElevationOutcome, RegistryError};
1131
1132 pub(super) fn is_elevated() -> bool {
1133 unsafe { libc::geteuid() == 0 }
1135 }
1136
1137 #[cfg(target_os = "macos")]
1138 pub(super) fn physical_memory_bytes() -> Option<u64> {
1139 let mut value = 0u64;
1140 let mut size = std::mem::size_of::<u64>();
1141 let name = c"hw.memsize";
1142 let result = unsafe {
1145 libc::sysctlbyname(
1146 name.as_ptr(),
1147 (&raw mut value).cast(),
1148 &raw mut size,
1149 std::ptr::null_mut(),
1150 0,
1151 )
1152 };
1153 (result == 0).then_some(value)
1154 }
1155
1156 #[cfg(not(target_os = "macos"))]
1157 pub(super) fn physical_memory_bytes() -> Option<u64> {
1158 meminfo_bytes("MemTotal:")
1159 }
1160
1161 #[cfg(not(target_os = "macos"))]
1163 fn meminfo_bytes(field: &str) -> Option<u64> {
1164 super::meminfo_field(&std::fs::read_to_string("/proc/meminfo").ok()?, field)
1165 }
1166
1167 #[cfg(target_os = "macos")]
1170 #[allow(
1171 deprecated,
1172 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1173 )]
1174 fn host_port() -> libc::mach_port_t {
1175 static PORT: std::sync::OnceLock<libc::mach_port_t> = std::sync::OnceLock::new();
1176 *PORT.get_or_init(|| unsafe { libc::mach_host_self() })
1178 }
1179
1180 #[cfg(target_os = "macos")]
1181 #[allow(
1182 deprecated,
1183 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1184 )]
1185 pub(super) fn available_memory_bytes() -> Option<u64> {
1186 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
1188 let mut count = libc::HOST_VM_INFO64_COUNT;
1189 let result = unsafe {
1192 libc::host_statistics64(
1193 host_port(),
1194 libc::HOST_VM_INFO64,
1195 (&raw mut stats).cast(),
1196 &raw mut count,
1197 )
1198 };
1199 if result != libc::KERN_SUCCESS {
1200 return None;
1201 }
1202 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }).ok()?;
1204 let (free, speculative, inactive) = (
1205 stats.free_count,
1206 stats.speculative_count,
1207 stats.inactive_count,
1208 );
1209 super::available_pages_bytes(
1212 u64::from(free).saturating_sub(u64::from(speculative)),
1213 u64::from(inactive),
1214 page,
1215 )
1216 }
1217
1218 #[cfg(not(target_os = "macos"))]
1219 pub(super) fn available_memory_bytes() -> Option<u64> {
1220 meminfo_bytes("MemAvailable:")
1221 }
1222
1223 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
1224 use std::os::unix::ffi::OsStrExt as _;
1225 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1226 let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
1228 if unsafe { libc::statvfs(name.as_ptr(), &raw mut stats) } != 0 {
1231 return None;
1232 }
1233 #[allow(clippy::useless_conversion, reason = "the field widths differ per OS")]
1234 u64::from(stats.f_bavail).checked_mul(u64::from(stats.f_frsize))
1235 }
1236
1237 pub(super) fn cpu_load_percent() -> Option<u32> {
1238 let mut loads = [0f64; 3];
1239 if unsafe { libc::getloadavg(loads.as_mut_ptr(), 1) } != 1 {
1241 return None;
1242 }
1243 let cores = std::thread::available_parallelism().ok()?.get();
1244 let percent = loads[0] * 100.0 / f64::from(u32::try_from(cores).ok()?);
1245 #[allow(
1246 clippy::cast_possible_truncation,
1247 clippy::cast_sign_loss,
1248 reason = "clamped to a small non-negative range first"
1249 )]
1250 Some(percent.clamp(0.0, 10_000.0).round() as u32)
1251 }
1252
1253 fn unsupported() -> RegistryError {
1254 RegistryError::Other("the registry exists only on Windows".into())
1255 }
1256
1257 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1258 Err(unsupported())
1259 }
1260
1261 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1262 Err(unsupported())
1263 }
1264
1265 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1266 Err(unsupported())
1267 }
1268
1269 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1270 Err(unsupported())
1271 }
1272
1273 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1274 Err(unsupported())
1275 }
1276
1277 fn exit_outcome(
1278 status: std::io::Result<std::process::ExitStatus>,
1279 what: &str,
1280 ) -> ElevationOutcome {
1281 match status {
1282 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1283 Err(error) => {
1284 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1285 }
1286 }
1287 }
1288
1289 pub(super) fn run_elevated(
1290 program: &Path,
1291 args: &[OsString],
1292 terminal: bool,
1293 ) -> ElevationOutcome {
1294 if terminal
1295 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1296 {
1297 let status = Command::new("sudo")
1298 .arg("--")
1299 .arg(program)
1300 .args(args)
1301 .status();
1302 return match exit_outcome(status, "sudo") {
1306 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1307 other => other,
1308 };
1309 }
1310 if cfg!(target_os = "macos") {
1311 let command = std::iter::once(program.as_os_str().to_owned())
1312 .chain(args.iter().cloned())
1313 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1314 .collect::<Vec<_>>()
1315 .join(" ");
1316 let output = Command::new("/usr/bin/osascript")
1317 .arg("-e")
1318 .arg(super::administrator_applescript(&command))
1319 .output();
1320 return match output {
1321 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1322 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1324 ElevationOutcome::Refused
1325 }
1326 Ok(output) => {
1327 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1328 if detail.contains("(-60007)") || detail.contains("(-1743)") {
1329 ElevationOutcome::Unavailable(format!(
1330 "macOS could not show the administrator dialog here: {detail}"
1331 ))
1332 } else {
1333 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1334 }
1335 }
1336 Err(error) => ElevationOutcome::Unavailable(format!(
1337 "osascript could not be started: {error}"
1338 )),
1339 };
1340 }
1341 ElevationOutcome::Unavailable(
1342 "no terminal to ask for a sudo password; run the command from a terminal".into(),
1343 )
1344 }
1345}
1346
1347#[cfg(test)]
1348mod tests {
1349 use super::*;
1350
1351 #[test]
1352 fn the_busy_share_comes_from_the_difference_of_two_readings() {
1353 assert_eq!(busy_percent((0, 0, 0), (250, 400, 600)), Some(75));
1355 assert_eq!(busy_percent((250, 400, 600), (250, 400, 600)), None);
1356 assert_eq!(busy_percent((10, 10, 10), (5, 20, 20)), None, "went back");
1357 assert_eq!(available_pages_bytes(10, 5, 16_384), Some(15 * 16_384));
1358 }
1359
1360 #[test]
1363 fn available_memory_is_memavailable_not_memfree() {
1364 let meminfo = "MemTotal: 32000000 kB\n\
1365 MemFree: 9000000 kB\n\
1366 MemAvailable: 18000000 kB\n";
1367 assert_eq!(
1368 meminfo_field(meminfo, "MemAvailable:"),
1369 Some(18_000_000 * 1024)
1370 );
1371 assert_eq!(meminfo_field(meminfo, "SwapFree:"), None);
1372 }
1373
1374 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1376 #[test]
1377 fn free_memory_is_reported_and_below_the_total() {
1378 let free = available_memory_bytes().expect("free memory");
1379 let total = physical_memory_bytes().expect("total memory");
1380 assert!(free > 0 && free <= total, "{free} of {total}");
1381 }
1382
1383 #[cfg(windows)]
1384 #[test]
1385 fn windows_reports_cpu_load_from_the_second_reading() {
1386 let _ = cpu_load_percent();
1387 std::thread::sleep(std::time::Duration::from_millis(200));
1388 assert!(cpu_load_percent().is_some_and(|percent| percent <= 100));
1389 }
1390
1391 #[test]
1392 fn windows_arguments_survive_the_command_line_round_trip() {
1393 for (argument, expected) in [
1394 ("plain", "plain"),
1395 ("", "\"\""),
1396 ("with space", "\"with space\""),
1397 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1398 ("say \"hi\"", "\"say \\\"hi\\\"\""),
1399 ("a\\\"b", "\"a\\\\\\\"b\""),
1400 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1401 ] {
1402 assert_eq!(
1403 crate::service::quote_argument(argument),
1404 expected,
1405 "{argument}"
1406 );
1407 }
1408 }
1409
1410 #[test]
1411 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1412 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1413 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1414 assert_eq!(
1415 script,
1416 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1417 );
1418 }
1419
1420 #[test]
1421 fn a_symlink_probe_leaves_nothing_behind() {
1422 let directory = tempfile::tempdir().unwrap();
1423 let _ = can_create_symlink(directory.path()).unwrap();
1424 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1425 }
1426
1427 #[cfg(unix)]
1428 #[test]
1429 fn unix_accounts_can_always_create_symlinks() {
1430 let directory = tempfile::tempdir().unwrap();
1431 assert!(can_create_symlink(directory.path()).unwrap());
1432 }
1433
1434 #[test]
1435 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1436 let directory = tempfile::tempdir().unwrap();
1437 let name = if cfg!(windows) {
1438 "rmtool.exe"
1439 } else {
1440 "rmtool"
1441 };
1442 let tool = directory.path().join(name);
1443 std::fs::write(&tool, b"").unwrap();
1444 #[cfg(unix)]
1445 {
1446 use std::os::unix::fs::PermissionsExt as _;
1447 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1448 }
1449 let path = std::env::join_paths([directory.path()]).unwrap();
1450 assert_eq!(find_on_path("rmtool", &path), Some(tool));
1451 assert_eq!(find_on_path("absent-tool", &path), None);
1452 assert_eq!(find_on_path("../rmtool", &path), None);
1453 }
1454
1455 #[cfg(windows)]
1458 #[test]
1459 fn an_extensionless_file_is_never_the_windows_answer() {
1460 let directory = tempfile::tempdir().unwrap();
1461 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1462 let path = std::env::join_paths([directory.path()]).unwrap();
1463 assert_eq!(find_on_path("npm", &path), None);
1464 let cmd = directory.path().join("npm.cmd");
1465 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1466 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1467 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1468 }
1469
1470 fn path_of(directories: &[&Path]) -> OsString {
1472 std::env::join_paths(directories).expect("joinable directories")
1473 }
1474
1475 fn touch(path: &Path) {
1476 std::fs::write(path, b"").expect("a fixture file");
1477 }
1478
1479 #[test]
1483 fn npm_shipped_only_as_npm_cmd_is_found_on_a_windows_path() {
1484 let empty = tempfile::tempdir().unwrap();
1485 let nodejs = tempfile::tempdir().unwrap();
1486 touch(&nodejs.path().join("npm"));
1487 touch(&nodejs.path().join("npm.cmd"));
1488 let path = path_of(&[empty.path(), nodejs.path()]);
1489 let cmd = Some(nodejs.path().join("npm.cmd"));
1490
1491 assert_eq!(
1492 find_on_windows_path("npm", &path, Some(".COM;.EXE;.BAT;.CMD".as_ref())),
1493 cmd
1494 );
1495 assert_eq!(find_on_windows_path("npm", &path, None), cmd);
1497 assert_eq!(find_on_windows_path("npm", &path, Some(" ".as_ref())), cmd);
1498 assert_eq!(find_on_windows_path("npm.cmd", &path, None), cmd);
1500 touch(&nodejs.path().join("npm.ps1"));
1501 assert_eq!(find_on_windows_path("npm.ps1", &path, None), None);
1502 }
1503
1504 #[test]
1507 fn pathext_orders_extensions_within_a_directory_and_path_orders_directories() {
1508 let both = tempfile::tempdir().unwrap();
1509 touch(&both.path().join("npm.cmd"));
1510 touch(&both.path().join("npm.exe"));
1511 let path = path_of(&[both.path()]);
1512 assert_eq!(
1513 find_on_windows_path("npm", &path, Some(".CMD;.EXE".as_ref())),
1514 Some(both.path().join("npm.cmd"))
1515 );
1516 assert_eq!(
1517 find_on_windows_path("npm", &path, Some(".exe;.cmd".as_ref())),
1518 Some(both.path().join("npm.exe"))
1519 );
1520
1521 let first = tempfile::tempdir().unwrap();
1522 let second = tempfile::tempdir().unwrap();
1523 touch(&first.path().join("npm.cmd"));
1524 touch(&second.path().join("npm.exe"));
1525 assert_eq!(
1526 find_on_windows_path(
1527 "npm",
1528 &path_of(&[first.path(), second.path()]),
1529 Some(".EXE;.CMD".as_ref())
1530 ),
1531 Some(first.path().join("npm.cmd")),
1532 "an earlier PATH directory wins over a preferred extension later on"
1533 );
1534 }
1535
1536 #[test]
1539 #[serial_test::serial(current_dir)]
1540 fn a_missing_npm_is_not_found_on_a_windows_path() {
1541 let empty = tempfile::tempdir().unwrap();
1542 assert_eq!(
1543 find_on_windows_path("npm", &path_of(&[empty.path()]), None),
1544 None
1545 );
1546
1547 let script_only = tempfile::tempdir().unwrap();
1548 touch(&script_only.path().join("npm.ps1"));
1549 assert_eq!(
1550 find_on_windows_path(
1551 "npm",
1552 &path_of(&[script_only.path()]),
1553 Some(".PS1;.EXE;.CMD".as_ref())
1554 ),
1555 None
1556 );
1557
1558 let planted = tempfile::tempdir().unwrap();
1561 touch(&planted.path().join("npm.cmd"));
1562 if let Some(relative) = relative_to_current_directory(planted.path()) {
1563 assert!(relative.join("npm.cmd").is_file(), "{}", relative.display());
1564 assert_eq!(
1565 find_on_windows_path("npm", &path_of(&[&relative]), None),
1566 None
1567 );
1568 }
1569 }
1570
1571 fn relative_to_current_directory(target: &Path) -> Option<PathBuf> {
1574 let here = std::env::current_dir().ok()?;
1575 let target = std::fs::canonicalize(target).ok()?;
1576 let here = std::fs::canonicalize(here).ok()?;
1577 let (here, target): (Vec<_>, Vec<_>) =
1578 (here.components().collect(), target.components().collect());
1579 let common = here
1580 .iter()
1581 .zip(&target)
1582 .take_while(|(left, right)| left == right)
1583 .count();
1584 if common == 0 {
1585 return None;
1586 }
1587 let mut relative: PathBuf = std::iter::repeat_n("..", here.len() - common).collect();
1588 relative.extend(&target[common..]);
1589 Some(relative)
1590 }
1591
1592 fn unfit_finding(id: &str) -> UnfitFinding {
1593 UnfitFinding {
1594 id: id.into(),
1595 detail: "it fails".into(),
1596 remedy: None,
1597 }
1598 }
1599
1600 #[test]
1603 fn who_can_answer_an_administrator_prompt() {
1604 assert!(
1605 prompt_answerable(Some(1), false),
1606 "the desktop, no terminal"
1607 );
1608 assert!(prompt_answerable(Some(2), true));
1609 assert!(!prompt_answerable(Some(0), true), "services and SSH");
1610 assert!(prompt_answerable(None, true), "sudo on a terminal");
1611 assert!(!prompt_answerable(None, false));
1612 }
1613
1614 #[test]
1616 fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1617 let root = tempfile::tempdir().unwrap();
1618 let paths = AppPaths::rooted_at(root.path());
1619 paths.create_all().unwrap();
1620 let then = Utc::now();
1621 record_host_unfit(
1622 &paths,
1623 &[unfit_finding("host.runner_root_responsive")],
1624 then,
1625 )
1626 .unwrap();
1627 let later = then + chrono::Duration::hours(1);
1628 assert_eq!(
1629 host_unfit_in_force(&paths, later),
1630 None,
1631 "nobody beats: stopped"
1632 );
1633
1634 crate::daemon_heartbeat::beat(&paths, then).unwrap();
1636 assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1637 crate::daemon_heartbeat::beat(&paths, later).unwrap();
1638 assert_eq!(
1639 host_unfit_in_force(&paths, later),
1640 None,
1641 "a daemon still beating would have re-stamped a refusal in force"
1642 );
1643
1644 let much_later = later + chrono::Duration::hours(1);
1647 assert_eq!(host_unfit_in_force(&paths, much_later), None);
1648 }
1649
1650 #[test]
1651 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1652 let root = tempfile::tempdir().unwrap();
1653 let paths = AppPaths::rooted_at(root.path());
1654 paths.create_all().unwrap();
1655 assert_eq!(host_unfit(&paths).unwrap(), None);
1656 let first = Utc::now() - chrono::Duration::minutes(10);
1657 record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1658 let later = Utc::now();
1659 record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1660 let record = host_unfit(&paths).unwrap().unwrap();
1661 assert_eq!(record.since, first);
1662 assert_eq!(record.checked_at, later);
1663 assert_eq!(record.checks, ["host.required_tools"]);
1664 clear_host_unfit(&paths).unwrap();
1665 assert_eq!(host_unfit(&paths).unwrap(), None);
1666 clear_host_unfit(&paths).unwrap();
1667 }
1668
1669 #[test]
1670 fn physical_memory_is_reported() {
1671 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1672 }
1673
1674 #[test]
1675 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1676 let directory = tempfile::tempdir().unwrap();
1677 let deadline = std::time::Duration::from_secs(10);
1678 assert_eq!(
1679 directory_responds(directory.path(), deadline),
1680 Responsiveness::Responds
1681 );
1682 assert_eq!(
1683 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1684 Responsiveness::Responds
1685 );
1686 assert!(
1689 !PROBES_IN_FLIGHT
1690 .lock()
1691 .unwrap()
1692 .iter()
1693 .any(|(path, _)| path.starts_with(directory.path())),
1694 "a finished probe deregisters"
1695 );
1696 }
1697
1698 #[test]
1703 fn a_path_still_being_probed_answers_without_a_second_thread() {
1704 let directory = tempfile::tempdir().unwrap();
1705 for (name, listing, expected) in [
1706 ("stuck-volume", false, Responsiveness::Hung),
1707 ("asking-volume", true, Responsiveness::ListingBlocked),
1708 ] {
1709 let stuck = directory.path().join(name);
1710 PROBES_IN_FLIGHT.lock().unwrap().push((
1711 stuck.clone(),
1712 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1713 ));
1714 let started = std::time::Instant::now();
1715 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1716 PROBES_IN_FLIGHT
1717 .lock()
1718 .unwrap()
1719 .retain(|(path, _)| *path != stuck);
1720 assert_eq!(answer, expected, "{name}");
1721 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1722 }
1723 }
1724}