Skip to main content

hardware_enclave/internal/wsl/
install.rs

1// Copyright 2026 Jay Gowdy
2// SPDX-License-Identifier: MIT
3
4//! Higher-level WSL installation orchestration.
5//!
6//! Provides the full "detect distros, find homes, inject shell blocks, install
7//! dependencies" flow that sshenc, sso-jwt, and other enclave apps share.
8#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
9
10use super::detect::{detect_distros, WslDistro};
11use super::shell_config::{install_block, uninstall_block, ShellBlockConfig};
12use std::path::{Path, PathBuf};
13#[cfg(target_os = "windows")]
14use std::time::Duration;
15
16/// Timeout for downloading a Linux release tarball from GitHub
17/// (used by [`LinuxReleaseSpec`]). Generous so a slow GitHub mirror
18/// doesn't kill the install, bounded so a wedged route doesn't
19/// hang it forever.
20#[cfg(target_os = "windows")]
21const WSL_DEP_INSTALL_TIMEOUT: Duration = Duration::from_secs(300);
22
23/// Timeout for quick WSL shell commands (chmod, which, ldd, etc.).
24#[cfg(target_os = "windows")]
25const WSL_QUICK_CMD_TIMEOUT: Duration = Duration::from_secs(15);
26
27pub use super::detect::decode_wsl_output;
28
29/// GitHub-release-driven Linux binary install spec.
30///
31/// When set on [`WslInstallConfig::auto_install_linux_release`], the
32/// installer probes each detected distro's libc (glibc → `_gnu`,
33/// musl → `_musl`), `curl`s the matching tarball from a GitHub
34/// release URL, and `tar`-extracts the listed binaries straight to
35/// `/usr/local/bin/`.
36///
37/// This replaces the old socat + npiperelay bridge-dependency install.
38/// Native `sshenc-agent` (or whichever app) running inside the distro
39/// supersedes the SSH-protocol-over-socat hack — the native agent
40/// handles SSH protocol locally and crosses the WSL/Windows boundary
41/// only for the JSON-RPC TPM bridge, which is a different
42/// (deterministic) transport.
43#[derive(Debug, Clone)]
44pub struct LinuxReleaseSpec {
45    /// GitHub repo in `owner/name` form, e.g. `"godaddy/sshenc"`.
46    pub repo: String,
47    /// Release tag to install, e.g. `"v0.6.36"`. Caller passes this
48    /// in rather than reading `CARGO_PKG_VERSION` so consumer apps
49    /// can pin to a known-good version on rollback if needed.
50    pub tag: String,
51    /// Tarball name for glibc-based distros, e.g.
52    /// `"sshenc-x86_64-unknown-linux-gnu.tar.gz"`. Resolved to
53    /// `https://github.com/{repo}/releases/download/{tag}/{asset_gnu}`.
54    pub asset_gnu: String,
55    /// Tarball name for musl-based distros, e.g.
56    /// `"sshenc-x86_64-unknown-linux-musl.tar.gz"`. Used when the
57    /// distro's `ldd --version` output doesn't mention "GNU".
58    pub asset_musl: String,
59    /// Binaries to install from the extracted tarball, e.g.
60    /// `["sshenc", "sshenc-agent", "sshenc-keygen", "gitenc"]`.
61    pub binaries: Vec<String>,
62}
63
64/// Configuration for WSL installation.
65#[derive(Debug, Clone)]
66pub struct WslInstallConfig {
67    /// Application name (used in markers and messages).
68    pub app_name: String,
69    /// Shell block content to inject (the script body, without markers).
70    pub shell_block: String,
71    /// Optional: path to a Linux binary to copy into each distro.
72    pub linux_binary_path: Option<PathBuf>,
73    /// Target path for the Linux binary inside each distro
74    /// (relative to home, e.g., `.local/bin/myapp`).
75    pub linux_binary_target: Option<String>,
76    /// Optional: download a matching Linux release tarball from
77    /// GitHub at install time and extract the named binaries into
78    /// `/usr/local/bin/`. Replaces the old socat+npiperelay dance.
79    pub auto_install_linux_release: Option<LinuxReleaseSpec>,
80    /// Binaries to remove from `/usr/local/bin/` on unconfigure.
81    /// Set regardless of release/dev status so a `sshenc uninstall`
82    /// from a dev build still removes binaries installed by a prior
83    /// release build. Leave empty for apps that don't install to
84    /// `/usr/local/bin/` (e.g. apps that only use `linux_binary_target`).
85    pub linux_binaries_to_remove: Vec<String>,
86}
87
88/// Result of configuring or unconfiguring a single distro.
89#[derive(Debug)]
90pub struct DistroResult {
91    /// Distribution name.
92    pub distro_name: String,
93    /// Outcome: Ok with a list of actions taken, or Err with an error message.
94    pub outcome: Result<Vec<String>, String>,
95}
96
97/// Configure all detected WSL distros.
98///
99/// For each distro:
100/// 1. Discovers the home directory via UNC path
101/// 2. Copies a Linux binary if configured
102/// 3. Injects the managed shell block into `.bashrc`/`.zshrc`
103/// 4. Downloads + extracts the matching Linux release tarball into
104///    `/usr/local/bin/` if `auto_install_linux_release` is set
105///    (replaces the old socat + npiperelay bridge dependency path —
106///    keeping a native agent inside the distro is the deterministic
107///    transport, the SSH-protocol-over-socat hack is gone).
108///
109/// Returns one result per distro so the caller can report progress.
110pub fn configure_all_distros(config: &WslInstallConfig) -> Vec<DistroResult> {
111    let distros = detect_distros();
112    distros
113        .into_iter()
114        .map(|distro| {
115            let name = distro.name.clone();
116            let outcome = configure_distro(&distro, config);
117            DistroResult {
118                distro_name: name,
119                outcome,
120            }
121        })
122        .collect()
123}
124
125/// Remove configuration from all detected WSL distros.
126///
127/// For each distro:
128/// 1. Removes the managed shell block from `.bashrc`/`.zshrc`/`.profile`
129/// 2. Removes the Linux binary if `linux_binary_target` is set
130///
131/// Returns one result per distro.
132pub fn unconfigure_all_distros(config: &WslInstallConfig) -> Vec<DistroResult> {
133    let distros = detect_distros();
134    distros
135        .into_iter()
136        .map(|distro| {
137            let name = distro.name.clone();
138            let outcome = unconfigure_distro(&distro, config);
139            DistroResult {
140                distro_name: name,
141                outcome,
142            }
143        })
144        .collect()
145}
146
147/// Find the WSL user's home directory path from Windows as a UNC path.
148///
149/// Tries `\\wsl$\<distro>\<path>` first, then `\\wsl.localhost\<distro>\<path>`.
150#[cfg(target_os = "windows")]
151pub fn find_wsl_home(distro: &str) -> Option<PathBuf> {
152    let linux_home = find_linux_home(distro)?;
153    if linux_home.is_empty() {
154        return None;
155    }
156
157    for prefix in &[r"\\wsl$", r"\\wsl.localhost"] {
158        let win_path = format!(r"{}\{}{}", prefix, distro, linux_home.replace('/', r"\"));
159        let path = PathBuf::from(&win_path);
160        if path.exists() {
161            return Some(path);
162        }
163    }
164
165    None
166}
167
168/// Stub on non-Windows: always returns None.
169#[cfg(not(target_os = "windows"))]
170pub fn find_wsl_home(_distro: &str) -> Option<PathBuf> {
171    None
172}
173
174/// Get the Linux home path string for a distro (e.g., `/home/user`).
175#[cfg(target_os = "windows")]
176fn find_linux_home(distro: &str) -> Option<String> {
177    crate::internal::wsl::detect::linux_home(distro)
178}
179
180/// Configure a single WSL distro.
181fn configure_distro(distro: &WslDistro, config: &WslInstallConfig) -> Result<Vec<String>, String> {
182    let home_path = distro
183        .home_path
184        .as_ref()
185        .ok_or_else(|| format!("could not find home directory for {}", distro.name))?;
186
187    let mut actions = Vec::new();
188
189    // Copy Linux binary if configured
190    #[cfg(target_os = "windows")]
191    if let (Some(src), Some(target)) = (&config.linux_binary_path, &config.linux_binary_target) {
192        copy_linux_binary(home_path, src, target, &distro.name, &mut actions)?;
193    }
194
195    // Inject shell block into config files
196    let block_config = ShellBlockConfig::new(&config.app_name, &config.shell_block);
197    inject_shell_configs(home_path, &block_config, &mut actions)?;
198
199    // Install Linux release binaries from GitHub (replaces the old
200    // socat + npiperelay path).
201    #[cfg(target_os = "windows")]
202    if let Some(release) = config.auto_install_linux_release.as_ref() {
203        install_linux_release(&distro.name, release, &mut actions)?;
204    }
205
206    Ok(actions)
207}
208
209/// Remove configuration from a single WSL distro.
210fn unconfigure_distro(
211    distro: &WslDistro,
212    config: &WslInstallConfig,
213) -> Result<Vec<String>, String> {
214    let home_path = distro
215        .home_path
216        .as_ref()
217        .ok_or_else(|| format!("could not find home directory for {}", distro.name))?;
218
219    let mut actions = Vec::new();
220
221    // Remove shell blocks
222    let block_config = ShellBlockConfig::new(&config.app_name, &config.shell_block);
223    for name in &[".bashrc", ".zshrc", ".profile"] {
224        let path = home_path.join(name);
225        if path.exists() {
226            match uninstall_block(&path, &block_config) {
227                Ok(crate::internal::wsl::shell_config::UninstallResult::Removed) => {
228                    actions.push(format!("Removed block from {name}"));
229                }
230                Ok(crate::internal::wsl::shell_config::UninstallResult::NotPresent) => {}
231                Err(e) => {
232                    return Err(format!("{name}: {e}"));
233                }
234            }
235        }
236    }
237
238    // Remove Linux binary if configured
239    if let Some(target) = &config.linux_binary_target {
240        let binary_path = home_path.join(target);
241        if binary_path.exists() {
242            std::fs::remove_file(&binary_path).map_err(|e| format!("remove binary: {e}"))?;
243            actions.push(format!("Removed ~/{target}"));
244        }
245    }
246
247    // Remove /usr/local/bin/ binaries installed by auto_install_linux_release.
248    #[cfg(target_os = "windows")]
249    if !config.linux_binaries_to_remove.is_empty() {
250        remove_linux_release_binaries(&distro.name, &config.linux_binaries_to_remove, &mut actions);
251    }
252
253    // Clean up the app's runtime directory (~/.sshenc/ or ~/.{app_name}/).
254    // This removes the agent socket and pid file; the keys subdirectory
255    // is intentionally preserved (it contains user key material).
256    #[cfg(target_os = "windows")]
257    {
258        let app = &config.app_name;
259        let runtime_dir = home_path.join(format!(".{app}"));
260        if runtime_dir.exists() {
261            remove_runtime_files_in_distro(&distro.name, &runtime_dir, app, &mut actions);
262        }
263    }
264
265    Ok(actions)
266}
267
268/// Remove the listed binaries from `/usr/local/bin/` inside a WSL distro.
269/// Uses `sudo rm -f` — same privilege escalation path as the install side.
270/// Best-effort: failures are reported as warnings, not errors.
271#[cfg(target_os = "windows")]
272fn remove_linux_release_binaries(
273    distro_name: &str,
274    binaries: &[String],
275    actions: &mut Vec<String>,
276) {
277    let rm_args: Vec<String> = binaries
278        .iter()
279        .map(|b| format!("/usr/local/bin/{b}"))
280        .collect();
281    let script = format!(
282        "set -e\nfor b in {}; do sudo rm -f \"$b\" 2>/dev/null || true; done",
283        rm_args
284            .iter()
285            .map(|p| format!("'{p}'"))
286            .collect::<Vec<_>>()
287            .join(" ")
288    );
289    let mut cmd = std::process::Command::new("wsl");
290    cmd.args(["-d", distro_name, "-e", "bash", "-c", &script]);
291    match crate::internal::core::timeout::run_with_timeout(cmd, WSL_QUICK_CMD_TIMEOUT) {
292        Ok(crate::internal::core::timeout::TimeoutResult::Completed(output))
293            if output.status.success() =>
294        {
295            actions.push(format!(
296                "Removed {} from /usr/local/bin/",
297                binaries.join(", ")
298            ));
299        }
300        Ok(crate::internal::core::timeout::TimeoutResult::Completed(output)) => {
301            let stderr = String::from_utf8_lossy(&output.stderr);
302            actions.push(format!(
303                "Warning: could not remove binaries from /usr/local/bin/ ({})",
304                stderr.lines().next().unwrap_or("unknown error")
305            ));
306        }
307        Ok(crate::internal::core::timeout::TimeoutResult::TimedOut) => {
308            actions.push("Warning: timed out removing binaries from /usr/local/bin/".to_string());
309        }
310        Err(e) => {
311            actions.push(format!(
312                "Warning: could not launch wsl to remove binaries: {e}"
313            ));
314        }
315    }
316}
317
318/// Remove agent runtime files (socket, pid) from the app's home dir in the
319/// distro. Leaves subdirectories (e.g. `keys/`) intact.
320#[cfg(target_os = "windows")]
321fn remove_runtime_files_in_distro(
322    distro_name: &str,
323    runtime_dir: &Path,
324    app_name: &str,
325    actions: &mut Vec<String>,
326) {
327    let dir = runtime_dir.display().to_string();
328    // Kill any running agent, then remove the socket and pid file.
329    // `rmdir` at the end removes the directory only if it is now empty
330    // (i.e. keys/ is gone too); if not, it is silently left behind.
331    let script = format!(
332        "set -e\n\
333         pkill -TERM -x {app_name}-agent 2>/dev/null || true\n\
334         sleep 0.3\n\
335         rm -f '{dir}/agent.sock' '{dir}/agent.pid'\n\
336         rmdir '{dir}' 2>/dev/null || true"
337    );
338    let mut cmd = std::process::Command::new("wsl");
339    cmd.args(["-d", distro_name, "-e", "bash", "-c", &script]);
340    drop(crate::internal::core::timeout::run_with_timeout(
341        cmd,
342        WSL_QUICK_CMD_TIMEOUT,
343    ));
344    actions.push(format!("Cleaned up runtime files in ~/.{app_name}/"));
345}
346
347/// Inject the managed shell block into shell config files.
348fn inject_shell_configs(
349    home_path: &Path,
350    block_config: &ShellBlockConfig,
351    actions: &mut Vec<String>,
352) -> Result<(), String> {
353    let mut configured = false;
354
355    // .bashrc -- primary target for bash users
356    let bashrc = home_path.join(".bashrc");
357    if bashrc.exists() {
358        match install_block(&bashrc, block_config) {
359            Ok(crate::internal::wsl::shell_config::InstallResult::Installed) => {
360                actions.push("Updated .bashrc".to_string());
361                configured = true;
362            }
363            Ok(crate::internal::wsl::shell_config::InstallResult::AlreadyPresent) => {
364                configured = true;
365            }
366            Err(e) => return Err(format!(".bashrc: {e}")),
367        }
368    }
369
370    // .zshrc -- for zsh users
371    let zshrc = home_path.join(".zshrc");
372    if zshrc.exists() {
373        match install_block(&zshrc, block_config) {
374            Ok(crate::internal::wsl::shell_config::InstallResult::Installed) => {
375                actions.push("Updated .zshrc".to_string());
376                configured = true;
377            }
378            Ok(crate::internal::wsl::shell_config::InstallResult::AlreadyPresent) => {
379                configured = true;
380            }
381            Err(e) => return Err(format!(".zshrc: {e}")),
382        }
383    }
384
385    // Fallback: .profile or create .bashrc
386    if !configured {
387        let profile = home_path.join(".profile");
388        if profile.exists() {
389            match install_block(&profile, block_config) {
390                Ok(crate::internal::wsl::shell_config::InstallResult::Installed) => {
391                    actions.push("Updated .profile".to_string());
392                }
393                Ok(crate::internal::wsl::shell_config::InstallResult::AlreadyPresent) => {}
394                Err(e) => return Err(format!(".profile: {e}")),
395            }
396        } else {
397            // Create .bashrc as last resort
398            match install_block(&bashrc, block_config) {
399                Ok(_) => {
400                    actions.push("Created .bashrc".to_string());
401                }
402                Err(e) => return Err(format!("create .bashrc: {e}")),
403            }
404        }
405    }
406
407    Ok(())
408}
409
410/// Copy a Linux binary into the distro's home directory.
411#[cfg(target_os = "windows")]
412fn copy_linux_binary(
413    home_path: &Path,
414    src: &Path,
415    target: &str,
416    distro_name: &str,
417    actions: &mut Vec<String>,
418) -> Result<(), String> {
419    let dest = home_path.join(target);
420    if let Some(parent) = dest.parent() {
421        std::fs::create_dir_all(parent)
422            .map_err(|e| format!("create directory {}: {e}", parent.display()))?;
423    }
424    std::fs::copy(src, &dest).map_err(|e| format!("copy binary: {e}"))?;
425
426    // Make executable via WSL (bounded timeout so a wedged distro can't hang us).
427    if let Some(linux_home) = find_linux_home(distro_name) {
428        let linux_path = format!("{linux_home}/{target}");
429        let mut cmd = std::process::Command::new("wsl");
430        cmd.args(["-d", distro_name, "-e", "chmod", "+x", &linux_path]);
431        drop(crate::internal::core::timeout::run_status_with_timeout(
432            cmd,
433            WSL_QUICK_CMD_TIMEOUT,
434        ));
435    }
436
437    actions.push(format!("Installed binary to ~/{target}"));
438    Ok(())
439}
440
441/// Detect whether the distro's libc is glibc (`Ok(true)`) or musl
442/// (`Ok(false)`). Done by running `ldd --version` inside the
443/// distro and checking the first line — glibc says "GNU libc",
444/// musl says "musl libc". Anything else (e.g., Alpine where `ldd`
445/// is part of busybox) defaults to musl since that's the only
446/// statically-linked tarball that's guaranteed to run there.
447#[cfg(target_os = "windows")]
448fn distro_is_glibc(distro_name: &str) -> bool {
449    let mut wsl = std::process::Command::new("wsl");
450    wsl.args(["-d", distro_name, "-e", "ldd", "--version"]);
451    match crate::internal::core::timeout::run_with_timeout(wsl, WSL_QUICK_CMD_TIMEOUT) {
452        Ok(crate::internal::core::timeout::TimeoutResult::Completed(o)) => {
453            let stdout = String::from_utf8_lossy(&o.stdout);
454            let stderr = String::from_utf8_lossy(&o.stderr);
455            let combined = format!("{stdout}{stderr}");
456            // glibc's ldd prints to stdout and includes "GNU libc";
457            // musl's ldd writes a usage line to stderr that mentions
458            // "musl". Check both streams either way.
459            combined.contains("GNU libc") || combined.contains("Free Software Foundation")
460        }
461        _ => false, // ldd missing → assume musl (statically-linked tarball runs anywhere)
462    }
463}
464
465/// Download the matching Linux release tarball from GitHub and
466/// extract the listed binaries into `/usr/local/bin/` inside the
467/// distro. Done with `curl` and `tar`, both of which are present
468/// in WSL distros (and on Windows but we run them inside the
469/// distro so the artifacts go straight to the right filesystem
470/// without crossing the WSL/Windows boundary).
471#[cfg(target_os = "windows")]
472fn install_linux_release(
473    distro_name: &str,
474    spec: &LinuxReleaseSpec,
475    actions: &mut Vec<String>,
476) -> Result<(), String> {
477    let asset = if distro_is_glibc(distro_name) {
478        &spec.asset_gnu
479    } else {
480        &spec.asset_musl
481    };
482    let url = format!(
483        "https://github.com/{}/releases/download/{}/{}",
484        spec.repo, spec.tag, asset
485    );
486
487    // Curl the tarball, untar into a per-PID work dir, atomically
488    // replace the binaries under /usr/local/bin/. All in one bash
489    // invocation so the installer runs in a single subprocess per
490    // distro.
491    //
492    // Atomic rename rather than `cp` in place: writing over a
493    // running ELF returns ETXTBSY on Linux ("text file busy"). The
494    // previous mitigation tried `pkill -x sshenc-agent` before
495    // `cp`, but in practice it was unreliable — on some distros
496    // (`pgrep -x` doesn't match its own running agent on
497    // AlmaLinux 9; pkill leaves siblings alive on Debian under
498    // some race), so the `cp` still hit ETXTBSY and the user saw
499    // a misleading "(network? release missing?)" warning. The
500    // rename pattern (`cp src dst.tmp && mv dst.tmp dst`) sidesteps
501    // the issue entirely: rename(2) atomically swaps the path
502    // entry, leaving any running process with the old inode
503    // (which lives until that process exits) and pointing all
504    // future invocations at the new file. No kernel text-page
505    // conflict, no install failure.
506    //
507    // Using a fixed `/tmp/sshenc-install-$$` rather than mktemp:
508    // mktemp under `wsl bash -c` was observed to silently land in
509    // `cd ""` on some distros, at which point `tar` extracted into
510    // `$HOME` and collided with existing files.
511    // `set -e` is the FIRST statement so every subsequent failure
512    // aborts immediately. The previous structure stitched everything
513    // together with `&&` and put `set -e` mid-chain, then ended with
514    // `; pkill ... || true` — which made bash's exit code always 0
515    // regardless of whether curl / tar / cp actually succeeded. The
516    // wrapper saw `output.status.success() == true` and reported
517    // "Installed" even when nothing had been copied. Multiple v0.6.71
518    // installs reported success in 4 distros while leaving the
519    // binaries at v0.6.70.
520    //
521    // The pkill at the end is gated by `||true` so it doesn't trip
522    // `set -e` when no agent is running, but the install steps above
523    // it can no longer be silently masked.
524    let bins = spec.binaries.join(" ");
525    let script = format!(
526        "set -e\n\
527         work=/tmp/sshenc-install-$$\n\
528         rm -rf \"$work\"\n\
529         mkdir -p \"$work\"\n\
530         cd \"$work\"\n\
531         trap 'rm -rf \"$work\"' EXIT\n\
532         curl -fsSL '{url}' -o release.tar.gz\n\
533         tar xzf release.tar.gz\n\
534         for b in {bins}; do\n\
535           sudo cp \"$b\" \"/usr/local/bin/$b.new\"\n\
536           sudo chmod +x \"/usr/local/bin/$b.new\"\n\
537           sudo mv \"/usr/local/bin/$b.new\" \"/usr/local/bin/$b\"\n\
538         done\n\
539         pkill -KILL -x sshenc-agent 2>/dev/null || true\n"
540    );
541    // `-e bash -c <script>` rather than `-- bash -c <script>`: the
542    // `--` form runs the user's login shell as the wrapper, and
543    // empirically that wrapper performs a layer of variable
544    // expansion against ITS environment before bash -c sees the
545    // script. So `foo=bar; echo "$foo"` arrives at bash as
546    // `foo=bar; echo ""` and the install silently no-ops on every
547    // path that depends on a script-local variable.
548    // `-e <prog> <args>` execs `<prog>` directly without the login
549    // shell wrapper, so bash -c sees the script intact and `$foo`
550    // expands at the bash level as intended. Repro:
551    //   wsl -d <distro> -- bash -c 'foo=bar; echo "[$foo]"' -> "[]"
552    //   wsl -d <distro> -e bash -c 'foo=bar; echo "[$foo]"' -> "[bar]"
553    let mut cmd = std::process::Command::new("wsl");
554    cmd.args(["-d", distro_name, "-e", "bash", "-c", &script]);
555    match crate::internal::core::timeout::run_with_timeout(cmd, WSL_DEP_INSTALL_TIMEOUT) {
556        Ok(crate::internal::core::timeout::TimeoutResult::Completed(output))
557            if output.status.success() =>
558        {
559            actions.push(format!(
560                "Installed {} from {} {}",
561                spec.binaries.join(", "),
562                spec.repo,
563                spec.tag
564            ));
565            Ok(())
566        }
567        Ok(crate::internal::core::timeout::TimeoutResult::TimedOut) => {
568            actions.push(format!(
569                "Warning: {} install timed out after {}s",
570                spec.repo,
571                WSL_DEP_INSTALL_TIMEOUT.as_secs()
572            ));
573            Ok(())
574        }
575        Ok(crate::internal::core::timeout::TimeoutResult::Completed(output)) => {
576            // Non-zero exit. Surface the actual stderr (last few
577            // lines, trimmed) instead of the old "(network? release
578            // missing?)" guess that masked the real failure for
579            // operators trying to diagnose a stuck distro.
580            let stderr = String::from_utf8_lossy(&output.stderr);
581            let tail: Vec<&str> = stderr
582                .lines()
583                .rev()
584                .filter(|l| !l.trim().is_empty())
585                .take(3)
586                .collect();
587            let detail: String = tail.into_iter().rev().collect::<Vec<_>>().join(" / ");
588            let exit = output
589                .status
590                .code()
591                .map(|c| format!("exit {c}"))
592                .unwrap_or_else(|| "signaled".to_string());
593            actions.push(format!(
594                "Warning: failed to install {} from {} ({}: {})",
595                spec.binaries.join(", "),
596                url,
597                exit,
598                if detail.is_empty() {
599                    "no stderr"
600                } else {
601                    detail.as_str()
602                }
603            ));
604            Ok(())
605        }
606        Err(e) => {
607            actions.push(format!(
608                "Warning: failed to launch wsl install for {} ({e})",
609                spec.binaries.join(", "),
610            ));
611            Ok(())
612        }
613    }
614}
615
616// `wsl_has_command` was used by the old socat / npiperelay path and
617// is no longer needed — `install_linux_release` invokes `curl` + `tar`
618// (both ubiquitous in WSL distros) directly via a single bash script.
619// Removed rather than dead-coded so the per-distro setup path stays
620// transparent.
621
622#[cfg(test)]
623#[allow(clippy::unwrap_used, clippy::panic, let_underscore_drop)]
624mod tests {
625    use super::*;
626    use std::sync::atomic::{AtomicU64, Ordering};
627
628    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
629
630    fn test_dir(name: &str) -> PathBuf {
631        let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst);
632        let pid = std::process::id();
633        let dir =
634            std::env::temp_dir().join(format!("enclaveapp-wsl-install-test-{pid}-{id}-{name}"));
635        std::fs::remove_dir_all(&dir).ok();
636        std::fs::create_dir_all(&dir).unwrap();
637        dir
638    }
639
640    #[test]
641    fn test_inject_shell_configs_bashrc() {
642        let dir = test_dir("inject-bashrc");
643        std::fs::write(dir.join(".bashrc"), "# existing\n").unwrap();
644
645        let config = ShellBlockConfig::new("testapp", "export FOO=bar");
646        let mut actions = Vec::new();
647        inject_shell_configs(&dir, &config, &mut actions).unwrap();
648
649        assert!(!actions.is_empty());
650        let content = std::fs::read_to_string(dir.join(".bashrc")).unwrap();
651        assert!(content.contains("BEGIN testapp managed block"));
652        assert!(content.contains("export FOO=bar"));
653
654        std::fs::remove_dir_all(&dir).unwrap();
655    }
656
657    #[test]
658    fn test_inject_shell_configs_zshrc() {
659        let dir = test_dir("inject-zshrc");
660        std::fs::write(dir.join(".zshrc"), "# zsh config\n").unwrap();
661
662        let config = ShellBlockConfig::new("testapp", "export BAR=baz");
663        let mut actions = Vec::new();
664        inject_shell_configs(&dir, &config, &mut actions).unwrap();
665
666        assert!(actions.iter().any(|a| a.contains(".zshrc")));
667        let content = std::fs::read_to_string(dir.join(".zshrc")).unwrap();
668        assert!(content.contains("export BAR=baz"));
669
670        std::fs::remove_dir_all(&dir).unwrap();
671    }
672
673    #[test]
674    fn test_inject_shell_configs_both() {
675        let dir = test_dir("inject-both");
676        std::fs::write(dir.join(".bashrc"), "# bash\n").unwrap();
677        std::fs::write(dir.join(".zshrc"), "# zsh\n").unwrap();
678
679        let config = ShellBlockConfig::new("testapp", "export X=1");
680        let mut actions = Vec::new();
681        inject_shell_configs(&dir, &config, &mut actions).unwrap();
682
683        assert!(actions.iter().any(|a| a.contains(".bashrc")));
684        assert!(actions.iter().any(|a| a.contains(".zshrc")));
685
686        std::fs::remove_dir_all(&dir).unwrap();
687    }
688
689    #[test]
690    fn test_inject_shell_configs_fallback_profile() {
691        let dir = test_dir("inject-profile");
692        // No .bashrc or .zshrc, only .profile
693        std::fs::write(dir.join(".profile"), "# profile\n").unwrap();
694
695        let config = ShellBlockConfig::new("testapp", "export Y=2");
696        let mut actions = Vec::new();
697        inject_shell_configs(&dir, &config, &mut actions).unwrap();
698
699        assert!(actions.iter().any(|a| a.contains(".profile")));
700        let content = std::fs::read_to_string(dir.join(".profile")).unwrap();
701        assert!(content.contains("export Y=2"));
702
703        std::fs::remove_dir_all(&dir).unwrap();
704    }
705
706    #[test]
707    fn test_inject_shell_configs_creates_bashrc() {
708        let dir = test_dir("inject-create");
709        // No existing shell configs at all
710
711        let config = ShellBlockConfig::new("testapp", "export Z=3");
712        let mut actions = Vec::new();
713        inject_shell_configs(&dir, &config, &mut actions).unwrap();
714
715        assert!(actions.iter().any(|a| a.contains(".bashrc")));
716        let content = std::fs::read_to_string(dir.join(".bashrc")).unwrap();
717        assert!(content.contains("export Z=3"));
718
719        std::fs::remove_dir_all(&dir).unwrap();
720    }
721
722    #[test]
723    fn test_inject_idempotent() {
724        let dir = test_dir("inject-idempotent");
725        std::fs::write(dir.join(".bashrc"), "# existing\n").unwrap();
726
727        let config = ShellBlockConfig::new("testapp", "export A=1");
728        let mut actions1 = Vec::new();
729        inject_shell_configs(&dir, &config, &mut actions1).unwrap();
730        let content1 = std::fs::read_to_string(dir.join(".bashrc")).unwrap();
731
732        let mut actions2 = Vec::new();
733        inject_shell_configs(&dir, &config, &mut actions2).unwrap();
734        let content2 = std::fs::read_to_string(dir.join(".bashrc")).unwrap();
735
736        assert_eq!(content1, content2);
737
738        std::fs::remove_dir_all(&dir).unwrap();
739    }
740
741    #[test]
742    fn test_unconfigure_distro_removes_blocks() {
743        let dir = test_dir("unconfigure");
744        std::fs::write(dir.join(".bashrc"), "# before\n").unwrap();
745
746        let block_config = ShellBlockConfig::new("testapp", "export Q=1");
747        install_block(dir.join(".bashrc").as_path(), &block_config).unwrap();
748
749        let distro = WslDistro {
750            name: "TestDistro".to_string(),
751            home_path: Some(dir.clone()),
752        };
753        let config = WslInstallConfig {
754            app_name: "testapp".to_string(),
755            shell_block: "export Q=1".to_string(),
756            auto_install_linux_release: None,
757            linux_binary_path: None,
758            linux_binary_target: None,
759            linux_binaries_to_remove: vec![],
760        };
761        let result = unconfigure_distro(&distro, &config).unwrap();
762        assert!(result.iter().any(|a| a.contains("Removed")));
763
764        let content = std::fs::read_to_string(dir.join(".bashrc")).unwrap();
765        assert!(!content.contains("BEGIN testapp managed block"));
766
767        std::fs::remove_dir_all(&dir).unwrap();
768    }
769
770    #[test]
771    fn test_decode_wsl_output_utf8() {
772        let input = b"Ubuntu\nDebian\n";
773        let result = decode_wsl_output(input);
774        assert_eq!(result, "Ubuntu\nDebian\n");
775    }
776
777    #[test]
778    fn test_decode_wsl_output_utf16le_bom() {
779        // UTF-16LE with BOM: "Hi\n"
780        let mut bytes = vec![0xFF_u8, 0xFE]; // BOM
781        for ch in "Hi\n".encode_utf16() {
782            bytes.extend_from_slice(&ch.to_le_bytes());
783        }
784        let result = decode_wsl_output(&bytes);
785        assert_eq!(result, "Hi\n");
786    }
787
788    #[test]
789    fn test_find_wsl_home_non_windows() {
790        // On non-Windows, always returns None
791        #[cfg(not(target_os = "windows"))]
792        assert!(find_wsl_home("Ubuntu").is_none());
793    }
794
795    #[test]
796    fn test_distro_result_debug() {
797        let result = DistroResult {
798            distro_name: "Ubuntu".to_string(),
799            outcome: Ok(vec!["Updated .bashrc".to_string()]),
800        };
801        let debug_str = format!("{result:?}");
802        assert!(debug_str.contains("Ubuntu"));
803    }
804
805    #[test]
806    fn test_wsl_install_config_clone() {
807        let config = WslInstallConfig {
808            app_name: "test".to_string(),
809            shell_block: "# test".to_string(),
810            auto_install_linux_release: None,
811            linux_binary_path: None,
812            linux_binary_target: None,
813            linux_binaries_to_remove: vec![],
814        };
815        let cloned = config.clone();
816        assert_eq!(cloned.app_name, config.app_name);
817        assert_eq!(cloned.shell_block, config.shell_block);
818    }
819
820    #[test]
821    fn test_decode_wsl_output_real_utf16le_bom() {
822        // Simulate real UTF-16LE BOM output: "Ubuntu\r\n"
823        let text = "Ubuntu\r\n";
824        let mut bytes = vec![0xFF_u8, 0xFE]; // BOM
825        for ch in text.encode_utf16() {
826            bytes.extend_from_slice(&ch.to_le_bytes());
827        }
828        let result = decode_wsl_output(&bytes);
829        assert_eq!(result, "Ubuntu\r\n");
830    }
831
832    #[test]
833    fn test_decode_wsl_output_plain_utf8() {
834        let input = b"Debian GNU/Linux\n";
835        let result = decode_wsl_output(input);
836        assert_eq!(result, "Debian GNU/Linux\n");
837    }
838
839    #[test]
840    fn test_configure_distro_creates_backup_like_file() {
841        // Configure a distro with shell configs — the .bashrc should be modified
842        let dir = test_dir("configure-backup");
843        let bashrc = dir.join(".bashrc");
844        std::fs::write(&bashrc, "# original content\nexport PATH=/usr/bin\n").unwrap();
845        let original_content = std::fs::read_to_string(&bashrc).unwrap();
846
847        let distro = WslDistro {
848            name: "TestDistro".to_string(),
849            home_path: Some(dir.clone()),
850        };
851        let config = WslInstallConfig {
852            app_name: "testapp".to_string(),
853            shell_block: "export TEST=1".to_string(),
854            auto_install_linux_release: None,
855            linux_binary_path: None,
856            linux_binary_target: None,
857            linux_binaries_to_remove: vec![],
858        };
859
860        let result = configure_distro(&distro, &config).unwrap();
861        assert!(!result.is_empty());
862
863        // .bashrc should now contain the block
864        let new_content = std::fs::read_to_string(&bashrc).unwrap();
865        assert!(new_content.contains("BEGIN testapp managed block"));
866        // Original content should still be present
867        assert!(new_content.contains(&original_content.trim_end().to_string()));
868
869        std::fs::remove_dir_all(&dir).unwrap();
870    }
871
872    #[test]
873    fn test_unconfigure_distro_removes_block_but_keeps_content() {
874        let dir = test_dir("unconfigure-keep");
875        let bashrc = dir.join(".bashrc");
876        std::fs::write(&bashrc, "# my config\nexport FOO=bar\n").unwrap();
877
878        let block_config = ShellBlockConfig::new("testapp", "export Q=1");
879        install_block(bashrc.as_path(), &block_config).unwrap();
880
881        // Verify block is there
882        let content = std::fs::read_to_string(&bashrc).unwrap();
883        assert!(content.contains("BEGIN testapp managed block"));
884
885        let distro = WslDistro {
886            name: "TestDistro".to_string(),
887            home_path: Some(dir.clone()),
888        };
889        let config = WslInstallConfig {
890            app_name: "testapp".to_string(),
891            shell_block: "export Q=1".to_string(),
892            auto_install_linux_release: None,
893            linux_binary_path: None,
894            linux_binary_target: None,
895            linux_binaries_to_remove: vec![],
896        };
897        let result = unconfigure_distro(&distro, &config).unwrap();
898        assert!(result.iter().any(|a| a.contains("Removed")));
899
900        let final_content = std::fs::read_to_string(&bashrc).unwrap();
901        assert!(!final_content.contains("BEGIN testapp managed block"));
902        assert!(final_content.contains("# my config"));
903        assert!(final_content.contains("export FOO=bar"));
904
905        std::fs::remove_dir_all(&dir).unwrap();
906    }
907
908    #[test]
909    fn test_decode_wsl_output_utf16le_multiple_lines() {
910        // UTF-16LE BOM with multiple lines: "Ubuntu\nDebian\n"
911        let text = "Ubuntu\nDebian\n";
912        let mut bytes = vec![0xFF_u8, 0xFE];
913        for ch in text.encode_utf16() {
914            bytes.extend_from_slice(&ch.to_le_bytes());
915        }
916        let result = decode_wsl_output(&bytes);
917        assert_eq!(result, "Ubuntu\nDebian\n");
918    }
919
920    #[test]
921    fn test_decode_wsl_output_empty_utf8() {
922        let result = decode_wsl_output(b"");
923        assert_eq!(result, "");
924    }
925}