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::disk_space(path).map(|(free, _)| free)
124}
125
126#[must_use]
129pub fn disk_space(path: &Path) -> Option<(u64, u64)> {
130 sys::disk_space(path)
131}
132
133#[must_use]
142pub fn cpu_load_percent() -> Option<u32> {
143 static SAMPLER: std::sync::Mutex<CpuSampler> = std::sync::Mutex::new(CpuSampler::new());
144 let now = sys::cpu_times()?;
145 SAMPLER
146 .lock()
147 .unwrap_or_else(std::sync::PoisonError::into_inner)
148 .read(now, std::time::Instant::now())
149}
150
151const CPU_WINDOW: std::time::Duration = std::time::Duration::from_secs(5);
156
157struct CpuSampler {
160 start: Option<((u64, u64), std::time::Instant)>,
161 last: Option<u32>,
162}
163
164impl CpuSampler {
165 const fn new() -> Self {
166 Self {
167 start: None,
168 last: None,
169 }
170 }
171
172 fn read(&mut self, now: (u64, u64), at: std::time::Instant) -> Option<u32> {
176 match self.start {
177 Some((_, began)) if at.duration_since(began) < CPU_WINDOW => self.last,
178 Some((previous, _)) => {
179 self.start = Some((now, at));
180 self.last = busy_share(previous, now);
181 self.last
182 }
183 None => {
184 self.start = Some((now, at));
185 None
186 }
187 }
188 }
189}
190
191fn busy_share(previous: (u64, u64), now: (u64, u64)) -> Option<u32> {
196 let idle = now.0.checked_sub(previous.0)?;
197 let total = now.1.checked_sub(previous.1)?;
198 if total == 0 {
199 return None;
200 }
201 let busy = total.saturating_sub(idle);
202 u32::try_from(busy.saturating_mul(100).saturating_add(total / 2) / total).ok()
203}
204
205#[cfg_attr(any(windows, target_os = "macos"), allow(dead_code))]
210fn proc_stat_cpu_times(text: &str) -> Option<(u64, u64)> {
211 let line = text.lines().find(|line| line.starts_with("cpu "))?;
212 let fields: Vec<u64> = line
213 .split_whitespace()
214 .skip(1)
215 .take(8)
216 .map(str::parse)
217 .collect::<Result<_, _>>()
218 .ok()?;
219 if fields.len() < 5 {
220 return None;
221 }
222 let idle = fields[3].checked_add(fields[4])?;
223 let total = fields
224 .iter()
225 .try_fold(0_u64, |sum, field| sum.checked_add(*field))?;
226 Some((idle, total))
227}
228
229#[cfg_attr(any(windows, target_os = "macos"), allow(dead_code))]
231fn meminfo_field(text: &str, field: &str) -> Option<u64> {
232 let line = text.lines().find(|line| line.starts_with(field))?;
233 let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
234 kib.checked_mul(1024)
235}
236
237#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
240#[derive(Clone, Copy)]
241struct VmPages {
242 free: u64,
243 speculative: u64,
244 external: u64,
245 purgeable: u64,
246}
247
248#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
257fn available_pages_bytes(pages: VmPages, page: u64) -> Option<u64> {
258 pages
260 .free
261 .saturating_sub(pages.speculative)
262 .checked_add(pages.external)?
263 .checked_add(pages.purgeable)?
264 .checked_mul(page)
265}
266
267#[cfg(windows)]
273pub fn system_directory() -> std::io::Result<PathBuf> {
274 crate::runner_root::windows_system_directory().map(PathBuf::from)
275}
276
277pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
283 use std::io::Write as _;
284 let parent = path.parent().unwrap_or_else(|| Path::new("."));
285 std::fs::create_dir_all(parent)?;
286 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
287 temporary.write_all(bytes)?;
288 temporary.as_file().sync_all()?;
289 temporary
290 .persist(path)
291 .map(|_| ())
292 .map_err(|error| error.error)
293}
294
295#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum Responsiveness {
298 Responds,
300 Hung,
303 ListingBlocked,
309 NotPermitted(String),
313 Failed(String),
315}
316
317type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
320
321static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
325
326#[must_use]
328pub fn probes_in_flight() -> Vec<PathBuf> {
329 PROBES_IN_FLIGHT
330 .lock()
331 .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
332 .unwrap_or_default()
333}
334
335fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
337 if listing.load(std::sync::atomic::Ordering::Acquire) {
338 Responsiveness::ListingBlocked
339 } else {
340 Responsiveness::Hung
341 }
342}
343
344#[must_use]
352pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
353 let path = directory.to_path_buf();
354 let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
357 {
358 let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
359 return Responsiveness::Failed("the probe registry is poisoned".into());
360 };
361 if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
362 return unanswered(stuck);
363 }
364 in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
365 }
366 let (sender, receiver) = std::sync::mpsc::channel();
367 let probed = path.clone();
368 let listing_started = std::sync::Arc::clone(&listing);
369 let spawned = std::thread::Builder::new()
370 .name("runner-root-probe".into())
371 .spawn(move || {
372 let existing = probed
373 .ancestors()
374 .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
375 .map(Path::to_path_buf);
376 listing_started.store(true, std::sync::atomic::Ordering::Release);
377 let refused = |error: std::io::Error| {
378 if is_not_permitted(&error) {
379 Responsiveness::NotPermitted(error.to_string())
380 } else {
381 Responsiveness::Failed(error.to_string())
382 }
383 };
384 let answer = match existing {
385 None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
386 Some(existing) => match std::fs::read_dir(&existing) {
387 Ok(mut entries) => match entries.next() {
388 Some(Err(error)) => refused(error),
389 _ => Responsiveness::Responds,
390 },
391 Err(error) => refused(error),
392 },
393 };
394 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
395 in_flight.retain(|(candidate, _)| *candidate != probed);
396 }
397 let _ = sender.send(answer);
398 });
399 if let Err(error) = spawned {
400 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
401 in_flight.retain(|(candidate, _)| *candidate != path);
402 }
403 return Responsiveness::Failed(error.to_string());
404 }
405 receiver
406 .recv_timeout(deadline)
407 .unwrap_or_else(|_| unanswered(&listing))
408}
409
410fn is_not_permitted(error: &std::io::Error) -> bool {
412 #[cfg(unix)]
413 {
414 error.raw_os_error() == Some(libc::EPERM)
415 }
416 #[cfg(not(unix))]
417 {
418 let _ = error;
419 false
420 }
421}
422
423pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
435 std::fs::create_dir_all(directory)?;
436 let unique = format!(
437 ".rm-symlink-probe-{}-{}",
438 std::process::id(),
439 uuid::Uuid::new_v4().simple()
440 );
441 let target = directory.join(format!("{unique}.target"));
442 let link = directory.join(format!("{unique}.link"));
443 std::fs::write(&target, b"")?;
444 let created = symlink_file(&target, &link);
445 let _ = std::fs::remove_file(&link);
446 let _ = std::fs::remove_file(&target);
447 Ok(created.is_ok())
448}
449
450#[cfg(windows)]
451fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
452 std::os::windows::fs::symlink_file(target, link)
453}
454
455#[cfg(unix)]
456fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
457 std::os::unix::fs::symlink(target, link)
458}
459
460#[must_use]
463pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
464 if cfg!(windows) {
465 return find_on_windows_path(tool, path, std::env::var_os("PATHEXT").as_deref());
466 }
467 if tool.is_empty() || tool.contains(['/', '\\']) {
468 return None;
469 }
470 std::env::split_paths(path)
471 .filter(|dir| !dir.as_os_str().is_empty())
472 .map(|dir| dir.join(tool))
473 .find(|candidate| is_executable(candidate))
474}
475
476#[must_use]
494pub fn find_on_windows_path(tool: &str, path: &OsStr, pathext: Option<&OsStr>) -> Option<PathBuf> {
495 const RUNNABLE: [&str; 4] = [".exe", ".com", ".cmd", ".bat"];
496 if tool.is_empty() || tool.contains(['/', '\\']) {
497 return None;
498 }
499 let pathext = pathext
500 .and_then(OsStr::to_str)
501 .filter(|value| !value.trim().is_empty())
502 .unwrap_or(".COM;.EXE;.BAT;.CMD");
503 let bare = Path::new(tool)
504 .extension()
505 .and_then(OsStr::to_str)
506 .is_some_and(|extension| {
507 RUNNABLE
508 .iter()
509 .any(|runnable| runnable[1..].eq_ignore_ascii_case(extension))
510 })
511 .then_some("");
512 let extensions: Vec<&str> = bare
513 .into_iter()
514 .chain(pathext.split(';').filter_map(|extension| {
515 RUNNABLE
516 .into_iter()
517 .find(|runnable| runnable.eq_ignore_ascii_case(extension.trim()))
518 }))
519 .collect();
520 std::env::split_paths(path)
521 .filter(|dir| dir.is_absolute())
522 .find_map(|dir| {
523 extensions.iter().find_map(|extension| {
524 let candidate = dir.join(format!("{tool}{extension}"));
525 candidate.is_file().then_some(candidate)
526 })
527 })
528}
529
530#[cfg(unix)]
531fn is_executable(path: &Path) -> bool {
532 use std::os::unix::fs::PermissionsExt as _;
533 std::fs::metadata(path)
534 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
535}
536
537#[cfg(windows)]
538fn is_executable(path: &Path) -> bool {
539 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
540}
541
542#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
548pub enum RegistryError {
549 #[error("access denied")]
552 AccessDenied,
553 #[error("{0}")]
555 Other(String),
556}
557
558pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
564 sys::read_dword(subkey, value)
565}
566
567pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
572 sys::read_string(subkey, value)
573}
574
575pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
581 sys::value_names(subkey)
582}
583
584pub fn write_registry_dword(
590 subkey: &str,
591 value: &str,
592 data: Option<u32>,
593) -> Result<(), RegistryError> {
594 sys::write_dword(subkey, value, data)
595}
596
597pub fn write_registry_string(
603 subkey: &str,
604 value: &str,
605 data: Option<&str>,
606) -> Result<(), RegistryError> {
607 sys::write_string(subkey, value, data)
608}
609
610pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
616
617pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
620
621pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
625 std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
626const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
627
628#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
630pub struct HostUnfitRecord {
631 pub schema_version: u32,
632 pub since: DateTime<Utc>,
634 pub checked_at: DateTime<Utc>,
636 pub checks: Vec<String>,
638 #[serde(default)]
641 pub findings: Vec<UnfitFinding>,
642}
643
644#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
646pub struct UnfitFinding {
647 pub id: String,
648 pub detail: String,
649 #[serde(default)]
650 pub remedy: Option<String>,
651}
652
653impl HostUnfitRecord {
654 #[must_use]
656 pub fn describe(&self) -> String {
657 if self.findings.is_empty() {
658 return self.checks.join(", ");
659 }
660 self.findings
661 .iter()
662 .map(|finding| match &finding.remedy {
663 Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
664 None => format!("{}: {}", finding.id, finding.detail),
665 })
666 .collect::<Vec<_>>()
667 .join("; ")
668 }
669}
670
671fn host_unfit_path(paths: &AppPaths) -> PathBuf {
672 paths.state_dir().join(HOST_UNFIT_FILE)
673}
674
675pub fn record_host_unfit(
681 paths: &AppPaths,
682 findings: &[UnfitFinding],
683 at: DateTime<Utc>,
684) -> Result<(), String> {
685 let since = host_unfit(paths)
686 .ok()
687 .flatten()
688 .map_or(at, |record| record.since);
689 let record = HostUnfitRecord {
690 schema_version: HOST_UNFIT_SCHEMA_VERSION,
691 since,
692 checked_at: at,
693 checks: findings.iter().map(|finding| finding.id.clone()).collect(),
694 findings: findings.to_vec(),
695 };
696 let path = host_unfit_path(paths);
697 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
698 write_atomically(&path, &text)
699 .map_err(|error| format!("cannot write {}: {error}", path.display()))
700}
701
702pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
707 let path = host_unfit_path(paths);
708 match std::fs::remove_file(&path) {
709 Ok(()) => Ok(()),
710 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
711 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
712 }
713}
714
715#[must_use]
724pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
725 host_unfit(paths).ok().flatten().filter(|record| {
726 if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
727 return true;
728 }
729 match crate::daemon_heartbeat::liveness(paths, now) {
732 crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
733 !crate::wsl::fence::elapsed_at_least(
734 record.checked_at,
735 since,
736 HOST_UNFIT_RECORD_FRESH,
737 )
738 }
739 _ => false,
740 }
741 })
742}
743
744pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
749 let path = host_unfit_path(paths);
750 match std::fs::read_to_string(&path) {
751 Ok(text) => serde_json::from_str(&text)
752 .map(Some)
753 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
754 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
755 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
756 }
757}
758
759#[cfg(windows)]
764mod sys {
765 use std::ffi::OsString;
766 use std::os::windows::ffi::OsStrExt as _;
767 use std::path::Path;
768
769 use windows::Win32::Foundation::{
770 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
771 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
772 };
773 use windows::Win32::Security::{
774 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
775 };
776 use windows::Win32::System::Registry::{
777 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
778 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
779 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
780 RegSetValueExW,
781 };
782 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
783 use windows::Win32::System::Threading::{
784 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
785 };
786 use windows::Win32::UI::Shell::{
787 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
788 };
789 use windows::core::{PCWSTR, PWSTR};
790
791 use super::{ElevationOutcome, RegistryError};
792
793 const SW_HIDE: i32 = 0;
796 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
800
801 fn wide(text: &str) -> Vec<u16> {
802 text.encode_utf16().chain(std::iter::once(0)).collect()
803 }
804
805 fn registry_error(code: WIN32_ERROR) -> RegistryError {
806 if code == ERROR_ACCESS_DENIED {
807 RegistryError::AccessDenied
808 } else {
809 RegistryError::Other(format!("registry error {}", code.0))
810 }
811 }
812
813 pub(super) fn session_id() -> Option<u32> {
815 let mut token = HANDLE::default();
816 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
819 return None;
820 }
821 let mut session = 0u32;
822 let mut returned = 0u32;
823 let queried = unsafe {
826 GetTokenInformation(
827 token,
828 TokenSessionId,
829 Some((&raw mut session).cast()),
830 4,
831 &raw mut returned,
832 )
833 };
834 let _ = unsafe { CloseHandle(token) };
836 queried.is_ok().then_some(session)
837 }
838
839 pub(super) fn is_elevated() -> bool {
840 let mut token = HANDLE::default();
841 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
844 return false;
845 }
846 let mut elevation = TOKEN_ELEVATION::default();
847 let mut returned = 0u32;
848 let queried = unsafe {
851 GetTokenInformation(
852 token,
853 TokenElevation,
854 Some((&raw mut elevation).cast()),
855 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
856 &raw mut returned,
857 )
858 };
859 let _ = unsafe { CloseHandle(token) };
861 queried.is_ok() && elevation.TokenIsElevated != 0
862 }
863
864 fn memory_status() -> Option<MEMORYSTATUSEX> {
865 let mut status = MEMORYSTATUSEX {
866 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
867 ..MEMORYSTATUSEX::default()
868 };
869 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
872 Some(status)
873 }
874
875 pub(super) fn physical_memory_bytes() -> Option<u64> {
876 memory_status().map(|status| status.ullTotalPhys)
877 }
878
879 pub(super) fn available_memory_bytes() -> Option<u64> {
880 memory_status().map(|status| status.ullAvailPhys)
881 }
882
883 pub(super) fn disk_space(path: &Path) -> Option<(u64, u64)> {
885 let wide: Vec<u16> = path
886 .as_os_str()
887 .encode_wide()
888 .chain(std::iter::once(0))
889 .collect();
890 let (mut free, mut total) = (0u64, 0u64);
891 unsafe {
895 windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
896 PCWSTR(wide.as_ptr()),
897 Some(&raw mut free),
898 Some(&raw mut total),
899 None,
900 )
901 }
902 .ok()?;
903 Some((free, total))
904 }
905
906 pub(super) fn cpu_times() -> Option<(u64, u64)> {
909 use windows::Win32::Foundation::FILETIME;
910 let (mut idle, mut kernel, mut user) = (
911 FILETIME::default(),
912 FILETIME::default(),
913 FILETIME::default(),
914 );
915 unsafe {
917 windows::Win32::System::Threading::GetSystemTimes(
918 Some(&raw mut idle),
919 Some(&raw mut kernel),
920 Some(&raw mut user),
921 )
922 }
923 .ok()?;
924 let value =
925 |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime);
926 Some((value(idle), value(kernel).checked_add(value(user))?))
927 }
928
929 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
930 let subkey = wide(subkey);
931 let value = wide(value);
932 let mut data = 0u32;
933 let mut size = 4u32;
934 let code = unsafe {
937 RegGetValueW(
938 HKEY_LOCAL_MACHINE,
939 PCWSTR(subkey.as_ptr()),
940 PCWSTR(value.as_ptr()),
941 RRF_RT_REG_DWORD,
942 None,
943 Some((&raw mut data).cast()),
944 Some(&raw mut size),
945 )
946 };
947 match code {
948 ERROR_SUCCESS => Ok(Some(data)),
949 ERROR_FILE_NOT_FOUND => Ok(None),
950 other => Err(registry_error(other)),
951 }
952 }
953
954 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
955 let subkey = wide(subkey);
956 let value = wide(value);
957 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
958 let mut size = 0u32;
959 let code = unsafe {
961 RegGetValueW(
962 HKEY_LOCAL_MACHINE,
963 PCWSTR(subkey.as_ptr()),
964 PCWSTR(value.as_ptr()),
965 flags,
966 None,
967 None,
968 Some(&raw mut size),
969 )
970 };
971 match code {
972 ERROR_SUCCESS => {}
973 ERROR_FILE_NOT_FOUND => return Ok(None),
974 other => return Err(registry_error(other)),
975 }
976 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
977 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
978 let code = unsafe {
981 RegGetValueW(
982 HKEY_LOCAL_MACHINE,
983 PCWSTR(subkey.as_ptr()),
984 PCWSTR(value.as_ptr()),
985 flags,
986 None,
987 Some(buffer.as_mut_ptr().cast()),
988 Some(&raw mut size),
989 )
990 };
991 match code {
992 ERROR_SUCCESS => {
993 let units = (size as usize / 2).min(buffer.len());
994 let text = String::from_utf16_lossy(&buffer[..units]);
995 Ok(Some(text.trim_end_matches('\0').to_owned()))
996 }
997 ERROR_FILE_NOT_FOUND => Ok(None),
998 other => Err(registry_error(other)),
999 }
1000 }
1001
1002 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
1003 let subkey = wide(subkey);
1004 let mut key = HKEY::default();
1005 let code = unsafe {
1007 RegOpenKeyExW(
1008 HKEY_LOCAL_MACHINE,
1009 PCWSTR(subkey.as_ptr()),
1010 None,
1011 KEY_READ,
1012 &raw mut key,
1013 )
1014 };
1015 match code {
1016 ERROR_SUCCESS => {}
1017 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
1018 other => return Err(registry_error(other)),
1019 }
1020 let mut names = Vec::new();
1021 let mut index = 0u32;
1022 let result = loop {
1023 let mut name = vec![0u16; 16_384];
1025 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
1026 let code = unsafe {
1029 RegEnumValueW(
1030 key,
1031 index,
1032 Some(PWSTR(name.as_mut_ptr())),
1033 &raw mut length,
1034 None,
1035 None,
1036 None,
1037 None,
1038 )
1039 };
1040 match code {
1041 ERROR_SUCCESS => {
1042 names.push(String::from_utf16_lossy(&name[..length as usize]));
1043 index += 1;
1044 }
1045 ERROR_NO_MORE_ITEMS => break Ok(names),
1046 other => break Err(registry_error(other)),
1047 }
1048 };
1049 let _ = unsafe { RegCloseKey(key) };
1051 result
1052 }
1053
1054 fn write_value(
1055 subkey: &str,
1056 value: &str,
1057 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
1058 ) -> Result<(), RegistryError> {
1059 let subkey_wide = wide(subkey);
1060 let value_wide = wide(value);
1061 let Some((kind, bytes)) = data else {
1062 let code = unsafe {
1064 RegDeleteKeyValueW(
1065 HKEY_LOCAL_MACHINE,
1066 PCWSTR(subkey_wide.as_ptr()),
1067 PCWSTR(value_wide.as_ptr()),
1068 )
1069 };
1070 return match code {
1071 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
1072 other => Err(registry_error(other)),
1073 };
1074 };
1075 let mut key = HKEY::default();
1076 let code = unsafe {
1079 RegCreateKeyExW(
1080 HKEY_LOCAL_MACHINE,
1081 PCWSTR(subkey_wide.as_ptr()),
1082 None,
1083 PCWSTR::null(),
1084 REG_OPTION_NON_VOLATILE,
1085 KEY_SET_VALUE,
1086 None,
1087 &raw mut key,
1088 None,
1089 )
1090 };
1091 if code != ERROR_SUCCESS {
1092 return Err(registry_error(code));
1093 }
1094 let code =
1097 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
1098 let _ = unsafe { RegCloseKey(key) };
1100 if code == ERROR_SUCCESS {
1101 Ok(())
1102 } else {
1103 Err(registry_error(code))
1104 }
1105 }
1106
1107 pub(super) fn write_dword(
1108 subkey: &str,
1109 value: &str,
1110 data: Option<u32>,
1111 ) -> Result<(), RegistryError> {
1112 write_value(
1113 subkey,
1114 value,
1115 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
1116 )
1117 }
1118
1119 pub(super) fn write_string(
1120 subkey: &str,
1121 value: &str,
1122 data: Option<&str>,
1123 ) -> Result<(), RegistryError> {
1124 write_value(
1125 subkey,
1126 value,
1127 data.map(|text| {
1128 let bytes = wide(text)
1129 .iter()
1130 .flat_map(|unit| unit.to_le_bytes())
1131 .collect();
1132 (REG_SZ, bytes)
1133 }),
1134 )
1135 }
1136
1137 pub(super) fn run_elevated(
1138 program: &Path,
1139 args: &[OsString],
1140 _terminal: bool,
1141 ) -> ElevationOutcome {
1142 let file: Vec<u16> = program
1143 .as_os_str()
1144 .encode_wide()
1145 .chain(std::iter::once(0))
1146 .collect();
1147 let parameters = args
1148 .iter()
1149 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
1150 .collect::<Vec<_>>()
1151 .join(" ");
1152 let parameters = wide(¶meters);
1153 let verb = wide("runas");
1154 let mut info = SHELLEXECUTEINFOW {
1155 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
1156 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
1157 lpVerb: PCWSTR(verb.as_ptr()),
1158 lpFile: PCWSTR(file.as_ptr()),
1159 lpParameters: PCWSTR(parameters.as_ptr()),
1160 nShow: SW_HIDE,
1161 ..SHELLEXECUTEINFOW::default()
1162 };
1163 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
1166 #[allow(clippy::cast_sign_loss)]
1167 let code = (error.code().0 as u32) & 0xffff;
1168 return if code == ERROR_CANCELLED.0 {
1169 ElevationOutcome::Refused
1170 } else {
1171 ElevationOutcome::Unavailable(format!(
1172 "Windows could not show the administrator prompt: {}",
1173 error.message()
1174 ))
1175 };
1176 }
1177 if info.hProcess.is_invalid() {
1178 return ElevationOutcome::Unavailable(
1179 "Windows started no elevated process to wait for".into(),
1180 );
1181 }
1182 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
1185 let mut code = 1u32;
1186 let outcome = if waited == WAIT_OBJECT_0 {
1187 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
1189 #[allow(clippy::cast_possible_wrap)]
1190 Ok(()) => ElevationOutcome::Exited(code as i32),
1191 Err(error) => ElevationOutcome::Unavailable(format!(
1192 "the elevated process's exit code could not be read: {}",
1193 error.message()
1194 )),
1195 }
1196 } else {
1197 ElevationOutcome::Unavailable(
1198 "the elevated process did not finish within 15 minutes".into(),
1199 )
1200 };
1201 let _ = unsafe { CloseHandle(info.hProcess) };
1203 outcome
1204 }
1205}
1206
1207#[cfg(unix)]
1212mod sys {
1213 use std::ffi::OsString;
1214 use std::path::Path;
1215 use std::process::Command;
1216
1217 pub(super) const fn session_id() -> Option<u32> {
1219 None
1220 }
1221
1222 use super::{ElevationOutcome, RegistryError};
1223
1224 pub(super) fn is_elevated() -> bool {
1225 unsafe { libc::geteuid() == 0 }
1227 }
1228
1229 #[cfg(target_os = "macos")]
1230 pub(super) fn physical_memory_bytes() -> Option<u64> {
1231 let mut value = 0u64;
1232 let mut size = std::mem::size_of::<u64>();
1233 let name = c"hw.memsize";
1234 let result = unsafe {
1237 libc::sysctlbyname(
1238 name.as_ptr(),
1239 (&raw mut value).cast(),
1240 &raw mut size,
1241 std::ptr::null_mut(),
1242 0,
1243 )
1244 };
1245 (result == 0).then_some(value)
1246 }
1247
1248 #[cfg(not(target_os = "macos"))]
1249 pub(super) fn physical_memory_bytes() -> Option<u64> {
1250 meminfo_bytes("MemTotal:")
1251 }
1252
1253 #[cfg(not(target_os = "macos"))]
1255 fn meminfo_bytes(field: &str) -> Option<u64> {
1256 super::meminfo_field(&std::fs::read_to_string("/proc/meminfo").ok()?, field)
1257 }
1258
1259 #[cfg(target_os = "macos")]
1262 #[allow(
1263 deprecated,
1264 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1265 )]
1266 fn host_port() -> libc::mach_port_t {
1267 static PORT: std::sync::OnceLock<libc::mach_port_t> = std::sync::OnceLock::new();
1268 *PORT.get_or_init(|| unsafe { libc::mach_host_self() })
1270 }
1271
1272 #[cfg(target_os = "macos")]
1273 #[allow(
1274 deprecated,
1275 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1276 )]
1277 pub(super) fn available_memory_bytes() -> Option<u64> {
1278 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
1280 let mut count = libc::HOST_VM_INFO64_COUNT;
1281 let result = unsafe {
1284 libc::host_statistics64(
1285 host_port(),
1286 libc::HOST_VM_INFO64,
1287 (&raw mut stats).cast(),
1288 &raw mut count,
1289 )
1290 };
1291 if result != libc::KERN_SUCCESS {
1292 return None;
1293 }
1294 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }).ok()?;
1296 super::available_pages_bytes(
1297 super::VmPages {
1298 free: u64::from(stats.free_count),
1299 speculative: u64::from(stats.speculative_count),
1300 external: u64::from(stats.external_page_count),
1301 purgeable: u64::from(stats.purgeable_count),
1302 },
1303 page,
1304 )
1305 }
1306
1307 #[cfg(not(target_os = "macos"))]
1308 pub(super) fn available_memory_bytes() -> Option<u64> {
1309 meminfo_bytes("MemAvailable:")
1310 }
1311
1312 pub(super) fn disk_space(path: &Path) -> Option<(u64, u64)> {
1314 use std::os::unix::ffi::OsStrExt as _;
1315 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1316 let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
1318 if unsafe { libc::statvfs(name.as_ptr(), &raw mut stats) } != 0 {
1321 return None;
1322 }
1323 #[allow(clippy::useless_conversion, reason = "the field widths differ per OS")]
1324 let (available, free, blocks, size) = (
1325 u64::from(stats.f_bavail),
1326 u64::from(stats.f_bfree),
1327 u64::from(stats.f_blocks),
1328 u64::from(stats.f_frsize),
1329 );
1330 let usable = blocks.saturating_sub(free).checked_add(available)?;
1334 Some((available.checked_mul(size)?, usable.checked_mul(size)?))
1335 }
1336
1337 #[cfg(not(target_os = "macos"))]
1338 pub(super) fn cpu_times() -> Option<(u64, u64)> {
1339 super::proc_stat_cpu_times(&std::fs::read_to_string("/proc/stat").ok()?)
1340 }
1341
1342 #[cfg(target_os = "macos")]
1346 #[allow(
1347 deprecated,
1348 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1349 )]
1350 pub(super) fn cpu_times() -> Option<(u64, u64)> {
1351 let mut load: libc::host_cpu_load_info = unsafe { std::mem::zeroed() };
1353 let mut count = libc::HOST_CPU_LOAD_INFO_COUNT;
1354 let result = unsafe {
1357 libc::host_statistics(
1358 host_port(),
1359 libc::HOST_CPU_LOAD_INFO,
1360 (&raw mut load).cast(),
1361 &raw mut count,
1362 )
1363 };
1364 if result != libc::KERN_SUCCESS {
1365 return None;
1366 }
1367 let ticks = load.cpu_ticks.map(u64::from);
1368 let idle = ticks[usize::try_from(libc::CPU_STATE_IDLE).ok()?];
1369 Some((idle, ticks.iter().sum()))
1370 }
1371
1372 fn unsupported() -> RegistryError {
1373 RegistryError::Other("the registry exists only on Windows".into())
1374 }
1375
1376 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1377 Err(unsupported())
1378 }
1379
1380 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1381 Err(unsupported())
1382 }
1383
1384 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1385 Err(unsupported())
1386 }
1387
1388 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1389 Err(unsupported())
1390 }
1391
1392 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1393 Err(unsupported())
1394 }
1395
1396 fn exit_outcome(
1397 status: std::io::Result<std::process::ExitStatus>,
1398 what: &str,
1399 ) -> ElevationOutcome {
1400 match status {
1401 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1402 Err(error) => {
1403 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1404 }
1405 }
1406 }
1407
1408 pub(super) fn run_elevated(
1409 program: &Path,
1410 args: &[OsString],
1411 terminal: bool,
1412 ) -> ElevationOutcome {
1413 if terminal
1414 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1415 {
1416 let status = Command::new("sudo")
1417 .arg("--")
1418 .arg(program)
1419 .args(args)
1420 .status();
1421 return match exit_outcome(status, "sudo") {
1425 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1426 other => other,
1427 };
1428 }
1429 if cfg!(target_os = "macos") {
1430 let command = std::iter::once(program.as_os_str().to_owned())
1431 .chain(args.iter().cloned())
1432 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1433 .collect::<Vec<_>>()
1434 .join(" ");
1435 let output = Command::new("/usr/bin/osascript")
1436 .arg("-e")
1437 .arg(super::administrator_applescript(&command))
1438 .output();
1439 return match output {
1440 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1441 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1443 ElevationOutcome::Refused
1444 }
1445 Ok(output) => {
1446 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1447 if detail.contains("(-60007)") || detail.contains("(-1743)") {
1448 ElevationOutcome::Unavailable(format!(
1449 "macOS could not show the administrator dialog here: {detail}"
1450 ))
1451 } else {
1452 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1453 }
1454 }
1455 Err(error) => ElevationOutcome::Unavailable(format!(
1456 "osascript could not be started: {error}"
1457 )),
1458 };
1459 }
1460 ElevationOutcome::Unavailable(
1461 "no terminal to ask for a sudo password; run the command from a terminal".into(),
1462 )
1463 }
1464}
1465
1466#[cfg(test)]
1467mod tests {
1468 use super::*;
1469
1470 #[test]
1471 fn a_cpu_figure_asked_too_soon_repeats_the_last_one_and_keeps_its_window() {
1472 let start = std::time::Instant::now();
1473 let after = |secs| start + std::time::Duration::from_secs(secs);
1474 let mut sampler = CpuSampler::new();
1475 assert_eq!(
1476 sampler.read((0, 0), start),
1477 None,
1478 "the first reading starts a window"
1479 );
1480 assert_eq!(
1481 sampler.read((50, 100), after(1)),
1482 None,
1483 "too soon, and nothing yet"
1484 );
1485 assert_eq!(sampler.read((50, 100), after(6)), Some(50));
1486 assert_eq!(sampler.read((50, 110), after(7)), Some(50));
1488 assert_eq!(sampler.read((60, 200), after(11)), Some(90));
1490 assert_eq!(
1491 busy_share((0, 100), (11, 110)),
1492 Some(0),
1493 "rounding past the total"
1494 );
1495 }
1496
1497 #[test]
1498 fn the_cpu_share_is_busy_time_over_all_time_and_never_past_100() {
1499 let first = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 1 2 3 4 5\n";
1500 let second = "cpu 400 0 150 1000 50 0 0 0 0 0\n";
1501 let (a, b) = (
1502 proc_stat_cpu_times(first).unwrap(),
1503 proc_stat_cpu_times(second).unwrap(),
1504 );
1505 assert_eq!(a, (850, 1000), "iowait counts as idle");
1506 assert_eq!(busy_share(a, b), Some(67));
1508 assert_eq!(busy_share(a, a), None, "no time passed");
1509 assert_eq!(busy_share(b, a), None, "a counter went back");
1510 assert_eq!(busy_share((0, 100), (0, 200)), Some(100));
1511 assert_eq!(proc_stat_cpu_times("cpu0 1 2 3 4 5\n"), None);
1512 assert_eq!(proc_stat_cpu_times("cpu 1 2 x 4 5\n"), None);
1513 }
1514
1515 #[test]
1516 fn the_busy_share_comes_from_the_difference_of_two_readings() {
1517 assert_eq!(busy_share((0, 0), (250, 1000)), Some(75));
1519 assert_eq!(busy_share((250, 1000), (250, 1000)), None);
1520 assert_eq!(busy_share((10, 20), (5, 40)), None, "went back");
1521 }
1522
1523 #[test]
1526 fn file_cache_a_job_reads_in_stays_available_on_macos() {
1527 let before = VmPages {
1529 free: 231_617 + 35_446,
1530 speculative: 35_446,
1531 external: 361_612,
1532 purgeable: 15_016,
1533 };
1534 let read = 100_000;
1535 let after = VmPages {
1536 free: before.free - read,
1537 external: before.external + read,
1538 ..before
1539 };
1540 let page = 16_384;
1541 assert_eq!(
1542 available_pages_bytes(after, page),
1543 available_pages_bytes(before, page)
1544 );
1545 assert_eq!(
1546 available_pages_bytes(before, page),
1547 Some((231_617 + 361_612 + 15_016) * page)
1548 );
1549 }
1550
1551 #[test]
1554 fn available_memory_is_memavailable_not_memfree() {
1555 let meminfo = "MemTotal: 32000000 kB\n\
1556 MemFree: 9000000 kB\n\
1557 MemAvailable: 18000000 kB\n";
1558 assert_eq!(
1559 meminfo_field(meminfo, "MemAvailable:"),
1560 Some(18_000_000 * 1024)
1561 );
1562 assert_eq!(meminfo_field(meminfo, "SwapFree:"), None);
1563 }
1564
1565 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1567 #[test]
1568 fn free_memory_is_reported_and_below_the_total() {
1569 let free = available_memory_bytes().expect("free memory");
1570 let total = physical_memory_bytes().expect("total memory");
1571 assert!(free > 0 && free <= total, "{free} of {total}");
1572 }
1573
1574 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1575 #[test]
1576 fn the_disk_is_reported_with_its_size() {
1577 let here = std::env::current_dir().expect("current directory");
1578 let (free, total) = disk_space(&here).expect("disk space");
1579 assert!(total > 0 && free <= total, "{free} of {total}");
1580 }
1581
1582 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1583 #[test]
1584 fn this_hosts_cpu_counters_are_read_and_move_forward() {
1585 let first = sys::cpu_times().expect("CPU times");
1586 std::thread::sleep(std::time::Duration::from_millis(200));
1587 let second = sys::cpu_times().expect("CPU times");
1588 assert!(second.1 > first.1, "{first:?} then {second:?}");
1589 assert!(busy_share(first, second).is_some_and(|percent| percent <= 100));
1590 }
1591
1592 #[test]
1593 fn windows_arguments_survive_the_command_line_round_trip() {
1594 for (argument, expected) in [
1595 ("plain", "plain"),
1596 ("", "\"\""),
1597 ("with space", "\"with space\""),
1598 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1599 ("say \"hi\"", "\"say \\\"hi\\\"\""),
1600 ("a\\\"b", "\"a\\\\\\\"b\""),
1601 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1602 ] {
1603 assert_eq!(
1604 crate::service::quote_argument(argument),
1605 expected,
1606 "{argument}"
1607 );
1608 }
1609 }
1610
1611 #[test]
1612 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1613 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1614 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1615 assert_eq!(
1616 script,
1617 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1618 );
1619 }
1620
1621 #[test]
1622 fn a_symlink_probe_leaves_nothing_behind() {
1623 let directory = tempfile::tempdir().unwrap();
1624 let _ = can_create_symlink(directory.path()).unwrap();
1625 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1626 }
1627
1628 #[cfg(unix)]
1629 #[test]
1630 fn unix_accounts_can_always_create_symlinks() {
1631 let directory = tempfile::tempdir().unwrap();
1632 assert!(can_create_symlink(directory.path()).unwrap());
1633 }
1634
1635 #[test]
1636 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1637 let directory = tempfile::tempdir().unwrap();
1638 let name = if cfg!(windows) {
1639 "rmtool.exe"
1640 } else {
1641 "rmtool"
1642 };
1643 let tool = directory.path().join(name);
1644 std::fs::write(&tool, b"").unwrap();
1645 #[cfg(unix)]
1646 {
1647 use std::os::unix::fs::PermissionsExt as _;
1648 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1649 }
1650 let path = std::env::join_paths([directory.path()]).unwrap();
1651 assert_eq!(find_on_path("rmtool", &path), Some(tool));
1652 assert_eq!(find_on_path("absent-tool", &path), None);
1653 assert_eq!(find_on_path("../rmtool", &path), None);
1654 }
1655
1656 #[cfg(windows)]
1659 #[test]
1660 fn an_extensionless_file_is_never_the_windows_answer() {
1661 let directory = tempfile::tempdir().unwrap();
1662 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1663 let path = std::env::join_paths([directory.path()]).unwrap();
1664 assert_eq!(find_on_path("npm", &path), None);
1665 let cmd = directory.path().join("npm.cmd");
1666 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1667 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1668 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1669 }
1670
1671 fn path_of(directories: &[&Path]) -> OsString {
1673 std::env::join_paths(directories).expect("joinable directories")
1674 }
1675
1676 fn touch(path: &Path) {
1677 std::fs::write(path, b"").expect("a fixture file");
1678 }
1679
1680 #[test]
1684 fn npm_shipped_only_as_npm_cmd_is_found_on_a_windows_path() {
1685 let empty = tempfile::tempdir().unwrap();
1686 let nodejs = tempfile::tempdir().unwrap();
1687 touch(&nodejs.path().join("npm"));
1688 touch(&nodejs.path().join("npm.cmd"));
1689 let path = path_of(&[empty.path(), nodejs.path()]);
1690 let cmd = Some(nodejs.path().join("npm.cmd"));
1691
1692 assert_eq!(
1693 find_on_windows_path("npm", &path, Some(".COM;.EXE;.BAT;.CMD".as_ref())),
1694 cmd
1695 );
1696 assert_eq!(find_on_windows_path("npm", &path, None), cmd);
1698 assert_eq!(find_on_windows_path("npm", &path, Some(" ".as_ref())), cmd);
1699 assert_eq!(find_on_windows_path("npm.cmd", &path, None), cmd);
1701 touch(&nodejs.path().join("npm.ps1"));
1702 assert_eq!(find_on_windows_path("npm.ps1", &path, None), None);
1703 }
1704
1705 #[test]
1708 fn pathext_orders_extensions_within_a_directory_and_path_orders_directories() {
1709 let both = tempfile::tempdir().unwrap();
1710 touch(&both.path().join("npm.cmd"));
1711 touch(&both.path().join("npm.exe"));
1712 let path = path_of(&[both.path()]);
1713 assert_eq!(
1714 find_on_windows_path("npm", &path, Some(".CMD;.EXE".as_ref())),
1715 Some(both.path().join("npm.cmd"))
1716 );
1717 assert_eq!(
1718 find_on_windows_path("npm", &path, Some(".exe;.cmd".as_ref())),
1719 Some(both.path().join("npm.exe"))
1720 );
1721
1722 let first = tempfile::tempdir().unwrap();
1723 let second = tempfile::tempdir().unwrap();
1724 touch(&first.path().join("npm.cmd"));
1725 touch(&second.path().join("npm.exe"));
1726 assert_eq!(
1727 find_on_windows_path(
1728 "npm",
1729 &path_of(&[first.path(), second.path()]),
1730 Some(".EXE;.CMD".as_ref())
1731 ),
1732 Some(first.path().join("npm.cmd")),
1733 "an earlier PATH directory wins over a preferred extension later on"
1734 );
1735 }
1736
1737 #[test]
1740 #[serial_test::serial(current_dir)]
1741 fn a_missing_npm_is_not_found_on_a_windows_path() {
1742 let empty = tempfile::tempdir().unwrap();
1743 assert_eq!(
1744 find_on_windows_path("npm", &path_of(&[empty.path()]), None),
1745 None
1746 );
1747
1748 let script_only = tempfile::tempdir().unwrap();
1749 touch(&script_only.path().join("npm.ps1"));
1750 assert_eq!(
1751 find_on_windows_path(
1752 "npm",
1753 &path_of(&[script_only.path()]),
1754 Some(".PS1;.EXE;.CMD".as_ref())
1755 ),
1756 None
1757 );
1758
1759 let planted = tempfile::tempdir().unwrap();
1762 touch(&planted.path().join("npm.cmd"));
1763 if let Some(relative) = relative_to_current_directory(planted.path()) {
1764 assert!(relative.join("npm.cmd").is_file(), "{}", relative.display());
1765 assert_eq!(
1766 find_on_windows_path("npm", &path_of(&[&relative]), None),
1767 None
1768 );
1769 }
1770 }
1771
1772 fn relative_to_current_directory(target: &Path) -> Option<PathBuf> {
1775 let here = std::env::current_dir().ok()?;
1776 let target = std::fs::canonicalize(target).ok()?;
1777 let here = std::fs::canonicalize(here).ok()?;
1778 let (here, target): (Vec<_>, Vec<_>) =
1779 (here.components().collect(), target.components().collect());
1780 let common = here
1781 .iter()
1782 .zip(&target)
1783 .take_while(|(left, right)| left == right)
1784 .count();
1785 if common == 0 {
1786 return None;
1787 }
1788 let mut relative: PathBuf = std::iter::repeat_n("..", here.len() - common).collect();
1789 relative.extend(&target[common..]);
1790 Some(relative)
1791 }
1792
1793 fn unfit_finding(id: &str) -> UnfitFinding {
1794 UnfitFinding {
1795 id: id.into(),
1796 detail: "it fails".into(),
1797 remedy: None,
1798 }
1799 }
1800
1801 #[test]
1804 fn who_can_answer_an_administrator_prompt() {
1805 assert!(
1806 prompt_answerable(Some(1), false),
1807 "the desktop, no terminal"
1808 );
1809 assert!(prompt_answerable(Some(2), true));
1810 assert!(!prompt_answerable(Some(0), true), "services and SSH");
1811 assert!(prompt_answerable(None, true), "sudo on a terminal");
1812 assert!(!prompt_answerable(None, false));
1813 }
1814
1815 #[test]
1817 fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1818 let root = tempfile::tempdir().unwrap();
1819 let paths = AppPaths::rooted_at(root.path());
1820 paths.create_all().unwrap();
1821 let then = Utc::now();
1822 record_host_unfit(
1823 &paths,
1824 &[unfit_finding("host.runner_root_responsive")],
1825 then,
1826 )
1827 .unwrap();
1828 let later = then + chrono::Duration::hours(1);
1829 assert_eq!(
1830 host_unfit_in_force(&paths, later),
1831 None,
1832 "nobody beats: stopped"
1833 );
1834
1835 crate::daemon_heartbeat::beat(&paths, then).unwrap();
1837 assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1838 crate::daemon_heartbeat::beat(&paths, later).unwrap();
1839 assert_eq!(
1840 host_unfit_in_force(&paths, later),
1841 None,
1842 "a daemon still beating would have re-stamped a refusal in force"
1843 );
1844
1845 let much_later = later + chrono::Duration::hours(1);
1848 assert_eq!(host_unfit_in_force(&paths, much_later), None);
1849 }
1850
1851 #[test]
1852 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1853 let root = tempfile::tempdir().unwrap();
1854 let paths = AppPaths::rooted_at(root.path());
1855 paths.create_all().unwrap();
1856 assert_eq!(host_unfit(&paths).unwrap(), None);
1857 let first = Utc::now() - chrono::Duration::minutes(10);
1858 record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1859 let later = Utc::now();
1860 record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1861 let record = host_unfit(&paths).unwrap().unwrap();
1862 assert_eq!(record.since, first);
1863 assert_eq!(record.checked_at, later);
1864 assert_eq!(record.checks, ["host.required_tools"]);
1865 clear_host_unfit(&paths).unwrap();
1866 assert_eq!(host_unfit(&paths).unwrap(), None);
1867 clear_host_unfit(&paths).unwrap();
1868 }
1869
1870 #[test]
1871 fn physical_memory_is_reported() {
1872 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1873 }
1874
1875 #[test]
1876 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1877 let directory = tempfile::tempdir().unwrap();
1878 let deadline = std::time::Duration::from_secs(10);
1879 assert_eq!(
1880 directory_responds(directory.path(), deadline),
1881 Responsiveness::Responds
1882 );
1883 assert_eq!(
1884 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1885 Responsiveness::Responds
1886 );
1887 assert!(
1890 !PROBES_IN_FLIGHT
1891 .lock()
1892 .unwrap()
1893 .iter()
1894 .any(|(path, _)| path.starts_with(directory.path())),
1895 "a finished probe deregisters"
1896 );
1897 }
1898
1899 #[test]
1904 fn a_path_still_being_probed_answers_without_a_second_thread() {
1905 let directory = tempfile::tempdir().unwrap();
1906 for (name, listing, expected) in [
1907 ("stuck-volume", false, Responsiveness::Hung),
1908 ("asking-volume", true, Responsiveness::ListingBlocked),
1909 ] {
1910 let stuck = directory.path().join(name);
1911 PROBES_IN_FLIGHT.lock().unwrap().push((
1912 stuck.clone(),
1913 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1914 ));
1915 let started = std::time::Instant::now();
1916 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1917 PROBES_IN_FLIGHT
1918 .lock()
1919 .unwrap()
1920 .retain(|(path, _)| *path != stuck);
1921 assert_eq!(answer, expected, "{name}");
1922 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1923 }
1924 }
1925}