use std::collections::HashSet;
const PROTECTED_PREFIXES: &[&str] = &[
"\\\\wsl$",
"\\\\wsl.localhost",
"cygwin",
"cygdrive",
"/cygdrive",
"/mnt/",
"msys",
"git\\usr",
"git\\bin",
"git\\cmd",
];
#[derive(Debug, Clone, Default)]
pub struct SubsystemSafety {
extra_prefixes: Vec<String>,
}
impl SubsystemSafety {
pub fn new() -> Self {
Self::default()
}
pub fn with_prefix(mut self, prefix: impl Into<String>) -> Self {
self.extra_prefixes.push(prefix.into());
self
}
pub fn should_preserve(&self, entry: &str) -> bool {
let lower = entry.to_lowercase();
for p in PROTECTED_PREFIXES {
if lower.contains(&p.to_lowercase()) {
return true;
}
}
for p in &self.extra_prefixes {
if lower.contains(&p.to_lowercase()) {
return true;
}
}
false
}
pub fn removable_entries<'a>(&self, entries: &'a [String]) -> Vec<&'a String> {
entries
.iter()
.filter(|e| !self.should_preserve(e))
.collect()
}
}
pub fn parse_wsl_path_output(raw: &str) -> HashSet<String> {
let mut out = HashSet::new();
let mut tokens: Vec<String> = Vec::new();
for part in raw.split(':') {
let part = part.trim();
if part.is_empty() {
continue;
}
if !tokens.is_empty() {
let last = tokens.last().unwrap();
let merge = last.len() == 1
&& last.chars().next().unwrap().is_ascii_alphabetic()
&& part.starts_with('\\');
if merge {
let last_mut = tokens.last_mut().unwrap();
*last_mut = format!("{}:{}", last_mut, part);
continue;
}
}
tokens.push(part.to_string());
}
for part in &tokens {
if let Some(win) = wsl_part_to_windows(part) {
out.insert(win);
}
}
out
}
fn wsl_part_to_windows(part: &str) -> Option<String> {
let lowered = part.to_lowercase();
if let Some(rest) = lowered.strip_prefix("/mnt/") {
let well_formed = rest.len() >= 2 && rest.chars().nth(1) == Some('/');
if well_formed {
let drive = rest.chars().next().unwrap();
return Some(format!("{}:\\{}", drive, &rest[2..]).replace('/', "\\"));
}
}
if lowered.starts_with('/') && lowered.len() >= 3 {
let chars: Vec<char> = lowered.chars().collect();
if chars[1].is_ascii_alphabetic() && chars[2] == '/' {
let drive = chars[1];
return Some(format!("{}:\\{}", drive, &lowered[3..]).replace('/', "\\"));
}
}
if part.contains('\\') && part.chars().nth(1) == Some(':') {
return Some(part.to_lowercase());
}
None
}
pub fn probe_wsl_path() -> Option<HashSet<String>> {
let output = std::process::Command::new("wsl.exe").arg("echo").arg("$PATH").output().ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8_lossy(&output.stdout);
let parsed = parse_wsl_path_output(&raw);
if parsed.is_empty() {
None
} else {
Some(parsed)
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
#[test]
fn protected_prefixes_are_preserved() {
let safety = SubsystemSafety::new();
assert!(safety.should_preserve(r"\\wsl$\Ubuntu\bin"));
assert!(safety.should_preserve(r"C:\cygwin64\bin"));
assert!(safety.should_preserve(r"C:\Program Files\Git\usr\bin"));
assert!(!safety.should_preserve(r"C:\Users\me\tools"));
}
#[test]
fn extra_prefix_is_protected() {
let safety = SubsystemSafety::new().with_prefix(r"\\my-nas\share");
assert!(safety.should_preserve(r"\\my-nas\share\app"));
}
#[test]
fn removable_entries_skips_protected() {
let safety = SubsystemSafety::new();
let entries = vec![
r"C:\Users\me\tools".to_string(),
r"\\wsl$\Ubuntu\bin".to_string(),
];
let removable: Vec<&String> = safety.removable_entries(&entries);
assert_eq!(removable.len(), 1);
assert_eq!(removable[0], &r"C:\Users\me\tools".to_string());
}
#[test]
fn parse_wsl_mnt_paths() {
let parsed = parse_wsl_path_output("/mnt/c/Windows/System32:/mnt/d/tools:/usr/bin");
assert!(parsed.contains(r"c:\windows\system32"));
assert!(parsed.contains(r"d:\tools"));
assert!(!parsed.contains("/usr/bin"));
}
#[test]
fn parse_wsl_drive_letter_paths() {
let parsed = parse_wsl_path_output("/c/Users/me:/d/Data");
assert!(parsed.contains(r"c:\users\me"));
assert!(parsed.contains(r"d:\data"));
}
#[test]
fn parse_wsl_native_windows_paths() {
let parsed = parse_wsl_path_output(r"C:\Python39:D:\Apps");
assert!(parsed.contains(r"c:\python39"));
assert!(parsed.contains(r"d:\apps"));
}
proptest! {
#[test]
fn parse_wsl_path_output_never_panics(ref raw in "\\PC*") {
let result = parse_wsl_path_output(raw);
for path in &result {
let bytes = path.as_bytes();
prop_assert!(path.len() >= 3, "path '{}' too short", path);
prop_assert!(
bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'\\',
"extracted '{}' is not a valid Windows path", path
);
}
}
#[test]
fn should_preserve_is_deterministic(
ref entry in "\\PC{1,100}",
ref extra_prefix in "\\PC{0,20}",
) {
let safety = if extra_prefix.is_empty() {
SubsystemSafety::new()
} else {
SubsystemSafety::new().with_prefix(extra_prefix.clone())
};
let v1 = safety.should_preserve(entry);
let v2 = safety.should_preserve(entry);
prop_assert_eq!(v1, v2, "should_preserve must be deterministic");
}
#[test]
fn removable_entries_is_subset(
ref entries in prop::collection::vec("\\PC{1,50}", 0..10),
) {
let safety = SubsystemSafety::new();
let removable = safety.removable_entries(entries);
for r in &removable {
prop_assert!(
entries.iter().any(|e| e == *r),
"removable entry '{}' not in original", r
);
}
let preserved: Vec<&String> = entries.iter().filter(|e| safety.should_preserve(e)).collect();
prop_assert_eq!(removable.len() + preserved.len(), entries.len(),
"removable + preserved must equal total entries");
}
}
}