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]
135pub fn cpu_load_percent() -> Option<u32> {
136 static SAMPLER: std::sync::Mutex<CpuSampler> = std::sync::Mutex::new(CpuSampler::new());
137 let now = sys::cpu_times()?;
138 SAMPLER
139 .lock()
140 .unwrap_or_else(std::sync::PoisonError::into_inner)
141 .read(now, std::time::Instant::now())
142}
143
144const CPU_WINDOW: std::time::Duration = std::time::Duration::from_secs(5);
149
150struct CpuSampler {
153 start: Option<((u64, u64), std::time::Instant)>,
154 last: Option<u32>,
155}
156
157impl CpuSampler {
158 const fn new() -> Self {
159 Self {
160 start: None,
161 last: None,
162 }
163 }
164
165 fn read(&mut self, now: (u64, u64), at: std::time::Instant) -> Option<u32> {
169 match self.start {
170 Some((_, began)) if at.duration_since(began) < CPU_WINDOW => self.last,
171 Some((previous, _)) => {
172 self.start = Some((now, at));
173 self.last = busy_share(previous, now);
174 self.last
175 }
176 None => {
177 self.start = Some((now, at));
178 None
179 }
180 }
181 }
182}
183
184fn busy_share(previous: (u64, u64), now: (u64, u64)) -> Option<u32> {
189 let idle = now.0.checked_sub(previous.0)?;
190 let total = now.1.checked_sub(previous.1)?;
191 if total == 0 {
192 return None;
193 }
194 let busy = total.saturating_sub(idle);
195 u32::try_from(busy.saturating_mul(100).saturating_add(total / 2) / total).ok()
196}
197
198#[cfg_attr(any(windows, target_os = "macos"), allow(dead_code))]
203fn proc_stat_cpu_times(text: &str) -> Option<(u64, u64)> {
204 let line = text.lines().find(|line| line.starts_with("cpu "))?;
205 let fields: Vec<u64> = line
206 .split_whitespace()
207 .skip(1)
208 .take(8)
209 .map(str::parse)
210 .collect::<Result<_, _>>()
211 .ok()?;
212 if fields.len() < 5 {
213 return None;
214 }
215 let idle = fields[3].checked_add(fields[4])?;
216 let total = fields
217 .iter()
218 .try_fold(0_u64, |sum, field| sum.checked_add(*field))?;
219 Some((idle, total))
220}
221
222#[cfg_attr(any(windows, target_os = "macos"), allow(dead_code))]
224fn meminfo_field(text: &str, field: &str) -> Option<u64> {
225 let line = text.lines().find(|line| line.starts_with(field))?;
226 let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
227 kib.checked_mul(1024)
228}
229
230#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
233fn available_pages_bytes(free: u64, inactive: u64, page: u64) -> Option<u64> {
234 free.checked_add(inactive)?.checked_mul(page)
235}
236
237#[cfg(windows)]
243pub fn system_directory() -> std::io::Result<PathBuf> {
244 crate::runner_root::windows_system_directory().map(PathBuf::from)
245}
246
247pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
253 use std::io::Write as _;
254 let parent = path.parent().unwrap_or_else(|| Path::new("."));
255 std::fs::create_dir_all(parent)?;
256 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
257 temporary.write_all(bytes)?;
258 temporary.as_file().sync_all()?;
259 temporary
260 .persist(path)
261 .map(|_| ())
262 .map_err(|error| error.error)
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
267pub enum Responsiveness {
268 Responds,
270 Hung,
273 ListingBlocked,
279 NotPermitted(String),
283 Failed(String),
285}
286
287type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
290
291static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
295
296#[must_use]
298pub fn probes_in_flight() -> Vec<PathBuf> {
299 PROBES_IN_FLIGHT
300 .lock()
301 .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
302 .unwrap_or_default()
303}
304
305fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
307 if listing.load(std::sync::atomic::Ordering::Acquire) {
308 Responsiveness::ListingBlocked
309 } else {
310 Responsiveness::Hung
311 }
312}
313
314#[must_use]
322pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
323 let path = directory.to_path_buf();
324 let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
327 {
328 let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
329 return Responsiveness::Failed("the probe registry is poisoned".into());
330 };
331 if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
332 return unanswered(stuck);
333 }
334 in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
335 }
336 let (sender, receiver) = std::sync::mpsc::channel();
337 let probed = path.clone();
338 let listing_started = std::sync::Arc::clone(&listing);
339 let spawned = std::thread::Builder::new()
340 .name("runner-root-probe".into())
341 .spawn(move || {
342 let existing = probed
343 .ancestors()
344 .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
345 .map(Path::to_path_buf);
346 listing_started.store(true, std::sync::atomic::Ordering::Release);
347 let refused = |error: std::io::Error| {
348 if is_not_permitted(&error) {
349 Responsiveness::NotPermitted(error.to_string())
350 } else {
351 Responsiveness::Failed(error.to_string())
352 }
353 };
354 let answer = match existing {
355 None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
356 Some(existing) => match std::fs::read_dir(&existing) {
357 Ok(mut entries) => match entries.next() {
358 Some(Err(error)) => refused(error),
359 _ => Responsiveness::Responds,
360 },
361 Err(error) => refused(error),
362 },
363 };
364 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
365 in_flight.retain(|(candidate, _)| *candidate != probed);
366 }
367 let _ = sender.send(answer);
368 });
369 if let Err(error) = spawned {
370 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
371 in_flight.retain(|(candidate, _)| *candidate != path);
372 }
373 return Responsiveness::Failed(error.to_string());
374 }
375 receiver
376 .recv_timeout(deadline)
377 .unwrap_or_else(|_| unanswered(&listing))
378}
379
380fn is_not_permitted(error: &std::io::Error) -> bool {
382 #[cfg(unix)]
383 {
384 error.raw_os_error() == Some(libc::EPERM)
385 }
386 #[cfg(not(unix))]
387 {
388 let _ = error;
389 false
390 }
391}
392
393pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
405 std::fs::create_dir_all(directory)?;
406 let unique = format!(
407 ".rm-symlink-probe-{}-{}",
408 std::process::id(),
409 uuid::Uuid::new_v4().simple()
410 );
411 let target = directory.join(format!("{unique}.target"));
412 let link = directory.join(format!("{unique}.link"));
413 std::fs::write(&target, b"")?;
414 let created = symlink_file(&target, &link);
415 let _ = std::fs::remove_file(&link);
416 let _ = std::fs::remove_file(&target);
417 Ok(created.is_ok())
418}
419
420#[cfg(windows)]
421fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
422 std::os::windows::fs::symlink_file(target, link)
423}
424
425#[cfg(unix)]
426fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
427 std::os::unix::fs::symlink(target, link)
428}
429
430#[must_use]
433pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
434 if cfg!(windows) {
435 return find_on_windows_path(tool, path, std::env::var_os("PATHEXT").as_deref());
436 }
437 if tool.is_empty() || tool.contains(['/', '\\']) {
438 return None;
439 }
440 std::env::split_paths(path)
441 .filter(|dir| !dir.as_os_str().is_empty())
442 .map(|dir| dir.join(tool))
443 .find(|candidate| is_executable(candidate))
444}
445
446#[must_use]
464pub fn find_on_windows_path(tool: &str, path: &OsStr, pathext: Option<&OsStr>) -> Option<PathBuf> {
465 const RUNNABLE: [&str; 4] = [".exe", ".com", ".cmd", ".bat"];
466 if tool.is_empty() || tool.contains(['/', '\\']) {
467 return None;
468 }
469 let pathext = pathext
470 .and_then(OsStr::to_str)
471 .filter(|value| !value.trim().is_empty())
472 .unwrap_or(".COM;.EXE;.BAT;.CMD");
473 let bare = Path::new(tool)
474 .extension()
475 .and_then(OsStr::to_str)
476 .is_some_and(|extension| {
477 RUNNABLE
478 .iter()
479 .any(|runnable| runnable[1..].eq_ignore_ascii_case(extension))
480 })
481 .then_some("");
482 let extensions: Vec<&str> = bare
483 .into_iter()
484 .chain(pathext.split(';').filter_map(|extension| {
485 RUNNABLE
486 .into_iter()
487 .find(|runnable| runnable.eq_ignore_ascii_case(extension.trim()))
488 }))
489 .collect();
490 std::env::split_paths(path)
491 .filter(|dir| dir.is_absolute())
492 .find_map(|dir| {
493 extensions.iter().find_map(|extension| {
494 let candidate = dir.join(format!("{tool}{extension}"));
495 candidate.is_file().then_some(candidate)
496 })
497 })
498}
499
500#[cfg(unix)]
501fn is_executable(path: &Path) -> bool {
502 use std::os::unix::fs::PermissionsExt as _;
503 std::fs::metadata(path)
504 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
505}
506
507#[cfg(windows)]
508fn is_executable(path: &Path) -> bool {
509 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
510}
511
512#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
518pub enum RegistryError {
519 #[error("access denied")]
522 AccessDenied,
523 #[error("{0}")]
525 Other(String),
526}
527
528pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
534 sys::read_dword(subkey, value)
535}
536
537pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
542 sys::read_string(subkey, value)
543}
544
545pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
551 sys::value_names(subkey)
552}
553
554pub fn write_registry_dword(
560 subkey: &str,
561 value: &str,
562 data: Option<u32>,
563) -> Result<(), RegistryError> {
564 sys::write_dword(subkey, value, data)
565}
566
567pub fn write_registry_string(
573 subkey: &str,
574 value: &str,
575 data: Option<&str>,
576) -> Result<(), RegistryError> {
577 sys::write_string(subkey, value, data)
578}
579
580pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
586
587pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
590
591pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
595 std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
596const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
597
598#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
600pub struct HostUnfitRecord {
601 pub schema_version: u32,
602 pub since: DateTime<Utc>,
604 pub checked_at: DateTime<Utc>,
606 pub checks: Vec<String>,
608 #[serde(default)]
611 pub findings: Vec<UnfitFinding>,
612}
613
614#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
616pub struct UnfitFinding {
617 pub id: String,
618 pub detail: String,
619 #[serde(default)]
620 pub remedy: Option<String>,
621}
622
623impl HostUnfitRecord {
624 #[must_use]
626 pub fn describe(&self) -> String {
627 if self.findings.is_empty() {
628 return self.checks.join(", ");
629 }
630 self.findings
631 .iter()
632 .map(|finding| match &finding.remedy {
633 Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
634 None => format!("{}: {}", finding.id, finding.detail),
635 })
636 .collect::<Vec<_>>()
637 .join("; ")
638 }
639}
640
641fn host_unfit_path(paths: &AppPaths) -> PathBuf {
642 paths.state_dir().join(HOST_UNFIT_FILE)
643}
644
645pub fn record_host_unfit(
651 paths: &AppPaths,
652 findings: &[UnfitFinding],
653 at: DateTime<Utc>,
654) -> Result<(), String> {
655 let since = host_unfit(paths)
656 .ok()
657 .flatten()
658 .map_or(at, |record| record.since);
659 let record = HostUnfitRecord {
660 schema_version: HOST_UNFIT_SCHEMA_VERSION,
661 since,
662 checked_at: at,
663 checks: findings.iter().map(|finding| finding.id.clone()).collect(),
664 findings: findings.to_vec(),
665 };
666 let path = host_unfit_path(paths);
667 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
668 write_atomically(&path, &text)
669 .map_err(|error| format!("cannot write {}: {error}", path.display()))
670}
671
672pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
677 let path = host_unfit_path(paths);
678 match std::fs::remove_file(&path) {
679 Ok(()) => Ok(()),
680 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
681 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
682 }
683}
684
685#[must_use]
694pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
695 host_unfit(paths).ok().flatten().filter(|record| {
696 if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
697 return true;
698 }
699 match crate::daemon_heartbeat::liveness(paths, now) {
702 crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
703 !crate::wsl::fence::elapsed_at_least(
704 record.checked_at,
705 since,
706 HOST_UNFIT_RECORD_FRESH,
707 )
708 }
709 _ => false,
710 }
711 })
712}
713
714pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
719 let path = host_unfit_path(paths);
720 match std::fs::read_to_string(&path) {
721 Ok(text) => serde_json::from_str(&text)
722 .map(Some)
723 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
724 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
725 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
726 }
727}
728
729#[cfg(windows)]
734mod sys {
735 use std::ffi::OsString;
736 use std::os::windows::ffi::OsStrExt as _;
737 use std::path::Path;
738
739 use windows::Win32::Foundation::{
740 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
741 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
742 };
743 use windows::Win32::Security::{
744 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
745 };
746 use windows::Win32::System::Registry::{
747 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
748 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
749 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
750 RegSetValueExW,
751 };
752 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
753 use windows::Win32::System::Threading::{
754 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
755 };
756 use windows::Win32::UI::Shell::{
757 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
758 };
759 use windows::core::{PCWSTR, PWSTR};
760
761 use super::{ElevationOutcome, RegistryError};
762
763 const SW_HIDE: i32 = 0;
766 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
770
771 fn wide(text: &str) -> Vec<u16> {
772 text.encode_utf16().chain(std::iter::once(0)).collect()
773 }
774
775 fn registry_error(code: WIN32_ERROR) -> RegistryError {
776 if code == ERROR_ACCESS_DENIED {
777 RegistryError::AccessDenied
778 } else {
779 RegistryError::Other(format!("registry error {}", code.0))
780 }
781 }
782
783 pub(super) fn session_id() -> Option<u32> {
785 let mut token = HANDLE::default();
786 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
789 return None;
790 }
791 let mut session = 0u32;
792 let mut returned = 0u32;
793 let queried = unsafe {
796 GetTokenInformation(
797 token,
798 TokenSessionId,
799 Some((&raw mut session).cast()),
800 4,
801 &raw mut returned,
802 )
803 };
804 let _ = unsafe { CloseHandle(token) };
806 queried.is_ok().then_some(session)
807 }
808
809 pub(super) fn is_elevated() -> bool {
810 let mut token = HANDLE::default();
811 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
814 return false;
815 }
816 let mut elevation = TOKEN_ELEVATION::default();
817 let mut returned = 0u32;
818 let queried = unsafe {
821 GetTokenInformation(
822 token,
823 TokenElevation,
824 Some((&raw mut elevation).cast()),
825 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
826 &raw mut returned,
827 )
828 };
829 let _ = unsafe { CloseHandle(token) };
831 queried.is_ok() && elevation.TokenIsElevated != 0
832 }
833
834 fn memory_status() -> Option<MEMORYSTATUSEX> {
835 let mut status = MEMORYSTATUSEX {
836 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
837 ..MEMORYSTATUSEX::default()
838 };
839 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
842 Some(status)
843 }
844
845 pub(super) fn physical_memory_bytes() -> Option<u64> {
846 memory_status().map(|status| status.ullTotalPhys)
847 }
848
849 pub(super) fn available_memory_bytes() -> Option<u64> {
850 memory_status().map(|status| status.ullAvailPhys)
851 }
852
853 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
854 let wide: Vec<u16> = path
855 .as_os_str()
856 .encode_wide()
857 .chain(std::iter::once(0))
858 .collect();
859 let mut free = 0u64;
860 unsafe {
863 windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
864 PCWSTR(wide.as_ptr()),
865 Some(&raw mut free),
866 None,
867 None,
868 )
869 }
870 .ok()?;
871 Some(free)
872 }
873
874 pub(super) fn cpu_times() -> Option<(u64, u64)> {
877 use windows::Win32::Foundation::FILETIME;
878 let (mut idle, mut kernel, mut user) = (
879 FILETIME::default(),
880 FILETIME::default(),
881 FILETIME::default(),
882 );
883 unsafe {
885 windows::Win32::System::Threading::GetSystemTimes(
886 Some(&raw mut idle),
887 Some(&raw mut kernel),
888 Some(&raw mut user),
889 )
890 }
891 .ok()?;
892 let value =
893 |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime);
894 Some((value(idle), value(kernel).checked_add(value(user))?))
895 }
896
897 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
898 let subkey = wide(subkey);
899 let value = wide(value);
900 let mut data = 0u32;
901 let mut size = 4u32;
902 let code = unsafe {
905 RegGetValueW(
906 HKEY_LOCAL_MACHINE,
907 PCWSTR(subkey.as_ptr()),
908 PCWSTR(value.as_ptr()),
909 RRF_RT_REG_DWORD,
910 None,
911 Some((&raw mut data).cast()),
912 Some(&raw mut size),
913 )
914 };
915 match code {
916 ERROR_SUCCESS => Ok(Some(data)),
917 ERROR_FILE_NOT_FOUND => Ok(None),
918 other => Err(registry_error(other)),
919 }
920 }
921
922 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
923 let subkey = wide(subkey);
924 let value = wide(value);
925 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
926 let mut size = 0u32;
927 let code = unsafe {
929 RegGetValueW(
930 HKEY_LOCAL_MACHINE,
931 PCWSTR(subkey.as_ptr()),
932 PCWSTR(value.as_ptr()),
933 flags,
934 None,
935 None,
936 Some(&raw mut size),
937 )
938 };
939 match code {
940 ERROR_SUCCESS => {}
941 ERROR_FILE_NOT_FOUND => return Ok(None),
942 other => return Err(registry_error(other)),
943 }
944 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
945 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
946 let code = unsafe {
949 RegGetValueW(
950 HKEY_LOCAL_MACHINE,
951 PCWSTR(subkey.as_ptr()),
952 PCWSTR(value.as_ptr()),
953 flags,
954 None,
955 Some(buffer.as_mut_ptr().cast()),
956 Some(&raw mut size),
957 )
958 };
959 match code {
960 ERROR_SUCCESS => {
961 let units = (size as usize / 2).min(buffer.len());
962 let text = String::from_utf16_lossy(&buffer[..units]);
963 Ok(Some(text.trim_end_matches('\0').to_owned()))
964 }
965 ERROR_FILE_NOT_FOUND => Ok(None),
966 other => Err(registry_error(other)),
967 }
968 }
969
970 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
971 let subkey = wide(subkey);
972 let mut key = HKEY::default();
973 let code = unsafe {
975 RegOpenKeyExW(
976 HKEY_LOCAL_MACHINE,
977 PCWSTR(subkey.as_ptr()),
978 None,
979 KEY_READ,
980 &raw mut key,
981 )
982 };
983 match code {
984 ERROR_SUCCESS => {}
985 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
986 other => return Err(registry_error(other)),
987 }
988 let mut names = Vec::new();
989 let mut index = 0u32;
990 let result = loop {
991 let mut name = vec![0u16; 16_384];
993 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
994 let code = unsafe {
997 RegEnumValueW(
998 key,
999 index,
1000 Some(PWSTR(name.as_mut_ptr())),
1001 &raw mut length,
1002 None,
1003 None,
1004 None,
1005 None,
1006 )
1007 };
1008 match code {
1009 ERROR_SUCCESS => {
1010 names.push(String::from_utf16_lossy(&name[..length as usize]));
1011 index += 1;
1012 }
1013 ERROR_NO_MORE_ITEMS => break Ok(names),
1014 other => break Err(registry_error(other)),
1015 }
1016 };
1017 let _ = unsafe { RegCloseKey(key) };
1019 result
1020 }
1021
1022 fn write_value(
1023 subkey: &str,
1024 value: &str,
1025 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
1026 ) -> Result<(), RegistryError> {
1027 let subkey_wide = wide(subkey);
1028 let value_wide = wide(value);
1029 let Some((kind, bytes)) = data else {
1030 let code = unsafe {
1032 RegDeleteKeyValueW(
1033 HKEY_LOCAL_MACHINE,
1034 PCWSTR(subkey_wide.as_ptr()),
1035 PCWSTR(value_wide.as_ptr()),
1036 )
1037 };
1038 return match code {
1039 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
1040 other => Err(registry_error(other)),
1041 };
1042 };
1043 let mut key = HKEY::default();
1044 let code = unsafe {
1047 RegCreateKeyExW(
1048 HKEY_LOCAL_MACHINE,
1049 PCWSTR(subkey_wide.as_ptr()),
1050 None,
1051 PCWSTR::null(),
1052 REG_OPTION_NON_VOLATILE,
1053 KEY_SET_VALUE,
1054 None,
1055 &raw mut key,
1056 None,
1057 )
1058 };
1059 if code != ERROR_SUCCESS {
1060 return Err(registry_error(code));
1061 }
1062 let code =
1065 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
1066 let _ = unsafe { RegCloseKey(key) };
1068 if code == ERROR_SUCCESS {
1069 Ok(())
1070 } else {
1071 Err(registry_error(code))
1072 }
1073 }
1074
1075 pub(super) fn write_dword(
1076 subkey: &str,
1077 value: &str,
1078 data: Option<u32>,
1079 ) -> Result<(), RegistryError> {
1080 write_value(
1081 subkey,
1082 value,
1083 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
1084 )
1085 }
1086
1087 pub(super) fn write_string(
1088 subkey: &str,
1089 value: &str,
1090 data: Option<&str>,
1091 ) -> Result<(), RegistryError> {
1092 write_value(
1093 subkey,
1094 value,
1095 data.map(|text| {
1096 let bytes = wide(text)
1097 .iter()
1098 .flat_map(|unit| unit.to_le_bytes())
1099 .collect();
1100 (REG_SZ, bytes)
1101 }),
1102 )
1103 }
1104
1105 pub(super) fn run_elevated(
1106 program: &Path,
1107 args: &[OsString],
1108 _terminal: bool,
1109 ) -> ElevationOutcome {
1110 let file: Vec<u16> = program
1111 .as_os_str()
1112 .encode_wide()
1113 .chain(std::iter::once(0))
1114 .collect();
1115 let parameters = args
1116 .iter()
1117 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
1118 .collect::<Vec<_>>()
1119 .join(" ");
1120 let parameters = wide(¶meters);
1121 let verb = wide("runas");
1122 let mut info = SHELLEXECUTEINFOW {
1123 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
1124 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
1125 lpVerb: PCWSTR(verb.as_ptr()),
1126 lpFile: PCWSTR(file.as_ptr()),
1127 lpParameters: PCWSTR(parameters.as_ptr()),
1128 nShow: SW_HIDE,
1129 ..SHELLEXECUTEINFOW::default()
1130 };
1131 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
1134 #[allow(clippy::cast_sign_loss)]
1135 let code = (error.code().0 as u32) & 0xffff;
1136 return if code == ERROR_CANCELLED.0 {
1137 ElevationOutcome::Refused
1138 } else {
1139 ElevationOutcome::Unavailable(format!(
1140 "Windows could not show the administrator prompt: {}",
1141 error.message()
1142 ))
1143 };
1144 }
1145 if info.hProcess.is_invalid() {
1146 return ElevationOutcome::Unavailable(
1147 "Windows started no elevated process to wait for".into(),
1148 );
1149 }
1150 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
1153 let mut code = 1u32;
1154 let outcome = if waited == WAIT_OBJECT_0 {
1155 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
1157 #[allow(clippy::cast_possible_wrap)]
1158 Ok(()) => ElevationOutcome::Exited(code as i32),
1159 Err(error) => ElevationOutcome::Unavailable(format!(
1160 "the elevated process's exit code could not be read: {}",
1161 error.message()
1162 )),
1163 }
1164 } else {
1165 ElevationOutcome::Unavailable(
1166 "the elevated process did not finish within 15 minutes".into(),
1167 )
1168 };
1169 let _ = unsafe { CloseHandle(info.hProcess) };
1171 outcome
1172 }
1173}
1174
1175#[cfg(unix)]
1180mod sys {
1181 use std::ffi::OsString;
1182 use std::path::Path;
1183 use std::process::Command;
1184
1185 pub(super) const fn session_id() -> Option<u32> {
1187 None
1188 }
1189
1190 use super::{ElevationOutcome, RegistryError};
1191
1192 pub(super) fn is_elevated() -> bool {
1193 unsafe { libc::geteuid() == 0 }
1195 }
1196
1197 #[cfg(target_os = "macos")]
1198 pub(super) fn physical_memory_bytes() -> Option<u64> {
1199 let mut value = 0u64;
1200 let mut size = std::mem::size_of::<u64>();
1201 let name = c"hw.memsize";
1202 let result = unsafe {
1205 libc::sysctlbyname(
1206 name.as_ptr(),
1207 (&raw mut value).cast(),
1208 &raw mut size,
1209 std::ptr::null_mut(),
1210 0,
1211 )
1212 };
1213 (result == 0).then_some(value)
1214 }
1215
1216 #[cfg(not(target_os = "macos"))]
1217 pub(super) fn physical_memory_bytes() -> Option<u64> {
1218 meminfo_bytes("MemTotal:")
1219 }
1220
1221 #[cfg(not(target_os = "macos"))]
1223 fn meminfo_bytes(field: &str) -> Option<u64> {
1224 super::meminfo_field(&std::fs::read_to_string("/proc/meminfo").ok()?, field)
1225 }
1226
1227 #[cfg(target_os = "macos")]
1230 #[allow(
1231 deprecated,
1232 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1233 )]
1234 fn host_port() -> libc::mach_port_t {
1235 static PORT: std::sync::OnceLock<libc::mach_port_t> = std::sync::OnceLock::new();
1236 *PORT.get_or_init(|| unsafe { libc::mach_host_self() })
1238 }
1239
1240 #[cfg(target_os = "macos")]
1241 #[allow(
1242 deprecated,
1243 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1244 )]
1245 pub(super) fn available_memory_bytes() -> Option<u64> {
1246 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
1248 let mut count = libc::HOST_VM_INFO64_COUNT;
1249 let result = unsafe {
1252 libc::host_statistics64(
1253 host_port(),
1254 libc::HOST_VM_INFO64,
1255 (&raw mut stats).cast(),
1256 &raw mut count,
1257 )
1258 };
1259 if result != libc::KERN_SUCCESS {
1260 return None;
1261 }
1262 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }).ok()?;
1264 let (free, speculative, inactive) = (
1265 stats.free_count,
1266 stats.speculative_count,
1267 stats.inactive_count,
1268 );
1269 super::available_pages_bytes(
1272 u64::from(free).saturating_sub(u64::from(speculative)),
1273 u64::from(inactive),
1274 page,
1275 )
1276 }
1277
1278 #[cfg(not(target_os = "macos"))]
1279 pub(super) fn available_memory_bytes() -> Option<u64> {
1280 meminfo_bytes("MemAvailable:")
1281 }
1282
1283 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
1284 use std::os::unix::ffi::OsStrExt as _;
1285 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1286 let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
1288 if unsafe { libc::statvfs(name.as_ptr(), &raw mut stats) } != 0 {
1291 return None;
1292 }
1293 #[allow(clippy::useless_conversion, reason = "the field widths differ per OS")]
1294 u64::from(stats.f_bavail).checked_mul(u64::from(stats.f_frsize))
1295 }
1296
1297 #[cfg(not(target_os = "macos"))]
1298 pub(super) fn cpu_times() -> Option<(u64, u64)> {
1299 super::proc_stat_cpu_times(&std::fs::read_to_string("/proc/stat").ok()?)
1300 }
1301
1302 #[cfg(target_os = "macos")]
1306 #[allow(
1307 deprecated,
1308 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1309 )]
1310 pub(super) fn cpu_times() -> Option<(u64, u64)> {
1311 let mut load: libc::host_cpu_load_info = unsafe { std::mem::zeroed() };
1313 let mut count = libc::HOST_CPU_LOAD_INFO_COUNT;
1314 let result = unsafe {
1317 libc::host_statistics(
1318 host_port(),
1319 libc::HOST_CPU_LOAD_INFO,
1320 (&raw mut load).cast(),
1321 &raw mut count,
1322 )
1323 };
1324 if result != libc::KERN_SUCCESS {
1325 return None;
1326 }
1327 let ticks = load.cpu_ticks.map(u64::from);
1328 let idle = ticks[usize::try_from(libc::CPU_STATE_IDLE).ok()?];
1329 Some((idle, ticks.iter().sum()))
1330 }
1331
1332 fn unsupported() -> RegistryError {
1333 RegistryError::Other("the registry exists only on Windows".into())
1334 }
1335
1336 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1337 Err(unsupported())
1338 }
1339
1340 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1341 Err(unsupported())
1342 }
1343
1344 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1345 Err(unsupported())
1346 }
1347
1348 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1349 Err(unsupported())
1350 }
1351
1352 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1353 Err(unsupported())
1354 }
1355
1356 fn exit_outcome(
1357 status: std::io::Result<std::process::ExitStatus>,
1358 what: &str,
1359 ) -> ElevationOutcome {
1360 match status {
1361 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1362 Err(error) => {
1363 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1364 }
1365 }
1366 }
1367
1368 pub(super) fn run_elevated(
1369 program: &Path,
1370 args: &[OsString],
1371 terminal: bool,
1372 ) -> ElevationOutcome {
1373 if terminal
1374 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1375 {
1376 let status = Command::new("sudo")
1377 .arg("--")
1378 .arg(program)
1379 .args(args)
1380 .status();
1381 return match exit_outcome(status, "sudo") {
1385 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1386 other => other,
1387 };
1388 }
1389 if cfg!(target_os = "macos") {
1390 let command = std::iter::once(program.as_os_str().to_owned())
1391 .chain(args.iter().cloned())
1392 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1393 .collect::<Vec<_>>()
1394 .join(" ");
1395 let output = Command::new("/usr/bin/osascript")
1396 .arg("-e")
1397 .arg(super::administrator_applescript(&command))
1398 .output();
1399 return match output {
1400 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1401 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1403 ElevationOutcome::Refused
1404 }
1405 Ok(output) => {
1406 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1407 if detail.contains("(-60007)") || detail.contains("(-1743)") {
1408 ElevationOutcome::Unavailable(format!(
1409 "macOS could not show the administrator dialog here: {detail}"
1410 ))
1411 } else {
1412 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1413 }
1414 }
1415 Err(error) => ElevationOutcome::Unavailable(format!(
1416 "osascript could not be started: {error}"
1417 )),
1418 };
1419 }
1420 ElevationOutcome::Unavailable(
1421 "no terminal to ask for a sudo password; run the command from a terminal".into(),
1422 )
1423 }
1424}
1425
1426#[cfg(test)]
1427mod tests {
1428 use super::*;
1429
1430 #[test]
1431 fn a_cpu_figure_asked_too_soon_repeats_the_last_one_and_keeps_its_window() {
1432 let start = std::time::Instant::now();
1433 let after = |secs| start + std::time::Duration::from_secs(secs);
1434 let mut sampler = CpuSampler::new();
1435 assert_eq!(
1436 sampler.read((0, 0), start),
1437 None,
1438 "the first reading starts a window"
1439 );
1440 assert_eq!(
1441 sampler.read((50, 100), after(1)),
1442 None,
1443 "too soon, and nothing yet"
1444 );
1445 assert_eq!(sampler.read((50, 100), after(6)), Some(50));
1446 assert_eq!(sampler.read((50, 110), after(7)), Some(50));
1448 assert_eq!(sampler.read((60, 200), after(11)), Some(90));
1450 assert_eq!(
1451 busy_share((0, 100), (11, 110)),
1452 Some(0),
1453 "rounding past the total"
1454 );
1455 }
1456
1457 #[test]
1458 fn the_cpu_share_is_busy_time_over_all_time_and_never_past_100() {
1459 let first = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 1 2 3 4 5\n";
1460 let second = "cpu 400 0 150 1000 50 0 0 0 0 0\n";
1461 let (a, b) = (
1462 proc_stat_cpu_times(first).unwrap(),
1463 proc_stat_cpu_times(second).unwrap(),
1464 );
1465 assert_eq!(a, (850, 1000), "iowait counts as idle");
1466 assert_eq!(busy_share(a, b), Some(67));
1468 assert_eq!(busy_share(a, a), None, "no time passed");
1469 assert_eq!(busy_share(b, a), None, "a counter went back");
1470 assert_eq!(busy_share((0, 100), (0, 200)), Some(100));
1471 assert_eq!(proc_stat_cpu_times("cpu0 1 2 3 4 5\n"), None);
1472 assert_eq!(proc_stat_cpu_times("cpu 1 2 x 4 5\n"), None);
1473 }
1474
1475 #[test]
1476 fn the_busy_share_comes_from_the_difference_of_two_readings() {
1477 assert_eq!(busy_share((0, 0), (250, 1000)), Some(75));
1479 assert_eq!(busy_share((250, 1000), (250, 1000)), None);
1480 assert_eq!(busy_share((10, 20), (5, 40)), None, "went back");
1481 assert_eq!(available_pages_bytes(10, 5, 16_384), Some(15 * 16_384));
1482 }
1483
1484 #[test]
1487 fn available_memory_is_memavailable_not_memfree() {
1488 let meminfo = "MemTotal: 32000000 kB\n\
1489 MemFree: 9000000 kB\n\
1490 MemAvailable: 18000000 kB\n";
1491 assert_eq!(
1492 meminfo_field(meminfo, "MemAvailable:"),
1493 Some(18_000_000 * 1024)
1494 );
1495 assert_eq!(meminfo_field(meminfo, "SwapFree:"), None);
1496 }
1497
1498 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1500 #[test]
1501 fn free_memory_is_reported_and_below_the_total() {
1502 let free = available_memory_bytes().expect("free memory");
1503 let total = physical_memory_bytes().expect("total memory");
1504 assert!(free > 0 && free <= total, "{free} of {total}");
1505 }
1506
1507 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1508 #[test]
1509 fn this_hosts_cpu_counters_are_read_and_move_forward() {
1510 let first = sys::cpu_times().expect("CPU times");
1511 std::thread::sleep(std::time::Duration::from_millis(200));
1512 let second = sys::cpu_times().expect("CPU times");
1513 assert!(second.1 > first.1, "{first:?} then {second:?}");
1514 assert!(busy_share(first, second).is_some_and(|percent| percent <= 100));
1515 }
1516
1517 #[test]
1518 fn windows_arguments_survive_the_command_line_round_trip() {
1519 for (argument, expected) in [
1520 ("plain", "plain"),
1521 ("", "\"\""),
1522 ("with space", "\"with space\""),
1523 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1524 ("say \"hi\"", "\"say \\\"hi\\\"\""),
1525 ("a\\\"b", "\"a\\\\\\\"b\""),
1526 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1527 ] {
1528 assert_eq!(
1529 crate::service::quote_argument(argument),
1530 expected,
1531 "{argument}"
1532 );
1533 }
1534 }
1535
1536 #[test]
1537 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1538 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1539 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1540 assert_eq!(
1541 script,
1542 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1543 );
1544 }
1545
1546 #[test]
1547 fn a_symlink_probe_leaves_nothing_behind() {
1548 let directory = tempfile::tempdir().unwrap();
1549 let _ = can_create_symlink(directory.path()).unwrap();
1550 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1551 }
1552
1553 #[cfg(unix)]
1554 #[test]
1555 fn unix_accounts_can_always_create_symlinks() {
1556 let directory = tempfile::tempdir().unwrap();
1557 assert!(can_create_symlink(directory.path()).unwrap());
1558 }
1559
1560 #[test]
1561 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1562 let directory = tempfile::tempdir().unwrap();
1563 let name = if cfg!(windows) {
1564 "rmtool.exe"
1565 } else {
1566 "rmtool"
1567 };
1568 let tool = directory.path().join(name);
1569 std::fs::write(&tool, b"").unwrap();
1570 #[cfg(unix)]
1571 {
1572 use std::os::unix::fs::PermissionsExt as _;
1573 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1574 }
1575 let path = std::env::join_paths([directory.path()]).unwrap();
1576 assert_eq!(find_on_path("rmtool", &path), Some(tool));
1577 assert_eq!(find_on_path("absent-tool", &path), None);
1578 assert_eq!(find_on_path("../rmtool", &path), None);
1579 }
1580
1581 #[cfg(windows)]
1584 #[test]
1585 fn an_extensionless_file_is_never_the_windows_answer() {
1586 let directory = tempfile::tempdir().unwrap();
1587 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1588 let path = std::env::join_paths([directory.path()]).unwrap();
1589 assert_eq!(find_on_path("npm", &path), None);
1590 let cmd = directory.path().join("npm.cmd");
1591 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1592 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1593 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1594 }
1595
1596 fn path_of(directories: &[&Path]) -> OsString {
1598 std::env::join_paths(directories).expect("joinable directories")
1599 }
1600
1601 fn touch(path: &Path) {
1602 std::fs::write(path, b"").expect("a fixture file");
1603 }
1604
1605 #[test]
1609 fn npm_shipped_only_as_npm_cmd_is_found_on_a_windows_path() {
1610 let empty = tempfile::tempdir().unwrap();
1611 let nodejs = tempfile::tempdir().unwrap();
1612 touch(&nodejs.path().join("npm"));
1613 touch(&nodejs.path().join("npm.cmd"));
1614 let path = path_of(&[empty.path(), nodejs.path()]);
1615 let cmd = Some(nodejs.path().join("npm.cmd"));
1616
1617 assert_eq!(
1618 find_on_windows_path("npm", &path, Some(".COM;.EXE;.BAT;.CMD".as_ref())),
1619 cmd
1620 );
1621 assert_eq!(find_on_windows_path("npm", &path, None), cmd);
1623 assert_eq!(find_on_windows_path("npm", &path, Some(" ".as_ref())), cmd);
1624 assert_eq!(find_on_windows_path("npm.cmd", &path, None), cmd);
1626 touch(&nodejs.path().join("npm.ps1"));
1627 assert_eq!(find_on_windows_path("npm.ps1", &path, None), None);
1628 }
1629
1630 #[test]
1633 fn pathext_orders_extensions_within_a_directory_and_path_orders_directories() {
1634 let both = tempfile::tempdir().unwrap();
1635 touch(&both.path().join("npm.cmd"));
1636 touch(&both.path().join("npm.exe"));
1637 let path = path_of(&[both.path()]);
1638 assert_eq!(
1639 find_on_windows_path("npm", &path, Some(".CMD;.EXE".as_ref())),
1640 Some(both.path().join("npm.cmd"))
1641 );
1642 assert_eq!(
1643 find_on_windows_path("npm", &path, Some(".exe;.cmd".as_ref())),
1644 Some(both.path().join("npm.exe"))
1645 );
1646
1647 let first = tempfile::tempdir().unwrap();
1648 let second = tempfile::tempdir().unwrap();
1649 touch(&first.path().join("npm.cmd"));
1650 touch(&second.path().join("npm.exe"));
1651 assert_eq!(
1652 find_on_windows_path(
1653 "npm",
1654 &path_of(&[first.path(), second.path()]),
1655 Some(".EXE;.CMD".as_ref())
1656 ),
1657 Some(first.path().join("npm.cmd")),
1658 "an earlier PATH directory wins over a preferred extension later on"
1659 );
1660 }
1661
1662 #[test]
1665 #[serial_test::serial(current_dir)]
1666 fn a_missing_npm_is_not_found_on_a_windows_path() {
1667 let empty = tempfile::tempdir().unwrap();
1668 assert_eq!(
1669 find_on_windows_path("npm", &path_of(&[empty.path()]), None),
1670 None
1671 );
1672
1673 let script_only = tempfile::tempdir().unwrap();
1674 touch(&script_only.path().join("npm.ps1"));
1675 assert_eq!(
1676 find_on_windows_path(
1677 "npm",
1678 &path_of(&[script_only.path()]),
1679 Some(".PS1;.EXE;.CMD".as_ref())
1680 ),
1681 None
1682 );
1683
1684 let planted = tempfile::tempdir().unwrap();
1687 touch(&planted.path().join("npm.cmd"));
1688 if let Some(relative) = relative_to_current_directory(planted.path()) {
1689 assert!(relative.join("npm.cmd").is_file(), "{}", relative.display());
1690 assert_eq!(
1691 find_on_windows_path("npm", &path_of(&[&relative]), None),
1692 None
1693 );
1694 }
1695 }
1696
1697 fn relative_to_current_directory(target: &Path) -> Option<PathBuf> {
1700 let here = std::env::current_dir().ok()?;
1701 let target = std::fs::canonicalize(target).ok()?;
1702 let here = std::fs::canonicalize(here).ok()?;
1703 let (here, target): (Vec<_>, Vec<_>) =
1704 (here.components().collect(), target.components().collect());
1705 let common = here
1706 .iter()
1707 .zip(&target)
1708 .take_while(|(left, right)| left == right)
1709 .count();
1710 if common == 0 {
1711 return None;
1712 }
1713 let mut relative: PathBuf = std::iter::repeat_n("..", here.len() - common).collect();
1714 relative.extend(&target[common..]);
1715 Some(relative)
1716 }
1717
1718 fn unfit_finding(id: &str) -> UnfitFinding {
1719 UnfitFinding {
1720 id: id.into(),
1721 detail: "it fails".into(),
1722 remedy: None,
1723 }
1724 }
1725
1726 #[test]
1729 fn who_can_answer_an_administrator_prompt() {
1730 assert!(
1731 prompt_answerable(Some(1), false),
1732 "the desktop, no terminal"
1733 );
1734 assert!(prompt_answerable(Some(2), true));
1735 assert!(!prompt_answerable(Some(0), true), "services and SSH");
1736 assert!(prompt_answerable(None, true), "sudo on a terminal");
1737 assert!(!prompt_answerable(None, false));
1738 }
1739
1740 #[test]
1742 fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1743 let root = tempfile::tempdir().unwrap();
1744 let paths = AppPaths::rooted_at(root.path());
1745 paths.create_all().unwrap();
1746 let then = Utc::now();
1747 record_host_unfit(
1748 &paths,
1749 &[unfit_finding("host.runner_root_responsive")],
1750 then,
1751 )
1752 .unwrap();
1753 let later = then + chrono::Duration::hours(1);
1754 assert_eq!(
1755 host_unfit_in_force(&paths, later),
1756 None,
1757 "nobody beats: stopped"
1758 );
1759
1760 crate::daemon_heartbeat::beat(&paths, then).unwrap();
1762 assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1763 crate::daemon_heartbeat::beat(&paths, later).unwrap();
1764 assert_eq!(
1765 host_unfit_in_force(&paths, later),
1766 None,
1767 "a daemon still beating would have re-stamped a refusal in force"
1768 );
1769
1770 let much_later = later + chrono::Duration::hours(1);
1773 assert_eq!(host_unfit_in_force(&paths, much_later), None);
1774 }
1775
1776 #[test]
1777 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1778 let root = tempfile::tempdir().unwrap();
1779 let paths = AppPaths::rooted_at(root.path());
1780 paths.create_all().unwrap();
1781 assert_eq!(host_unfit(&paths).unwrap(), None);
1782 let first = Utc::now() - chrono::Duration::minutes(10);
1783 record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1784 let later = Utc::now();
1785 record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1786 let record = host_unfit(&paths).unwrap().unwrap();
1787 assert_eq!(record.since, first);
1788 assert_eq!(record.checked_at, later);
1789 assert_eq!(record.checks, ["host.required_tools"]);
1790 clear_host_unfit(&paths).unwrap();
1791 assert_eq!(host_unfit(&paths).unwrap(), None);
1792 clear_host_unfit(&paths).unwrap();
1793 }
1794
1795 #[test]
1796 fn physical_memory_is_reported() {
1797 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1798 }
1799
1800 #[test]
1801 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1802 let directory = tempfile::tempdir().unwrap();
1803 let deadline = std::time::Duration::from_secs(10);
1804 assert_eq!(
1805 directory_responds(directory.path(), deadline),
1806 Responsiveness::Responds
1807 );
1808 assert_eq!(
1809 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1810 Responsiveness::Responds
1811 );
1812 assert!(
1815 !PROBES_IN_FLIGHT
1816 .lock()
1817 .unwrap()
1818 .iter()
1819 .any(|(path, _)| path.starts_with(directory.path())),
1820 "a finished probe deregisters"
1821 );
1822 }
1823
1824 #[test]
1829 fn a_path_still_being_probed_answers_without_a_second_thread() {
1830 let directory = tempfile::tempdir().unwrap();
1831 for (name, listing, expected) in [
1832 ("stuck-volume", false, Responsiveness::Hung),
1833 ("asking-volume", true, Responsiveness::ListingBlocked),
1834 ] {
1835 let stuck = directory.path().join(name);
1836 PROBES_IN_FLIGHT.lock().unwrap().push((
1837 stuck.clone(),
1838 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1839 ));
1840 let started = std::time::Instant::now();
1841 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1842 PROBES_IN_FLIGHT
1843 .lock()
1844 .unwrap()
1845 .retain(|(path, _)| *path != stuck);
1846 assert_eq!(answer, expected, "{name}");
1847 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1848 }
1849 }
1850}