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]
66pub fn quote_posix_argument(argument: &str) -> String {
67 format!("'{}'", argument.replace('\'', "'\\''"))
68}
69
70#[must_use]
73pub fn administrator_applescript(command: &str) -> String {
74 let escaped = command.replace('\\', "\\\\").replace('"', "\\\"");
75 format!("do shell script \"{escaped}\" with administrator privileges")
76}
77
78#[must_use]
84pub fn physical_memory_bytes() -> Option<u64> {
85 sys::physical_memory_bytes()
86}
87
88#[cfg(windows)]
94pub fn system_directory() -> std::io::Result<PathBuf> {
95 crate::runner_root::windows_system_directory().map(PathBuf::from)
96}
97
98pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
104 use std::io::Write as _;
105 let parent = path.parent().unwrap_or_else(|| Path::new("."));
106 std::fs::create_dir_all(parent)?;
107 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
108 temporary.write_all(bytes)?;
109 temporary.as_file().sync_all()?;
110 temporary
111 .persist(path)
112 .map(|_| ())
113 .map_err(|error| error.error)
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
118pub enum Responsiveness {
119 Responds,
121 Hung,
124 Failed(String),
126}
127
128static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<PathBuf>> = std::sync::Mutex::new(Vec::new());
131
132#[must_use]
140pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
141 let path = directory.to_path_buf();
142 {
143 let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
144 return Responsiveness::Failed("the probe registry is poisoned".into());
145 };
146 if in_flight.contains(&path) {
147 return Responsiveness::Hung;
148 }
149 in_flight.push(path.clone());
150 }
151 let (sender, receiver) = std::sync::mpsc::channel();
152 let probed = path.clone();
153 let spawned = std::thread::Builder::new()
154 .name("runner-root-probe".into())
155 .spawn(move || {
156 let existing = probed
157 .ancestors()
158 .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
159 .map(Path::to_path_buf);
160 let answer = match existing {
161 None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
162 Some(existing) => match std::fs::read_dir(&existing) {
163 Ok(mut entries) => match entries.next() {
164 Some(Err(error)) => Responsiveness::Failed(error.to_string()),
165 _ => Responsiveness::Responds,
166 },
167 Err(error) => Responsiveness::Failed(error.to_string()),
168 },
169 };
170 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
171 in_flight.retain(|candidate| *candidate != probed);
172 }
173 let _ = sender.send(answer);
174 });
175 if let Err(error) = spawned {
176 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
177 in_flight.retain(|candidate| *candidate != path);
178 }
179 return Responsiveness::Failed(error.to_string());
180 }
181 receiver
182 .recv_timeout(deadline)
183 .unwrap_or(Responsiveness::Hung)
184}
185
186pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
198 std::fs::create_dir_all(directory)?;
199 let unique = format!(
200 ".rm-symlink-probe-{}-{}",
201 std::process::id(),
202 uuid::Uuid::new_v4().simple()
203 );
204 let target = directory.join(format!("{unique}.target"));
205 let link = directory.join(format!("{unique}.link"));
206 std::fs::write(&target, b"")?;
207 let created = symlink_file(&target, &link);
208 let _ = std::fs::remove_file(&link);
209 let _ = std::fs::remove_file(&target);
210 Ok(created.is_ok())
211}
212
213#[cfg(windows)]
214fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
215 std::os::windows::fs::symlink_file(target, link)
216}
217
218#[cfg(unix)]
219fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
220 std::os::unix::fs::symlink(target, link)
221}
222
223#[must_use]
226pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
227 if tool.is_empty() || tool.contains(['/', '\\']) {
228 return None;
229 }
230 let extensions: Vec<String> = if cfg!(windows) {
231 let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
232 let mut list = Vec::new();
236 if tool.contains('.') {
237 list.push(String::new());
238 }
239 list.extend(
240 pathext
241 .split(';')
242 .filter(|ext| !ext.is_empty())
243 .map(str::to_ascii_lowercase),
244 );
245 list
246 } else {
247 vec![String::new()]
248 };
249 std::env::split_paths(path)
250 .filter(|dir| !dir.as_os_str().is_empty())
251 .find_map(|dir| {
252 extensions.iter().find_map(|extension| {
253 let candidate = dir.join(format!("{tool}{extension}"));
254 is_executable(&candidate).then_some(candidate)
255 })
256 })
257}
258
259#[cfg(unix)]
260fn is_executable(path: &Path) -> bool {
261 use std::os::unix::fs::PermissionsExt as _;
262 std::fs::metadata(path)
263 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
264}
265
266#[cfg(windows)]
267fn is_executable(path: &Path) -> bool {
268 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
269}
270
271#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
277pub enum RegistryError {
278 #[error("access denied")]
281 AccessDenied,
282 #[error("{0}")]
284 Other(String),
285}
286
287pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
293 sys::read_dword(subkey, value)
294}
295
296pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
301 sys::read_string(subkey, value)
302}
303
304pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
310 sys::value_names(subkey)
311}
312
313pub fn write_registry_dword(
319 subkey: &str,
320 value: &str,
321 data: Option<u32>,
322) -> Result<(), RegistryError> {
323 sys::write_dword(subkey, value, data)
324}
325
326pub fn write_registry_string(
332 subkey: &str,
333 value: &str,
334 data: Option<&str>,
335) -> Result<(), RegistryError> {
336 sys::write_string(subkey, value, data)
337}
338
339pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
345const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
346
347#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct HostUnfitRecord {
350 pub schema_version: u32,
351 pub since: DateTime<Utc>,
353 pub checked_at: DateTime<Utc>,
355 pub checks: Vec<String>,
357}
358
359fn host_unfit_path(paths: &AppPaths) -> PathBuf {
360 paths.state_dir().join(HOST_UNFIT_FILE)
361}
362
363pub fn record_host_unfit(
369 paths: &AppPaths,
370 checks: &[String],
371 at: DateTime<Utc>,
372) -> Result<(), String> {
373 let since = host_unfit(paths)
374 .ok()
375 .flatten()
376 .map_or(at, |record| record.since);
377 let record = HostUnfitRecord {
378 schema_version: HOST_UNFIT_SCHEMA_VERSION,
379 since,
380 checked_at: at,
381 checks: checks.to_vec(),
382 };
383 let path = host_unfit_path(paths);
384 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
385 write_atomically(&path, &text)
386 .map_err(|error| format!("cannot write {}: {error}", path.display()))
387}
388
389pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
394 let path = host_unfit_path(paths);
395 match std::fs::remove_file(&path) {
396 Ok(()) => Ok(()),
397 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
398 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
399 }
400}
401
402pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
407 let path = host_unfit_path(paths);
408 match std::fs::read_to_string(&path) {
409 Ok(text) => serde_json::from_str(&text)
410 .map(Some)
411 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
412 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
413 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
414 }
415}
416
417#[cfg(windows)]
422mod sys {
423 use std::ffi::OsString;
424 use std::os::windows::ffi::OsStrExt as _;
425 use std::path::Path;
426
427 use windows::Win32::Foundation::{
428 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
429 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
430 };
431 use windows::Win32::Security::{
432 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation,
433 };
434 use windows::Win32::System::Registry::{
435 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
436 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
437 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
438 RegSetValueExW,
439 };
440 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
441 use windows::Win32::System::Threading::{
442 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
443 };
444 use windows::Win32::UI::Shell::{
445 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
446 };
447 use windows::core::{PCWSTR, PWSTR};
448
449 use super::{ElevationOutcome, RegistryError};
450
451 const SW_HIDE: i32 = 0;
454 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
458
459 fn wide(text: &str) -> Vec<u16> {
460 text.encode_utf16().chain(std::iter::once(0)).collect()
461 }
462
463 fn registry_error(code: WIN32_ERROR) -> RegistryError {
464 if code == ERROR_ACCESS_DENIED {
465 RegistryError::AccessDenied
466 } else {
467 RegistryError::Other(format!("registry error {}", code.0))
468 }
469 }
470
471 pub(super) fn is_elevated() -> bool {
472 let mut token = HANDLE::default();
473 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
476 return false;
477 }
478 let mut elevation = TOKEN_ELEVATION::default();
479 let mut returned = 0u32;
480 let queried = unsafe {
483 GetTokenInformation(
484 token,
485 TokenElevation,
486 Some((&raw mut elevation).cast()),
487 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
488 &raw mut returned,
489 )
490 };
491 let _ = unsafe { CloseHandle(token) };
493 queried.is_ok() && elevation.TokenIsElevated != 0
494 }
495
496 pub(super) fn physical_memory_bytes() -> Option<u64> {
497 let mut status = MEMORYSTATUSEX {
498 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
499 ..MEMORYSTATUSEX::default()
500 };
501 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
504 Some(status.ullTotalPhys)
505 }
506
507 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
508 let subkey = wide(subkey);
509 let value = wide(value);
510 let mut data = 0u32;
511 let mut size = 4u32;
512 let code = unsafe {
515 RegGetValueW(
516 HKEY_LOCAL_MACHINE,
517 PCWSTR(subkey.as_ptr()),
518 PCWSTR(value.as_ptr()),
519 RRF_RT_REG_DWORD,
520 None,
521 Some((&raw mut data).cast()),
522 Some(&raw mut size),
523 )
524 };
525 match code {
526 ERROR_SUCCESS => Ok(Some(data)),
527 ERROR_FILE_NOT_FOUND => Ok(None),
528 other => Err(registry_error(other)),
529 }
530 }
531
532 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
533 let subkey = wide(subkey);
534 let value = wide(value);
535 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
536 let mut size = 0u32;
537 let code = unsafe {
539 RegGetValueW(
540 HKEY_LOCAL_MACHINE,
541 PCWSTR(subkey.as_ptr()),
542 PCWSTR(value.as_ptr()),
543 flags,
544 None,
545 None,
546 Some(&raw mut size),
547 )
548 };
549 match code {
550 ERROR_SUCCESS => {}
551 ERROR_FILE_NOT_FOUND => return Ok(None),
552 other => return Err(registry_error(other)),
553 }
554 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
555 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
556 let code = unsafe {
559 RegGetValueW(
560 HKEY_LOCAL_MACHINE,
561 PCWSTR(subkey.as_ptr()),
562 PCWSTR(value.as_ptr()),
563 flags,
564 None,
565 Some(buffer.as_mut_ptr().cast()),
566 Some(&raw mut size),
567 )
568 };
569 match code {
570 ERROR_SUCCESS => {
571 let units = (size as usize / 2).min(buffer.len());
572 let text = String::from_utf16_lossy(&buffer[..units]);
573 Ok(Some(text.trim_end_matches('\0').to_owned()))
574 }
575 ERROR_FILE_NOT_FOUND => Ok(None),
576 other => Err(registry_error(other)),
577 }
578 }
579
580 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
581 let subkey = wide(subkey);
582 let mut key = HKEY::default();
583 let code = unsafe {
585 RegOpenKeyExW(
586 HKEY_LOCAL_MACHINE,
587 PCWSTR(subkey.as_ptr()),
588 None,
589 KEY_READ,
590 &raw mut key,
591 )
592 };
593 match code {
594 ERROR_SUCCESS => {}
595 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
596 other => return Err(registry_error(other)),
597 }
598 let mut names = Vec::new();
599 let mut index = 0u32;
600 let result = loop {
601 let mut name = vec![0u16; 16_384];
603 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
604 let code = unsafe {
607 RegEnumValueW(
608 key,
609 index,
610 Some(PWSTR(name.as_mut_ptr())),
611 &raw mut length,
612 None,
613 None,
614 None,
615 None,
616 )
617 };
618 match code {
619 ERROR_SUCCESS => {
620 names.push(String::from_utf16_lossy(&name[..length as usize]));
621 index += 1;
622 }
623 ERROR_NO_MORE_ITEMS => break Ok(names),
624 other => break Err(registry_error(other)),
625 }
626 };
627 let _ = unsafe { RegCloseKey(key) };
629 result
630 }
631
632 fn write_value(
633 subkey: &str,
634 value: &str,
635 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
636 ) -> Result<(), RegistryError> {
637 let subkey_wide = wide(subkey);
638 let value_wide = wide(value);
639 let Some((kind, bytes)) = data else {
640 let code = unsafe {
642 RegDeleteKeyValueW(
643 HKEY_LOCAL_MACHINE,
644 PCWSTR(subkey_wide.as_ptr()),
645 PCWSTR(value_wide.as_ptr()),
646 )
647 };
648 return match code {
649 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
650 other => Err(registry_error(other)),
651 };
652 };
653 let mut key = HKEY::default();
654 let code = unsafe {
657 RegCreateKeyExW(
658 HKEY_LOCAL_MACHINE,
659 PCWSTR(subkey_wide.as_ptr()),
660 None,
661 PCWSTR::null(),
662 REG_OPTION_NON_VOLATILE,
663 KEY_SET_VALUE,
664 None,
665 &raw mut key,
666 None,
667 )
668 };
669 if code != ERROR_SUCCESS {
670 return Err(registry_error(code));
671 }
672 let code =
675 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
676 let _ = unsafe { RegCloseKey(key) };
678 if code == ERROR_SUCCESS {
679 Ok(())
680 } else {
681 Err(registry_error(code))
682 }
683 }
684
685 pub(super) fn write_dword(
686 subkey: &str,
687 value: &str,
688 data: Option<u32>,
689 ) -> Result<(), RegistryError> {
690 write_value(
691 subkey,
692 value,
693 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
694 )
695 }
696
697 pub(super) fn write_string(
698 subkey: &str,
699 value: &str,
700 data: Option<&str>,
701 ) -> Result<(), RegistryError> {
702 write_value(
703 subkey,
704 value,
705 data.map(|text| {
706 let bytes = wide(text)
707 .iter()
708 .flat_map(|unit| unit.to_le_bytes())
709 .collect();
710 (REG_SZ, bytes)
711 }),
712 )
713 }
714
715 pub(super) fn run_elevated(
716 program: &Path,
717 args: &[OsString],
718 _terminal: bool,
719 ) -> ElevationOutcome {
720 let file: Vec<u16> = program
721 .as_os_str()
722 .encode_wide()
723 .chain(std::iter::once(0))
724 .collect();
725 let parameters = args
726 .iter()
727 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
728 .collect::<Vec<_>>()
729 .join(" ");
730 let parameters = wide(¶meters);
731 let verb = wide("runas");
732 let mut info = SHELLEXECUTEINFOW {
733 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
734 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
735 lpVerb: PCWSTR(verb.as_ptr()),
736 lpFile: PCWSTR(file.as_ptr()),
737 lpParameters: PCWSTR(parameters.as_ptr()),
738 nShow: SW_HIDE,
739 ..SHELLEXECUTEINFOW::default()
740 };
741 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
744 #[allow(clippy::cast_sign_loss)]
745 let code = (error.code().0 as u32) & 0xffff;
746 return if code == ERROR_CANCELLED.0 {
747 ElevationOutcome::Refused
748 } else {
749 ElevationOutcome::Unavailable(format!(
750 "Windows could not show the administrator prompt: {}",
751 error.message()
752 ))
753 };
754 }
755 if info.hProcess.is_invalid() {
756 return ElevationOutcome::Unavailable(
757 "Windows started no elevated process to wait for".into(),
758 );
759 }
760 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
763 let mut code = 1u32;
764 let outcome = if waited == WAIT_OBJECT_0 {
765 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
767 #[allow(clippy::cast_possible_wrap)]
768 Ok(()) => ElevationOutcome::Exited(code as i32),
769 Err(error) => ElevationOutcome::Unavailable(format!(
770 "the elevated process's exit code could not be read: {}",
771 error.message()
772 )),
773 }
774 } else {
775 ElevationOutcome::Unavailable(
776 "the elevated process did not finish within 15 minutes".into(),
777 )
778 };
779 let _ = unsafe { CloseHandle(info.hProcess) };
781 outcome
782 }
783}
784
785#[cfg(unix)]
790mod sys {
791 use std::ffi::OsString;
792 use std::path::Path;
793 use std::process::Command;
794
795 use super::{ElevationOutcome, RegistryError};
796
797 pub(super) fn is_elevated() -> bool {
798 unsafe { libc::geteuid() == 0 }
800 }
801
802 #[cfg(target_os = "macos")]
803 pub(super) fn physical_memory_bytes() -> Option<u64> {
804 let mut value = 0u64;
805 let mut size = std::mem::size_of::<u64>();
806 let name = c"hw.memsize";
807 let result = unsafe {
810 libc::sysctlbyname(
811 name.as_ptr(),
812 (&raw mut value).cast(),
813 &raw mut size,
814 std::ptr::null_mut(),
815 0,
816 )
817 };
818 (result == 0).then_some(value)
819 }
820
821 #[cfg(not(target_os = "macos"))]
822 pub(super) fn physical_memory_bytes() -> Option<u64> {
823 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
824 let line = text.lines().find(|line| line.starts_with("MemTotal:"))?;
825 let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
826 kib.checked_mul(1024)
827 }
828
829 fn unsupported() -> RegistryError {
830 RegistryError::Other("the registry exists only on Windows".into())
831 }
832
833 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
834 Err(unsupported())
835 }
836
837 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
838 Err(unsupported())
839 }
840
841 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
842 Err(unsupported())
843 }
844
845 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
846 Err(unsupported())
847 }
848
849 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
850 Err(unsupported())
851 }
852
853 fn exit_outcome(
854 status: std::io::Result<std::process::ExitStatus>,
855 what: &str,
856 ) -> ElevationOutcome {
857 match status {
858 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
859 Err(error) => {
860 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
861 }
862 }
863 }
864
865 pub(super) fn run_elevated(
866 program: &Path,
867 args: &[OsString],
868 terminal: bool,
869 ) -> ElevationOutcome {
870 if terminal
871 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
872 {
873 let status = Command::new("sudo")
874 .arg("--")
875 .arg(program)
876 .args(args)
877 .status();
878 return match exit_outcome(status, "sudo") {
882 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
883 other => other,
884 };
885 }
886 if cfg!(target_os = "macos") {
887 let command = std::iter::once(program.as_os_str().to_owned())
888 .chain(args.iter().cloned())
889 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
890 .collect::<Vec<_>>()
891 .join(" ");
892 let output = Command::new("/usr/bin/osascript")
893 .arg("-e")
894 .arg(super::administrator_applescript(&command))
895 .output();
896 return match output {
897 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
898 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
900 ElevationOutcome::Refused
901 }
902 Ok(output) => {
903 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
904 if detail.contains("(-60007)") || detail.contains("(-1743)") {
905 ElevationOutcome::Unavailable(format!(
906 "macOS could not show the administrator dialog here: {detail}"
907 ))
908 } else {
909 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
910 }
911 }
912 Err(error) => ElevationOutcome::Unavailable(format!(
913 "osascript could not be started: {error}"
914 )),
915 };
916 }
917 ElevationOutcome::Unavailable(
918 "no terminal to ask for a sudo password; run the command from a terminal".into(),
919 )
920 }
921}
922
923#[cfg(test)]
924mod tests {
925 use super::*;
926
927 #[test]
928 fn windows_arguments_survive_the_command_line_round_trip() {
929 for (argument, expected) in [
930 ("plain", "plain"),
931 ("", "\"\""),
932 ("with space", "\"with space\""),
933 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
934 ("say \"hi\"", "\"say \\\"hi\\\"\""),
935 ("a\\\"b", "\"a\\\\\\\"b\""),
936 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
937 ] {
938 assert_eq!(
939 crate::service::quote_argument(argument),
940 expected,
941 "{argument}"
942 );
943 }
944 }
945
946 #[test]
947 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
948 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
949 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
950 assert_eq!(
951 script,
952 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
953 );
954 }
955
956 #[test]
957 fn a_symlink_probe_leaves_nothing_behind() {
958 let directory = tempfile::tempdir().unwrap();
959 let _ = can_create_symlink(directory.path()).unwrap();
960 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
961 }
962
963 #[cfg(unix)]
964 #[test]
965 fn unix_accounts_can_always_create_symlinks() {
966 let directory = tempfile::tempdir().unwrap();
967 assert!(can_create_symlink(directory.path()).unwrap());
968 }
969
970 #[test]
971 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
972 let directory = tempfile::tempdir().unwrap();
973 let name = if cfg!(windows) {
974 "rmtool.exe"
975 } else {
976 "rmtool"
977 };
978 let tool = directory.path().join(name);
979 std::fs::write(&tool, b"").unwrap();
980 #[cfg(unix)]
981 {
982 use std::os::unix::fs::PermissionsExt as _;
983 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
984 }
985 let path = std::env::join_paths([directory.path()]).unwrap();
986 assert_eq!(find_on_path("rmtool", &path), Some(tool));
987 assert_eq!(find_on_path("absent-tool", &path), None);
988 assert_eq!(find_on_path("../rmtool", &path), None);
989 }
990
991 #[cfg(windows)]
994 #[test]
995 fn an_extensionless_file_is_never_the_windows_answer() {
996 let directory = tempfile::tempdir().unwrap();
997 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
998 let path = std::env::join_paths([directory.path()]).unwrap();
999 assert_eq!(find_on_path("npm", &path), None);
1000 let cmd = directory.path().join("npm.cmd");
1001 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1002 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1003 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1004 }
1005
1006 #[test]
1007 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1008 let root = tempfile::tempdir().unwrap();
1009 let paths = AppPaths::rooted_at(root.path());
1010 paths.create_all().unwrap();
1011 assert_eq!(host_unfit(&paths).unwrap(), None);
1012 let first = Utc::now() - chrono::Duration::minutes(10);
1013 record_host_unfit(&paths, &["windows.symlink_privilege".into()], first).unwrap();
1014 let later = Utc::now();
1015 record_host_unfit(&paths, &["host.required_tools".into()], later).unwrap();
1016 let record = host_unfit(&paths).unwrap().unwrap();
1017 assert_eq!(record.since, first);
1018 assert_eq!(record.checked_at, later);
1019 assert_eq!(record.checks, ["host.required_tools"]);
1020 clear_host_unfit(&paths).unwrap();
1021 assert_eq!(host_unfit(&paths).unwrap(), None);
1022 clear_host_unfit(&paths).unwrap();
1023 }
1024
1025 #[test]
1026 fn physical_memory_is_reported() {
1027 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1028 }
1029
1030 #[test]
1031 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1032 let directory = tempfile::tempdir().unwrap();
1033 let deadline = std::time::Duration::from_secs(10);
1034 assert_eq!(
1035 directory_responds(directory.path(), deadline),
1036 Responsiveness::Responds
1037 );
1038 assert_eq!(
1039 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1040 Responsiveness::Responds
1041 );
1042 assert!(
1045 !PROBES_IN_FLIGHT
1046 .lock()
1047 .unwrap()
1048 .iter()
1049 .any(|path| path.starts_with(directory.path())),
1050 "a finished probe deregisters"
1051 );
1052 }
1053
1054 #[test]
1057 fn a_path_still_being_probed_is_hung_without_a_second_thread() {
1058 let directory = tempfile::tempdir().unwrap();
1059 let stuck = directory.path().join("stuck-volume");
1060 PROBES_IN_FLIGHT.lock().unwrap().push(stuck.clone());
1061 let started = std::time::Instant::now();
1062 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1063 PROBES_IN_FLIGHT
1064 .lock()
1065 .unwrap()
1066 .retain(|path| *path != stuck);
1067 assert_eq!(answer, Responsiveness::Hung);
1068 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1069 }
1070}