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))]
233#[derive(Clone, Copy)]
234struct VmPages {
235 free: u64,
236 speculative: u64,
237 external: u64,
238 purgeable: u64,
239}
240
241#[cfg_attr(not(target_os = "macos"), allow(dead_code))]
250fn available_pages_bytes(pages: VmPages, page: u64) -> Option<u64> {
251 pages
253 .free
254 .saturating_sub(pages.speculative)
255 .checked_add(pages.external)?
256 .checked_add(pages.purgeable)?
257 .checked_mul(page)
258}
259
260#[cfg(windows)]
266pub fn system_directory() -> std::io::Result<PathBuf> {
267 crate::runner_root::windows_system_directory().map(PathBuf::from)
268}
269
270pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
276 use std::io::Write as _;
277 let parent = path.parent().unwrap_or_else(|| Path::new("."));
278 std::fs::create_dir_all(parent)?;
279 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
280 temporary.write_all(bytes)?;
281 temporary.as_file().sync_all()?;
282 temporary
283 .persist(path)
284 .map(|_| ())
285 .map_err(|error| error.error)
286}
287
288#[derive(Debug, Clone, PartialEq, Eq)]
290pub enum Responsiveness {
291 Responds,
293 Hung,
296 ListingBlocked,
302 NotPermitted(String),
306 Failed(String),
308}
309
310type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
313
314static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
318
319#[must_use]
321pub fn probes_in_flight() -> Vec<PathBuf> {
322 PROBES_IN_FLIGHT
323 .lock()
324 .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
325 .unwrap_or_default()
326}
327
328fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
330 if listing.load(std::sync::atomic::Ordering::Acquire) {
331 Responsiveness::ListingBlocked
332 } else {
333 Responsiveness::Hung
334 }
335}
336
337#[must_use]
345pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
346 let path = directory.to_path_buf();
347 let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
350 {
351 let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
352 return Responsiveness::Failed("the probe registry is poisoned".into());
353 };
354 if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
355 return unanswered(stuck);
356 }
357 in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
358 }
359 let (sender, receiver) = std::sync::mpsc::channel();
360 let probed = path.clone();
361 let listing_started = std::sync::Arc::clone(&listing);
362 let spawned = std::thread::Builder::new()
363 .name("runner-root-probe".into())
364 .spawn(move || {
365 let existing = probed
366 .ancestors()
367 .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
368 .map(Path::to_path_buf);
369 listing_started.store(true, std::sync::atomic::Ordering::Release);
370 let refused = |error: std::io::Error| {
371 if is_not_permitted(&error) {
372 Responsiveness::NotPermitted(error.to_string())
373 } else {
374 Responsiveness::Failed(error.to_string())
375 }
376 };
377 let answer = match existing {
378 None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
379 Some(existing) => match std::fs::read_dir(&existing) {
380 Ok(mut entries) => match entries.next() {
381 Some(Err(error)) => refused(error),
382 _ => Responsiveness::Responds,
383 },
384 Err(error) => refused(error),
385 },
386 };
387 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
388 in_flight.retain(|(candidate, _)| *candidate != probed);
389 }
390 let _ = sender.send(answer);
391 });
392 if let Err(error) = spawned {
393 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
394 in_flight.retain(|(candidate, _)| *candidate != path);
395 }
396 return Responsiveness::Failed(error.to_string());
397 }
398 receiver
399 .recv_timeout(deadline)
400 .unwrap_or_else(|_| unanswered(&listing))
401}
402
403fn is_not_permitted(error: &std::io::Error) -> bool {
405 #[cfg(unix)]
406 {
407 error.raw_os_error() == Some(libc::EPERM)
408 }
409 #[cfg(not(unix))]
410 {
411 let _ = error;
412 false
413 }
414}
415
416pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
428 std::fs::create_dir_all(directory)?;
429 let unique = format!(
430 ".rm-symlink-probe-{}-{}",
431 std::process::id(),
432 uuid::Uuid::new_v4().simple()
433 );
434 let target = directory.join(format!("{unique}.target"));
435 let link = directory.join(format!("{unique}.link"));
436 std::fs::write(&target, b"")?;
437 let created = symlink_file(&target, &link);
438 let _ = std::fs::remove_file(&link);
439 let _ = std::fs::remove_file(&target);
440 Ok(created.is_ok())
441}
442
443#[cfg(windows)]
444fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
445 std::os::windows::fs::symlink_file(target, link)
446}
447
448#[cfg(unix)]
449fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
450 std::os::unix::fs::symlink(target, link)
451}
452
453#[must_use]
456pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
457 if cfg!(windows) {
458 return find_on_windows_path(tool, path, std::env::var_os("PATHEXT").as_deref());
459 }
460 if tool.is_empty() || tool.contains(['/', '\\']) {
461 return None;
462 }
463 std::env::split_paths(path)
464 .filter(|dir| !dir.as_os_str().is_empty())
465 .map(|dir| dir.join(tool))
466 .find(|candidate| is_executable(candidate))
467}
468
469#[must_use]
487pub fn find_on_windows_path(tool: &str, path: &OsStr, pathext: Option<&OsStr>) -> Option<PathBuf> {
488 const RUNNABLE: [&str; 4] = [".exe", ".com", ".cmd", ".bat"];
489 if tool.is_empty() || tool.contains(['/', '\\']) {
490 return None;
491 }
492 let pathext = pathext
493 .and_then(OsStr::to_str)
494 .filter(|value| !value.trim().is_empty())
495 .unwrap_or(".COM;.EXE;.BAT;.CMD");
496 let bare = Path::new(tool)
497 .extension()
498 .and_then(OsStr::to_str)
499 .is_some_and(|extension| {
500 RUNNABLE
501 .iter()
502 .any(|runnable| runnable[1..].eq_ignore_ascii_case(extension))
503 })
504 .then_some("");
505 let extensions: Vec<&str> = bare
506 .into_iter()
507 .chain(pathext.split(';').filter_map(|extension| {
508 RUNNABLE
509 .into_iter()
510 .find(|runnable| runnable.eq_ignore_ascii_case(extension.trim()))
511 }))
512 .collect();
513 std::env::split_paths(path)
514 .filter(|dir| dir.is_absolute())
515 .find_map(|dir| {
516 extensions.iter().find_map(|extension| {
517 let candidate = dir.join(format!("{tool}{extension}"));
518 candidate.is_file().then_some(candidate)
519 })
520 })
521}
522
523#[cfg(unix)]
524fn is_executable(path: &Path) -> bool {
525 use std::os::unix::fs::PermissionsExt as _;
526 std::fs::metadata(path)
527 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
528}
529
530#[cfg(windows)]
531fn is_executable(path: &Path) -> bool {
532 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
533}
534
535#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
541pub enum RegistryError {
542 #[error("access denied")]
545 AccessDenied,
546 #[error("{0}")]
548 Other(String),
549}
550
551pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
557 sys::read_dword(subkey, value)
558}
559
560pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
565 sys::read_string(subkey, value)
566}
567
568pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
574 sys::value_names(subkey)
575}
576
577pub fn write_registry_dword(
583 subkey: &str,
584 value: &str,
585 data: Option<u32>,
586) -> Result<(), RegistryError> {
587 sys::write_dword(subkey, value, data)
588}
589
590pub fn write_registry_string(
596 subkey: &str,
597 value: &str,
598 data: Option<&str>,
599) -> Result<(), RegistryError> {
600 sys::write_string(subkey, value, data)
601}
602
603pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
609
610pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
613
614pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
618 std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
619const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
620
621#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
623pub struct HostUnfitRecord {
624 pub schema_version: u32,
625 pub since: DateTime<Utc>,
627 pub checked_at: DateTime<Utc>,
629 pub checks: Vec<String>,
631 #[serde(default)]
634 pub findings: Vec<UnfitFinding>,
635}
636
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
639pub struct UnfitFinding {
640 pub id: String,
641 pub detail: String,
642 #[serde(default)]
643 pub remedy: Option<String>,
644}
645
646impl HostUnfitRecord {
647 #[must_use]
649 pub fn describe(&self) -> String {
650 if self.findings.is_empty() {
651 return self.checks.join(", ");
652 }
653 self.findings
654 .iter()
655 .map(|finding| match &finding.remedy {
656 Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
657 None => format!("{}: {}", finding.id, finding.detail),
658 })
659 .collect::<Vec<_>>()
660 .join("; ")
661 }
662}
663
664fn host_unfit_path(paths: &AppPaths) -> PathBuf {
665 paths.state_dir().join(HOST_UNFIT_FILE)
666}
667
668pub fn record_host_unfit(
674 paths: &AppPaths,
675 findings: &[UnfitFinding],
676 at: DateTime<Utc>,
677) -> Result<(), String> {
678 let since = host_unfit(paths)
679 .ok()
680 .flatten()
681 .map_or(at, |record| record.since);
682 let record = HostUnfitRecord {
683 schema_version: HOST_UNFIT_SCHEMA_VERSION,
684 since,
685 checked_at: at,
686 checks: findings.iter().map(|finding| finding.id.clone()).collect(),
687 findings: findings.to_vec(),
688 };
689 let path = host_unfit_path(paths);
690 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
691 write_atomically(&path, &text)
692 .map_err(|error| format!("cannot write {}: {error}", path.display()))
693}
694
695pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
700 let path = host_unfit_path(paths);
701 match std::fs::remove_file(&path) {
702 Ok(()) => Ok(()),
703 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
704 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
705 }
706}
707
708#[must_use]
717pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
718 host_unfit(paths).ok().flatten().filter(|record| {
719 if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
720 return true;
721 }
722 match crate::daemon_heartbeat::liveness(paths, now) {
725 crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
726 !crate::wsl::fence::elapsed_at_least(
727 record.checked_at,
728 since,
729 HOST_UNFIT_RECORD_FRESH,
730 )
731 }
732 _ => false,
733 }
734 })
735}
736
737pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
742 let path = host_unfit_path(paths);
743 match std::fs::read_to_string(&path) {
744 Ok(text) => serde_json::from_str(&text)
745 .map(Some)
746 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
747 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
748 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
749 }
750}
751
752#[cfg(windows)]
757mod sys {
758 use std::ffi::OsString;
759 use std::os::windows::ffi::OsStrExt as _;
760 use std::path::Path;
761
762 use windows::Win32::Foundation::{
763 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
764 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
765 };
766 use windows::Win32::Security::{
767 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
768 };
769 use windows::Win32::System::Registry::{
770 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
771 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
772 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
773 RegSetValueExW,
774 };
775 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
776 use windows::Win32::System::Threading::{
777 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
778 };
779 use windows::Win32::UI::Shell::{
780 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
781 };
782 use windows::core::{PCWSTR, PWSTR};
783
784 use super::{ElevationOutcome, RegistryError};
785
786 const SW_HIDE: i32 = 0;
789 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
793
794 fn wide(text: &str) -> Vec<u16> {
795 text.encode_utf16().chain(std::iter::once(0)).collect()
796 }
797
798 fn registry_error(code: WIN32_ERROR) -> RegistryError {
799 if code == ERROR_ACCESS_DENIED {
800 RegistryError::AccessDenied
801 } else {
802 RegistryError::Other(format!("registry error {}", code.0))
803 }
804 }
805
806 pub(super) fn session_id() -> Option<u32> {
808 let mut token = HANDLE::default();
809 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
812 return None;
813 }
814 let mut session = 0u32;
815 let mut returned = 0u32;
816 let queried = unsafe {
819 GetTokenInformation(
820 token,
821 TokenSessionId,
822 Some((&raw mut session).cast()),
823 4,
824 &raw mut returned,
825 )
826 };
827 let _ = unsafe { CloseHandle(token) };
829 queried.is_ok().then_some(session)
830 }
831
832 pub(super) fn is_elevated() -> bool {
833 let mut token = HANDLE::default();
834 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
837 return false;
838 }
839 let mut elevation = TOKEN_ELEVATION::default();
840 let mut returned = 0u32;
841 let queried = unsafe {
844 GetTokenInformation(
845 token,
846 TokenElevation,
847 Some((&raw mut elevation).cast()),
848 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
849 &raw mut returned,
850 )
851 };
852 let _ = unsafe { CloseHandle(token) };
854 queried.is_ok() && elevation.TokenIsElevated != 0
855 }
856
857 fn memory_status() -> Option<MEMORYSTATUSEX> {
858 let mut status = MEMORYSTATUSEX {
859 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
860 ..MEMORYSTATUSEX::default()
861 };
862 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
865 Some(status)
866 }
867
868 pub(super) fn physical_memory_bytes() -> Option<u64> {
869 memory_status().map(|status| status.ullTotalPhys)
870 }
871
872 pub(super) fn available_memory_bytes() -> Option<u64> {
873 memory_status().map(|status| status.ullAvailPhys)
874 }
875
876 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
877 let wide: Vec<u16> = path
878 .as_os_str()
879 .encode_wide()
880 .chain(std::iter::once(0))
881 .collect();
882 let mut free = 0u64;
883 unsafe {
886 windows::Win32::Storage::FileSystem::GetDiskFreeSpaceExW(
887 PCWSTR(wide.as_ptr()),
888 Some(&raw mut free),
889 None,
890 None,
891 )
892 }
893 .ok()?;
894 Some(free)
895 }
896
897 pub(super) fn cpu_times() -> Option<(u64, u64)> {
900 use windows::Win32::Foundation::FILETIME;
901 let (mut idle, mut kernel, mut user) = (
902 FILETIME::default(),
903 FILETIME::default(),
904 FILETIME::default(),
905 );
906 unsafe {
908 windows::Win32::System::Threading::GetSystemTimes(
909 Some(&raw mut idle),
910 Some(&raw mut kernel),
911 Some(&raw mut user),
912 )
913 }
914 .ok()?;
915 let value =
916 |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime);
917 Some((value(idle), value(kernel).checked_add(value(user))?))
918 }
919
920 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
921 let subkey = wide(subkey);
922 let value = wide(value);
923 let mut data = 0u32;
924 let mut size = 4u32;
925 let code = unsafe {
928 RegGetValueW(
929 HKEY_LOCAL_MACHINE,
930 PCWSTR(subkey.as_ptr()),
931 PCWSTR(value.as_ptr()),
932 RRF_RT_REG_DWORD,
933 None,
934 Some((&raw mut data).cast()),
935 Some(&raw mut size),
936 )
937 };
938 match code {
939 ERROR_SUCCESS => Ok(Some(data)),
940 ERROR_FILE_NOT_FOUND => Ok(None),
941 other => Err(registry_error(other)),
942 }
943 }
944
945 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
946 let subkey = wide(subkey);
947 let value = wide(value);
948 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
949 let mut size = 0u32;
950 let code = unsafe {
952 RegGetValueW(
953 HKEY_LOCAL_MACHINE,
954 PCWSTR(subkey.as_ptr()),
955 PCWSTR(value.as_ptr()),
956 flags,
957 None,
958 None,
959 Some(&raw mut size),
960 )
961 };
962 match code {
963 ERROR_SUCCESS => {}
964 ERROR_FILE_NOT_FOUND => return Ok(None),
965 other => return Err(registry_error(other)),
966 }
967 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
968 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
969 let code = unsafe {
972 RegGetValueW(
973 HKEY_LOCAL_MACHINE,
974 PCWSTR(subkey.as_ptr()),
975 PCWSTR(value.as_ptr()),
976 flags,
977 None,
978 Some(buffer.as_mut_ptr().cast()),
979 Some(&raw mut size),
980 )
981 };
982 match code {
983 ERROR_SUCCESS => {
984 let units = (size as usize / 2).min(buffer.len());
985 let text = String::from_utf16_lossy(&buffer[..units]);
986 Ok(Some(text.trim_end_matches('\0').to_owned()))
987 }
988 ERROR_FILE_NOT_FOUND => Ok(None),
989 other => Err(registry_error(other)),
990 }
991 }
992
993 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
994 let subkey = wide(subkey);
995 let mut key = HKEY::default();
996 let code = unsafe {
998 RegOpenKeyExW(
999 HKEY_LOCAL_MACHINE,
1000 PCWSTR(subkey.as_ptr()),
1001 None,
1002 KEY_READ,
1003 &raw mut key,
1004 )
1005 };
1006 match code {
1007 ERROR_SUCCESS => {}
1008 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
1009 other => return Err(registry_error(other)),
1010 }
1011 let mut names = Vec::new();
1012 let mut index = 0u32;
1013 let result = loop {
1014 let mut name = vec![0u16; 16_384];
1016 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
1017 let code = unsafe {
1020 RegEnumValueW(
1021 key,
1022 index,
1023 Some(PWSTR(name.as_mut_ptr())),
1024 &raw mut length,
1025 None,
1026 None,
1027 None,
1028 None,
1029 )
1030 };
1031 match code {
1032 ERROR_SUCCESS => {
1033 names.push(String::from_utf16_lossy(&name[..length as usize]));
1034 index += 1;
1035 }
1036 ERROR_NO_MORE_ITEMS => break Ok(names),
1037 other => break Err(registry_error(other)),
1038 }
1039 };
1040 let _ = unsafe { RegCloseKey(key) };
1042 result
1043 }
1044
1045 fn write_value(
1046 subkey: &str,
1047 value: &str,
1048 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
1049 ) -> Result<(), RegistryError> {
1050 let subkey_wide = wide(subkey);
1051 let value_wide = wide(value);
1052 let Some((kind, bytes)) = data else {
1053 let code = unsafe {
1055 RegDeleteKeyValueW(
1056 HKEY_LOCAL_MACHINE,
1057 PCWSTR(subkey_wide.as_ptr()),
1058 PCWSTR(value_wide.as_ptr()),
1059 )
1060 };
1061 return match code {
1062 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
1063 other => Err(registry_error(other)),
1064 };
1065 };
1066 let mut key = HKEY::default();
1067 let code = unsafe {
1070 RegCreateKeyExW(
1071 HKEY_LOCAL_MACHINE,
1072 PCWSTR(subkey_wide.as_ptr()),
1073 None,
1074 PCWSTR::null(),
1075 REG_OPTION_NON_VOLATILE,
1076 KEY_SET_VALUE,
1077 None,
1078 &raw mut key,
1079 None,
1080 )
1081 };
1082 if code != ERROR_SUCCESS {
1083 return Err(registry_error(code));
1084 }
1085 let code =
1088 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
1089 let _ = unsafe { RegCloseKey(key) };
1091 if code == ERROR_SUCCESS {
1092 Ok(())
1093 } else {
1094 Err(registry_error(code))
1095 }
1096 }
1097
1098 pub(super) fn write_dword(
1099 subkey: &str,
1100 value: &str,
1101 data: Option<u32>,
1102 ) -> Result<(), RegistryError> {
1103 write_value(
1104 subkey,
1105 value,
1106 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
1107 )
1108 }
1109
1110 pub(super) fn write_string(
1111 subkey: &str,
1112 value: &str,
1113 data: Option<&str>,
1114 ) -> Result<(), RegistryError> {
1115 write_value(
1116 subkey,
1117 value,
1118 data.map(|text| {
1119 let bytes = wide(text)
1120 .iter()
1121 .flat_map(|unit| unit.to_le_bytes())
1122 .collect();
1123 (REG_SZ, bytes)
1124 }),
1125 )
1126 }
1127
1128 pub(super) fn run_elevated(
1129 program: &Path,
1130 args: &[OsString],
1131 _terminal: bool,
1132 ) -> ElevationOutcome {
1133 let file: Vec<u16> = program
1134 .as_os_str()
1135 .encode_wide()
1136 .chain(std::iter::once(0))
1137 .collect();
1138 let parameters = args
1139 .iter()
1140 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
1141 .collect::<Vec<_>>()
1142 .join(" ");
1143 let parameters = wide(¶meters);
1144 let verb = wide("runas");
1145 let mut info = SHELLEXECUTEINFOW {
1146 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
1147 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
1148 lpVerb: PCWSTR(verb.as_ptr()),
1149 lpFile: PCWSTR(file.as_ptr()),
1150 lpParameters: PCWSTR(parameters.as_ptr()),
1151 nShow: SW_HIDE,
1152 ..SHELLEXECUTEINFOW::default()
1153 };
1154 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
1157 #[allow(clippy::cast_sign_loss)]
1158 let code = (error.code().0 as u32) & 0xffff;
1159 return if code == ERROR_CANCELLED.0 {
1160 ElevationOutcome::Refused
1161 } else {
1162 ElevationOutcome::Unavailable(format!(
1163 "Windows could not show the administrator prompt: {}",
1164 error.message()
1165 ))
1166 };
1167 }
1168 if info.hProcess.is_invalid() {
1169 return ElevationOutcome::Unavailable(
1170 "Windows started no elevated process to wait for".into(),
1171 );
1172 }
1173 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
1176 let mut code = 1u32;
1177 let outcome = if waited == WAIT_OBJECT_0 {
1178 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
1180 #[allow(clippy::cast_possible_wrap)]
1181 Ok(()) => ElevationOutcome::Exited(code as i32),
1182 Err(error) => ElevationOutcome::Unavailable(format!(
1183 "the elevated process's exit code could not be read: {}",
1184 error.message()
1185 )),
1186 }
1187 } else {
1188 ElevationOutcome::Unavailable(
1189 "the elevated process did not finish within 15 minutes".into(),
1190 )
1191 };
1192 let _ = unsafe { CloseHandle(info.hProcess) };
1194 outcome
1195 }
1196}
1197
1198#[cfg(unix)]
1203mod sys {
1204 use std::ffi::OsString;
1205 use std::path::Path;
1206 use std::process::Command;
1207
1208 pub(super) const fn session_id() -> Option<u32> {
1210 None
1211 }
1212
1213 use super::{ElevationOutcome, RegistryError};
1214
1215 pub(super) fn is_elevated() -> bool {
1216 unsafe { libc::geteuid() == 0 }
1218 }
1219
1220 #[cfg(target_os = "macos")]
1221 pub(super) fn physical_memory_bytes() -> Option<u64> {
1222 let mut value = 0u64;
1223 let mut size = std::mem::size_of::<u64>();
1224 let name = c"hw.memsize";
1225 let result = unsafe {
1228 libc::sysctlbyname(
1229 name.as_ptr(),
1230 (&raw mut value).cast(),
1231 &raw mut size,
1232 std::ptr::null_mut(),
1233 0,
1234 )
1235 };
1236 (result == 0).then_some(value)
1237 }
1238
1239 #[cfg(not(target_os = "macos"))]
1240 pub(super) fn physical_memory_bytes() -> Option<u64> {
1241 meminfo_bytes("MemTotal:")
1242 }
1243
1244 #[cfg(not(target_os = "macos"))]
1246 fn meminfo_bytes(field: &str) -> Option<u64> {
1247 super::meminfo_field(&std::fs::read_to_string("/proc/meminfo").ok()?, field)
1248 }
1249
1250 #[cfg(target_os = "macos")]
1253 #[allow(
1254 deprecated,
1255 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1256 )]
1257 fn host_port() -> libc::mach_port_t {
1258 static PORT: std::sync::OnceLock<libc::mach_port_t> = std::sync::OnceLock::new();
1259 *PORT.get_or_init(|| unsafe { libc::mach_host_self() })
1261 }
1262
1263 #[cfg(target_os = "macos")]
1264 #[allow(
1265 deprecated,
1266 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1267 )]
1268 pub(super) fn available_memory_bytes() -> Option<u64> {
1269 let mut stats: libc::vm_statistics64 = unsafe { std::mem::zeroed() };
1271 let mut count = libc::HOST_VM_INFO64_COUNT;
1272 let result = unsafe {
1275 libc::host_statistics64(
1276 host_port(),
1277 libc::HOST_VM_INFO64,
1278 (&raw mut stats).cast(),
1279 &raw mut count,
1280 )
1281 };
1282 if result != libc::KERN_SUCCESS {
1283 return None;
1284 }
1285 let page = u64::try_from(unsafe { libc::sysconf(libc::_SC_PAGESIZE) }).ok()?;
1287 super::available_pages_bytes(
1288 super::VmPages {
1289 free: u64::from(stats.free_count),
1290 speculative: u64::from(stats.speculative_count),
1291 external: u64::from(stats.external_page_count),
1292 purgeable: u64::from(stats.purgeable_count),
1293 },
1294 page,
1295 )
1296 }
1297
1298 #[cfg(not(target_os = "macos"))]
1299 pub(super) fn available_memory_bytes() -> Option<u64> {
1300 meminfo_bytes("MemAvailable:")
1301 }
1302
1303 pub(super) fn free_disk_bytes(path: &Path) -> Option<u64> {
1304 use std::os::unix::ffi::OsStrExt as _;
1305 let name = std::ffi::CString::new(path.as_os_str().as_bytes()).ok()?;
1306 let mut stats: libc::statvfs = unsafe { std::mem::zeroed() };
1308 if unsafe { libc::statvfs(name.as_ptr(), &raw mut stats) } != 0 {
1311 return None;
1312 }
1313 #[allow(clippy::useless_conversion, reason = "the field widths differ per OS")]
1314 u64::from(stats.f_bavail).checked_mul(u64::from(stats.f_frsize))
1315 }
1316
1317 #[cfg(not(target_os = "macos"))]
1318 pub(super) fn cpu_times() -> Option<(u64, u64)> {
1319 super::proc_stat_cpu_times(&std::fs::read_to_string("/proc/stat").ok()?)
1320 }
1321
1322 #[cfg(target_os = "macos")]
1326 #[allow(
1327 deprecated,
1328 reason = "libc marks the mach bindings deprecated in favour of a crate this workspace does not take; the call is stable"
1329 )]
1330 pub(super) fn cpu_times() -> Option<(u64, u64)> {
1331 let mut load: libc::host_cpu_load_info = unsafe { std::mem::zeroed() };
1333 let mut count = libc::HOST_CPU_LOAD_INFO_COUNT;
1334 let result = unsafe {
1337 libc::host_statistics(
1338 host_port(),
1339 libc::HOST_CPU_LOAD_INFO,
1340 (&raw mut load).cast(),
1341 &raw mut count,
1342 )
1343 };
1344 if result != libc::KERN_SUCCESS {
1345 return None;
1346 }
1347 let ticks = load.cpu_ticks.map(u64::from);
1348 let idle = ticks[usize::try_from(libc::CPU_STATE_IDLE).ok()?];
1349 Some((idle, ticks.iter().sum()))
1350 }
1351
1352 fn unsupported() -> RegistryError {
1353 RegistryError::Other("the registry exists only on Windows".into())
1354 }
1355
1356 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1357 Err(unsupported())
1358 }
1359
1360 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1361 Err(unsupported())
1362 }
1363
1364 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1365 Err(unsupported())
1366 }
1367
1368 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1369 Err(unsupported())
1370 }
1371
1372 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1373 Err(unsupported())
1374 }
1375
1376 fn exit_outcome(
1377 status: std::io::Result<std::process::ExitStatus>,
1378 what: &str,
1379 ) -> ElevationOutcome {
1380 match status {
1381 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1382 Err(error) => {
1383 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1384 }
1385 }
1386 }
1387
1388 pub(super) fn run_elevated(
1389 program: &Path,
1390 args: &[OsString],
1391 terminal: bool,
1392 ) -> ElevationOutcome {
1393 if terminal
1394 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1395 {
1396 let status = Command::new("sudo")
1397 .arg("--")
1398 .arg(program)
1399 .args(args)
1400 .status();
1401 return match exit_outcome(status, "sudo") {
1405 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1406 other => other,
1407 };
1408 }
1409 if cfg!(target_os = "macos") {
1410 let command = std::iter::once(program.as_os_str().to_owned())
1411 .chain(args.iter().cloned())
1412 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1413 .collect::<Vec<_>>()
1414 .join(" ");
1415 let output = Command::new("/usr/bin/osascript")
1416 .arg("-e")
1417 .arg(super::administrator_applescript(&command))
1418 .output();
1419 return match output {
1420 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1421 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1423 ElevationOutcome::Refused
1424 }
1425 Ok(output) => {
1426 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1427 if detail.contains("(-60007)") || detail.contains("(-1743)") {
1428 ElevationOutcome::Unavailable(format!(
1429 "macOS could not show the administrator dialog here: {detail}"
1430 ))
1431 } else {
1432 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1433 }
1434 }
1435 Err(error) => ElevationOutcome::Unavailable(format!(
1436 "osascript could not be started: {error}"
1437 )),
1438 };
1439 }
1440 ElevationOutcome::Unavailable(
1441 "no terminal to ask for a sudo password; run the command from a terminal".into(),
1442 )
1443 }
1444}
1445
1446#[cfg(test)]
1447mod tests {
1448 use super::*;
1449
1450 #[test]
1451 fn a_cpu_figure_asked_too_soon_repeats_the_last_one_and_keeps_its_window() {
1452 let start = std::time::Instant::now();
1453 let after = |secs| start + std::time::Duration::from_secs(secs);
1454 let mut sampler = CpuSampler::new();
1455 assert_eq!(
1456 sampler.read((0, 0), start),
1457 None,
1458 "the first reading starts a window"
1459 );
1460 assert_eq!(
1461 sampler.read((50, 100), after(1)),
1462 None,
1463 "too soon, and nothing yet"
1464 );
1465 assert_eq!(sampler.read((50, 100), after(6)), Some(50));
1466 assert_eq!(sampler.read((50, 110), after(7)), Some(50));
1468 assert_eq!(sampler.read((60, 200), after(11)), Some(90));
1470 assert_eq!(
1471 busy_share((0, 100), (11, 110)),
1472 Some(0),
1473 "rounding past the total"
1474 );
1475 }
1476
1477 #[test]
1478 fn the_cpu_share_is_busy_time_over_all_time_and_never_past_100() {
1479 let first = "cpu 100 0 50 800 50 0 0 0 0 0\ncpu0 1 2 3 4 5\n";
1480 let second = "cpu 400 0 150 1000 50 0 0 0 0 0\n";
1481 let (a, b) = (
1482 proc_stat_cpu_times(first).unwrap(),
1483 proc_stat_cpu_times(second).unwrap(),
1484 );
1485 assert_eq!(a, (850, 1000), "iowait counts as idle");
1486 assert_eq!(busy_share(a, b), Some(67));
1488 assert_eq!(busy_share(a, a), None, "no time passed");
1489 assert_eq!(busy_share(b, a), None, "a counter went back");
1490 assert_eq!(busy_share((0, 100), (0, 200)), Some(100));
1491 assert_eq!(proc_stat_cpu_times("cpu0 1 2 3 4 5\n"), None);
1492 assert_eq!(proc_stat_cpu_times("cpu 1 2 x 4 5\n"), None);
1493 }
1494
1495 #[test]
1496 fn the_busy_share_comes_from_the_difference_of_two_readings() {
1497 assert_eq!(busy_share((0, 0), (250, 1000)), Some(75));
1499 assert_eq!(busy_share((250, 1000), (250, 1000)), None);
1500 assert_eq!(busy_share((10, 20), (5, 40)), None, "went back");
1501 }
1502
1503 #[test]
1506 fn file_cache_a_job_reads_in_stays_available_on_macos() {
1507 let before = VmPages {
1509 free: 231_617 + 35_446,
1510 speculative: 35_446,
1511 external: 361_612,
1512 purgeable: 15_016,
1513 };
1514 let read = 100_000;
1515 let after = VmPages {
1516 free: before.free - read,
1517 external: before.external + read,
1518 ..before
1519 };
1520 let page = 16_384;
1521 assert_eq!(
1522 available_pages_bytes(after, page),
1523 available_pages_bytes(before, page)
1524 );
1525 assert_eq!(
1526 available_pages_bytes(before, page),
1527 Some((231_617 + 361_612 + 15_016) * page)
1528 );
1529 }
1530
1531 #[test]
1534 fn available_memory_is_memavailable_not_memfree() {
1535 let meminfo = "MemTotal: 32000000 kB\n\
1536 MemFree: 9000000 kB\n\
1537 MemAvailable: 18000000 kB\n";
1538 assert_eq!(
1539 meminfo_field(meminfo, "MemAvailable:"),
1540 Some(18_000_000 * 1024)
1541 );
1542 assert_eq!(meminfo_field(meminfo, "SwapFree:"), None);
1543 }
1544
1545 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1547 #[test]
1548 fn free_memory_is_reported_and_below_the_total() {
1549 let free = available_memory_bytes().expect("free memory");
1550 let total = physical_memory_bytes().expect("total memory");
1551 assert!(free > 0 && free <= total, "{free} of {total}");
1552 }
1553
1554 #[cfg(any(windows, target_os = "linux", target_os = "macos"))]
1555 #[test]
1556 fn this_hosts_cpu_counters_are_read_and_move_forward() {
1557 let first = sys::cpu_times().expect("CPU times");
1558 std::thread::sleep(std::time::Duration::from_millis(200));
1559 let second = sys::cpu_times().expect("CPU times");
1560 assert!(second.1 > first.1, "{first:?} then {second:?}");
1561 assert!(busy_share(first, second).is_some_and(|percent| percent <= 100));
1562 }
1563
1564 #[test]
1565 fn windows_arguments_survive_the_command_line_round_trip() {
1566 for (argument, expected) in [
1567 ("plain", "plain"),
1568 ("", "\"\""),
1569 ("with space", "\"with space\""),
1570 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1571 ("say \"hi\"", "\"say \\\"hi\\\"\""),
1572 ("a\\\"b", "\"a\\\\\\\"b\""),
1573 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1574 ] {
1575 assert_eq!(
1576 crate::service::quote_argument(argument),
1577 expected,
1578 "{argument}"
1579 );
1580 }
1581 }
1582
1583 #[test]
1584 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1585 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1586 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1587 assert_eq!(
1588 script,
1589 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1590 );
1591 }
1592
1593 #[test]
1594 fn a_symlink_probe_leaves_nothing_behind() {
1595 let directory = tempfile::tempdir().unwrap();
1596 let _ = can_create_symlink(directory.path()).unwrap();
1597 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1598 }
1599
1600 #[cfg(unix)]
1601 #[test]
1602 fn unix_accounts_can_always_create_symlinks() {
1603 let directory = tempfile::tempdir().unwrap();
1604 assert!(can_create_symlink(directory.path()).unwrap());
1605 }
1606
1607 #[test]
1608 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1609 let directory = tempfile::tempdir().unwrap();
1610 let name = if cfg!(windows) {
1611 "rmtool.exe"
1612 } else {
1613 "rmtool"
1614 };
1615 let tool = directory.path().join(name);
1616 std::fs::write(&tool, b"").unwrap();
1617 #[cfg(unix)]
1618 {
1619 use std::os::unix::fs::PermissionsExt as _;
1620 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1621 }
1622 let path = std::env::join_paths([directory.path()]).unwrap();
1623 assert_eq!(find_on_path("rmtool", &path), Some(tool));
1624 assert_eq!(find_on_path("absent-tool", &path), None);
1625 assert_eq!(find_on_path("../rmtool", &path), None);
1626 }
1627
1628 #[cfg(windows)]
1631 #[test]
1632 fn an_extensionless_file_is_never_the_windows_answer() {
1633 let directory = tempfile::tempdir().unwrap();
1634 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1635 let path = std::env::join_paths([directory.path()]).unwrap();
1636 assert_eq!(find_on_path("npm", &path), None);
1637 let cmd = directory.path().join("npm.cmd");
1638 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1639 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1640 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1641 }
1642
1643 fn path_of(directories: &[&Path]) -> OsString {
1645 std::env::join_paths(directories).expect("joinable directories")
1646 }
1647
1648 fn touch(path: &Path) {
1649 std::fs::write(path, b"").expect("a fixture file");
1650 }
1651
1652 #[test]
1656 fn npm_shipped_only_as_npm_cmd_is_found_on_a_windows_path() {
1657 let empty = tempfile::tempdir().unwrap();
1658 let nodejs = tempfile::tempdir().unwrap();
1659 touch(&nodejs.path().join("npm"));
1660 touch(&nodejs.path().join("npm.cmd"));
1661 let path = path_of(&[empty.path(), nodejs.path()]);
1662 let cmd = Some(nodejs.path().join("npm.cmd"));
1663
1664 assert_eq!(
1665 find_on_windows_path("npm", &path, Some(".COM;.EXE;.BAT;.CMD".as_ref())),
1666 cmd
1667 );
1668 assert_eq!(find_on_windows_path("npm", &path, None), cmd);
1670 assert_eq!(find_on_windows_path("npm", &path, Some(" ".as_ref())), cmd);
1671 assert_eq!(find_on_windows_path("npm.cmd", &path, None), cmd);
1673 touch(&nodejs.path().join("npm.ps1"));
1674 assert_eq!(find_on_windows_path("npm.ps1", &path, None), None);
1675 }
1676
1677 #[test]
1680 fn pathext_orders_extensions_within_a_directory_and_path_orders_directories() {
1681 let both = tempfile::tempdir().unwrap();
1682 touch(&both.path().join("npm.cmd"));
1683 touch(&both.path().join("npm.exe"));
1684 let path = path_of(&[both.path()]);
1685 assert_eq!(
1686 find_on_windows_path("npm", &path, Some(".CMD;.EXE".as_ref())),
1687 Some(both.path().join("npm.cmd"))
1688 );
1689 assert_eq!(
1690 find_on_windows_path("npm", &path, Some(".exe;.cmd".as_ref())),
1691 Some(both.path().join("npm.exe"))
1692 );
1693
1694 let first = tempfile::tempdir().unwrap();
1695 let second = tempfile::tempdir().unwrap();
1696 touch(&first.path().join("npm.cmd"));
1697 touch(&second.path().join("npm.exe"));
1698 assert_eq!(
1699 find_on_windows_path(
1700 "npm",
1701 &path_of(&[first.path(), second.path()]),
1702 Some(".EXE;.CMD".as_ref())
1703 ),
1704 Some(first.path().join("npm.cmd")),
1705 "an earlier PATH directory wins over a preferred extension later on"
1706 );
1707 }
1708
1709 #[test]
1712 #[serial_test::serial(current_dir)]
1713 fn a_missing_npm_is_not_found_on_a_windows_path() {
1714 let empty = tempfile::tempdir().unwrap();
1715 assert_eq!(
1716 find_on_windows_path("npm", &path_of(&[empty.path()]), None),
1717 None
1718 );
1719
1720 let script_only = tempfile::tempdir().unwrap();
1721 touch(&script_only.path().join("npm.ps1"));
1722 assert_eq!(
1723 find_on_windows_path(
1724 "npm",
1725 &path_of(&[script_only.path()]),
1726 Some(".PS1;.EXE;.CMD".as_ref())
1727 ),
1728 None
1729 );
1730
1731 let planted = tempfile::tempdir().unwrap();
1734 touch(&planted.path().join("npm.cmd"));
1735 if let Some(relative) = relative_to_current_directory(planted.path()) {
1736 assert!(relative.join("npm.cmd").is_file(), "{}", relative.display());
1737 assert_eq!(
1738 find_on_windows_path("npm", &path_of(&[&relative]), None),
1739 None
1740 );
1741 }
1742 }
1743
1744 fn relative_to_current_directory(target: &Path) -> Option<PathBuf> {
1747 let here = std::env::current_dir().ok()?;
1748 let target = std::fs::canonicalize(target).ok()?;
1749 let here = std::fs::canonicalize(here).ok()?;
1750 let (here, target): (Vec<_>, Vec<_>) =
1751 (here.components().collect(), target.components().collect());
1752 let common = here
1753 .iter()
1754 .zip(&target)
1755 .take_while(|(left, right)| left == right)
1756 .count();
1757 if common == 0 {
1758 return None;
1759 }
1760 let mut relative: PathBuf = std::iter::repeat_n("..", here.len() - common).collect();
1761 relative.extend(&target[common..]);
1762 Some(relative)
1763 }
1764
1765 fn unfit_finding(id: &str) -> UnfitFinding {
1766 UnfitFinding {
1767 id: id.into(),
1768 detail: "it fails".into(),
1769 remedy: None,
1770 }
1771 }
1772
1773 #[test]
1776 fn who_can_answer_an_administrator_prompt() {
1777 assert!(
1778 prompt_answerable(Some(1), false),
1779 "the desktop, no terminal"
1780 );
1781 assert!(prompt_answerable(Some(2), true));
1782 assert!(!prompt_answerable(Some(0), true), "services and SSH");
1783 assert!(prompt_answerable(None, true), "sudo on a terminal");
1784 assert!(!prompt_answerable(None, false));
1785 }
1786
1787 #[test]
1789 fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1790 let root = tempfile::tempdir().unwrap();
1791 let paths = AppPaths::rooted_at(root.path());
1792 paths.create_all().unwrap();
1793 let then = Utc::now();
1794 record_host_unfit(
1795 &paths,
1796 &[unfit_finding("host.runner_root_responsive")],
1797 then,
1798 )
1799 .unwrap();
1800 let later = then + chrono::Duration::hours(1);
1801 assert_eq!(
1802 host_unfit_in_force(&paths, later),
1803 None,
1804 "nobody beats: stopped"
1805 );
1806
1807 crate::daemon_heartbeat::beat(&paths, then).unwrap();
1809 assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1810 crate::daemon_heartbeat::beat(&paths, later).unwrap();
1811 assert_eq!(
1812 host_unfit_in_force(&paths, later),
1813 None,
1814 "a daemon still beating would have re-stamped a refusal in force"
1815 );
1816
1817 let much_later = later + chrono::Duration::hours(1);
1820 assert_eq!(host_unfit_in_force(&paths, much_later), None);
1821 }
1822
1823 #[test]
1824 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1825 let root = tempfile::tempdir().unwrap();
1826 let paths = AppPaths::rooted_at(root.path());
1827 paths.create_all().unwrap();
1828 assert_eq!(host_unfit(&paths).unwrap(), None);
1829 let first = Utc::now() - chrono::Duration::minutes(10);
1830 record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1831 let later = Utc::now();
1832 record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1833 let record = host_unfit(&paths).unwrap().unwrap();
1834 assert_eq!(record.since, first);
1835 assert_eq!(record.checked_at, later);
1836 assert_eq!(record.checks, ["host.required_tools"]);
1837 clear_host_unfit(&paths).unwrap();
1838 assert_eq!(host_unfit(&paths).unwrap(), None);
1839 clear_host_unfit(&paths).unwrap();
1840 }
1841
1842 #[test]
1843 fn physical_memory_is_reported() {
1844 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1845 }
1846
1847 #[test]
1848 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1849 let directory = tempfile::tempdir().unwrap();
1850 let deadline = std::time::Duration::from_secs(10);
1851 assert_eq!(
1852 directory_responds(directory.path(), deadline),
1853 Responsiveness::Responds
1854 );
1855 assert_eq!(
1856 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1857 Responsiveness::Responds
1858 );
1859 assert!(
1862 !PROBES_IN_FLIGHT
1863 .lock()
1864 .unwrap()
1865 .iter()
1866 .any(|(path, _)| path.starts_with(directory.path())),
1867 "a finished probe deregisters"
1868 );
1869 }
1870
1871 #[test]
1876 fn a_path_still_being_probed_answers_without_a_second_thread() {
1877 let directory = tempfile::tempdir().unwrap();
1878 for (name, listing, expected) in [
1879 ("stuck-volume", false, Responsiveness::Hung),
1880 ("asking-volume", true, Responsiveness::ListingBlocked),
1881 ] {
1882 let stuck = directory.path().join(name);
1883 PROBES_IN_FLIGHT.lock().unwrap().push((
1884 stuck.clone(),
1885 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1886 ));
1887 let started = std::time::Instant::now();
1888 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1889 PROBES_IN_FLIGHT
1890 .lock()
1891 .unwrap()
1892 .retain(|(path, _)| *path != stuck);
1893 assert_eq!(answer, expected, "{name}");
1894 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1895 }
1896 }
1897}