1use std::{
10 collections::BTreeMap,
11 env,
12 ffi::{OsStr, OsString},
13 io,
14 path::{Path, PathBuf},
15 process::{Output, Stdio},
16};
17
18use smol::{io::AsyncReadExt as _, process::Command, unblock};
19
20use crate::utils::{CommandError, format_failure_stream, std_output_enabled};
21
22mod detached;
23
24#[derive(Debug, Clone)]
32pub struct Host {
33 env: BTreeMap<OsString, OsString>,
34 cwd: PathBuf,
35 home: Option<PathBuf>,
36 app_dirs: Vec<PathBuf>,
37}
38
39impl Host {
40 #[must_use]
45 pub fn current() -> Self {
46 let env = env::vars_os().collect();
47 Self {
48 env,
49 cwd: env::current_dir().expect("process must have a working directory"),
50 home: dirs::home_dir(),
51 app_dirs: default_app_dirs(),
52 }
53 }
54
55 pub fn new<P, K, V>(
73 path_dirs: impl IntoIterator<Item = P>,
74 vars: impl IntoIterator<Item = (K, V)>,
75 ) -> Self
76 where
77 P: AsRef<Path>,
78 K: AsRef<OsStr>,
79 V: AsRef<OsStr>,
80 {
81 let mut env = BTreeMap::new();
82 seed_process_plumbing(&mut env);
83 let path = env::join_paths(
84 path_dirs
85 .into_iter()
86 .map(|dir| dir.as_ref().as_os_str().to_os_string()),
87 )
88 .expect("Host::new PATH entries must join into a valid PATH string");
89 env.insert(OsString::from("PATH"), path);
90 for (key, value) in vars {
91 env.insert(key.as_ref().to_os_string(), value.as_ref().to_os_string());
92 }
93 let home = home_dir_from_env(&env);
94 Self {
95 env,
96 cwd: env::current_dir().expect("process must have a working directory"),
97 home,
98 app_dirs: Vec::new(),
99 }
100 }
101
102 #[must_use]
104 pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
105 self.cwd = cwd.into();
106 self
107 }
108
109 #[must_use]
111 pub fn with_app_dirs(mut self, app_dirs: impl IntoIterator<Item = PathBuf>) -> Self {
112 self.app_dirs = app_dirs.into_iter().collect();
113 self
114 }
115
116 #[must_use]
121 pub fn env(&self, key: impl AsRef<OsStr>) -> Option<&OsStr> {
122 env_get(&self.env, key.as_ref())
123 }
124
125 #[must_use]
127 pub fn env_string(&self, key: impl AsRef<OsStr>) -> Option<String> {
128 self.env(key)
129 .and_then(|value| value.to_str().map(ToOwned::to_owned))
130 }
131
132 #[must_use]
138 pub fn path_entries(&self) -> Vec<PathBuf> {
139 self.env("PATH")
140 .map(|paths| {
141 env::split_paths(paths)
142 .filter(|entry| !entry.as_os_str().is_empty())
143 .collect()
144 })
145 .unwrap_or_default()
146 }
147
148 #[must_use]
150 pub fn cwd(&self) -> &Path {
151 &self.cwd
152 }
153
154 #[must_use]
156 pub fn home_dir(&self) -> Option<&Path> {
157 self.home.as_deref()
158 }
159
160 #[must_use]
166 pub fn app_dirs(&self) -> &[PathBuf] {
167 &self.app_dirs
168 }
169
170 pub async fn which(&self, name: impl AsRef<OsStr>) -> Result<PathBuf, which::Error> {
178 let name = name.as_ref().to_os_string();
179 let paths = self.joined_path();
180 let cwd = self.cwd.clone();
181 unblock(move || which::which_in(name, paths, cwd)).await
182 }
183
184 #[must_use]
190 pub fn with_env(&self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
191 let mut host = self.clone();
192 host.env
193 .insert(key.as_ref().to_os_string(), value.as_ref().to_os_string());
194 host
195 }
196
197 #[must_use]
204 pub fn command(&self, program: impl AsRef<OsStr>) -> Command {
205 withhold_std_handles_from_children();
206 let mut command = Command::new(self.resolve_program(program.as_ref()));
207 command.env_clear().envs(&self.env).current_dir(&self.cwd);
208 command
209 }
210
211 #[must_use]
217 pub fn std_command(&self, program: impl AsRef<OsStr>) -> std::process::Command {
218 withhold_std_handles_from_children();
219 let mut command = std::process::Command::new(self.resolve_program(program.as_ref()));
220 command.env_clear().envs(&self.env).current_dir(&self.cwd);
221 command
222 }
223
224 pub async fn output(
239 &self,
240 program: impl AsRef<OsStr>,
241 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
242 ) -> Result<Output, CommandError> {
243 let program = program.as_ref();
244 let program_name = program.to_string_lossy().into_owned();
245 let args = args
246 .into_iter()
247 .map(|argument| argument.as_ref().to_os_string())
248 .collect::<Vec<_>>();
249 tracing::debug!(program = %program_name, ?args, "spawning");
250 let started = std::time::Instant::now();
251 let mut command = self.command(program);
252 command
253 .args(&args)
254 .kill_on_drop(true)
255 .stdout(Stdio::piped())
256 .stderr(Stdio::piped());
257 let mut child = command.spawn().map_err(|source| CommandError::Spawn {
258 program: program_name.clone(),
259 source,
260 })?;
261
262 let echo = std_output_enabled();
263 let stdout_task = smol::spawn(drain_child_pipe(
264 child.stdout.take().expect("stdout is piped"),
265 io::stdout(),
266 echo,
267 ));
268 let stderr_task = smol::spawn(drain_child_pipe(
269 child.stderr.take().expect("stderr is piped"),
270 io::stderr(),
271 echo,
272 ));
273
274 let status = child.status().await.map_err(|source| CommandError::Spawn {
275 program: program_name.clone(),
276 source,
277 })?;
278 let stdout = stdout_task.await.map_err(|source| CommandError::Spawn {
279 program: program_name.clone(),
280 source,
281 })?;
282 let stderr = stderr_task.await.map_err(|source| CommandError::Spawn {
283 program: program_name.clone(),
284 source,
285 })?;
286 tracing::debug!(
287 program = %program_name,
288 %status,
289 elapsed_ms = started.elapsed().as_millis(),
290 "exited"
291 );
292 Ok(Output {
293 status,
294 stdout,
295 stderr,
296 })
297 }
298
299 pub async fn run(
306 &self,
307 program: impl AsRef<OsStr>,
308 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
309 ) -> Result<String, CommandError> {
310 let program = program.as_ref();
311 let output = self.output(program, args).await?;
312 if output.status.success() {
313 Ok(String::from_utf8_lossy(&output.stdout).to_string())
314 } else {
315 Err(CommandError::Failed {
316 program: program.to_string_lossy().into_owned(),
317 status: output.status,
318 report: format!(
319 "{}{}",
320 format_failure_stream("stderr", &output.stderr),
321 format_failure_stream("stdout", &output.stdout),
322 ),
323 })
324 }
325 }
326
327 pub async fn run_detached(
342 &self,
343 program: impl AsRef<OsStr>,
344 args: impl IntoIterator<Item = impl AsRef<OsStr>>,
345 ) -> Result<std::process::ExitStatus, CommandError> {
346 let program_name = program.as_ref().to_string_lossy().into_owned();
347 let args = args
348 .into_iter()
349 .map(|argument| argument.as_ref().to_os_string())
350 .collect::<Vec<_>>();
351 tracing::debug!(program = %program_name, ?args, "spawning detached");
352 let resolved = self.resolve_program(program.as_ref());
353 let env = self.env.clone();
354 let cwd = self.cwd.clone();
355 let status = unblock(move || detached::run(&resolved, &args, &env, &cwd))
356 .await
357 .map_err(|source| CommandError::Spawn {
358 program: program_name.clone(),
359 source,
360 })?;
361 tracing::debug!(program = %program_name, %status, "detached launcher exited");
362 Ok(status)
363 }
364
365 fn resolve_program(&self, program: &OsStr) -> OsString {
375 let path = Path::new(program);
376 if path.components().count() > 1 {
377 return program.to_os_string();
378 }
379 let paths = self.joined_path();
380 which::which_in(program, paths, &self.cwd)
381 .map_or_else(|_| program.to_os_string(), PathBuf::into_os_string)
382 }
383
384 fn joined_path(&self) -> Option<OsString> {
390 let entries = self.path_entries();
391 if entries.is_empty() {
392 return None;
393 }
394 Some(
395 env::join_paths(entries)
396 .expect("PATH entries produced by split_paths re-join into a PATH string"),
397 )
398 }
399}
400
401#[cfg(windows)]
415fn withhold_std_handles_from_children() {
416 use windows_sys::Win32::{
417 Foundation::{HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation},
418 System::Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE},
419 };
420
421 for (name, id) in [
422 ("stdin", STD_INPUT_HANDLE),
423 ("stdout", STD_OUTPUT_HANDLE),
424 ("stderr", STD_ERROR_HANDLE),
425 ] {
426 let handle = unsafe { GetStdHandle(id) };
428 if handle.is_null() || handle == INVALID_HANDLE_VALUE {
430 continue;
431 }
432 let cleared = unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) };
435 assert!(
436 cleared != 0,
437 "failed to make {name} non-inheritable: {}",
438 io::Error::last_os_error()
439 );
440 }
441}
442
443#[cfg(not(windows))]
444const fn withhold_std_handles_from_children() {
445 }
449
450async fn drain_child_pipe(
458 mut reader: impl smol::io::AsyncRead + Unpin,
459 mut sink: impl io::Write,
460 echo: bool,
461) -> io::Result<Vec<u8>> {
462 let mut collected = Vec::new();
463 let mut chunk = [0u8; 8192];
464 loop {
465 let read = reader.read(&mut chunk).await?;
466 if read == 0 {
467 break;
468 }
469 if echo {
470 let _ = sink.write_all(&chunk[..read]);
471 let _ = sink.flush();
472 }
473 collected.extend_from_slice(&chunk[..read]);
474 }
475 Ok(collected)
476}
477
478fn env_get<'a>(env: &'a BTreeMap<OsString, OsString>, key: &OsStr) -> Option<&'a OsStr> {
480 if cfg!(target_os = "windows") {
481 env.iter()
482 .find(|(existing, _)| existing.as_os_str().eq_ignore_ascii_case(key))
483 .map(|(_, value)| value.as_os_str())
484 } else {
485 env.get(key).map(OsString::as_os_str)
486 }
487}
488
489fn home_dir_from_env(env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
491 if cfg!(target_os = "windows") {
492 env_get(env, "USERPROFILE".as_ref())
493 .or_else(|| env_get(env, "HOME".as_ref()))
494 .map(PathBuf::from)
495 } else {
496 env_get(env, "HOME".as_ref())
497 .or_else(|| env_get(env, "USERPROFILE".as_ref()))
498 .map(PathBuf::from)
499 }
500}
501
502fn default_app_dirs() -> Vec<PathBuf> {
504 if cfg!(target_os = "macos") {
505 vec![PathBuf::from("/Applications")]
506 } else {
507 Vec::new()
508 }
509}
510
511#[cfg(target_os = "windows")]
513fn seed_process_plumbing(env: &mut BTreeMap<OsString, OsString>) {
514 for key in ["SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT"] {
515 if let Some(value) = env::var_os(key) {
516 env.entry(OsString::from(key)).or_insert(value);
517 }
518 }
519}
520
521#[cfg(not(target_os = "windows"))]
522const fn seed_process_plumbing(_env: &mut BTreeMap<OsString, OsString>) {}
523
524#[cfg(test)]
525mod tests {
526 use super::Host;
527 use crate::toolchain::testing::TestMachine;
528
529 const UNDECLARED: &str = "WATERUI_TEST_NEVER_DECLARED";
532
533 #[test]
534 fn declared_host_env_contains_only_what_was_declared() {
535 let host = Host::new(
536 Vec::<std::path::PathBuf>::new(),
537 [(String::from("WATERUI_TEST_DECLARED"), String::from("yes"))],
538 );
539 assert_eq!(
540 host.env_string("WATERUI_TEST_DECLARED").as_deref(),
541 Some("yes")
542 );
543 assert!(
544 host.env(UNDECLARED).is_none(),
545 "declared hosts must not see ambient environment variables"
546 );
547 assert!(host.path_entries().is_empty());
549 }
550
551 #[test]
552 fn declared_host_home_comes_from_declared_env() {
553 let machine = TestMachine::new();
554 let host = machine.host(Vec::<(String, String)>::new());
555 assert_eq!(host.home_dir(), Some(machine.home().as_path()));
556 assert_eq!(host.cwd(), machine.root());
557 assert!(
558 host.app_dirs().is_empty(),
559 "declared hosts never see installed application bundles"
560 );
561 }
562
563 #[test]
564 fn which_resolves_only_the_host_path() {
565 let machine = TestMachine::new();
566 let host = machine.host(Vec::<(String, String)>::new());
567 smol::block_on(async {
568 assert!(host.which("waterui-test-missing-tool").await.is_err());
569 assert!(
570 host.which("cargo").await.is_err(),
571 "real cargo must not leak"
572 );
573 machine.install("cargo");
574 let resolved = host
575 .which("cargo")
576 .await
577 .expect("installed fake tool must resolve");
578 assert_eq!(resolved.parent(), Some(machine.bin().as_path()));
579 });
580 }
581
582 #[test]
583 fn spawned_children_see_the_declared_environment() {
584 let machine = TestMachine::new();
585 machine.install("cargo");
586 let host = machine.host([(
587 String::from("WATERUI_FAKE_CARGO_VERSION"),
588 String::from("9.9.9-waterui-test"),
589 )]);
590 let output = smol::block_on(host.run("cargo", ["--version"]))
591 .expect("fake cargo must run under the declared host");
592 assert!(output.contains("9.9.9-waterui-test"));
593 }
594
595 #[test]
596 fn run_reports_nonzero_exit_with_output() {
597 let machine = TestMachine::new();
598 machine.install("rustup");
599 let host = machine.host(Vec::<(String, String)>::new());
600 let error = smol::block_on(host.run("rustup", ["frobnicate"]))
602 .expect_err("a failing tool must surface as an error");
603 assert!(error.to_string().contains("rustup"));
604 }
605}