Skip to main content

hardware_enclave/internal/wsl/
detect.rs

1#![allow(dead_code, unused_imports, unused_qualifications, unreachable_patterns)]
2// Copyright 2026 Jay Gowdy
3// SPDX-License-Identifier: MIT
4
5//! WSL detection and distribution enumeration.
6
7/// Decode WSL output, handling UTF-8, UTF-16LE BOM, and common UTF-16LE-without-BOM output.
8pub fn decode_wsl_output(bytes: &[u8]) -> String {
9    if bytes.len() >= 2 && bytes[0] == 0xFF && bytes[1] == 0xFE {
10        return decode_utf16le(&bytes[2..]);
11    }
12
13    let nul_bytes = bytes.iter().filter(|&&b| b == 0).count();
14    if bytes.len() >= 4 && nul_bytes >= bytes.len() / 4 {
15        return decode_utf16le(bytes);
16    }
17
18    String::from_utf8_lossy(bytes).to_string()
19}
20
21fn decode_utf16le(bytes: &[u8]) -> String {
22    let u16s: Vec<u16> = bytes
23        .chunks_exact(2)
24        .map(|chunk| u16::from_le_bytes([chunk[0], chunk[1]]))
25        .collect();
26    String::from_utf16_lossy(&u16s)
27}
28
29/// Information about a detected WSL distribution.
30#[derive(Debug, Clone)]
31pub struct WslDistro {
32    /// Distribution name (e.g., "Ubuntu", "Debian").
33    pub name: String,
34    /// Windows UNC path to the distro's home directory
35    /// (e.g., `\\wsl.localhost\Ubuntu\home\user`).
36    pub home_path: Option<std::path::PathBuf>,
37}
38
39/// Returns true if the current process is running inside WSL.
40pub fn is_wsl() -> bool {
41    #[cfg(target_os = "linux")]
42    {
43        if std::env::var("WSL_DISTRO_NAME").is_ok() {
44            return true;
45        }
46        if let Ok(version) = std::fs::read_to_string("/proc/version") {
47            let lower = version.to_lowercase();
48            if lower.contains("microsoft") || lower.contains("wsl") {
49                return true;
50            }
51        }
52        false
53    }
54    #[cfg(not(target_os = "linux"))]
55    false
56}
57
58/// Detect installed WSL distributions (runs on Windows host).
59///
60/// Returns an empty list on non-Windows platforms.
61pub fn detect_distros() -> Vec<WslDistro> {
62    #[cfg(target_os = "windows")]
63    {
64        use crate::internal::core::timeout::{run_with_timeout, TimeoutResult};
65        use std::time::Duration;
66        // Run: wsl --list --quiet — normally sub-second. Cap at 15s so a
67        // wedged WSL service can't freeze callers.
68        let mut cmd = std::process::Command::new("wsl");
69        cmd.args(["--list", "--quiet"]);
70        let output = match run_with_timeout(cmd, Duration::from_secs(15)) {
71            Ok(TimeoutResult::Completed(o)) if o.status.success() => o,
72            _ => return Vec::new(),
73        };
74
75        let stdout = decode_wsl_output(&output.stdout);
76        let names: Vec<String> = stdout
77            .lines()
78            .map(|l| l.trim().to_string())
79            .filter(|l| !l.is_empty())
80            .collect();
81
82        names
83            .into_iter()
84            .map(|name| {
85                let home_path = get_distro_home(&name);
86                WslDistro { name, home_path }
87            })
88            .collect()
89    }
90    #[cfg(not(target_os = "windows"))]
91    Vec::new()
92}
93
94/// Get the home directory path for a WSL distro, converted to a Windows UNC path.
95#[cfg(target_os = "windows")]
96fn get_distro_home(distro: &str) -> Option<std::path::PathBuf> {
97    let home = linux_home(distro)?;
98    if home.is_empty() {
99        return None;
100    }
101    // Convert Linux path to Windows UNC path
102    Some(std::path::PathBuf::from(format!(
103        "\\\\wsl.localhost\\{distro}{home}"
104    )))
105}
106
107/// Resolve a distro's `$HOME` by running a shell inside WSL.
108#[cfg(target_os = "windows")]
109pub(crate) fn linux_home(distro: &str) -> Option<String> {
110    use crate::internal::core::timeout::{run_with_timeout, TimeoutResult};
111    use std::time::Duration;
112    let mut cmd = std::process::Command::new("wsl");
113    cmd.args(["-d", distro, "-e", "sh", "-lc", r#"printf '%s' "$HOME""#]);
114    let output = match run_with_timeout(cmd, Duration::from_secs(15)) {
115        Ok(TimeoutResult::Completed(o)) => o,
116        _ => return None,
117    };
118    if !output.status.success() {
119        return None;
120    }
121    let home = decode_wsl_output(&output.stdout).trim().to_string();
122    if home.is_empty() {
123        return None;
124    }
125    Some(home)
126}
127
128#[cfg(test)]
129#[allow(clippy::unwrap_used, clippy::panic)]
130mod tests {
131    use super::*;
132
133    #[test]
134    fn test_is_wsl_false_on_non_linux() {
135        // On macOS (where CI runs), is_wsl() must return false.
136        #[cfg(not(target_os = "linux"))]
137        assert!(!is_wsl());
138    }
139
140    #[test]
141    fn test_detect_distros_empty_on_non_windows() {
142        // On macOS/Linux, detect_distros() returns empty.
143        #[cfg(not(target_os = "windows"))]
144        assert!(detect_distros().is_empty());
145    }
146
147    #[test]
148    fn test_decode_wsl_output_utf16le_without_bom() {
149        let bytes = b"U\0b\0u\0n\0t\0u\0";
150        assert_eq!(decode_wsl_output(bytes), "Ubuntu");
151    }
152
153    #[test]
154    fn decode_wsl_output_pure_utf8_returns_as_is() {
155        let bytes = b"hello world";
156        assert_eq!(decode_wsl_output(bytes), "hello world");
157    }
158
159    #[test]
160    fn decode_wsl_output_empty_bytes_returns_empty_string() {
161        assert_eq!(decode_wsl_output(b""), "");
162    }
163
164    #[test]
165    fn decode_wsl_output_utf16le_with_bom_decoded() {
166        // 0xFF 0xFE = BOM, followed by "Hi" in UTF-16LE
167        let bytes: &[u8] = &[0xFF, 0xFE, b'H', 0, b'i', 0];
168        assert_eq!(decode_wsl_output(bytes), "Hi");
169    }
170
171    #[test]
172    fn decode_wsl_output_only_bom_returns_empty() {
173        let bytes: &[u8] = &[0xFF, 0xFE];
174        assert_eq!(decode_wsl_output(bytes), "");
175    }
176
177    #[test]
178    fn decode_wsl_output_utf16le_bom_with_newline() {
179        // BOM + "A\n" in UTF-16LE
180        let bytes: &[u8] = &[0xFF, 0xFE, b'A', 0, b'\n', 0];
181        assert_eq!(decode_wsl_output(bytes), "A\n");
182    }
183
184    #[test]
185    fn decode_wsl_output_ascii_no_nulls_treated_as_utf8() {
186        let bytes = b"Debian";
187        assert_eq!(decode_wsl_output(bytes), "Debian");
188    }
189
190    #[test]
191    fn decode_wsl_output_high_null_density_treated_as_utf16le() {
192        // "ABCD" as UTF-16LE without BOM: 8 bytes, 4 nulls (density = 50% > 25%)
193        let bytes: &[u8] = &[b'A', 0, b'B', 0, b'C', 0, b'D', 0];
194        assert_eq!(decode_wsl_output(bytes), "ABCD");
195    }
196
197    #[test]
198    fn decode_wsl_output_low_null_density_treated_as_utf8() {
199        // 8 bytes with only 1 null (density = 12.5% < 25%) → UTF-8
200        let bytes: &[u8] = &[b'A', b'B', b'C', b'D', b'E', b'F', b'G', 0];
201        // from_utf8_lossy on bytes with embedded NUL: the NUL survives as NUL char
202        let result = decode_wsl_output(bytes);
203        assert!(result.starts_with("ABCDEFG"));
204    }
205
206    #[test]
207    fn decode_wsl_output_utf16le_bom_multiline() {
208        // BOM + "Ubuntu\nDebian" in UTF-16LE
209        let mut bytes = vec![0xFF_u8, 0xFE];
210        for ch in "Ubuntu\nDebian".encode_utf16() {
211            bytes.extend_from_slice(&ch.to_le_bytes());
212        }
213        let result = decode_wsl_output(&bytes);
214        assert!(result.contains("Ubuntu"));
215        assert!(result.contains("Debian"));
216    }
217
218    #[test]
219    fn decode_wsl_output_utf16le_without_bom_multiline() {
220        // "Ubuntu\nDebian" as UTF-16LE without BOM (high null density)
221        let mut bytes: Vec<u8> = Vec::new();
222        for ch in "Ubuntu\nDebian".encode_utf16() {
223            bytes.extend_from_slice(&ch.to_le_bytes());
224        }
225        let result = decode_wsl_output(&bytes);
226        assert!(result.contains("Ubuntu"));
227        assert!(result.contains("Debian"));
228    }
229
230    #[test]
231    fn decode_wsl_output_three_bytes_not_bom_not_utf16le() {
232        // Fewer than 4 bytes can't hit the null-density branch
233        let bytes: &[u8] = &[b'A', 0, b'B'];
234        // 3 bytes, 1 null: len < 4 so nul_density check fails → UTF-8
235        let result = decode_wsl_output(bytes);
236        assert!(result.contains('A'));
237    }
238
239    #[test]
240    fn decode_wsl_output_utf8_with_multibyte_char() {
241        let input = "Ubuntu 22.04 LTS";
242        assert_eq!(decode_wsl_output(input.as_bytes()), input);
243    }
244
245    #[test]
246    fn decode_utf16le_odd_length_ignores_trailing_byte() {
247        // chunks_exact(2) silently drops the trailing unpaired byte.
248        // "AB" as UTF-16LE = [0x41, 0x00, 0x42, 0x00], plus one extra byte.
249        let bytes: &[u8] = &[0x41, 0x00, 0x42, 0x00, 0xFF];
250        assert_eq!(decode_utf16le(bytes), "AB");
251    }
252
253    #[test]
254    fn decode_utf16le_non_ascii_codepoint() {
255        // U+00E9 LATIN SMALL LETTER E WITH ACUTE, encoded as UTF-16LE: [0xE9, 0x00]
256        let bytes: &[u8] = &[0xE9, 0x00];
257        assert_eq!(decode_utf16le(bytes), "\u{00E9}");
258    }
259}