agentmux_srv/backend/
posix_shell.rs1use std::ffi::OsString;
26use std::path::{Path, PathBuf};
27
28#[cfg_attr(not(windows), allow(dead_code))]
31pub const SHELL_OVERRIDE_ENV: &str = "AGENTMUX_BASH";
32
33pub fn resolve_posix_shell() -> Result<PathBuf, String> {
35 #[cfg(windows)]
36 {
37 resolve_windows(&|k| std::env::var_os(k), &|p| p.is_file()).ok_or_else(|| {
38 format!(
39 "no POSIX shell found — `!` commands need Git for Windows \
40 (https://git-scm.com/download/win), or set {SHELL_OVERRIDE_ENV} \
41 to a bash.exe/sh.exe"
42 )
43 })
44 }
45 #[cfg(not(windows))]
46 {
47 Ok(PathBuf::from("sh"))
48 }
49}
50
51#[cfg_attr(not(windows), allow(dead_code))]
67pub(crate) fn resolve_windows(
68 env: &dyn Fn(&str) -> Option<OsString>,
69 is_file: &dyn Fn(&Path) -> bool,
70) -> Option<PathBuf> {
71 if let Some(p) = env(SHELL_OVERRIDE_ENV)
72 .filter(|v| !v.is_empty())
73 .map(PathBuf::from)
74 {
75 if is_file(&p) {
76 return Some(p);
77 }
78 tracing::warn!(path = %p.display(), "{SHELL_OVERRIDE_ENV} is not a file; ignoring");
79 }
80
81 let path_dirs: Vec<PathBuf> = env("PATH")
82 .map(|v| {
83 v.to_string_lossy()
84 .split(';')
85 .map(|s| s.trim().trim_matches('"'))
86 .filter(|s| !s.is_empty())
87 .map(PathBuf::from)
88 .filter(|p| is_windows_absolute(p))
89 .collect()
90 })
91 .unwrap_or_default();
92
93 if let Some(sh) = path_dirs
94 .iter()
95 .map(|d| d.join("sh.exe"))
96 .find(|p| is_file(p))
97 {
98 return Some(sh);
99 }
100
101 for git in path_dirs
103 .iter()
104 .map(|d| d.join("git.exe"))
105 .filter(|p| is_file(p))
106 {
107 for root in git.ancestors().skip(2).take(3) {
108 let sh = git_launcher(root);
109 if is_file(&sh) {
110 return Some(sh);
111 }
112 }
113 }
114
115 let program_roots = ["ProgramW6432", "ProgramFiles", "ProgramFiles(x86)"]
116 .into_iter()
117 .filter_map(|k| env(k).map(PathBuf::from));
118 let per_user = env("LOCALAPPDATA").map(|d| PathBuf::from(d).join("Programs"));
119 program_roots
120 .chain(per_user)
121 .map(|base| git_launcher(&base.join("Git")))
122 .find(|p| is_file(p))
123}
124
125#[cfg_attr(not(windows), allow(dead_code))]
127fn git_launcher(root: &Path) -> PathBuf {
128 root.join("bin").join("sh.exe")
129}
130
131#[cfg_attr(not(windows), allow(dead_code))]
135fn is_windows_absolute(p: &Path) -> bool {
136 let s = p.to_string_lossy();
137 let b = s.as_bytes();
138 (b.len() >= 3 && b[0].is_ascii_alphabetic() && b[1] == b':' && (b[2] == b'\\' || b[2] == b'/'))
139 || s.starts_with(r"\\")
140}
141
142#[cfg(test)]
143mod tests {
144 use super::*;
145 use std::collections::{HashMap, HashSet};
146
147 fn git_root() -> PathBuf {
150 PathBuf::from("C:/").join("Program Files").join("Git")
151 }
152
153 fn run(env: &[(&str, String)], files: &[PathBuf]) -> Option<PathBuf> {
154 let env: HashMap<String, OsString> = env
155 .iter()
156 .map(|(k, v)| (k.to_string(), OsString::from(v)))
157 .collect();
158 let files: HashSet<PathBuf> = files.iter().cloned().collect();
159 resolve_windows(&|k| env.get(k).cloned(), &|p| files.contains(p))
160 }
161
162 fn path_of(dirs: &[PathBuf]) -> String {
163 dirs.iter()
164 .map(|d| d.to_string_lossy().into_owned())
165 .collect::<Vec<_>>()
166 .join(";")
167 }
168
169 #[test]
172 fn git_cmd_only_on_path_resolves_to_bin_launcher() {
173 let root = git_root();
174 let files = [
175 root.join("cmd").join("git.exe"),
176 root.join("bin").join("sh.exe"),
177 root.join("usr").join("bin").join("sh.exe"),
178 ];
179 let env = [(
180 "PATH",
181 path_of(&[PathBuf::from("C:/Windows/system32"), root.join("cmd")]),
182 )];
183 assert_eq!(run(&env, &files), Some(root.join("bin").join("sh.exe")));
184 }
185
186 #[test]
187 fn git_on_path_via_mingw64_bin_resolves_to_install_root() {
188 let root = PathBuf::from("D:/").join("tools").join("Git");
189 let mingw = root.join("mingw64").join("bin");
190 let files = [mingw.join("git.exe"), root.join("bin").join("sh.exe")];
191 assert_eq!(
192 run(&[("PATH", path_of(&[mingw]))], &files),
193 Some(root.join("bin").join("sh.exe"))
194 );
195 }
196
197 #[test]
199 fn sh_on_path_wins_over_git_derivation() {
200 let root = git_root();
201 let usr_bin = root.join("usr").join("bin");
202 let files = [
203 usr_bin.join("sh.exe"),
204 root.join("cmd").join("git.exe"),
205 root.join("bin").join("sh.exe"),
206 ];
207 let env = [("PATH", path_of(&[usr_bin.clone(), root.join("cmd")]))];
208 assert_eq!(run(&env, &files), Some(usr_bin.join("sh.exe")));
209 }
210
211 #[test]
213 fn wsl_bash_on_path_is_not_used() {
214 let sys32 = PathBuf::from("C:/").join("Windows").join("System32");
215 let files = [sys32.join("bash.exe")];
216 assert_eq!(run(&[("PATH", path_of(&[sys32]))], &files), None);
217 }
218
219 #[test]
220 fn falls_back_to_program_files_when_git_not_on_path() {
221 let pf = PathBuf::from("C:/").join("Program Files");
222 let files = [pf.join("Git").join("bin").join("sh.exe")];
223 let env = [
224 ("PATH", String::from("C:/Windows")),
225 ("ProgramFiles", pf.to_string_lossy().into_owned()),
226 ];
227 assert_eq!(
228 run(&env, &files),
229 Some(pf.join("Git").join("bin").join("sh.exe"))
230 );
231 }
232
233 #[test]
234 fn falls_back_to_per_user_install() {
235 let local = PathBuf::from("C:/")
236 .join("Users")
237 .join("u")
238 .join("AppData")
239 .join("Local");
240 let sh = local
241 .join("Programs")
242 .join("Git")
243 .join("bin")
244 .join("sh.exe");
245 let env = [("LOCALAPPDATA", local.to_string_lossy().into_owned())];
246 assert_eq!(run(&env, &[sh.clone()]), Some(sh));
247 }
248
249 #[test]
250 fn override_wins_when_it_exists_and_is_ignored_when_it_does_not() {
251 let root = git_root();
252 let custom = PathBuf::from("E:/")
253 .join("msys64")
254 .join("usr")
255 .join("bin")
256 .join("bash.exe");
257 let launcher = root.join("bin").join("sh.exe");
258 let path = ("PATH", path_of(&[root.join("cmd")]));
259 let files = [
260 custom.clone(),
261 root.join("cmd").join("git.exe"),
262 launcher.clone(),
263 ];
264 let env = [
265 (SHELL_OVERRIDE_ENV, custom.to_string_lossy().into_owned()),
266 path.clone(),
267 ];
268 assert_eq!(run(&env, &files), Some(custom.clone()));
269
270 let files = [root.join("cmd").join("git.exe"), launcher.clone()];
272 assert_eq!(run(&env, &files), Some(launcher));
273 }
274
275 #[test]
277 fn relative_path_entries_are_skipped() {
278 let files = [PathBuf::from(".").join("sh.exe")];
279 assert_eq!(run(&[("PATH", String::from("."))], &files), None);
280 }
281
282 #[test]
283 fn quoted_path_entries_are_honoured() {
284 let root = git_root();
285 let files = [
286 root.join("cmd").join("git.exe"),
287 root.join("bin").join("sh.exe"),
288 ];
289 let env = [(
290 "PATH",
291 format!("\"{}\"", root.join("cmd").to_string_lossy()),
292 )];
293 assert_eq!(run(&env, &files), Some(root.join("bin").join("sh.exe")));
294 }
295
296 #[test]
297 fn nothing_found_is_none() {
298 assert_eq!(run(&[("PATH", String::from("C:/Windows"))], &[]), None);
299 }
300}