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    /// Roots holding installed platform application bundles.
161    ///
162    /// `/Applications` on macOS, empty elsewhere and on declared hosts.
163    /// Probes that look inside installed `.app` bundles read them under these
164    /// roots so test machines do not leak real-machine installs.
165    #[must_use]
166    pub fn app_dirs(&self) -> &[PathBuf] {
167        &self.app_dirs
168    }
169
170    /// Locate `name` on this host's `PATH`.
171    ///
172    /// Never consults the process `PATH`: a host with no `PATH` or an empty
173    /// one reports every tool as missing.
174    ///
175    /// # Errors
176    /// - [`which::Error`] when no executable named `name` exists on this host.
177    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    /// A host whose environment additionally binds `key` to `value`.
185    ///
186    /// Use for variables that must reach a single child tree (for example
187    /// `WATERUI_SKIP_RUST_BUILD` on the `xcodebuild` invocation) instead of
188    /// mutating the process environment.
189    #[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    /// A [`Command`] that runs `program` under this host's environment.
198    ///
199    /// The child sees exactly this host's variables and starts in
200    /// [`Host::cwd`]; `program` is resolved against this host's `PATH`.
201    /// stdio configuration is left to the caller — see [`crate::utils::command`]
202    /// for the CLI's capture/inherit policy.
203    #[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    /// A [`std::process::Command`] that runs `program` under this host.
212    ///
213    /// Same environment and working directory as [`Host::command`], for the
214    /// places that need synchronous or `std`-only command features (process
215    /// groups, spawning from a non-async thread).
216    #[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    /// Spawn `program` with `args` under this host, capturing output.
225    ///
226    /// stdout and stderr are piped and always collected for the returned
227    /// [`Output`]; when the CLI's `--logs` passthrough is active each chunk is
228    /// additionally mirrored to the terminal as it arrives, matching the
229    /// historical `run_command_output_os` behavior.
230    ///
231    /// # Errors
232    /// - [`CommandError::Spawn`] when the program cannot be spawned or awaited.
233    ///
234    /// # Panics
235    /// Panics if the piped-stdio invariant above is violated — both streams
236    /// are configured `piped` immediately before spawn, so `take()` always
237    /// sees `Some`.
238    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    /// Run `program` under this host and return stdout as text.
300    ///
301    /// # Errors
302    /// - [`CommandError::Spawn`] when the program cannot be spawned.
303    /// - [`CommandError::Failed`] when it exits non-zero; the error embeds
304    ///   the captured stderr/stdout tails.
305    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    /// Run `program` to completion with nothing of this process in its hands:
328    /// no stdio and, on Windows, no inherited handles at all.
329    ///
330    /// This is how a daemon launcher is run. A child spawned the ordinary way
331    /// receives every inheritable handle this process holds — including
332    /// strays our own parent passed down — and hands them on to whatever it
333    /// spawns with inheritance on. `adb start-server` is the case that
334    /// matters: its server outlives `water`, and a pipe it inherited stays
335    /// open until the server exits. The exit status is the caller's to judge,
336    /// because a launcher's own output is discarded here.
337    ///
338    /// # Errors
339    /// [`CommandError::Spawn`] when the program cannot be started or waited
340    /// on.
341    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    /// Resolve a bare program name against this host's `PATH`.
366    ///
367    /// `CreateProcess` searches the *parent's* `PATH`, never the child's, so
368    /// passing a bare name through `env_clear` + `envs` would miss tools that
369    /// exist only on this host — exactly what fake-tool tests install.
370    /// Resolving here makes `command`/`std_command`/`output` agree with
371    /// [`Host::which`] on every platform. Paths (anything with a separator)
372    /// and names this host cannot resolve pass through unchanged, so a spawn
373    /// error still names what the caller asked for.
374    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    /// The declared `PATH` re-joined after empty-component filtering.
385    ///
386    /// `None` when the host declares no usable `PATH`, which makes
387    /// `which::which_in` report every lookup as missing instead of
388    /// searching the working directory.
389    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/// A child of this process must hold only the stdio it is given, never this
402/// process's own standard handles.
403///
404/// `CreateProcess` hands a child every inheritable handle of its parent, and
405/// the standard handles a shell passes in arrive inheritable, so a child
406/// spawned with piped stdio still receives this process's stdout and stderr
407/// as stray handles — and so does anything the child spawns with inheritance
408/// on. `adb` is the case that bites: its first client command launches the
409/// server daemon, which then outlives `water` holding the pipe whoever ran
410/// `water` is reading, and that reader never sees end-of-file. Clearing the
411/// inherit flag on our own standard handles ends the chain at the source;
412/// `Stdio::inherit` still works, because the standard library duplicates the
413/// handle inheritably for the one child that is meant to have it.
414#[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        // SAFETY: querying this process's own standard handle table.
427        let handle = unsafe { GetStdHandle(id) };
428        // A process started without that stream has nothing to withhold.
429        if handle.is_null() || handle == INVALID_HANDLE_VALUE {
430            continue;
431        }
432        // SAFETY: `handle` is a live handle of this process; clearing its
433        // inherit flag changes nothing about how this process uses it.
434        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    // POSIX children receive only the descriptors we pass: every descriptor
446    // the standard library opens is close-on-exec, and daemons detach through
447    // fork rather than handle inheritance.
448}
449
450/// Drain a piped child stream to EOF.
451///
452/// Every chunk is appended to the returned buffer; when `echo` is set it is
453/// also written to `sink` (the matching terminal stream) as it arrives, so
454/// `--logs` output appears incrementally instead of after the process exits.
455/// Terminal write failures are ignored — a broken sink must not kill output
456/// collection.
457async 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
478/// Case-aware environment lookup matching platform semantics.
479fn 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
489/// Home directory derived purely from a declared environment map.
490fn 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
502/// Application-install roots for the real machine.
503fn 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/// Seed variables a spawned Windows child cannot start without.
512#[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    /// A variable name nothing declares — proves reads never reach the
530    /// ambient process environment.
531    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        // PATH is exactly what was declared — here, nothing.
548        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        // `rustup frobnicate` is not a dispatched case → exit 2.
601        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}