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(not(target_os = "macos"), allow(dead_code))]
154fn available_pages_bytes(free: u64, inactive: u64, page: u64) -> Option<u64> {
155 free.checked_add(inactive)?.checked_mul(page)
156}
157
158#[cfg(windows)]
164pub fn system_directory() -> std::io::Result<PathBuf> {
165 crate::runner_root::windows_system_directory().map(PathBuf::from)
166}
167
168pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
174 use std::io::Write as _;
175 let parent = path.parent().unwrap_or_else(|| Path::new("."));
176 std::fs::create_dir_all(parent)?;
177 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
178 temporary.write_all(bytes)?;
179 temporary.as_file().sync_all()?;
180 temporary
181 .persist(path)
182 .map(|_| ())
183 .map_err(|error| error.error)
184}
185
186#[derive(Debug, Clone, PartialEq, Eq)]
188pub enum Responsiveness {
189 Responds,
191 Hung,
194 ListingBlocked,
200 NotPermitted(String),
204 Failed(String),
206}
207
208type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
211
212static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
216
217#[must_use]
219pub fn probes_in_flight() -> Vec<PathBuf> {
220 PROBES_IN_FLIGHT
221 .lock()
222 .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
223 .unwrap_or_default()
224}
225
226fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
228 if listing.load(std::sync::atomic::Ordering::Acquire) {
229 Responsiveness::ListingBlocked
230 } else {
231 Responsiveness::Hung
232 }
233}
234
235#[must_use]
243pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
244 let path = directory.to_path_buf();
245 let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
248 {
249 let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
250 return Responsiveness::Failed("the probe registry is poisoned".into());
251 };
252 if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
253 return unanswered(stuck);
254 }
255 in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
256 }
257 let (sender, receiver) = std::sync::mpsc::channel();
258 let probed = path.clone();
259 let listing_started = std::sync::Arc::clone(&listing);
260 let spawned = std::thread::Builder::new()
261 .name("runner-root-probe".into())
262 .spawn(move || {
263 let existing = probed
264 .ancestors()
265 .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
266 .map(Path::to_path_buf);
267 listing_started.store(true, std::sync::atomic::Ordering::Release);
268 let refused = |error: std::io::Error| {
269 if is_not_permitted(&error) {
270 Responsiveness::NotPermitted(error.to_string())
271 } else {
272 Responsiveness::Failed(error.to_string())
273 }
274 };
275 let answer = match existing {
276 None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
277 Some(existing) => match std::fs::read_dir(&existing) {
278 Ok(mut entries) => match entries.next() {
279 Some(Err(error)) => refused(error),
280 _ => Responsiveness::Responds,
281 },
282 Err(error) => refused(error),
283 },
284 };
285 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
286 in_flight.retain(|(candidate, _)| *candidate != probed);
287 }
288 let _ = sender.send(answer);
289 });
290 if let Err(error) = spawned {
291 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
292 in_flight.retain(|(candidate, _)| *candidate != path);
293 }
294 return Responsiveness::Failed(error.to_string());
295 }
296 receiver
297 .recv_timeout(deadline)
298 .unwrap_or_else(|_| unanswered(&listing))
299}
300
301fn is_not_permitted(error: &std::io::Error) -> bool {
303 #[cfg(unix)]
304 {
305 error.raw_os_error() == Some(libc::EPERM)
306 }
307 #[cfg(not(unix))]
308 {
309 let _ = error;
310 false
311 }
312}
313
314pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
326 std::fs::create_dir_all(directory)?;
327 let unique = format!(
328 ".rm-symlink-probe-{}-{}",
329 std::process::id(),
330 uuid::Uuid::new_v4().simple()
331 );
332 let target = directory.join(format!("{unique}.target"));
333 let link = directory.join(format!("{unique}.link"));
334 std::fs::write(&target, b"")?;
335 let created = symlink_file(&target, &link);
336 let _ = std::fs::remove_file(&link);
337 let _ = std::fs::remove_file(&target);
338 Ok(created.is_ok())
339}
340
341#[cfg(windows)]
342fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
343 std::os::windows::fs::symlink_file(target, link)
344}
345
346#[cfg(unix)]
347fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
348 std::os::unix::fs::symlink(target, link)
349}
350
351#[must_use]
354pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
355 if cfg!(windows) {
356 return find_on_windows_path(tool, path, std::env::var_os("PATHEXT").as_deref());
357 }
358 if tool.is_empty() || tool.contains(['/', '\\']) {
359 return None;
360 }
361 std::env::split_paths(path)
362 .filter(|dir| !dir.as_os_str().is_empty())
363 .map(|dir| dir.join(tool))
364 .find(|candidate| is_executable(candidate))
365}
366
367#[must_use]
385pub fn find_on_windows_path(tool: &str, path: &OsStr, pathext: Option<&OsStr>) -> Option<PathBuf> {
386 const RUNNABLE: [&str; 4] = [".exe", ".com", ".cmd", ".bat"];
387 if tool.is_empty() || tool.contains(['/', '\\']) {
388 return None;
389 }
390 let pathext = pathext
391 .and_then(OsStr::to_str)
392 .filter(|value| !value.trim().is_empty())
393 .unwrap_or(".COM;.EXE;.BAT;.CMD");
394 let bare = Path::new(tool)
395 .extension()
396 .and_then(OsStr::to_str)
397 .is_some_and(|extension| {
398 RUNNABLE
399 .iter()
400 .any(|runnable| runnable[1..].eq_ignore_ascii_case(extension))
401 })
402 .then_some("");
403 let extensions: Vec<&str> = bare
404 .into_iter()
405 .chain(pathext.split(';').filter_map(|extension| {
406 RUNNABLE
407 .into_iter()
408 .find(|runnable| runnable.eq_ignore_ascii_case(extension.trim()))
409 }))
410 .collect();
411 std::env::split_paths(path)
412 .filter(|dir| dir.is_absolute())
413 .find_map(|dir| {
414 extensions.iter().find_map(|extension| {
415 let candidate = dir.join(format!("{tool}{extension}"));
416 candidate.is_file().then_some(candidate)
417 })
418 })
419}
420
421#[cfg(unix)]
422fn is_executable(path: &Path) -> bool {
423 use std::os::unix::fs::PermissionsExt as _;
424 std::fs::metadata(path)
425 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
426}
427
428#[cfg(windows)]
429fn is_executable(path: &Path) -> bool {
430 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
431}
432
433#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
439pub enum RegistryError {
440 #[error("access denied")]
443 AccessDenied,
444 #[error("{0}")]
446 Other(String),
447}
448
449pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
455 sys::read_dword(subkey, value)
456}
457
458pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
463 sys::read_string(subkey, value)
464}
465
466pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
472 sys::value_names(subkey)
473}
474
475pub fn write_registry_dword(
481 subkey: &str,
482 value: &str,
483 data: Option<u32>,
484) -> Result<(), RegistryError> {
485 sys::write_dword(subkey, value, data)
486}
487
488pub fn write_registry_string(
494 subkey: &str,
495 value: &str,
496 data: Option<&str>,
497) -> Result<(), RegistryError> {
498 sys::write_string(subkey, value, data)
499}
500
501pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
507
508pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
511
512pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
516 std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
517const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
518
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
521pub struct HostUnfitRecord {
522 pub schema_version: u32,
523 pub since: DateTime<Utc>,
525 pub checked_at: DateTime<Utc>,
527 pub checks: Vec<String>,
529 #[serde(default)]
532 pub findings: Vec<UnfitFinding>,
533}
534
535#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537pub struct UnfitFinding {
538 pub id: String,
539 pub detail: String,
540 #[serde(default)]
541 pub remedy: Option<String>,
542}
543
544impl HostUnfitRecord {
545 #[must_use]
547 pub fn describe(&self) -> String {
548 if self.findings.is_empty() {
549 return self.checks.join(", ");
550 }
551 self.findings
552 .iter()
553 .map(|finding| match &finding.remedy {
554 Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
555 None => format!("{}: {}", finding.id, finding.detail),
556 })
557 .collect::<Vec<_>>()
558 .join("; ")
559 }
560}
561
562fn host_unfit_path(paths: &AppPaths) -> PathBuf {
563 paths.state_dir().join(HOST_UNFIT_FILE)
564}
565
566pub fn record_host_unfit(
572 paths: &AppPaths,
573 findings: &[UnfitFinding],
574 at: DateTime<Utc>,
575) -> Result<(), String> {
576 let since = host_unfit(paths)
577 .ok()
578 .flatten()
579 .map_or(at, |record| record.since);
580 let record = HostUnfitRecord {
581 schema_version: HOST_UNFIT_SCHEMA_VERSION,
582 since,
583 checked_at: at,
584 checks: findings.iter().map(|finding| finding.id.clone()).collect(),
585 findings: findings.to_vec(),
586 };
587 let path = host_unfit_path(paths);
588 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
589 write_atomically(&path, &text)
590 .map_err(|error| format!("cannot write {}: {error}", path.display()))
591}
592
593pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
598 let path = host_unfit_path(paths);
599 match std::fs::remove_file(&path) {
600 Ok(()) => Ok(()),
601 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
602 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
603 }
604}
605
606#[must_use]
615pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
616 host_unfit(paths).ok().flatten().filter(|record| {
617 if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
618 return true;
619 }
620 match crate::daemon_heartbeat::liveness(paths, now) {
623 crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
624 !crate::wsl::fence::elapsed_at_least(
625 record.checked_at,
626 since,
627 HOST_UNFIT_RECORD_FRESH,
628 )
629 }
630 _ => false,
631 }
632 })
633}
634
635pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
640 let path = host_unfit_path(paths);
641 match std::fs::read_to_string(&path) {
642 Ok(text) => serde_json::from_str(&text)
643 .map(Some)
644 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
645 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
646 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
647 }
648}
649
650#[cfg(windows)]
655mod sys {
656 use std::ffi::OsString;
657 use std::os::windows::ffi::OsStrExt as _;
658 use std::path::Path;
659
660 use windows::Win32::Foundation::{
661 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
662 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
663 };
664 use windows::Win32::Security::{
665 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
666 };
667 use windows::Win32::System::Registry::{
668 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
669 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
670 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
671 RegSetValueExW,
672 };
673 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
674 use windows::Win32::System::Threading::{
675 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
676 };
677 use windows::Win32::UI::Shell::{
678 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
679 };
680 use windows::core::{PCWSTR, PWSTR};
681
682 use super::{ElevationOutcome, RegistryError};
683
684 const SW_HIDE: i32 = 0;
687 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
691
692 fn wide(text: &str) -> Vec<u16> {
693 text.encode_utf16().chain(std::iter::once(0)).collect()
694 }
695
696 fn registry_error(code: WIN32_ERROR) -> RegistryError {
697 if code == ERROR_ACCESS_DENIED {
698 RegistryError::AccessDenied
699 } else {
700 RegistryError::Other(format!("registry error {}", code.0))
701 }
702 }
703
704 pub(super) fn session_id() -> Option<u32> {
706 let mut token = HANDLE::default();
707 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
710 return None;
711 }
712 let mut session = 0u32;
713 let mut returned = 0u32;
714 let queried = unsafe {
717 GetTokenInformation(
718 token,
719 TokenSessionId,
720 Some((&raw mut session).cast()),
721 4,
722 &raw mut returned,
723 )
724 };
725 let _ = unsafe { CloseHandle(token) };
727 queried.is_ok().then_some(session)
728 }
729
730 pub(super) fn is_elevated() -> bool {
731 let mut token = HANDLE::default();
732 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
735 return false;
736 }
737 let mut elevation = TOKEN_ELEVATION::default();
738 let mut returned = 0u32;
739 let queried = unsafe {
742 GetTokenInformation(
743 token,
744 TokenElevation,
745 Some((&raw mut elevation).cast()),
746 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
747 &raw mut returned,
748 )
749 };
750 let _ = unsafe { CloseHandle(token) };
752 queried.is_ok() && elevation.TokenIsElevated != 0
753 }
754
755 fn memory_status() -> Option<MEMORYSTATUSEX> {
756 let mut status = MEMORYSTATUSEX {
757 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
758 ..MEMORYSTATUSEX::default()
759 };
760 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
763 Some(status)
764 }
765
766 pub(super) fn physical_memory_bytes() -> Option<u64> {
767 memory_status().map(|status| status.ullTotalPhys)
768 }
769
770 pub(super) fn available_memory_bytes() -> Option<u64> {
771 memory_status().map(|status| status.ullAvailPhys)
772 }
773
774 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
775 let wide: Vec<u16> = path
776 .as_os_str()
777 .encode_wide()
778 .chain(std::iter::once(0))
779 .collect();
780 let mut free = 0u64;
781 unsafe {
784 windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
785 PCWSTR(wide.as_ptr()),
786 Some(&raw mut free),
787 None,
788 None,
789 )
790 }
791 .ok()?;
792 Some(free)
793 }
794
795 static PREVIOUS_TIMES: std::sync::Mutex<Option<(u64, u64, u64)>> = std::sync::Mutex::new(None);
798
799 fn system_times() -> Option<(u64, u64, u64)> {
800 use windows::Win32::Foundation::FILETIME;
801 let (mut idle, mut kernel, mut user) = (
802 FILETIME::default(),
803 FILETIME::default(),
804 FILETIME::default(),
805 );
806 unsafe {
808 windows::Win32::System::Threading::GetSystemTimes(
809 Some(&raw mut idle),
810 Some(&raw mut kernel),
811 Some(&raw mut user),
812 )
813 }
814 .ok()?;
815 let value =
816 |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime);
817 Some((value(idle), value(kernel), value(user)))
818 }
819
820 pub(super) fn cpu_load_percent() -> Option<u32> {
821 let now = system_times()?;
822 let previous = PREVIOUS_TIMES
823 .lock()
824 .unwrap_or_else(std::sync::PoisonError::into_inner)
825 .replace(now)?;
826 super::busy_percent(previous, now)
827 }
828
829 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
830 let subkey = wide(subkey);
831 let value = wide(value);
832 let mut data = 0u32;
833 let mut size = 4u32;
834 let code = unsafe {
837 RegGetValueW(
838 HKEY_LOCAL_MACHINE,
839 PCWSTR(subkey.as_ptr()),
840 PCWSTR(value.as_ptr()),
841 RRF_RT_REG_DWORD,
842 None,
843 Some((&raw mut data).cast()),
844 Some(&raw mut size),
845 )
846 };
847 match code {
848 ERROR_SUCCESS => Ok(Some(data)),
849 ERROR_FILE_NOT_FOUND => Ok(None),
850 other => Err(registry_error(other)),
851 }
852 }
853
854 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
855 let subkey = wide(subkey);
856 let value = wide(value);
857 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
858 let mut size = 0u32;
859 let code = unsafe {
861 RegGetValueW(
862 HKEY_LOCAL_MACHINE,
863 PCWSTR(subkey.as_ptr()),
864 PCWSTR(value.as_ptr()),
865 flags,
866 None,
867 None,
868 Some(&raw mut size),
869 )
870 };
871 match code {
872 ERROR_SUCCESS => {}
873 ERROR_FILE_NOT_FOUND => return Ok(None),
874 other => return Err(registry_error(other)),
875 }
876 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
877 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
878 let code = unsafe {
881 RegGetValueW(
882 HKEY_LOCAL_MACHINE,
883 PCWSTR(subkey.as_ptr()),
884 PCWSTR(value.as_ptr()),
885 flags,
886 None,
887 Some(buffer.as_mut_ptr().cast()),
888 Some(&raw mut size),
889 )
890 };
891 match code {
892 ERROR_SUCCESS => {
893 let units = (size as usize / 2).min(buffer.len());
894 let text = String::from_utf16_lossy(&buffer[..units]);
895 Ok(Some(text.trim_end_matches('\0').to_owned()))
896 }
897 ERROR_FILE_NOT_FOUND => Ok(None),
898 other => Err(registry_error(other)),
899 }
900 }
901
902 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
903 let subkey = wide(subkey);
904 let mut key = HKEY::default();
905 let code = unsafe {
907 RegOpenKeyExW(
908 HKEY_LOCAL_MACHINE,
909 PCWSTR(subkey.as_ptr()),
910 None,
911 KEY_READ,
912 &raw mut key,
913 )
914 };
915 match code {
916 ERROR_SUCCESS => {}
917 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
918 other => return Err(registry_error(other)),
919 }
920 let mut names = Vec::new();
921 let mut index = 0u32;
922 let result = loop {
923 let mut name = vec![0u16; 16_384];
925 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
926 let code = unsafe {
929 RegEnumValueW(
930 key,
931 index,
932 Some(PWSTR(name.as_mut_ptr())),
933 &raw mut length,
934 None,
935 None,
936 None,
937 None,
938 )
939 };
940 match code {
941 ERROR_SUCCESS => {
942 names.push(String::from_utf16_lossy(&name[..length as usize]));
943 index += 1;
944 }
945 ERROR_NO_MORE_ITEMS => break Ok(names),
946 other => break Err(registry_error(other)),
947 }
948 };
949 let _ = unsafe { RegCloseKey(key) };
951 result
952 }
953
954 fn write_value(
955 subkey: &str,
956 value: &str,
957 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
958 ) -> Result<(), RegistryError> {
959 let subkey_wide = wide(subkey);
960 let value_wide = wide(value);
961 let Some((kind, bytes)) = data else {
962 let code = unsafe {
964 RegDeleteKeyValueW(
965 HKEY_LOCAL_MACHINE,
966 PCWSTR(subkey_wide.as_ptr()),
967 PCWSTR(value_wide.as_ptr()),
968 )
969 };
970 return match code {
971 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
972 other => Err(registry_error(other)),
973 };
974 };
975 let mut key = HKEY::default();
976 let code = unsafe {
979 RegCreateKeyExW(
980 HKEY_LOCAL_MACHINE,
981 PCWSTR(subkey_wide.as_ptr()),
982 None,
983 PCWSTR::null(),
984 REG_OPTION_NON_VOLATILE,
985 KEY_SET_VALUE,
986 None,
987 &raw mut key,
988 None,
989 )
990 };
991 if code != ERROR_SUCCESS {
992 return Err(registry_error(code));
993 }
994 let code =
997 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
998 let _ = unsafe { RegCloseKey(key) };
1000 if code == ERROR_SUCCESS {
1001 Ok(())
1002 } else {
1003 Err(registry_error(code))
1004 }
1005 }
1006
1007 pub(super) fn write_dword(
1008 subkey: &str,
1009 value: &str,
1010 data: Option<u32>,
1011 ) -> Result<(), RegistryError> {
1012 write_value(
1013 subkey,
1014 value,
1015 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
1016 )
1017 }
1018
1019 pub(super) fn write_string(
1020 subkey: &str,
1021 value: &str,
1022 data: Option<&str>,
1023 ) -> Result<(), RegistryError> {
1024 write_value(
1025 subkey,
1026 value,
1027 data.map(|text| {
1028 let bytes = wide(text)
1029 .iter()
1030 .flat_map(|unit| unit.to_le_bytes())
1031 .collect();
1032 (REG_SZ, bytes)
1033 }),
1034 )
1035 }
1036
1037 pub(super) fn run_elevated(
1038 program: &Path,
1039 args: &[OsString],
1040 _terminal: bool,
1041 ) -> ElevationOutcome {
1042 let file: Vec<u16> = program
1043 .as_os_str()
1044 .encode_wide()
1045 .chain(std::iter::once(0))
1046 .collect();
1047 let parameters = args
1048 .iter()
1049 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
1050 .collect::<Vec<_>>()
1051 .join(" ");
1052 let parameters = wide(¶meters);
1053 let verb = wide("runas");
1054 let mut info = SHELLEXECUTEINFOW {
1055 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
1056 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
1057 lpVerb: PCWSTR(verb.as_ptr()),
1058 lpFile: PCWSTR(file.as_ptr()),
1059 lpParameters: PCWSTR(parameters.as_ptr()),
1060 nShow: SW_HIDE,
1061 ..SHELLEXECUTEINFOW::default()
1062 };
1063 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
1066 #[allow(clippy::cast_sign_loss)]
1067 let code = (error.code().0 as u32) & 0xffff;
1068 return if code == ERROR_CANCELLED.0 {
1069 ElevationOutcome::Refused
1070 } else {
1071 ElevationOutcome::Unavailable(format!(
1072 "Windows could not show the administrator prompt: {}",
1073 error.message()
1074 ))
1075 };
1076 }
1077 if info.hProcess.is_invalid() {
1078 return ElevationOutcome::Unavailable(
1079 "Windows started no elevated process to wait for".into(),
1080 );
1081 }
1082 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
1085 let mut code = 1u32;
1086 let outcome = if waited == WAIT_OBJECT_0 {
1087 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
1089 #[allow(clippy::cast_possible_wrap)]
1090 Ok(()) => ElevationOutcome::Exited(code as i32),
1091 Err(error) => ElevationOutcome::Unavailable(format!(
1092 "the elevated process's exit code could not be read: {}",
1093 error.message()
1094 )),
1095 }
1096 } else {
1097 ElevationOutcome::Unavailable(
1098 "the elevated process did not finish within 15 minutes".into(),
1099 )
1100 };
1101 let _ = unsafe { CloseHandle(info.hProcess) };
1103 outcome
1104 }
1105}
1106
1107#[cfg(unix)]
1112mod sys {
1113 use std::ffi::OsString;
1114 use std::path::Path;
1115 use std::process::Command;
1116
1117 pub(super) const fn session_id() -> Option<u32> {
1119 None
1120 }
1121
1122 use super::{ElevationOutcome, RegistryError};
1123
1124 pub(super) fn is_elevated() -> bool {
1125 unsafe { libc::geteuid() == 0 }
1127 }
1128
1129 #[cfg(target_os = "macos")]
1130 pub(super) fn physical_memory_bytes() -> Option<u64> {
1131 let mut value = 0u64;
1132 let mut size = std::mem::size_of::<u64>();
1133 let name = c"hw.memsize";
1134 let result = unsafe {
1137 libc::sysctlbyname(
1138 name.as_ptr(),
1139 (&raw mut value).cast(),
1140 &raw mut size,
1141 std::ptr::null_mut(),
1142 0,
1143 )
1144 };
1145 (result == 0).then_some(value)
1146 }
1147
1148 #[cfg(not(target_os = "macos"))]
1149 pub(super) fn physical_memory_bytes() -> Option<u64> {
1150 meminfo_bytes("MemTotal:")
1151 }
1152
1153 #[cfg(not(target_os = "macos"))]
1155 fn meminfo_bytes(field: &str) -> Option<u64> {
1156 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
1157 let line = text.lines().find(|line| line.starts_with(field))?;
1158 let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
1159 kib.checked_mul(1024)
1160 }
1161
1162 #[cfg(target_os = "macos")]
1165 #[allow(
1166 deprecated,
1167 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1168 )]
1169 fn host_port() -> libc::mach_port_t {
1170 static PORT: std::sync::OnceLock<libc::mach_port_t> = std::sync::OnceLock::new();
1171 *PORT.get_or_init(|| unsafe { libc::mach_host_self() })
1173 }
1174
1175 #[cfg(target_os = "macos")]
1176 #[allow(
1177 deprecated,
1178 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1179 )]
1180 pub(super) fn available_memory_bytes() -> Option<u64> {
1181 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
1183 let mut count = libc::HOST_VM_INFO64_COUNT;
1184 let result = unsafe {
1187 libc::host_statistics64(
1188 host_port(),
1189 libc::HOST_VM_INFO64,
1190 (&raw mut stats).cast(),
1191 &raw mut count,
1192 )
1193 };
1194 if result != libc::KERN_SUCCESS {
1195 return None;
1196 }
1197 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }).ok()?;
1199 let (free, speculative, inactive) = (
1200 stats.free_count,
1201 stats.speculative_count,
1202 stats.inactive_count,
1203 );
1204 super::available_pages_bytes(
1207 u64::from(free).saturating_sub(u64::from(speculative)),
1208 u64::from(inactive),
1209 page,
1210 )
1211 }
1212
1213 #[cfg(not(target_os = "macos"))]
1214 pub(super) fn available_memory_bytes() -> Option<u64> {
1215 meminfo_bytes("MemAvailable:")
1216 }
1217
1218 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
1219 use std::os::unix::ffi::OsStrExt as _;
1220 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1221 let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
1223 if unsafe { libc::statvfs(name.as_ptr(), &raw mut stats) } != 0 {
1226 return None;
1227 }
1228 #[allow(clippy::useless_conversion, reason = "the field widths differ per OS")]
1229 u64::from(stats.f_bavail).checked_mul(u64::from(stats.f_frsize))
1230 }
1231
1232 pub(super) fn cpu_load_percent() -> Option<u32> {
1233 let mut loads = [0f64; 3];
1234 if unsafe { libc::getloadavg(loads.as_mut_ptr(), 1) } != 1 {
1236 return None;
1237 }
1238 let cores = std::thread::available_parallelism().ok()?.get();
1239 let percent = loads[0] * 100.0 / f64::from(u32::try_from(cores).ok()?);
1240 #[allow(
1241 clippy::cast_possible_truncation,
1242 clippy::cast_sign_loss,
1243 reason = "clamped to a small non-negative range first"
1244 )]
1245 Some(percent.clamp(0.0, 10_000.0).round() as u32)
1246 }
1247
1248 fn unsupported() -> RegistryError {
1249 RegistryError::Other("the registry exists only on Windows".into())
1250 }
1251
1252 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1253 Err(unsupported())
1254 }
1255
1256 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1257 Err(unsupported())
1258 }
1259
1260 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1261 Err(unsupported())
1262 }
1263
1264 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1265 Err(unsupported())
1266 }
1267
1268 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1269 Err(unsupported())
1270 }
1271
1272 fn exit_outcome(
1273 status: std::io::Result<std::process::ExitStatus>,
1274 what: &str,
1275 ) -> ElevationOutcome {
1276 match status {
1277 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1278 Err(error) => {
1279 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1280 }
1281 }
1282 }
1283
1284 pub(super) fn run_elevated(
1285 program: &Path,
1286 args: &[OsString],
1287 terminal: bool,
1288 ) -> ElevationOutcome {
1289 if terminal
1290 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1291 {
1292 let status = Command::new("sudo")
1293 .arg("--")
1294 .arg(program)
1295 .args(args)
1296 .status();
1297 return match exit_outcome(status, "sudo") {
1301 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1302 other => other,
1303 };
1304 }
1305 if cfg!(target_os = "macos") {
1306 let command = std::iter::once(program.as_os_str().to_owned())
1307 .chain(args.iter().cloned())
1308 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1309 .collect::<Vec<_>>()
1310 .join(" ");
1311 let output = Command::new("/usr/bin/osascript")
1312 .arg("-e")
1313 .arg(super::administrator_applescript(&command))
1314 .output();
1315 return match output {
1316 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1317 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1319 ElevationOutcome::Refused
1320 }
1321 Ok(output) => {
1322 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1323 if detail.contains("(-60007)") || detail.contains("(-1743)") {
1324 ElevationOutcome::Unavailable(format!(
1325 "macOS could not show the administrator dialog here: {detail}"
1326 ))
1327 } else {
1328 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1329 }
1330 }
1331 Err(error) => ElevationOutcome::Unavailable(format!(
1332 "osascript could not be started: {error}"
1333 )),
1334 };
1335 }
1336 ElevationOutcome::Unavailable(
1337 "no terminal to ask for a sudo password; run the command from a terminal".into(),
1338 )
1339 }
1340}
1341
1342#[cfg(test)]
1343mod tests {
1344 use super::*;
1345
1346 #[test]
1347 fn the_busy_share_comes_from_the_difference_of_two_readings() {
1348 assert_eq!(busy_percent((0, 0, 0), (250, 400, 600)), Some(75));
1350 assert_eq!(busy_percent((250, 400, 600), (250, 400, 600)), None);
1351 assert_eq!(busy_percent((10, 10, 10), (5, 20, 20)), None, "went back");
1352 assert_eq!(available_pages_bytes(10, 5, 16_384), Some(15 * 16_384));
1353 }
1354
1355 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1357 #[test]
1358 fn free_memory_is_reported_and_below_the_total() {
1359 let free = available_memory_bytes().expect("free memory");
1360 let total = physical_memory_bytes().expect("total memory");
1361 assert!(free > 0 && free <= total, "{free} of {total}");
1362 }
1363
1364 #[cfg(windows)]
1365 #[test]
1366 fn windows_reports_cpu_load_from_the_second_reading() {
1367 let _ = cpu_load_percent();
1368 std::thread::sleep(std::time::Duration::from_millis(200));
1369 assert!(cpu_load_percent().is_some_and(|percent| percent <= 100));
1370 }
1371
1372 #[test]
1373 fn windows_arguments_survive_the_command_line_round_trip() {
1374 for (argument, expected) in [
1375 ("plain", "plain"),
1376 ("", "\"\""),
1377 ("with space", "\"with space\""),
1378 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1379 ("say \"hi\"", "\"say \\\"hi\\\"\""),
1380 ("a\\\"b", "\"a\\\\\\\"b\""),
1381 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1382 ] {
1383 assert_eq!(
1384 crate::service::quote_argument(argument),
1385 expected,
1386 "{argument}"
1387 );
1388 }
1389 }
1390
1391 #[test]
1392 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1393 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1394 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1395 assert_eq!(
1396 script,
1397 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1398 );
1399 }
1400
1401 #[test]
1402 fn a_symlink_probe_leaves_nothing_behind() {
1403 let directory = tempfile::tempdir().unwrap();
1404 let _ = can_create_symlink(directory.path()).unwrap();
1405 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1406 }
1407
1408 #[cfg(unix)]
1409 #[test]
1410 fn unix_accounts_can_always_create_symlinks() {
1411 let directory = tempfile::tempdir().unwrap();
1412 assert!(can_create_symlink(directory.path()).unwrap());
1413 }
1414
1415 #[test]
1416 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1417 let directory = tempfile::tempdir().unwrap();
1418 let name = if cfg!(windows) {
1419 "rmtool.exe"
1420 } else {
1421 "rmtool"
1422 };
1423 let tool = directory.path().join(name);
1424 std::fs::write(&tool, b"").unwrap();
1425 #[cfg(unix)]
1426 {
1427 use std::os::unix::fs::PermissionsExt as _;
1428 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1429 }
1430 let path = std::env::join_paths([directory.path()]).unwrap();
1431 assert_eq!(find_on_path("rmtool", &path), Some(tool));
1432 assert_eq!(find_on_path("absent-tool", &path), None);
1433 assert_eq!(find_on_path("../rmtool", &path), None);
1434 }
1435
1436 #[cfg(windows)]
1439 #[test]
1440 fn an_extensionless_file_is_never_the_windows_answer() {
1441 let directory = tempfile::tempdir().unwrap();
1442 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1443 let path = std::env::join_paths([directory.path()]).unwrap();
1444 assert_eq!(find_on_path("npm", &path), None);
1445 let cmd = directory.path().join("npm.cmd");
1446 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1447 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1448 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1449 }
1450
1451 fn path_of(directories: &[&Path]) -> OsString {
1453 std::env::join_paths(directories).expect("joinable directories")
1454 }
1455
1456 fn touch(path: &Path) {
1457 std::fs::write(path, b"").expect("a fixture file");
1458 }
1459
1460 #[test]
1464 fn npm_shipped_only_as_npm_cmd_is_found_on_a_windows_path() {
1465 let empty = tempfile::tempdir().unwrap();
1466 let nodejs = tempfile::tempdir().unwrap();
1467 touch(&nodejs.path().join("npm"));
1468 touch(&nodejs.path().join("npm.cmd"));
1469 let path = path_of(&[empty.path(), nodejs.path()]);
1470 let cmd = Some(nodejs.path().join("npm.cmd"));
1471
1472 assert_eq!(
1473 find_on_windows_path("npm", &path, Some(".COM;.EXE;.BAT;.CMD".as_ref())),
1474 cmd
1475 );
1476 assert_eq!(find_on_windows_path("npm", &path, None), cmd);
1478 assert_eq!(find_on_windows_path("npm", &path, Some(" ".as_ref())), cmd);
1479 assert_eq!(find_on_windows_path("npm.cmd", &path, None), cmd);
1481 touch(&nodejs.path().join("npm.ps1"));
1482 assert_eq!(find_on_windows_path("npm.ps1", &path, None), None);
1483 }
1484
1485 #[test]
1488 fn pathext_orders_extensions_within_a_directory_and_path_orders_directories() {
1489 let both = tempfile::tempdir().unwrap();
1490 touch(&both.path().join("npm.cmd"));
1491 touch(&both.path().join("npm.exe"));
1492 let path = path_of(&[both.path()]);
1493 assert_eq!(
1494 find_on_windows_path("npm", &path, Some(".CMD;.EXE".as_ref())),
1495 Some(both.path().join("npm.cmd"))
1496 );
1497 assert_eq!(
1498 find_on_windows_path("npm", &path, Some(".exe;.cmd".as_ref())),
1499 Some(both.path().join("npm.exe"))
1500 );
1501
1502 let first = tempfile::tempdir().unwrap();
1503 let second = tempfile::tempdir().unwrap();
1504 touch(&first.path().join("npm.cmd"));
1505 touch(&second.path().join("npm.exe"));
1506 assert_eq!(
1507 find_on_windows_path(
1508 "npm",
1509 &path_of(&[first.path(), second.path()]),
1510 Some(".EXE;.CMD".as_ref())
1511 ),
1512 Some(first.path().join("npm.cmd")),
1513 "an earlier PATH directory wins over a preferred extension later on"
1514 );
1515 }
1516
1517 #[test]
1520 #[serial_test::serial(current_dir)]
1521 fn a_missing_npm_is_not_found_on_a_windows_path() {
1522 let empty = tempfile::tempdir().unwrap();
1523 assert_eq!(
1524 find_on_windows_path("npm", &path_of(&[empty.path()]), None),
1525 None
1526 );
1527
1528 let script_only = tempfile::tempdir().unwrap();
1529 touch(&script_only.path().join("npm.ps1"));
1530 assert_eq!(
1531 find_on_windows_path(
1532 "npm",
1533 &path_of(&[script_only.path()]),
1534 Some(".PS1;.EXE;.CMD".as_ref())
1535 ),
1536 None
1537 );
1538
1539 let planted = tempfile::tempdir().unwrap();
1542 touch(&planted.path().join("npm.cmd"));
1543 if let Some(relative) = relative_to_current_directory(planted.path()) {
1544 assert!(relative.join("npm.cmd").is_file(), "{}", relative.display());
1545 assert_eq!(
1546 find_on_windows_path("npm", &path_of(&[&relative]), None),
1547 None
1548 );
1549 }
1550 }
1551
1552 fn relative_to_current_directory(target: &Path) -> Option<PathBuf> {
1555 let here = std::env::current_dir().ok()?;
1556 let target = std::fs::canonicalize(target).ok()?;
1557 let here = std::fs::canonicalize(here).ok()?;
1558 let (here, target): (Vec<_>, Vec<_>) =
1559 (here.components().collect(), target.components().collect());
1560 let common = here
1561 .iter()
1562 .zip(&target)
1563 .take_while(|(left, right)| left == right)
1564 .count();
1565 if common == 0 {
1566 return None;
1567 }
1568 let mut relative: PathBuf = std::iter::repeat_n("..", here.len() - common).collect();
1569 relative.extend(&target[common..]);
1570 Some(relative)
1571 }
1572
1573 fn unfit_finding(id: &str) -> UnfitFinding {
1574 UnfitFinding {
1575 id: id.into(),
1576 detail: "it fails".into(),
1577 remedy: None,
1578 }
1579 }
1580
1581 #[test]
1584 fn who_can_answer_an_administrator_prompt() {
1585 assert!(
1586 prompt_answerable(Some(1), false),
1587 "the desktop, no terminal"
1588 );
1589 assert!(prompt_answerable(Some(2), true));
1590 assert!(!prompt_answerable(Some(0), true), "services and SSH");
1591 assert!(prompt_answerable(None, true), "sudo on a terminal");
1592 assert!(!prompt_answerable(None, false));
1593 }
1594
1595 #[test]
1597 fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1598 let root = tempfile::tempdir().unwrap();
1599 let paths = AppPaths::rooted_at(root.path());
1600 paths.create_all().unwrap();
1601 let then = Utc::now();
1602 record_host_unfit(
1603 &paths,
1604 &[unfit_finding("host.runner_root_responsive")],
1605 then,
1606 )
1607 .unwrap();
1608 let later = then + chrono::Duration::hours(1);
1609 assert_eq!(
1610 host_unfit_in_force(&paths, later),
1611 None,
1612 "nobody beats: stopped"
1613 );
1614
1615 crate::daemon_heartbeat::beat(&paths, then).unwrap();
1617 assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1618 crate::daemon_heartbeat::beat(&paths, later).unwrap();
1619 assert_eq!(
1620 host_unfit_in_force(&paths, later),
1621 None,
1622 "a daemon still beating would have re-stamped a refusal in force"
1623 );
1624
1625 let much_later = later + chrono::Duration::hours(1);
1628 assert_eq!(host_unfit_in_force(&paths, much_later), None);
1629 }
1630
1631 #[test]
1632 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1633 let root = tempfile::tempdir().unwrap();
1634 let paths = AppPaths::rooted_at(root.path());
1635 paths.create_all().unwrap();
1636 assert_eq!(host_unfit(&paths).unwrap(), None);
1637 let first = Utc::now() - chrono::Duration::minutes(10);
1638 record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1639 let later = Utc::now();
1640 record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1641 let record = host_unfit(&paths).unwrap().unwrap();
1642 assert_eq!(record.since, first);
1643 assert_eq!(record.checked_at, later);
1644 assert_eq!(record.checks, ["host.required_tools"]);
1645 clear_host_unfit(&paths).unwrap();
1646 assert_eq!(host_unfit(&paths).unwrap(), None);
1647 clear_host_unfit(&paths).unwrap();
1648 }
1649
1650 #[test]
1651 fn physical_memory_is_reported() {
1652 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1653 }
1654
1655 #[test]
1656 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1657 let directory = tempfile::tempdir().unwrap();
1658 let deadline = std::time::Duration::from_secs(10);
1659 assert_eq!(
1660 directory_responds(directory.path(), deadline),
1661 Responsiveness::Responds
1662 );
1663 assert_eq!(
1664 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1665 Responsiveness::Responds
1666 );
1667 assert!(
1670 !PROBES_IN_FLIGHT
1671 .lock()
1672 .unwrap()
1673 .iter()
1674 .any(|(path, _)| path.starts_with(directory.path())),
1675 "a finished probe deregisters"
1676 );
1677 }
1678
1679 #[test]
1684 fn a_path_still_being_probed_answers_without_a_second_thread() {
1685 let directory = tempfile::tempdir().unwrap();
1686 for (name, listing, expected) in [
1687 ("stuck-volume", false, Responsiveness::Hung),
1688 ("asking-volume", true, Responsiveness::ListingBlocked),
1689 ] {
1690 let stuck = directory.path().join(name);
1691 PROBES_IN_FLIGHT.lock().unwrap().push((
1692 stuck.clone(),
1693 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1694 ));
1695 let started = std::time::Instant::now();
1696 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1697 PROBES_IN_FLIGHT
1698 .lock()
1699 .unwrap()
1700 .retain(|(path, _)| *path != stuck);
1701 assert_eq!(answer, expected, "{name}");
1702 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1703 }
1704 }
1705}