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#[cfg(windows)]
117pub fn system_directory() -> std::io::Result<PathBuf> {
118 crate::runner_root::windows_system_directory().map(PathBuf::from)
119}
120
121pub fn write_atomically(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
127 use std::io::Write as _;
128 let parent = path.parent().unwrap_or_else(|| Path::new("."));
129 std::fs::create_dir_all(parent)?;
130 let mut temporary = tempfile::NamedTempFile::new_in(parent)?;
131 temporary.write_all(bytes)?;
132 temporary.as_file().sync_all()?;
133 temporary
134 .persist(path)
135 .map(|_| ())
136 .map_err(|error| error.error)
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum Responsiveness {
142 Responds,
144 Hung,
147 ListingBlocked,
153 NotPermitted(String),
157 Failed(String),
159}
160
161type InFlight = (PathBuf, std::sync::Arc<std::sync::atomic::AtomicBool>);
164
165static PROBES_IN_FLIGHT: std::sync::Mutex<Vec<InFlight>> = std::sync::Mutex::new(Vec::new());
169
170#[must_use]
172pub fn probes_in_flight() -> Vec<PathBuf> {
173 PROBES_IN_FLIGHT
174 .lock()
175 .map(|in_flight| in_flight.iter().map(|(path, _)| path.clone()).collect())
176 .unwrap_or_default()
177}
178
179fn unanswered(listing: &std::sync::atomic::AtomicBool) -> Responsiveness {
181 if listing.load(std::sync::atomic::Ordering::Acquire) {
182 Responsiveness::ListingBlocked
183 } else {
184 Responsiveness::Hung
185 }
186}
187
188#[must_use]
196pub fn directory_responds(directory: &Path, deadline: std::time::Duration) -> Responsiveness {
197 let path = directory.to_path_buf();
198 let listing = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
201 {
202 let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() else {
203 return Responsiveness::Failed("the probe registry is poisoned".into());
204 };
205 if let Some((_, stuck)) = in_flight.iter().find(|(stuck, _)| *stuck == path) {
206 return unanswered(stuck);
207 }
208 in_flight.push((path.clone(), std::sync::Arc::clone(&listing)));
209 }
210 let (sender, receiver) = std::sync::mpsc::channel();
211 let probed = path.clone();
212 let listing_started = std::sync::Arc::clone(&listing);
213 let spawned = std::thread::Builder::new()
214 .name("runner-root-probe".into())
215 .spawn(move || {
216 let existing = probed
217 .ancestors()
218 .find(|ancestor| std::fs::symlink_metadata(ancestor).is_ok())
219 .map(Path::to_path_buf);
220 listing_started.store(true, std::sync::atomic::Ordering::Release);
221 let refused = |error: std::io::Error| {
222 if is_not_permitted(&error) {
223 Responsiveness::NotPermitted(error.to_string())
224 } else {
225 Responsiveness::Failed(error.to_string())
226 }
227 };
228 let answer = match existing {
229 None => Responsiveness::Failed(format!("{} does not exist", probed.display())),
230 Some(existing) => match std::fs::read_dir(&existing) {
231 Ok(mut entries) => match entries.next() {
232 Some(Err(error)) => refused(error),
233 _ => Responsiveness::Responds,
234 },
235 Err(error) => refused(error),
236 },
237 };
238 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
239 in_flight.retain(|(candidate, _)| *candidate != probed);
240 }
241 let _ = sender.send(answer);
242 });
243 if let Err(error) = spawned {
244 if let Ok(mut in_flight) = PROBES_IN_FLIGHT.lock() {
245 in_flight.retain(|(candidate, _)| *candidate != path);
246 }
247 return Responsiveness::Failed(error.to_string());
248 }
249 receiver
250 .recv_timeout(deadline)
251 .unwrap_or_else(|_| unanswered(&listing))
252}
253
254fn is_not_permitted(error: &std::io::Error) -> bool {
256 #[cfg(unix)]
257 {
258 error.raw_os_error() == Some(libc::EPERM)
259 }
260 #[cfg(not(unix))]
261 {
262 let _ = error;
263 false
264 }
265}
266
267pub fn can_create_symlink(directory: &Path) -> std::io::Result<bool> {
279 std::fs::create_dir_all(directory)?;
280 let unique = format!(
281 ".rm-symlink-probe-{}-{}",
282 std::process::id(),
283 uuid::Uuid::new_v4().simple()
284 );
285 let target = directory.join(format!("{unique}.target"));
286 let link = directory.join(format!("{unique}.link"));
287 std::fs::write(&target, b"")?;
288 let created = symlink_file(&target, &link);
289 let _ = std::fs::remove_file(&link);
290 let _ = std::fs::remove_file(&target);
291 Ok(created.is_ok())
292}
293
294#[cfg(windows)]
295fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
296 std::os::windows::fs::symlink_file(target, link)
297}
298
299#[cfg(unix)]
300fn symlink_file(target: &Path, link: &Path) -> std::io::Result<()> {
301 std::os::unix::fs::symlink(target, link)
302}
303
304#[must_use]
307pub fn find_on_path(tool: &str, path: &OsStr) -> Option<PathBuf> {
308 if cfg!(windows) {
309 return find_on_windows_path(tool, path, std::env::var_os("PATHEXT").as_deref());
310 }
311 if tool.is_empty() || tool.contains(['/', '\\']) {
312 return None;
313 }
314 std::env::split_paths(path)
315 .filter(|dir| !dir.as_os_str().is_empty())
316 .map(|dir| dir.join(tool))
317 .find(|candidate| is_executable(candidate))
318}
319
320#[must_use]
338pub fn find_on_windows_path(tool: &str, path: &OsStr, pathext: Option<&OsStr>) -> Option<PathBuf> {
339 const RUNNABLE: [&str; 4] = [".exe", ".com", ".cmd", ".bat"];
340 if tool.is_empty() || tool.contains(['/', '\\']) {
341 return None;
342 }
343 let pathext = pathext
344 .and_then(OsStr::to_str)
345 .filter(|value| !value.trim().is_empty())
346 .unwrap_or(".COM;.EXE;.BAT;.CMD");
347 let bare = Path::new(tool)
348 .extension()
349 .and_then(OsStr::to_str)
350 .is_some_and(|extension| {
351 RUNNABLE
352 .iter()
353 .any(|runnable| runnable[1..].eq_ignore_ascii_case(extension))
354 })
355 .then_some("");
356 let extensions: Vec<&str> = bare
357 .into_iter()
358 .chain(pathext.split(';').filter_map(|extension| {
359 RUNNABLE
360 .into_iter()
361 .find(|runnable| runnable.eq_ignore_ascii_case(extension.trim()))
362 }))
363 .collect();
364 std::env::split_paths(path)
365 .filter(|dir| dir.is_absolute())
366 .find_map(|dir| {
367 extensions.iter().find_map(|extension| {
368 let candidate = dir.join(format!("{tool}{extension}"));
369 candidate.is_file().then_some(candidate)
370 })
371 })
372}
373
374#[cfg(unix)]
375fn is_executable(path: &Path) -> bool {
376 use std::os::unix::fs::PermissionsExt as _;
377 std::fs::metadata(path)
378 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
379}
380
381#[cfg(windows)]
382fn is_executable(path: &Path) -> bool {
383 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
384}
385
386#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
392pub enum RegistryError {
393 #[error("access denied")]
396 AccessDenied,
397 #[error("{0}")]
399 Other(String),
400}
401
402pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
408 sys::read_dword(subkey, value)
409}
410
411pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
416 sys::read_string(subkey, value)
417}
418
419pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
425 sys::value_names(subkey)
426}
427
428pub fn write_registry_dword(
434 subkey: &str,
435 value: &str,
436 data: Option<u32>,
437) -> Result<(), RegistryError> {
438 sys::write_dword(subkey, value, data)
439}
440
441pub fn write_registry_string(
447 subkey: &str,
448 value: &str,
449 data: Option<&str>,
450) -> Result<(), RegistryError> {
451 sys::write_string(subkey, value, data)
452}
453
454pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
460
461pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
464
465pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
469 std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
470const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
471
472#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
474pub struct HostUnfitRecord {
475 pub schema_version: u32,
476 pub since: DateTime<Utc>,
478 pub checked_at: DateTime<Utc>,
480 pub checks: Vec<String>,
482 #[serde(default)]
485 pub findings: Vec<UnfitFinding>,
486}
487
488#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
490pub struct UnfitFinding {
491 pub id: String,
492 pub detail: String,
493 #[serde(default)]
494 pub remedy: Option<String>,
495}
496
497impl HostUnfitRecord {
498 #[must_use]
500 pub fn describe(&self) -> String {
501 if self.findings.is_empty() {
502 return self.checks.join(", ");
503 }
504 self.findings
505 .iter()
506 .map(|finding| match &finding.remedy {
507 Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
508 None => format!("{}: {}", finding.id, finding.detail),
509 })
510 .collect::<Vec<_>>()
511 .join("; ")
512 }
513}
514
515fn host_unfit_path(paths: &AppPaths) -> PathBuf {
516 paths.state_dir().join(HOST_UNFIT_FILE)
517}
518
519pub fn record_host_unfit(
525 paths: &AppPaths,
526 findings: &[UnfitFinding],
527 at: DateTime<Utc>,
528) -> Result<(), String> {
529 let since = host_unfit(paths)
530 .ok()
531 .flatten()
532 .map_or(at, |record| record.since);
533 let record = HostUnfitRecord {
534 schema_version: HOST_UNFIT_SCHEMA_VERSION,
535 since,
536 checked_at: at,
537 checks: findings.iter().map(|finding| finding.id.clone()).collect(),
538 findings: findings.to_vec(),
539 };
540 let path = host_unfit_path(paths);
541 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
542 write_atomically(&path, &text)
543 .map_err(|error| format!("cannot write {}: {error}", path.display()))
544}
545
546pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
551 let path = host_unfit_path(paths);
552 match std::fs::remove_file(&path) {
553 Ok(()) => Ok(()),
554 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
555 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
556 }
557}
558
559#[must_use]
568pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
569 host_unfit(paths).ok().flatten().filter(|record| {
570 if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
571 return true;
572 }
573 match crate::daemon_heartbeat::liveness(paths, now) {
576 crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
577 !crate::wsl::fence::elapsed_at_least(
578 record.checked_at,
579 since,
580 HOST_UNFIT_RECORD_FRESH,
581 )
582 }
583 _ => false,
584 }
585 })
586}
587
588pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
593 let path = host_unfit_path(paths);
594 match std::fs::read_to_string(&path) {
595 Ok(text) => serde_json::from_str(&text)
596 .map(Some)
597 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
598 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
599 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
600 }
601}
602
603#[cfg(windows)]
608mod sys {
609 use std::ffi::OsString;
610 use std::os::windows::ffi::OsStrExt as _;
611 use std::path::Path;
612
613 use windows::Win32::Foundation::{
614 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
615 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
616 };
617 use windows::Win32::Security::{
618 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
619 };
620 use windows::Win32::System::Registry::{
621 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
622 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
623 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
624 RegSetValueExW,
625 };
626 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
627 use windows::Win32::System::Threading::{
628 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
629 };
630 use windows::Win32::UI::Shell::{
631 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
632 };
633 use windows::core::{PCWSTR, PWSTR};
634
635 use super::{ElevationOutcome, RegistryError};
636
637 const SW_HIDE: i32 = 0;
640 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
644
645 fn wide(text: &str) -> Vec<u16> {
646 text.encode_utf16().chain(std::iter::once(0)).collect()
647 }
648
649 fn registry_error(code: WIN32_ERROR) -> RegistryError {
650 if code == ERROR_ACCESS_DENIED {
651 RegistryError::AccessDenied
652 } else {
653 RegistryError::Other(format!("registry error {}", code.0))
654 }
655 }
656
657 pub(super) fn session_id() -> Option<u32> {
659 let mut token = HANDLE::default();
660 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
663 return None;
664 }
665 let mut session = 0u32;
666 let mut returned = 0u32;
667 let queried = unsafe {
670 GetTokenInformation(
671 token,
672 TokenSessionId,
673 Some((&raw mut session).cast()),
674 4,
675 &raw mut returned,
676 )
677 };
678 let _ = unsafe { CloseHandle(token) };
680 queried.is_ok().then_some(session)
681 }
682
683 pub(super) fn is_elevated() -> bool {
684 let mut token = HANDLE::default();
685 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
688 return false;
689 }
690 let mut elevation = TOKEN_ELEVATION::default();
691 let mut returned = 0u32;
692 let queried = unsafe {
695 GetTokenInformation(
696 token,
697 TokenElevation,
698 Some((&raw mut elevation).cast()),
699 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
700 &raw mut returned,
701 )
702 };
703 let _ = unsafe { CloseHandle(token) };
705 queried.is_ok() && elevation.TokenIsElevated != 0
706 }
707
708 pub(super) fn physical_memory_bytes() -> Option<u64> {
709 let mut status = MEMORYSTATUSEX {
710 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
711 ..MEMORYSTATUSEX::default()
712 };
713 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
716 Some(status.ullTotalPhys)
717 }
718
719 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
720 let subkey = wide(subkey);
721 let value = wide(value);
722 let mut data = 0u32;
723 let mut size = 4u32;
724 let code = unsafe {
727 RegGetValueW(
728 HKEY_LOCAL_MACHINE,
729 PCWSTR(subkey.as_ptr()),
730 PCWSTR(value.as_ptr()),
731 RRF_RT_REG_DWORD,
732 None,
733 Some((&raw mut data).cast()),
734 Some(&raw mut size),
735 )
736 };
737 match code {
738 ERROR_SUCCESS => Ok(Some(data)),
739 ERROR_FILE_NOT_FOUND => Ok(None),
740 other => Err(registry_error(other)),
741 }
742 }
743
744 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
745 let subkey = wide(subkey);
746 let value = wide(value);
747 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
748 let mut size = 0u32;
749 let code = unsafe {
751 RegGetValueW(
752 HKEY_LOCAL_MACHINE,
753 PCWSTR(subkey.as_ptr()),
754 PCWSTR(value.as_ptr()),
755 flags,
756 None,
757 None,
758 Some(&raw mut size),
759 )
760 };
761 match code {
762 ERROR_SUCCESS => {}
763 ERROR_FILE_NOT_FOUND => return Ok(None),
764 other => return Err(registry_error(other)),
765 }
766 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
767 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
768 let code = unsafe {
771 RegGetValueW(
772 HKEY_LOCAL_MACHINE,
773 PCWSTR(subkey.as_ptr()),
774 PCWSTR(value.as_ptr()),
775 flags,
776 None,
777 Some(buffer.as_mut_ptr().cast()),
778 Some(&raw mut size),
779 )
780 };
781 match code {
782 ERROR_SUCCESS => {
783 let units = (size as usize / 2).min(buffer.len());
784 let text = String::from_utf16_lossy(&buffer[..units]);
785 Ok(Some(text.trim_end_matches('\0').to_owned()))
786 }
787 ERROR_FILE_NOT_FOUND => Ok(None),
788 other => Err(registry_error(other)),
789 }
790 }
791
792 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
793 let subkey = wide(subkey);
794 let mut key = HKEY::default();
795 let code = unsafe {
797 RegOpenKeyExW(
798 HKEY_LOCAL_MACHINE,
799 PCWSTR(subkey.as_ptr()),
800 None,
801 KEY_READ,
802 &raw mut key,
803 )
804 };
805 match code {
806 ERROR_SUCCESS => {}
807 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
808 other => return Err(registry_error(other)),
809 }
810 let mut names = Vec::new();
811 let mut index = 0u32;
812 let result = loop {
813 let mut name = vec![0u16; 16_384];
815 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
816 let code = unsafe {
819 RegEnumValueW(
820 key,
821 index,
822 Some(PWSTR(name.as_mut_ptr())),
823 &raw mut length,
824 None,
825 None,
826 None,
827 None,
828 )
829 };
830 match code {
831 ERROR_SUCCESS => {
832 names.push(String::from_utf16_lossy(&name[..length as usize]));
833 index += 1;
834 }
835 ERROR_NO_MORE_ITEMS => break Ok(names),
836 other => break Err(registry_error(other)),
837 }
838 };
839 let _ = unsafe { RegCloseKey(key) };
841 result
842 }
843
844 fn write_value(
845 subkey: &str,
846 value: &str,
847 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
848 ) -> Result<(), RegistryError> {
849 let subkey_wide = wide(subkey);
850 let value_wide = wide(value);
851 let Some((kind, bytes)) = data else {
852 let code = unsafe {
854 RegDeleteKeyValueW(
855 HKEY_LOCAL_MACHINE,
856 PCWSTR(subkey_wide.as_ptr()),
857 PCWSTR(value_wide.as_ptr()),
858 )
859 };
860 return match code {
861 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
862 other => Err(registry_error(other)),
863 };
864 };
865 let mut key = HKEY::default();
866 let code = unsafe {
869 RegCreateKeyExW(
870 HKEY_LOCAL_MACHINE,
871 PCWSTR(subkey_wide.as_ptr()),
872 None,
873 PCWSTR::null(),
874 REG_OPTION_NON_VOLATILE,
875 KEY_SET_VALUE,
876 None,
877 &raw mut key,
878 None,
879 )
880 };
881 if code != ERROR_SUCCESS {
882 return Err(registry_error(code));
883 }
884 let code =
887 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
888 let _ = unsafe { RegCloseKey(key) };
890 if code == ERROR_SUCCESS {
891 Ok(())
892 } else {
893 Err(registry_error(code))
894 }
895 }
896
897 pub(super) fn write_dword(
898 subkey: &str,
899 value: &str,
900 data: Option<u32>,
901 ) -> Result<(), RegistryError> {
902 write_value(
903 subkey,
904 value,
905 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
906 )
907 }
908
909 pub(super) fn write_string(
910 subkey: &str,
911 value: &str,
912 data: Option<&str>,
913 ) -> Result<(), RegistryError> {
914 write_value(
915 subkey,
916 value,
917 data.map(|text| {
918 let bytes = wide(text)
919 .iter()
920 .flat_map(|unit| unit.to_le_bytes())
921 .collect();
922 (REG_SZ, bytes)
923 }),
924 )
925 }
926
927 pub(super) fn run_elevated(
928 program: &Path,
929 args: &[OsString],
930 _terminal: bool,
931 ) -> ElevationOutcome {
932 let file: Vec<u16> = program
933 .as_os_str()
934 .encode_wide()
935 .chain(std::iter::once(0))
936 .collect();
937 let parameters = args
938 .iter()
939 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
940 .collect::<Vec<_>>()
941 .join(" ");
942 let parameters = wide(¶meters);
943 let verb = wide("runas");
944 let mut info = SHELLEXECUTEINFOW {
945 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
946 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
947 lpVerb: PCWSTR(verb.as_ptr()),
948 lpFile: PCWSTR(file.as_ptr()),
949 lpParameters: PCWSTR(parameters.as_ptr()),
950 nShow: SW_HIDE,
951 ..SHELLEXECUTEINFOW::default()
952 };
953 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
956 #[allow(clippy::cast_sign_loss)]
957 let code = (error.code().0 as u32) & 0xffff;
958 return if code == ERROR_CANCELLED.0 {
959 ElevationOutcome::Refused
960 } else {
961 ElevationOutcome::Unavailable(format!(
962 "Windows could not show the administrator prompt: {}",
963 error.message()
964 ))
965 };
966 }
967 if info.hProcess.is_invalid() {
968 return ElevationOutcome::Unavailable(
969 "Windows started no elevated process to wait for".into(),
970 );
971 }
972 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
975 let mut code = 1u32;
976 let outcome = if waited == WAIT_OBJECT_0 {
977 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
979 #[allow(clippy::cast_possible_wrap)]
980 Ok(()) => ElevationOutcome::Exited(code as i32),
981 Err(error) => ElevationOutcome::Unavailable(format!(
982 "the elevated process's exit code could not be read: {}",
983 error.message()
984 )),
985 }
986 } else {
987 ElevationOutcome::Unavailable(
988 "the elevated process did not finish within 15 minutes".into(),
989 )
990 };
991 let _ = unsafe { CloseHandle(info.hProcess) };
993 outcome
994 }
995}
996
997#[cfg(unix)]
1002mod sys {
1003 use std::ffi::OsString;
1004 use std::path::Path;
1005 use std::process::Command;
1006
1007 pub(super) const fn session_id() -> Option<u32> {
1009 None
1010 }
1011
1012 use super::{ElevationOutcome, RegistryError};
1013
1014 pub(super) fn is_elevated() -> bool {
1015 unsafe { libc::geteuid() == 0 }
1017 }
1018
1019 #[cfg(target_os = "macos")]
1020 pub(super) fn physical_memory_bytes() -> Option<u64> {
1021 let mut value = 0u64;
1022 let mut size = std::mem::size_of::<u64>();
1023 let name = c"hw.memsize";
1024 let result = unsafe {
1027 libc::sysctlbyname(
1028 name.as_ptr(),
1029 (&raw mut value).cast(),
1030 &raw mut size,
1031 std::ptr::null_mut(),
1032 0,
1033 )
1034 };
1035 (result == 0).then_some(value)
1036 }
1037
1038 #[cfg(not(target_os = "macos"))]
1039 pub(super) fn physical_memory_bytes() -> Option<u64> {
1040 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
1041 let line = text.lines().find(|line| line.starts_with("MemTotal:"))?;
1042 let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
1043 kib.checked_mul(1024)
1044 }
1045
1046 fn unsupported() -> RegistryError {
1047 RegistryError::Other("the registry exists only on Windows".into())
1048 }
1049
1050 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1051 Err(unsupported())
1052 }
1053
1054 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1055 Err(unsupported())
1056 }
1057
1058 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1059 Err(unsupported())
1060 }
1061
1062 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1063 Err(unsupported())
1064 }
1065
1066 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1067 Err(unsupported())
1068 }
1069
1070 fn exit_outcome(
1071 status: std::io::Result<std::process::ExitStatus>,
1072 what: &str,
1073 ) -> ElevationOutcome {
1074 match status {
1075 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1076 Err(error) => {
1077 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1078 }
1079 }
1080 }
1081
1082 pub(super) fn run_elevated(
1083 program: &Path,
1084 args: &[OsString],
1085 terminal: bool,
1086 ) -> ElevationOutcome {
1087 if terminal
1088 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1089 {
1090 let status = Command::new("sudo")
1091 .arg("--")
1092 .arg(program)
1093 .args(args)
1094 .status();
1095 return match exit_outcome(status, "sudo") {
1099 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1100 other => other,
1101 };
1102 }
1103 if cfg!(target_os = "macos") {
1104 let command = std::iter::once(program.as_os_str().to_owned())
1105 .chain(args.iter().cloned())
1106 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1107 .collect::<Vec<_>>()
1108 .join(" ");
1109 let output = Command::new("/usr/bin/osascript")
1110 .arg("-e")
1111 .arg(super::administrator_applescript(&command))
1112 .output();
1113 return match output {
1114 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1115 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1117 ElevationOutcome::Refused
1118 }
1119 Ok(output) => {
1120 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1121 if detail.contains("(-60007)") || detail.contains("(-1743)") {
1122 ElevationOutcome::Unavailable(format!(
1123 "macOS could not show the administrator dialog here: {detail}"
1124 ))
1125 } else {
1126 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1127 }
1128 }
1129 Err(error) => ElevationOutcome::Unavailable(format!(
1130 "osascript could not be started: {error}"
1131 )),
1132 };
1133 }
1134 ElevationOutcome::Unavailable(
1135 "no terminal to ask for a sudo password; run the command from a terminal".into(),
1136 )
1137 }
1138}
1139
1140#[cfg(test)]
1141mod tests {
1142 use super::*;
1143
1144 #[test]
1145 fn windows_arguments_survive_the_command_line_round_trip() {
1146 for (argument, expected) in [
1147 ("plain", "plain"),
1148 ("", "\"\""),
1149 ("with space", "\"with space\""),
1150 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1151 ("say \"hi\"", "\"say \\\"hi\\\"\""),
1152 ("a\\\"b", "\"a\\\\\\\"b\""),
1153 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1154 ] {
1155 assert_eq!(
1156 crate::service::quote_argument(argument),
1157 expected,
1158 "{argument}"
1159 );
1160 }
1161 }
1162
1163 #[test]
1164 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1165 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1166 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1167 assert_eq!(
1168 script,
1169 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1170 );
1171 }
1172
1173 #[test]
1174 fn a_symlink_probe_leaves_nothing_behind() {
1175 let directory = tempfile::tempdir().unwrap();
1176 let _ = can_create_symlink(directory.path()).unwrap();
1177 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1178 }
1179
1180 #[cfg(unix)]
1181 #[test]
1182 fn unix_accounts_can_always_create_symlinks() {
1183 let directory = tempfile::tempdir().unwrap();
1184 assert!(can_create_symlink(directory.path()).unwrap());
1185 }
1186
1187 #[test]
1188 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1189 let directory = tempfile::tempdir().unwrap();
1190 let name = if cfg!(windows) {
1191 "rmtool.exe"
1192 } else {
1193 "rmtool"
1194 };
1195 let tool = directory.path().join(name);
1196 std::fs::write(&tool, b"").unwrap();
1197 #[cfg(unix)]
1198 {
1199 use std::os::unix::fs::PermissionsExt as _;
1200 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1201 }
1202 let path = std::env::join_paths([directory.path()]).unwrap();
1203 assert_eq!(find_on_path("rmtool", &path), Some(tool));
1204 assert_eq!(find_on_path("absent-tool", &path), None);
1205 assert_eq!(find_on_path("../rmtool", &path), None);
1206 }
1207
1208 #[cfg(windows)]
1211 #[test]
1212 fn an_extensionless_file_is_never_the_windows_answer() {
1213 let directory = tempfile::tempdir().unwrap();
1214 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1215 let path = std::env::join_paths([directory.path()]).unwrap();
1216 assert_eq!(find_on_path("npm", &path), None);
1217 let cmd = directory.path().join("npm.cmd");
1218 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1219 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1220 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1221 }
1222
1223 fn path_of(directories: &[&Path]) -> OsString {
1225 std::env::join_paths(directories).expect("joinable directories")
1226 }
1227
1228 fn touch(path: &Path) {
1229 std::fs::write(path, b"").expect("a fixture file");
1230 }
1231
1232 #[test]
1236 fn npm_shipped_only_as_npm_cmd_is_found_on_a_windows_path() {
1237 let empty = tempfile::tempdir().unwrap();
1238 let nodejs = tempfile::tempdir().unwrap();
1239 touch(&nodejs.path().join("npm"));
1240 touch(&nodejs.path().join("npm.cmd"));
1241 let path = path_of(&[empty.path(), nodejs.path()]);
1242 let cmd = Some(nodejs.path().join("npm.cmd"));
1243
1244 assert_eq!(
1245 find_on_windows_path("npm", &path, Some(".COM;.EXE;.BAT;.CMD".as_ref())),
1246 cmd
1247 );
1248 assert_eq!(find_on_windows_path("npm", &path, None), cmd);
1250 assert_eq!(find_on_windows_path("npm", &path, Some(" ".as_ref())), cmd);
1251 assert_eq!(find_on_windows_path("npm.cmd", &path, None), cmd);
1253 touch(&nodejs.path().join("npm.ps1"));
1254 assert_eq!(find_on_windows_path("npm.ps1", &path, None), None);
1255 }
1256
1257 #[test]
1260 fn pathext_orders_extensions_within_a_directory_and_path_orders_directories() {
1261 let both = tempfile::tempdir().unwrap();
1262 touch(&both.path().join("npm.cmd"));
1263 touch(&both.path().join("npm.exe"));
1264 let path = path_of(&[both.path()]);
1265 assert_eq!(
1266 find_on_windows_path("npm", &path, Some(".CMD;.EXE".as_ref())),
1267 Some(both.path().join("npm.cmd"))
1268 );
1269 assert_eq!(
1270 find_on_windows_path("npm", &path, Some(".exe;.cmd".as_ref())),
1271 Some(both.path().join("npm.exe"))
1272 );
1273
1274 let first = tempfile::tempdir().unwrap();
1275 let second = tempfile::tempdir().unwrap();
1276 touch(&first.path().join("npm.cmd"));
1277 touch(&second.path().join("npm.exe"));
1278 assert_eq!(
1279 find_on_windows_path(
1280 "npm",
1281 &path_of(&[first.path(), second.path()]),
1282 Some(".EXE;.CMD".as_ref())
1283 ),
1284 Some(first.path().join("npm.cmd")),
1285 "an earlier PATH directory wins over a preferred extension later on"
1286 );
1287 }
1288
1289 #[test]
1292 #[serial_test::serial(current_dir)]
1293 fn a_missing_npm_is_not_found_on_a_windows_path() {
1294 let empty = tempfile::tempdir().unwrap();
1295 assert_eq!(
1296 find_on_windows_path("npm", &path_of(&[empty.path()]), None),
1297 None
1298 );
1299
1300 let script_only = tempfile::tempdir().unwrap();
1301 touch(&script_only.path().join("npm.ps1"));
1302 assert_eq!(
1303 find_on_windows_path(
1304 "npm",
1305 &path_of(&[script_only.path()]),
1306 Some(".PS1;.EXE;.CMD".as_ref())
1307 ),
1308 None
1309 );
1310
1311 let planted = tempfile::tempdir().unwrap();
1314 touch(&planted.path().join("npm.cmd"));
1315 if let Some(relative) = relative_to_current_directory(planted.path()) {
1316 assert!(relative.join("npm.cmd").is_file(), "{}", relative.display());
1317 assert_eq!(
1318 find_on_windows_path("npm", &path_of(&[&relative]), None),
1319 None
1320 );
1321 }
1322 }
1323
1324 fn relative_to_current_directory(target: &Path) -> Option<PathBuf> {
1327 let here = std::env::current_dir().ok()?;
1328 let target = std::fs::canonicalize(target).ok()?;
1329 let here = std::fs::canonicalize(here).ok()?;
1330 let (here, target): (Vec<_>, Vec<_>) =
1331 (here.components().collect(), target.components().collect());
1332 let common = here
1333 .iter()
1334 .zip(&target)
1335 .take_while(|(left, right)| left == right)
1336 .count();
1337 if common == 0 {
1338 return None;
1339 }
1340 let mut relative: PathBuf = std::iter::repeat_n("..", here.len() - common).collect();
1341 relative.extend(&target[common..]);
1342 Some(relative)
1343 }
1344
1345 fn unfit_finding(id: &str) -> UnfitFinding {
1346 UnfitFinding {
1347 id: id.into(),
1348 detail: "it fails".into(),
1349 remedy: None,
1350 }
1351 }
1352
1353 #[test]
1356 fn who_can_answer_an_administrator_prompt() {
1357 assert!(
1358 prompt_answerable(Some(1), false),
1359 "the desktop, no terminal"
1360 );
1361 assert!(prompt_answerable(Some(2), true));
1362 assert!(!prompt_answerable(Some(0), true), "services and SSH");
1363 assert!(prompt_answerable(None, true), "sudo on a terminal");
1364 assert!(!prompt_answerable(None, false));
1365 }
1366
1367 #[test]
1369 fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1370 let root = tempfile::tempdir().unwrap();
1371 let paths = AppPaths::rooted_at(root.path());
1372 paths.create_all().unwrap();
1373 let then = Utc::now();
1374 record_host_unfit(
1375 &paths,
1376 &[unfit_finding("host.runner_root_responsive")],
1377 then,
1378 )
1379 .unwrap();
1380 let later = then + chrono::Duration::hours(1);
1381 assert_eq!(
1382 host_unfit_in_force(&paths, later),
1383 None,
1384 "nobody beats: stopped"
1385 );
1386
1387 crate::daemon_heartbeat::beat(&paths, then).unwrap();
1389 assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1390 crate::daemon_heartbeat::beat(&paths, later).unwrap();
1391 assert_eq!(
1392 host_unfit_in_force(&paths, later),
1393 None,
1394 "a daemon still beating would have re-stamped a refusal in force"
1395 );
1396
1397 let much_later = later + chrono::Duration::hours(1);
1400 assert_eq!(host_unfit_in_force(&paths, much_later), None);
1401 }
1402
1403 #[test]
1404 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1405 let root = tempfile::tempdir().unwrap();
1406 let paths = AppPaths::rooted_at(root.path());
1407 paths.create_all().unwrap();
1408 assert_eq!(host_unfit(&paths).unwrap(), None);
1409 let first = Utc::now() - chrono::Duration::minutes(10);
1410 record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1411 let later = Utc::now();
1412 record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1413 let record = host_unfit(&paths).unwrap().unwrap();
1414 assert_eq!(record.since, first);
1415 assert_eq!(record.checked_at, later);
1416 assert_eq!(record.checks, ["host.required_tools"]);
1417 clear_host_unfit(&paths).unwrap();
1418 assert_eq!(host_unfit(&paths).unwrap(), None);
1419 clear_host_unfit(&paths).unwrap();
1420 }
1421
1422 #[test]
1423 fn physical_memory_is_reported() {
1424 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1425 }
1426
1427 #[test]
1428 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1429 let directory = tempfile::tempdir().unwrap();
1430 let deadline = std::time::Duration::from_secs(10);
1431 assert_eq!(
1432 directory_responds(directory.path(), deadline),
1433 Responsiveness::Responds
1434 );
1435 assert_eq!(
1436 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1437 Responsiveness::Responds
1438 );
1439 assert!(
1442 !PROBES_IN_FLIGHT
1443 .lock()
1444 .unwrap()
1445 .iter()
1446 .any(|(path, _)| path.starts_with(directory.path())),
1447 "a finished probe deregisters"
1448 );
1449 }
1450
1451 #[test]
1456 fn a_path_still_being_probed_answers_without_a_second_thread() {
1457 let directory = tempfile::tempdir().unwrap();
1458 for (name, listing, expected) in [
1459 ("stuck-volume", false, Responsiveness::Hung),
1460 ("asking-volume", true, Responsiveness::ListingBlocked),
1461 ] {
1462 let stuck = directory.path().join(name);
1463 PROBES_IN_FLIGHT.lock().unwrap().push((
1464 stuck.clone(),
1465 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1466 ));
1467 let started = std::time::Instant::now();
1468 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1469 PROBES_IN_FLIGHT
1470 .lock()
1471 .unwrap()
1472 .retain(|(path, _)| *path != stuck);
1473 assert_eq!(answer, expected, "{name}");
1474 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1475 }
1476 }
1477}