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 tool.is_empty() || tool.contains(['/', '\\']) {
309 return None;
310 }
311 let extensions: Vec<String> = if cfg!(windows) {
312 let pathext = std::env::var("PATHEXT").unwrap_or_else(|_| ".COM;.EXE;.BAT;.CMD".into());
313 let mut list = Vec::new();
317 if tool.contains('.') {
318 list.push(String::new());
319 }
320 list.extend(
321 pathext
322 .split(';')
323 .filter(|ext| !ext.is_empty())
324 .map(str::to_ascii_lowercase),
325 );
326 list
327 } else {
328 vec![String::new()]
329 };
330 std::env::split_paths(path)
331 .filter(|dir| !dir.as_os_str().is_empty())
332 .find_map(|dir| {
333 extensions.iter().find_map(|extension| {
334 let candidate = dir.join(format!("{tool}{extension}"));
335 is_executable(&candidate).then_some(candidate)
336 })
337 })
338}
339
340#[cfg(unix)]
341fn is_executable(path: &Path) -> bool {
342 use std::os::unix::fs::PermissionsExt as _;
343 std::fs::metadata(path)
344 .is_ok_and(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
345}
346
347#[cfg(windows)]
348fn is_executable(path: &Path) -> bool {
349 std::fs::metadata(path).is_ok_and(|meta| meta.is_file())
350}
351
352#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
358pub enum RegistryError {
359 #[error("access denied")]
362 AccessDenied,
363 #[error("{0}")]
365 Other(String),
366}
367
368pub fn read_registry_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
374 sys::read_dword(subkey, value)
375}
376
377pub fn read_registry_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
382 sys::read_string(subkey, value)
383}
384
385pub fn registry_value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
391 sys::value_names(subkey)
392}
393
394pub fn write_registry_dword(
400 subkey: &str,
401 value: &str,
402 data: Option<u32>,
403) -> Result<(), RegistryError> {
404 sys::write_dword(subkey, value, data)
405}
406
407pub fn write_registry_string(
413 subkey: &str,
414 value: &str,
415 data: Option<&str>,
416) -> Result<(), RegistryError> {
417 sys::write_string(subkey, value, data)
418}
419
420pub const HOST_UNFIT_FILE: &str = "host-unfit.json";
426
427pub const DAEMON_RECHECK: std::time::Duration = std::time::Duration::from_secs(5 * 60);
430
431pub const HOST_UNFIT_RECORD_FRESH: std::time::Duration =
435 std::time::Duration::from_secs(3 * DAEMON_RECHECK.as_secs());
436const HOST_UNFIT_SCHEMA_VERSION: u32 = 1;
437
438#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
440pub struct HostUnfitRecord {
441 pub schema_version: u32,
442 pub since: DateTime<Utc>,
444 pub checked_at: DateTime<Utc>,
446 pub checks: Vec<String>,
448 #[serde(default)]
451 pub findings: Vec<UnfitFinding>,
452}
453
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
456pub struct UnfitFinding {
457 pub id: String,
458 pub detail: String,
459 #[serde(default)]
460 pub remedy: Option<String>,
461}
462
463impl HostUnfitRecord {
464 #[must_use]
466 pub fn describe(&self) -> String {
467 if self.findings.is_empty() {
468 return self.checks.join(", ");
469 }
470 self.findings
471 .iter()
472 .map(|finding| match &finding.remedy {
473 Some(remedy) => format!("{}: {}. Fix: {remedy}", finding.id, finding.detail),
474 None => format!("{}: {}", finding.id, finding.detail),
475 })
476 .collect::<Vec<_>>()
477 .join("; ")
478 }
479}
480
481fn host_unfit_path(paths: &AppPaths) -> PathBuf {
482 paths.state_dir().join(HOST_UNFIT_FILE)
483}
484
485pub fn record_host_unfit(
491 paths: &AppPaths,
492 findings: &[UnfitFinding],
493 at: DateTime<Utc>,
494) -> Result<(), String> {
495 let since = host_unfit(paths)
496 .ok()
497 .flatten()
498 .map_or(at, |record| record.since);
499 let record = HostUnfitRecord {
500 schema_version: HOST_UNFIT_SCHEMA_VERSION,
501 since,
502 checked_at: at,
503 checks: findings.iter().map(|finding| finding.id.clone()).collect(),
504 findings: findings.to_vec(),
505 };
506 let path = host_unfit_path(paths);
507 let text = serde_json::to_vec_pretty(&record).map_err(|error| error.to_string())?;
508 write_atomically(&path, &text)
509 .map_err(|error| format!("cannot write {}: {error}", path.display()))
510}
511
512pub fn clear_host_unfit(paths: &AppPaths) -> Result<(), String> {
517 let path = host_unfit_path(paths);
518 match std::fs::remove_file(&path) {
519 Ok(()) => Ok(()),
520 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
521 Err(error) => Err(format!("cannot remove {}: {error}", path.display())),
522 }
523}
524
525#[must_use]
534pub fn host_unfit_in_force(paths: &AppPaths, now: DateTime<Utc>) -> Option<HostUnfitRecord> {
535 host_unfit(paths).ok().flatten().filter(|record| {
536 if !crate::wsl::fence::elapsed_at_least(record.checked_at, now, HOST_UNFIT_RECORD_FRESH) {
537 return true;
538 }
539 match crate::daemon_heartbeat::liveness(paths, now) {
542 crate::daemon_heartbeat::Liveness::Stalled { since, .. } => {
543 !crate::wsl::fence::elapsed_at_least(
544 record.checked_at,
545 since,
546 HOST_UNFIT_RECORD_FRESH,
547 )
548 }
549 _ => false,
550 }
551 })
552}
553
554pub fn host_unfit(paths: &AppPaths) -> Result<Option<HostUnfitRecord>, String> {
559 let path = host_unfit_path(paths);
560 match std::fs::read_to_string(&path) {
561 Ok(text) => serde_json::from_str(&text)
562 .map(Some)
563 .map_err(|error| format!("cannot parse {}: {error}", path.display())),
564 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
565 Err(error) => Err(format!("cannot read {}: {error}", path.display())),
566 }
567}
568
569#[cfg(windows)]
574mod sys {
575 use std::ffi::OsString;
576 use std::os::windows::ffi::OsStrExt as _;
577 use std::path::Path;
578
579 use windows::Win32::Foundation::{
580 CloseHandle, ERROR_ACCESS_DENIED, ERROR_CANCELLED, ERROR_FILE_NOT_FOUND,
581 ERROR_NO_MORE_ITEMS, ERROR_SUCCESS, HANDLE, WAIT_OBJECT_0, WIN32_ERROR,
582 };
583 use windows::Win32::Security::{
584 GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, TokenSessionId,
585 };
586 use windows::Win32::System::Registry::{
587 HKEY, HKEY_LOCAL_MACHINE, KEY_READ, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE,
588 REG_SZ, RRF_NOEXPAND, RRF_RT_REG_DWORD, RRF_RT_REG_EXPAND_SZ, RRF_RT_REG_SZ, RegCloseKey,
589 RegCreateKeyExW, RegDeleteKeyValueW, RegEnumValueW, RegGetValueW, RegOpenKeyExW,
590 RegSetValueExW,
591 };
592 use windows::Win32::System::SystemInformation::{GlobalMemoryStatusEx, MEMORYSTATUSEX};
593 use windows::Win32::System::Threading::{
594 GetCurrentProcess, GetExitCodeProcess, OpenProcessToken, WaitForSingleObject,
595 };
596 use windows::Win32::UI::Shell::{
597 SEE_MASK_NOASYNC, SEE_MASK_NOCLOSEPROCESS, SHELLEXECUTEINFOW, ShellExecuteExW,
598 };
599 use windows::core::{PCWSTR, PWSTR};
600
601 use super::{ElevationOutcome, RegistryError};
602
603 const SW_HIDE: i32 = 0;
606 const ELEVATED_WAIT_MS: u32 = 15 * 60 * 1000;
610
611 fn wide(text: &str) -> Vec<u16> {
612 text.encode_utf16().chain(std::iter::once(0)).collect()
613 }
614
615 fn registry_error(code: WIN32_ERROR) -> RegistryError {
616 if code == ERROR_ACCESS_DENIED {
617 RegistryError::AccessDenied
618 } else {
619 RegistryError::Other(format!("registry error {}", code.0))
620 }
621 }
622
623 pub(super) fn session_id() -> Option<u32> {
625 let mut token = HANDLE::default();
626 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
629 return None;
630 }
631 let mut session = 0u32;
632 let mut returned = 0u32;
633 let queried = unsafe {
636 GetTokenInformation(
637 token,
638 TokenSessionId,
639 Some((&raw mut session).cast()),
640 4,
641 &raw mut returned,
642 )
643 };
644 let _ = unsafe { CloseHandle(token) };
646 queried.is_ok().then_some(session)
647 }
648
649 pub(super) fn is_elevated() -> bool {
650 let mut token = HANDLE::default();
651 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &raw mut token) }.is_err() {
654 return false;
655 }
656 let mut elevation = TOKEN_ELEVATION::default();
657 let mut returned = 0u32;
658 let queried = unsafe {
661 GetTokenInformation(
662 token,
663 TokenElevation,
664 Some((&raw mut elevation).cast()),
665 u32::try_from(std::mem::size_of::<TOKEN_ELEVATION>()).unwrap_or(4),
666 &raw mut returned,
667 )
668 };
669 let _ = unsafe { CloseHandle(token) };
671 queried.is_ok() && elevation.TokenIsElevated != 0
672 }
673
674 pub(super) fn physical_memory_bytes() -> Option<u64> {
675 let mut status = MEMORYSTATUSEX {
676 dwLength: u32::try_from(std::mem::size_of::<MEMORYSTATUSEX>()).ok()?,
677 ..MEMORYSTATUSEX::default()
678 };
679 unsafe { GlobalMemoryStatusEx(&raw mut status) }.ok()?;
682 Some(status.ullTotalPhys)
683 }
684
685 pub(super) fn read_dword(subkey: &str, value: &str) -> Result<Option<u32>, RegistryError> {
686 let subkey = wide(subkey);
687 let value = wide(value);
688 let mut data = 0u32;
689 let mut size = 4u32;
690 let code = unsafe {
693 RegGetValueW(
694 HKEY_LOCAL_MACHINE,
695 PCWSTR(subkey.as_ptr()),
696 PCWSTR(value.as_ptr()),
697 RRF_RT_REG_DWORD,
698 None,
699 Some((&raw mut data).cast()),
700 Some(&raw mut size),
701 )
702 };
703 match code {
704 ERROR_SUCCESS => Ok(Some(data)),
705 ERROR_FILE_NOT_FOUND => Ok(None),
706 other => Err(registry_error(other)),
707 }
708 }
709
710 pub(super) fn read_string(subkey: &str, value: &str) -> Result<Option<String>, RegistryError> {
711 let subkey = wide(subkey);
712 let value = wide(value);
713 let flags = RRF_RT_REG_SZ | RRF_RT_REG_EXPAND_SZ | RRF_NOEXPAND;
714 let mut size = 0u32;
715 let code = unsafe {
717 RegGetValueW(
718 HKEY_LOCAL_MACHINE,
719 PCWSTR(subkey.as_ptr()),
720 PCWSTR(value.as_ptr()),
721 flags,
722 None,
723 None,
724 Some(&raw mut size),
725 )
726 };
727 match code {
728 ERROR_SUCCESS => {}
729 ERROR_FILE_NOT_FOUND => return Ok(None),
730 other => return Err(registry_error(other)),
731 }
732 let mut buffer = vec![0u16; (size as usize).div_ceil(2) + 1];
733 let mut size = u32::try_from(buffer.len() * 2).unwrap_or(u32::MAX);
734 let code = unsafe {
737 RegGetValueW(
738 HKEY_LOCAL_MACHINE,
739 PCWSTR(subkey.as_ptr()),
740 PCWSTR(value.as_ptr()),
741 flags,
742 None,
743 Some(buffer.as_mut_ptr().cast()),
744 Some(&raw mut size),
745 )
746 };
747 match code {
748 ERROR_SUCCESS => {
749 let units = (size as usize / 2).min(buffer.len());
750 let text = String::from_utf16_lossy(&buffer[..units]);
751 Ok(Some(text.trim_end_matches('\0').to_owned()))
752 }
753 ERROR_FILE_NOT_FOUND => Ok(None),
754 other => Err(registry_error(other)),
755 }
756 }
757
758 pub(super) fn value_names(subkey: &str) -> Result<Vec<String>, RegistryError> {
759 let subkey = wide(subkey);
760 let mut key = HKEY::default();
761 let code = unsafe {
763 RegOpenKeyExW(
764 HKEY_LOCAL_MACHINE,
765 PCWSTR(subkey.as_ptr()),
766 None,
767 KEY_READ,
768 &raw mut key,
769 )
770 };
771 match code {
772 ERROR_SUCCESS => {}
773 ERROR_FILE_NOT_FOUND => return Ok(Vec::new()),
774 other => return Err(registry_error(other)),
775 }
776 let mut names = Vec::new();
777 let mut index = 0u32;
778 let result = loop {
779 let mut name = vec![0u16; 16_384];
781 let mut length = u32::try_from(name.len()).unwrap_or(u32::MAX);
782 let code = unsafe {
785 RegEnumValueW(
786 key,
787 index,
788 Some(PWSTR(name.as_mut_ptr())),
789 &raw mut length,
790 None,
791 None,
792 None,
793 None,
794 )
795 };
796 match code {
797 ERROR_SUCCESS => {
798 names.push(String::from_utf16_lossy(&name[..length as usize]));
799 index += 1;
800 }
801 ERROR_NO_MORE_ITEMS => break Ok(names),
802 other => break Err(registry_error(other)),
803 }
804 };
805 let _ = unsafe { RegCloseKey(key) };
807 result
808 }
809
810 fn write_value(
811 subkey: &str,
812 value: &str,
813 data: Option<(windows::Win32::System::Registry::REG_VALUE_TYPE, Vec<u8>)>,
814 ) -> Result<(), RegistryError> {
815 let subkey_wide = wide(subkey);
816 let value_wide = wide(value);
817 let Some((kind, bytes)) = data else {
818 let code = unsafe {
820 RegDeleteKeyValueW(
821 HKEY_LOCAL_MACHINE,
822 PCWSTR(subkey_wide.as_ptr()),
823 PCWSTR(value_wide.as_ptr()),
824 )
825 };
826 return match code {
827 ERROR_SUCCESS | ERROR_FILE_NOT_FOUND => Ok(()),
828 other => Err(registry_error(other)),
829 };
830 };
831 let mut key = HKEY::default();
832 let code = unsafe {
835 RegCreateKeyExW(
836 HKEY_LOCAL_MACHINE,
837 PCWSTR(subkey_wide.as_ptr()),
838 None,
839 PCWSTR::null(),
840 REG_OPTION_NON_VOLATILE,
841 KEY_SET_VALUE,
842 None,
843 &raw mut key,
844 None,
845 )
846 };
847 if code != ERROR_SUCCESS {
848 return Err(registry_error(code));
849 }
850 let code =
853 unsafe { RegSetValueExW(key, PCWSTR(value_wide.as_ptr()), None, kind, Some(&bytes)) };
854 let _ = unsafe { RegCloseKey(key) };
856 if code == ERROR_SUCCESS {
857 Ok(())
858 } else {
859 Err(registry_error(code))
860 }
861 }
862
863 pub(super) fn write_dword(
864 subkey: &str,
865 value: &str,
866 data: Option<u32>,
867 ) -> Result<(), RegistryError> {
868 write_value(
869 subkey,
870 value,
871 data.map(|data| (REG_DWORD, data.to_le_bytes().to_vec())),
872 )
873 }
874
875 pub(super) fn write_string(
876 subkey: &str,
877 value: &str,
878 data: Option<&str>,
879 ) -> Result<(), RegistryError> {
880 write_value(
881 subkey,
882 value,
883 data.map(|text| {
884 let bytes = wide(text)
885 .iter()
886 .flat_map(|unit| unit.to_le_bytes())
887 .collect();
888 (REG_SZ, bytes)
889 }),
890 )
891 }
892
893 pub(super) fn run_elevated(
894 program: &Path,
895 args: &[OsString],
896 _terminal: bool,
897 ) -> ElevationOutcome {
898 let file: Vec<u16> = program
899 .as_os_str()
900 .encode_wide()
901 .chain(std::iter::once(0))
902 .collect();
903 let parameters = args
904 .iter()
905 .map(|argument| crate::service::quote_argument(&argument.to_string_lossy()))
906 .collect::<Vec<_>>()
907 .join(" ");
908 let parameters = wide(¶meters);
909 let verb = wide("runas");
910 let mut info = SHELLEXECUTEINFOW {
911 cbSize: u32::try_from(std::mem::size_of::<SHELLEXECUTEINFOW>()).unwrap_or(0),
912 fMask: SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC,
913 lpVerb: PCWSTR(verb.as_ptr()),
914 lpFile: PCWSTR(file.as_ptr()),
915 lpParameters: PCWSTR(parameters.as_ptr()),
916 nShow: SW_HIDE,
917 ..SHELLEXECUTEINFOW::default()
918 };
919 if let Err(error) = unsafe { ShellExecuteExW(&raw mut info) } {
922 #[allow(clippy::cast_sign_loss)]
923 let code = (error.code().0 as u32) & 0xffff;
924 return if code == ERROR_CANCELLED.0 {
925 ElevationOutcome::Refused
926 } else {
927 ElevationOutcome::Unavailable(format!(
928 "Windows could not show the administrator prompt: {}",
929 error.message()
930 ))
931 };
932 }
933 if info.hProcess.is_invalid() {
934 return ElevationOutcome::Unavailable(
935 "Windows started no elevated process to wait for".into(),
936 );
937 }
938 let waited = unsafe { WaitForSingleObject(info.hProcess, ELEVATED_WAIT_MS) };
941 let mut code = 1u32;
942 let outcome = if waited == WAIT_OBJECT_0 {
943 match unsafe { GetExitCodeProcess(info.hProcess, &raw mut code) } {
945 #[allow(clippy::cast_possible_wrap)]
946 Ok(()) => ElevationOutcome::Exited(code as i32),
947 Err(error) => ElevationOutcome::Unavailable(format!(
948 "the elevated process's exit code could not be read: {}",
949 error.message()
950 )),
951 }
952 } else {
953 ElevationOutcome::Unavailable(
954 "the elevated process did not finish within 15 minutes".into(),
955 )
956 };
957 let _ = unsafe { CloseHandle(info.hProcess) };
959 outcome
960 }
961}
962
963#[cfg(unix)]
968mod sys {
969 use std::ffi::OsString;
970 use std::path::Path;
971 use std::process::Command;
972
973 pub(super) const fn session_id() -> Option<u32> {
975 None
976 }
977
978 use super::{ElevationOutcome, RegistryError};
979
980 pub(super) fn is_elevated() -> bool {
981 unsafe { libc::geteuid() == 0 }
983 }
984
985 #[cfg(target_os = "macos")]
986 pub(super) fn physical_memory_bytes() -> Option<u64> {
987 let mut value = 0u64;
988 let mut size = std::mem::size_of::<u64>();
989 let name = c"hw.memsize";
990 let result = unsafe {
993 libc::sysctlbyname(
994 name.as_ptr(),
995 (&raw mut value).cast(),
996 &raw mut size,
997 std::ptr::null_mut(),
998 0,
999 )
1000 };
1001 (result == 0).then_some(value)
1002 }
1003
1004 #[cfg(not(target_os = "macos"))]
1005 pub(super) fn physical_memory_bytes() -> Option<u64> {
1006 let text = std::fs::read_to_string("/proc/meminfo").ok()?;
1007 let line = text.lines().find(|line| line.starts_with("MemTotal:"))?;
1008 let kib: u64 = line.split_whitespace().nth(1)?.parse().ok()?;
1009 kib.checked_mul(1024)
1010 }
1011
1012 fn unsupported() -> RegistryError {
1013 RegistryError::Other("the registry exists only on Windows".into())
1014 }
1015
1016 pub(super) fn read_dword(_: &str, _: &str) -> Result<Option<u32>, RegistryError> {
1017 Err(unsupported())
1018 }
1019
1020 pub(super) fn read_string(_: &str, _: &str) -> Result<Option<String>, RegistryError> {
1021 Err(unsupported())
1022 }
1023
1024 pub(super) fn value_names(_: &str) -> Result<Vec<String>, RegistryError> {
1025 Err(unsupported())
1026 }
1027
1028 pub(super) fn write_dword(_: &str, _: &str, _: Option<u32>) -> Result<(), RegistryError> {
1029 Err(unsupported())
1030 }
1031
1032 pub(super) fn write_string(_: &str, _: &str, _: Option<&str>) -> Result<(), RegistryError> {
1033 Err(unsupported())
1034 }
1035
1036 fn exit_outcome(
1037 status: std::io::Result<std::process::ExitStatus>,
1038 what: &str,
1039 ) -> ElevationOutcome {
1040 match status {
1041 Ok(status) => ElevationOutcome::Exited(status.code().unwrap_or(1)),
1042 Err(error) => {
1043 ElevationOutcome::Unavailable(format!("{what} could not be started: {error}"))
1044 }
1045 }
1046 }
1047
1048 pub(super) fn run_elevated(
1049 program: &Path,
1050 args: &[OsString],
1051 terminal: bool,
1052 ) -> ElevationOutcome {
1053 if terminal
1054 && super::find_on_path("sudo", &std::env::var_os("PATH").unwrap_or_default()).is_some()
1055 {
1056 let status = Command::new("sudo")
1057 .arg("--")
1058 .arg(program)
1059 .args(args)
1060 .status();
1061 return match exit_outcome(status, "sudo") {
1065 ElevationOutcome::Exited(1) => ElevationOutcome::Refused,
1066 other => other,
1067 };
1068 }
1069 if cfg!(target_os = "macos") {
1070 let command = std::iter::once(program.as_os_str().to_owned())
1071 .chain(args.iter().cloned())
1072 .map(|part| super::quote_posix_argument(&part.to_string_lossy()))
1073 .collect::<Vec<_>>()
1074 .join(" ");
1075 let output = Command::new("/usr/bin/osascript")
1076 .arg("-e")
1077 .arg(super::administrator_applescript(&command))
1078 .output();
1079 return match output {
1080 Ok(output) if output.status.success() => ElevationOutcome::Exited(0),
1081 Ok(output) if String::from_utf8_lossy(&output.stderr).contains("(-128)") => {
1083 ElevationOutcome::Refused
1084 }
1085 Ok(output) => {
1086 let detail = String::from_utf8_lossy(&output.stderr).trim().to_owned();
1087 if detail.contains("(-60007)") || detail.contains("(-1743)") {
1088 ElevationOutcome::Unavailable(format!(
1089 "macOS could not show the administrator dialog here: {detail}"
1090 ))
1091 } else {
1092 ElevationOutcome::Exited(output.status.code().unwrap_or(2))
1093 }
1094 }
1095 Err(error) => ElevationOutcome::Unavailable(format!(
1096 "osascript could not be started: {error}"
1097 )),
1098 };
1099 }
1100 ElevationOutcome::Unavailable(
1101 "no terminal to ask for a sudo password; run the command from a terminal".into(),
1102 )
1103 }
1104}
1105
1106#[cfg(test)]
1107mod tests {
1108 use super::*;
1109
1110 #[test]
1111 fn windows_arguments_survive_the_command_line_round_trip() {
1112 for (argument, expected) in [
1113 ("plain", "plain"),
1114 ("", "\"\""),
1115 ("with space", "\"with space\""),
1116 ("C:\\Program Files\\x\\", "\"C:\\Program Files\\x\\\\\""),
1117 ("say \"hi\"", "\"say \\\"hi\\\"\""),
1118 ("a\\\"b", "\"a\\\\\\\"b\""),
1119 ("{\"k\":\"C:\\\\r m\"}", "\"{\\\"k\\\":\\\"C:\\\\r m\\\"}\""),
1120 ] {
1121 assert_eq!(
1122 crate::service::quote_argument(argument),
1123 expected,
1124 "{argument}"
1125 );
1126 }
1127 }
1128
1129 #[test]
1130 fn posix_and_applescript_quoting_keep_hostile_characters_inert() {
1131 assert_eq!(quote_posix_argument("it's"), "'it'\\''s'");
1132 let script = administrator_applescript("'/a b/rm' 'x\"y' '\\'");
1133 assert_eq!(
1134 script,
1135 "do shell script \"'/a b/rm' 'x\\\"y' '\\\\'\" with administrator privileges"
1136 );
1137 }
1138
1139 #[test]
1140 fn a_symlink_probe_leaves_nothing_behind() {
1141 let directory = tempfile::tempdir().unwrap();
1142 let _ = can_create_symlink(directory.path()).unwrap();
1143 assert_eq!(std::fs::read_dir(directory.path()).unwrap().count(), 0);
1144 }
1145
1146 #[cfg(unix)]
1147 #[test]
1148 fn unix_accounts_can_always_create_symlinks() {
1149 let directory = tempfile::tempdir().unwrap();
1150 assert!(can_create_symlink(directory.path()).unwrap());
1151 }
1152
1153 #[test]
1154 fn a_tool_is_found_on_path_and_a_path_like_name_never_is() {
1155 let directory = tempfile::tempdir().unwrap();
1156 let name = if cfg!(windows) {
1157 "rmtool.exe"
1158 } else {
1159 "rmtool"
1160 };
1161 let tool = directory.path().join(name);
1162 std::fs::write(&tool, b"").unwrap();
1163 #[cfg(unix)]
1164 {
1165 use std::os::unix::fs::PermissionsExt as _;
1166 std::fs::set_permissions(&tool, std::fs::Permissions::from_mode(0o755)).unwrap();
1167 }
1168 let path = std::env::join_paths([directory.path()]).unwrap();
1169 assert_eq!(find_on_path("rmtool", &path), Some(tool));
1170 assert_eq!(find_on_path("absent-tool", &path), None);
1171 assert_eq!(find_on_path("../rmtool", &path), None);
1172 }
1173
1174 #[cfg(windows)]
1177 #[test]
1178 fn an_extensionless_file_is_never_the_windows_answer() {
1179 let directory = tempfile::tempdir().unwrap();
1180 std::fs::write(directory.path().join("npm"), b"#!/bin/sh\n").unwrap();
1181 let path = std::env::join_paths([directory.path()]).unwrap();
1182 assert_eq!(find_on_path("npm", &path), None);
1183 let cmd = directory.path().join("npm.cmd");
1184 std::fs::write(&cmd, b"@echo off\r\n").unwrap();
1185 assert_eq!(find_on_path("npm", &path), Some(cmd.clone()));
1186 assert_eq!(find_on_path("npm.cmd", &path), Some(cmd));
1187 }
1188
1189 fn unfit_finding(id: &str) -> UnfitFinding {
1190 UnfitFinding {
1191 id: id.into(),
1192 detail: "it fails".into(),
1193 remedy: None,
1194 }
1195 }
1196
1197 #[test]
1200 fn who_can_answer_an_administrator_prompt() {
1201 assert!(
1202 prompt_answerable(Some(1), false),
1203 "the desktop, no terminal"
1204 );
1205 assert!(prompt_answerable(Some(2), true));
1206 assert!(!prompt_answerable(Some(0), true), "services and SSH");
1207 assert!(prompt_answerable(None, true), "sudo on a terminal");
1208 assert!(!prompt_answerable(None, false));
1209 }
1210
1211 #[test]
1213 fn an_unfit_record_stays_in_force_while_its_daemon_is_stalled() {
1214 let root = tempfile::tempdir().unwrap();
1215 let paths = AppPaths::rooted_at(root.path());
1216 paths.create_all().unwrap();
1217 let then = Utc::now();
1218 record_host_unfit(
1219 &paths,
1220 &[unfit_finding("host.runner_root_responsive")],
1221 then,
1222 )
1223 .unwrap();
1224 let later = then + chrono::Duration::hours(1);
1225 assert_eq!(
1226 host_unfit_in_force(&paths, later),
1227 None,
1228 "nobody beats: stopped"
1229 );
1230
1231 crate::daemon_heartbeat::beat(&paths, then).unwrap();
1233 assert!(host_unfit_in_force(&paths, later).is_some(), "stalled");
1234 crate::daemon_heartbeat::beat(&paths, later).unwrap();
1235 assert_eq!(
1236 host_unfit_in_force(&paths, later),
1237 None,
1238 "a daemon still beating would have re-stamped a refusal in force"
1239 );
1240
1241 let much_later = later + chrono::Duration::hours(1);
1244 assert_eq!(host_unfit_in_force(&paths, much_later), None);
1245 }
1246
1247 #[test]
1248 fn the_unfit_record_keeps_its_first_moment_and_clears() {
1249 let root = tempfile::tempdir().unwrap();
1250 let paths = AppPaths::rooted_at(root.path());
1251 paths.create_all().unwrap();
1252 assert_eq!(host_unfit(&paths).unwrap(), None);
1253 let first = Utc::now() - chrono::Duration::minutes(10);
1254 record_host_unfit(&paths, &[unfit_finding("windows.symlink_privilege")], first).unwrap();
1255 let later = Utc::now();
1256 record_host_unfit(&paths, &[unfit_finding("host.required_tools")], later).unwrap();
1257 let record = host_unfit(&paths).unwrap().unwrap();
1258 assert_eq!(record.since, first);
1259 assert_eq!(record.checked_at, later);
1260 assert_eq!(record.checks, ["host.required_tools"]);
1261 clear_host_unfit(&paths).unwrap();
1262 assert_eq!(host_unfit(&paths).unwrap(), None);
1263 clear_host_unfit(&paths).unwrap();
1264 }
1265
1266 #[test]
1267 fn physical_memory_is_reported() {
1268 assert!(physical_memory_bytes().is_some_and(|bytes| bytes > 256 * 1024 * 1024));
1269 }
1270
1271 #[test]
1272 fn a_directory_responds_and_a_not_yet_created_one_is_judged_by_its_parent() {
1273 let directory = tempfile::tempdir().unwrap();
1274 let deadline = std::time::Duration::from_secs(10);
1275 assert_eq!(
1276 directory_responds(directory.path(), deadline),
1277 Responsiveness::Responds
1278 );
1279 assert_eq!(
1280 directory_responds(&directory.path().join("not-yet").join("deeper"), deadline),
1281 Responsiveness::Responds
1282 );
1283 assert!(
1286 !PROBES_IN_FLIGHT
1287 .lock()
1288 .unwrap()
1289 .iter()
1290 .any(|(path, _)| path.starts_with(directory.path())),
1291 "a finished probe deregisters"
1292 );
1293 }
1294
1295 #[test]
1300 fn a_path_still_being_probed_answers_without_a_second_thread() {
1301 let directory = tempfile::tempdir().unwrap();
1302 for (name, listing, expected) in [
1303 ("stuck-volume", false, Responsiveness::Hung),
1304 ("asking-volume", true, Responsiveness::ListingBlocked),
1305 ] {
1306 let stuck = directory.path().join(name);
1307 PROBES_IN_FLIGHT.lock().unwrap().push((
1308 stuck.clone(),
1309 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(listing)),
1310 ));
1311 let started = std::time::Instant::now();
1312 let answer = directory_responds(&stuck, std::time::Duration::from_secs(30));
1313 PROBES_IN_FLIGHT
1314 .lock()
1315 .unwrap()
1316 .retain(|(path, _)| *path != stuck);
1317 assert_eq!(answer, expected, "{name}");
1318 assert!(started.elapsed() < std::time::Duration::from_secs(5));
1319 }
1320 }
1321}