Skip to main content

waterui_cli/toolchain/
host.rs

1//! The host-machine seam for toolchain probing.
2//!
3//! Every probe a toolchain check makes against the machine — PATH lookup,
4//! environment reads, process spawning — goes through a [`Host`] value instead
5//! of process-global state. [`Host::current`] describes the real machine;
6//! tests build fully declared hosts with [`Host::new`] so checks run
7//! deterministically against fake tools on a private PATH.
8
9use 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/// The machine a toolchain check probes.
25///
26/// A `Host` carries its own environment map (including `PATH`), a working
27/// directory for spawned processes and relative-path lookups, a home
28/// directory, and the roots that hold installed platform applications
29/// (macOS `/Applications`). Detection code must read all of those through
30/// this value so a declared host cannot leak real-machine state into a check.
31#[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    /// The real machine this process runs on.
41    ///
42    /// # Panics
43    /// Panics when the process has no current directory.
44    #[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    /// A host declared entirely by the arguments.
56    ///
57    /// `PATH` is exactly `path_dirs`; the environment contains exactly `vars`
58    /// plus that `PATH` entry. The working directory defaults to the process
59    /// cwd — override it with [`Host::with_cwd`]. The home directory is taken
60    /// from the declared `HOME`/`USERPROFILE`; a host that declares neither
61    /// has none. Declared hosts have no [`Host::app_dirs`], so application-
62    /// bundle fallbacks (e.g. Android Studio's bundled JBR) cannot fire.
63    ///
64    /// On Windows the process-spawn plumbing variables (`SystemRoot`,
65    /// `SystemDrive`, `windir`, `ComSpec`, `PATHEXT`) are seeded from the
66    /// running process because children cannot start without them; they
67    /// describe how to launch a process, never what is installed.
68    ///
69    /// # Panics
70    /// Panics when `path_dirs` cannot be joined into a `PATH` string (e.g. an
71    /// entry containing the platform separator).
72    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    /// Override the working directory (builder-style).
103    #[must_use]
104    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
105        self.cwd = cwd.into();
106        self
107    }
108
109    /// Override the application-install roots (builder-style).
110    #[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    /// An environment variable on this host.
117    ///
118    /// Lookup is case-sensitive on Unix and case-insensitive on Windows,
119    /// matching the platform's own environment semantics.
120    #[must_use]
121    pub fn env(&self, key: impl AsRef<OsStr>) -> Option<&OsStr> {
122        env_get(&self.env, key.as_ref())
123    }
124
125    /// An environment variable decoded as UTF-8 text.
126    #[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    /// This host's `PATH` entries, in order.
133    ///
134    /// Empty components are dropped: an empty `PATH` element historically
135    /// means "the working directory", which would let tools sitting in
136    /// [`Host::cwd`] masquerade as installed on a declared host.
137    #[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    /// Working directory for spawned processes and relative-path lookups.
149    #[must_use]
150    pub fn cwd(&self) -> &Path {
151        &self.cwd
152    }
153
154    /// This host's home directory, when it declares one.
155    #[must_use]
156    pub fn home_dir(&self) -> Option<&Path> {
157        self.home.as_deref()
158    }
159
160    /// Path of the running `water` executable.
161    ///
162    /// A fact about this process rather than the declared machine — every
163    /// `Host` reports the same binary, which is what `RUSTC_WRAPPER`
164    /// self-wrapping must name. Associated with [`Host`] so the probe stays
165    /// inside the seam.
166    ///
167    /// # Errors
168    /// Returns an error when the OS cannot report the executable's path.
169    pub fn current_exe() -> io::Result<PathBuf> {
170        env::current_exe()
171    }
172
173    /// Roots holding installed platform application bundles.
174    ///
175    /// `/Applications` on macOS, empty elsewhere and on declared hosts.
176    /// Probes that look inside installed `.app` bundles read them under these
177    /// roots so test machines do not leak real-machine installs.
178    #[must_use]
179    pub fn app_dirs(&self) -> &[PathBuf] {
180        &self.app_dirs
181    }
182
183    /// Locate `name` on this host's `PATH`.
184    ///
185    /// Never consults the process `PATH`: a host with no `PATH` or an empty
186    /// one reports every tool as missing.
187    ///
188    /// # Errors
189    /// - [`which::Error`] when no executable named `name` exists on this host.
190    pub async fn which(&self, name: impl AsRef<OsStr>) -> Result<PathBuf, which::Error> {
191        let name = name.as_ref().to_os_string();
192        let paths = self.joined_path();
193        let cwd = self.cwd.clone();
194        unblock(move || which::which_in(name, paths, cwd)).await
195    }
196
197    /// A host whose environment additionally binds `key` to `value`.
198    ///
199    /// Use for variables that must reach a single child tree (for example
200    /// `WATERUI_SKIP_RUST_BUILD` on the `xcodebuild` invocation) instead of
201    /// mutating the process environment.
202    #[must_use]
203    pub fn with_env(&self, key: impl AsRef<OsStr>, value: impl AsRef<OsStr>) -> Self {
204        let mut host = self.clone();
205        host.env
206            .insert(key.as_ref().to_os_string(), value.as_ref().to_os_string());
207        host
208    }
209
210    /// A [`Command`] that runs `program` under this host's environment.
211    ///
212    /// The child sees exactly this host's variables and starts in
213    /// [`Host::cwd`]; `program` is resolved against this host's `PATH`.
214    /// stdio configuration is left to the caller — see [`crate::utils::command`]
215    /// for the CLI's capture/inherit policy.
216    #[must_use]
217    pub fn command(&self, program: impl AsRef<OsStr>) -> Command {
218        withhold_std_handles_from_children();
219        let mut command = Command::new(self.resolve_program(program.as_ref()));
220        command.env_clear().envs(&self.env).current_dir(&self.cwd);
221        command
222    }
223
224    /// A [`std::process::Command`] that runs `program` under this host.
225    ///
226    /// Same environment and working directory as [`Host::command`], for the
227    /// places that need synchronous or `std`-only command features (process
228    /// groups, spawning from a non-async thread).
229    #[must_use]
230    pub fn std_command(&self, program: impl AsRef<OsStr>) -> std::process::Command {
231        withhold_std_handles_from_children();
232        let mut command = std::process::Command::new(self.resolve_program(program.as_ref()));
233        command.env_clear().envs(&self.env).current_dir(&self.cwd);
234        command
235    }
236
237    /// Spawn `program` with `args` under this host, capturing output.
238    ///
239    /// stdout and stderr are piped and always collected for the returned
240    /// [`Output`]; when the CLI's `--logs` passthrough is active each chunk is
241    /// additionally mirrored to the terminal as it arrives, matching the
242    /// historical `run_command_output_os` behavior.
243    ///
244    /// # Errors
245    /// - [`CommandError::Spawn`] when the program cannot be spawned or awaited.
246    ///
247    /// # Panics
248    /// Panics if the piped-stdio invariant above is violated — both streams
249    /// are configured `piped` immediately before spawn, so `take()` always
250    /// sees `Some`.
251    pub async fn output(
252        &self,
253        program: impl AsRef<OsStr>,
254        args: impl IntoIterator<Item = impl AsRef<OsStr>>,
255    ) -> Result<Output, CommandError> {
256        let program = program.as_ref();
257        let program_name = program.to_string_lossy().into_owned();
258        let args = args
259            .into_iter()
260            .map(|argument| argument.as_ref().to_os_string())
261            .collect::<Vec<_>>();
262        tracing::debug!(program = %program_name, ?args, "spawning");
263        let started = std::time::Instant::now();
264        let mut command = self.command(program);
265        command
266            .args(&args)
267            .kill_on_drop(true)
268            .stdout(Stdio::piped())
269            .stderr(Stdio::piped());
270        let mut child = command.spawn().map_err(|source| CommandError::Spawn {
271            program: program_name.clone(),
272            source,
273        })?;
274
275        let echo = std_output_enabled();
276        let stdout_task = smol::spawn(drain_child_pipe(
277            child.stdout.take().expect("stdout is piped"),
278            io::stdout(),
279            echo,
280        ));
281        let stderr_task = smol::spawn(drain_child_pipe(
282            child.stderr.take().expect("stderr is piped"),
283            io::stderr(),
284            echo,
285        ));
286
287        let status = child.status().await.map_err(|source| CommandError::Spawn {
288            program: program_name.clone(),
289            source,
290        })?;
291        let stdout = stdout_task.await.map_err(|source| CommandError::Spawn {
292            program: program_name.clone(),
293            source,
294        })?;
295        let stderr = stderr_task.await.map_err(|source| CommandError::Spawn {
296            program: program_name.clone(),
297            source,
298        })?;
299        tracing::debug!(
300            program = %program_name,
301            %status,
302            elapsed_ms = started.elapsed().as_millis(),
303            "exited"
304        );
305        Ok(Output {
306            status,
307            stdout,
308            stderr,
309        })
310    }
311
312    /// Run `program` under this host and return stdout as text.
313    ///
314    /// # Errors
315    /// - [`CommandError::Spawn`] when the program cannot be spawned.
316    /// - [`CommandError::Failed`] when it exits non-zero; the error embeds
317    ///   the captured stderr/stdout tails.
318    pub async fn run(
319        &self,
320        program: impl AsRef<OsStr>,
321        args: impl IntoIterator<Item = impl AsRef<OsStr>>,
322    ) -> Result<String, CommandError> {
323        let program = program.as_ref();
324        let output = self.output(program, args).await?;
325        if output.status.success() {
326            Ok(String::from_utf8_lossy(&output.stdout).to_string())
327        } else {
328            Err(CommandError::Failed {
329                program: program.to_string_lossy().into_owned(),
330                status: output.status,
331                report: format!(
332                    "{}{}",
333                    format_failure_stream("stderr", &output.stderr),
334                    format_failure_stream("stdout", &output.stdout),
335                ),
336            })
337        }
338    }
339
340    /// Run `program` to completion with nothing of this process in its hands:
341    /// no stdio and, on Windows, no inherited handles at all.
342    ///
343    /// This is how a daemon launcher is run. A child spawned the ordinary way
344    /// receives every inheritable handle this process holds — including
345    /// strays our own parent passed down — and hands them on to whatever it
346    /// spawns with inheritance on. `adb start-server` is the case that
347    /// matters: its server outlives `water`, and a pipe it inherited stays
348    /// open until the server exits. The exit status is the caller's to judge,
349    /// because a launcher's own output is discarded here.
350    ///
351    /// # Errors
352    /// [`CommandError::Spawn`] when the program cannot be started or waited
353    /// on.
354    pub async fn run_detached(
355        &self,
356        program: impl AsRef<OsStr>,
357        args: impl IntoIterator<Item = impl AsRef<OsStr>>,
358    ) -> Result<std::process::ExitStatus, CommandError> {
359        let program_name = program.as_ref().to_string_lossy().into_owned();
360        let args = args
361            .into_iter()
362            .map(|argument| argument.as_ref().to_os_string())
363            .collect::<Vec<_>>();
364        tracing::debug!(program = %program_name, ?args, "spawning detached");
365        let resolved = self.resolve_program(program.as_ref());
366        let env = self.env.clone();
367        let cwd = self.cwd.clone();
368        let status = unblock(move || detached::run(&resolved, &args, &env, &cwd))
369            .await
370            .map_err(|source| CommandError::Spawn {
371                program: program_name.clone(),
372                source,
373            })?;
374        tracing::debug!(program = %program_name, %status, "detached launcher exited");
375        Ok(status)
376    }
377
378    /// Resolve a bare program name against this host's `PATH`.
379    ///
380    /// `CreateProcess` searches the *parent's* `PATH`, never the child's, so
381    /// passing a bare name through `env_clear` + `envs` would miss tools that
382    /// exist only on this host — exactly what fake-tool tests install.
383    /// Resolving here makes `command`/`std_command`/`output` agree with
384    /// [`Host::which`] on every platform. Paths (anything with a separator)
385    /// and names this host cannot resolve pass through unchanged, so a spawn
386    /// error still names what the caller asked for.
387    fn resolve_program(&self, program: &OsStr) -> OsString {
388        let path = Path::new(program);
389        if path.components().count() > 1 {
390            return program.to_os_string();
391        }
392        let paths = self.joined_path();
393        which::which_in(program, paths, &self.cwd)
394            .map_or_else(|_| program.to_os_string(), PathBuf::into_os_string)
395    }
396
397    /// The declared `PATH` re-joined after empty-component filtering.
398    ///
399    /// `None` when the host declares no usable `PATH`, which makes
400    /// `which::which_in` report every lookup as missing instead of
401    /// searching the working directory.
402    fn joined_path(&self) -> Option<OsString> {
403        let entries = self.path_entries();
404        if entries.is_empty() {
405            return None;
406        }
407        Some(
408            env::join_paths(entries)
409                .expect("PATH entries produced by split_paths re-join into a PATH string"),
410        )
411    }
412}
413
414/// A child of this process must hold only the stdio it is given, never this
415/// process's own standard handles.
416///
417/// `CreateProcess` hands a child every inheritable handle of its parent, and
418/// the standard handles a shell passes in arrive inheritable, so a child
419/// spawned with piped stdio still receives this process's stdout and stderr
420/// as stray handles — and so does anything the child spawns with inheritance
421/// on. `adb` is the case that bites: its first client command launches the
422/// server daemon, which then outlives `water` holding the pipe whoever ran
423/// `water` is reading, and that reader never sees end-of-file. Clearing the
424/// inherit flag on our own standard handles ends the chain at the source;
425/// `Stdio::inherit` still works, because the standard library duplicates the
426/// handle inheritably for the one child that is meant to have it.
427#[cfg(windows)]
428fn withhold_std_handles_from_children() {
429    use windows_sys::Win32::{
430        Foundation::{HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation},
431        System::Console::{GetStdHandle, STD_ERROR_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE},
432    };
433
434    for (name, id) in [
435        ("stdin", STD_INPUT_HANDLE),
436        ("stdout", STD_OUTPUT_HANDLE),
437        ("stderr", STD_ERROR_HANDLE),
438    ] {
439        // SAFETY: querying this process's own standard handle table.
440        let handle = unsafe { GetStdHandle(id) };
441        // A process started without that stream has nothing to withhold.
442        if handle.is_null() || handle == INVALID_HANDLE_VALUE {
443            continue;
444        }
445        // SAFETY: `handle` is a live handle of this process; clearing its
446        // inherit flag changes nothing about how this process uses it.
447        let cleared = unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, 0) };
448        assert!(
449            cleared != 0,
450            "failed to make {name} non-inheritable: {}",
451            io::Error::last_os_error()
452        );
453    }
454}
455
456#[cfg(not(windows))]
457const fn withhold_std_handles_from_children() {
458    // POSIX children receive only the descriptors we pass: every descriptor
459    // the standard library opens is close-on-exec, and daemons detach through
460    // fork rather than handle inheritance.
461}
462
463/// Drain a piped child stream to EOF.
464///
465/// Every chunk is appended to the returned buffer; when `echo` is set it is
466/// also written to `sink` (the matching terminal stream) as it arrives, so
467/// `--logs` output appears incrementally instead of after the process exits.
468/// Terminal write failures are ignored — a broken sink must not kill output
469/// collection.
470async fn drain_child_pipe(
471    mut reader: impl smol::io::AsyncRead + Unpin,
472    mut sink: impl io::Write,
473    echo: bool,
474) -> io::Result<Vec<u8>> {
475    let mut collected = Vec::new();
476    let mut chunk = [0u8; 8192];
477    loop {
478        let read = reader.read(&mut chunk).await?;
479        if read == 0 {
480            break;
481        }
482        if echo {
483            let _ = sink.write_all(&chunk[..read]);
484            let _ = sink.flush();
485        }
486        collected.extend_from_slice(&chunk[..read]);
487    }
488    Ok(collected)
489}
490
491/// Case-aware environment lookup matching platform semantics.
492fn env_get<'a>(env: &'a BTreeMap<OsString, OsString>, key: &OsStr) -> Option<&'a OsStr> {
493    if cfg!(target_os = "windows") {
494        env.iter()
495            .find(|(existing, _)| existing.as_os_str().eq_ignore_ascii_case(key))
496            .map(|(_, value)| value.as_os_str())
497    } else {
498        env.get(key).map(OsString::as_os_str)
499    }
500}
501
502/// Home directory derived purely from a declared environment map.
503fn home_dir_from_env(env: &BTreeMap<OsString, OsString>) -> Option<PathBuf> {
504    if cfg!(target_os = "windows") {
505        env_get(env, "USERPROFILE".as_ref())
506            .or_else(|| env_get(env, "HOME".as_ref()))
507            .map(PathBuf::from)
508    } else {
509        env_get(env, "HOME".as_ref())
510            .or_else(|| env_get(env, "USERPROFILE".as_ref()))
511            .map(PathBuf::from)
512    }
513}
514
515/// Application-install roots for the real machine.
516fn default_app_dirs() -> Vec<PathBuf> {
517    if cfg!(target_os = "macos") {
518        vec![PathBuf::from("/Applications")]
519    } else {
520        Vec::new()
521    }
522}
523
524/// Seed variables a spawned Windows child cannot start without.
525#[cfg(target_os = "windows")]
526fn seed_process_plumbing(env: &mut BTreeMap<OsString, OsString>) {
527    for key in ["SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT"] {
528        if let Some(value) = env::var_os(key) {
529            env.entry(OsString::from(key)).or_insert(value);
530        }
531    }
532}
533
534#[cfg(not(target_os = "windows"))]
535const fn seed_process_plumbing(_env: &mut BTreeMap<OsString, OsString>) {}
536
537#[cfg(test)]
538mod tests {
539    use super::Host;
540    use crate::toolchain::testing::TestMachine;
541
542    /// A variable name nothing declares — proves reads never reach the
543    /// ambient process environment.
544    const UNDECLARED: &str = "WATERUI_TEST_NEVER_DECLARED";
545
546    #[test]
547    fn declared_host_env_contains_only_what_was_declared() {
548        let host = Host::new(
549            Vec::<std::path::PathBuf>::new(),
550            [(String::from("WATERUI_TEST_DECLARED"), String::from("yes"))],
551        );
552        assert_eq!(
553            host.env_string("WATERUI_TEST_DECLARED").as_deref(),
554            Some("yes")
555        );
556        assert!(
557            host.env(UNDECLARED).is_none(),
558            "declared hosts must not see ambient environment variables"
559        );
560        // PATH is exactly what was declared — here, nothing.
561        assert!(host.path_entries().is_empty());
562    }
563
564    #[test]
565    fn declared_host_home_comes_from_declared_env() {
566        let machine = TestMachine::new();
567        let host = machine.host(Vec::<(String, String)>::new());
568        assert_eq!(host.home_dir(), Some(machine.home().as_path()));
569        assert_eq!(host.cwd(), machine.root());
570        assert!(
571            host.app_dirs().is_empty(),
572            "declared hosts never see installed application bundles"
573        );
574    }
575
576    #[test]
577    fn which_resolves_only_the_host_path() {
578        let machine = TestMachine::new();
579        let host = machine.host(Vec::<(String, String)>::new());
580        smol::block_on(async {
581            assert!(host.which("waterui-test-missing-tool").await.is_err());
582            assert!(
583                host.which("cargo").await.is_err(),
584                "real cargo must not leak"
585            );
586            machine.install("cargo");
587            let resolved = host
588                .which("cargo")
589                .await
590                .expect("installed fake tool must resolve");
591            assert_eq!(resolved.parent(), Some(machine.bin().as_path()));
592        });
593    }
594
595    #[test]
596    fn spawned_children_see_the_declared_environment() {
597        let machine = TestMachine::new();
598        machine.install("cargo");
599        let host = machine.host([(
600            String::from("WATERUI_FAKE_CARGO_VERSION"),
601            String::from("9.9.9-waterui-test"),
602        )]);
603        let output = smol::block_on(host.run("cargo", ["--version"]))
604            .expect("fake cargo must run under the declared host");
605        assert!(output.contains("9.9.9-waterui-test"));
606    }
607
608    #[test]
609    fn run_reports_nonzero_exit_with_output() {
610        let machine = TestMachine::new();
611        machine.install("rustup");
612        let host = machine.host(Vec::<(String, String)>::new());
613        // `rustup frobnicate` is not a dispatched case → exit 2.
614        let error = smol::block_on(host.run("rustup", ["frobnicate"]))
615            .expect_err("a failing tool must surface as an error");
616        assert!(error.to_string().contains("rustup"));
617    }
618}