upstream_rs/services/integration/
shell_manager.rs1#[cfg(unix)]
2use crate::utils::filesystem::atomic_ops::write_atomic;
3use anyhow::{Context, Result};
4#[cfg(unix)]
5use std::fs;
6use std::path::Path;
7#[cfg(unix)]
8use std::path::PathBuf;
9#[cfg(unix)]
10use std::sync::{Mutex, OnceLock};
11
12#[cfg(unix)]
14fn paths_file_lock() -> &'static Mutex<()> {
15 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
16 LOCK.get_or_init(|| Mutex::new(()))
17}
18
19#[cfg(windows)]
20fn normalize_windows_path(path: &str) -> String {
21 let mut normalized = path.replace('/', "\\").trim().to_ascii_lowercase();
22 while normalized.ends_with('\\') {
23 normalized.pop();
24 }
25 normalized
26}
27
28pub struct ShellManager<'a> {
29 paths_file: &'a Path,
30 #[cfg(unix)]
31 paths_nu_file: PathBuf,
32}
33
34impl<'a> ShellManager<'a> {
35 pub fn new(paths_file: &'a Path) -> Self {
36 Self {
37 paths_file,
38 #[cfg(unix)]
39 paths_nu_file: paths_file.with_extension("nu"),
40 }
41 }
42
43 pub fn add_to_paths(&self, install_path: &Path) -> Result<()> {
45 if !install_path.is_dir() {
46 anyhow::bail!(
47 "Package install directory not found: {}",
48 install_path.to_string_lossy()
49 );
50 }
51
52 #[cfg(unix)]
53 {
54 let _guard = paths_file_lock()
55 .lock()
56 .ok()
57 .ok_or_else(|| anyhow::anyhow!("Failed to lock PATH file for writing"))?;
58 let mut content =
59 fs::read_to_string(self.paths_file).context("Failed to read paths file")?;
60 let escaped = install_path
61 .to_string_lossy()
62 .replace('$', "\\$")
63 .replace('"', "\\\"");
64 let export_line = format!("export PATH=\"{escaped}:$PATH\"");
65
66 if !content.contains(&export_line) {
67 content.push_str(&format!("{export_line}\n"));
68 write_atomic(self.paths_file, content.as_bytes())
69 .context("Failed to write paths file")?;
70 }
71
72 let nushell_content = fs::read_to_string(&self.paths_nu_file).unwrap_or_default();
73 let mut nushell_paths = parse_nushell_paths_file(&nushell_content);
74 let install_path = install_path.to_string_lossy().to_string();
75
76 if !nushell_paths.contains(&install_path) {
77 nushell_paths.insert(0, install_path);
78 let rendered = render_nushell_paths_file(&nushell_paths);
79 write_atomic(&self.paths_nu_file, rendered.as_bytes())
80 .context("Failed to write Nushell paths file")?;
81 }
82 }
83
84 #[cfg(windows)]
85 {
86 let _ = self.paths_file;
87 self.add_to_windows_registry(install_path)?;
88 }
89
90 Ok(())
91 }
92
93 pub fn remove_from_paths(&self, install_path: &Path) -> Result<()> {
95 #[cfg(unix)]
96 {
97 let _guard = paths_file_lock()
98 .lock()
99 .ok()
100 .ok_or_else(|| anyhow::anyhow!("Failed to lock PATH file for writing"))?;
101 let mut content =
102 fs::read_to_string(self.paths_file).context("Failed to read paths file")?;
103 let escaped = install_path
104 .to_string_lossy()
105 .replace('$', "\\$")
106 .replace('"', "\\\"");
107 let export_line = format!("export PATH=\"{escaped}:$PATH\"");
108
109 content = content.replace(&format!("{export_line}\n"), "");
110 content = content.replace(&export_line, "");
111 write_atomic(self.paths_file, content.as_bytes())
112 .context("Failed to write paths file")?;
113
114 if self.paths_nu_file.exists() {
115 let nushell_content = fs::read_to_string(&self.paths_nu_file)
116 .context("Failed to read Nushell paths file")?;
117 let target = install_path.to_string_lossy().to_string();
118 let mut nushell_paths = parse_nushell_paths_file(&nushell_content);
119 let original_len = nushell_paths.len();
120 nushell_paths.retain(|path| path != &target);
121
122 if nushell_paths.len() != original_len {
123 let rendered = render_nushell_paths_file(&nushell_paths);
124 write_atomic(&self.paths_nu_file, rendered.as_bytes())
125 .context("Failed to write Nushell paths file")?;
126 }
127 }
128 }
129
130 #[cfg(windows)]
131 {
132 let _ = self.paths_file;
133 self.remove_from_windows_registry(install_path)?;
134 }
135
136 Ok(())
137 }
138
139 #[cfg(windows)]
140 fn add_to_windows_registry(&self, path: &Path) -> Result<()> {
141 use winreg::RegKey;
142 use winreg::enums::*;
143
144 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
145 let env_key = hkcu
146 .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
147 .context("Failed to open registry Environment key")?;
148
149 let target_path = path.to_string_lossy().to_string();
150 let target_norm = normalize_windows_path(&target_path);
151
152 let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
154
155 let path_entries: Vec<&str> = current_path.split(';').collect();
157 if path_entries
158 .iter()
159 .any(|&p| normalize_windows_path(p) == target_norm)
160 {
161 return Ok(()); }
163
164 let new_path = if current_path.is_empty() {
166 target_path
167 } else {
168 format!("{};{}", target_path, current_path)
169 };
170
171 env_key
172 .set_value("Path", &new_path)
173 .context("Failed to set PATH in registry")?;
174
175 Self::broadcast_environment_change();
177
178 Ok(())
179 }
180
181 #[cfg(windows)]
182 fn remove_from_windows_registry(&self, path: &Path) -> Result<()> {
183 use winreg::RegKey;
184 use winreg::enums::*;
185
186 let hkcu = RegKey::predef(HKEY_CURRENT_USER);
187 let env_key = hkcu
188 .open_subkey_with_flags("Environment", KEY_READ | KEY_WRITE)
189 .context("Failed to open registry Environment key")?;
190
191 let target_path = path.to_string_lossy().to_string();
192 let target_norm = normalize_windows_path(&target_path);
193
194 let current_path: String = env_key.get_value("Path").unwrap_or_else(|_| String::new());
196
197 let path_entries: Vec<&str> = current_path
199 .split(';')
200 .filter(|&p| normalize_windows_path(p) != target_norm)
201 .collect();
202
203 let new_path = path_entries.join(";");
204
205 env_key
206 .set_value("Path", &new_path)
207 .context("Failed to set PATH in registry")?;
208
209 Self::broadcast_environment_change();
211
212 Ok(())
213 }
214
215 #[cfg(windows)]
216 fn broadcast_environment_change() {
217 use std::ptr;
218 use winapi::shared::minwindef::LPARAM;
219 use winapi::um::winuser::{
220 HWND_BROADCAST, SMTO_ABORTIFHUNG, SendMessageTimeoutW, WM_SETTINGCHANGE,
221 };
222
223 unsafe {
224 let env_string: Vec<u16> = "Environment\0".encode_utf16().collect();
225 SendMessageTimeoutW(
226 HWND_BROADCAST,
227 WM_SETTINGCHANGE,
228 0,
229 env_string.as_ptr() as LPARAM,
230 SMTO_ABORTIFHUNG,
231 5000,
232 ptr::null_mut(),
233 );
234 }
235 }
236}
237
238#[cfg(unix)]
239fn escape_nushell_string(value: &str) -> String {
240 value.replace('\\', "\\\\").replace('"', "\\\"")
241}
242
243#[cfg(unix)]
244pub fn render_nushell_paths_file(paths: &[String]) -> String {
245 let mut content = String::from("# Upstream managed PATH additions\n\nlet upstream_paths = [\n");
246 for path in paths {
247 content.push_str(&format!(" \"{}\"\n", escape_nushell_string(path)));
248 }
249 content.push_str("]\n\n$env.PATH = ($upstream_paths ++ $env.PATH)\n");
250 content
251}
252
253#[cfg(unix)]
254pub fn nushell_paths_file_contains_path(content: &str, path: &str) -> bool {
255 parse_nushell_paths_file(content)
256 .iter()
257 .any(|entry| entry == path)
258}
259
260#[cfg(unix)]
261fn parse_nushell_paths_file(content: &str) -> Vec<String> {
262 let mut list_entries = Vec::new();
263 let mut prepend_entries = Vec::new();
264 let mut in_list = false;
265
266 for line in content.lines() {
267 let trimmed = line.trim();
268 if trimmed == "let upstream_paths = [" {
269 in_list = true;
270 continue;
271 }
272 if in_list && trimmed == "]" {
273 in_list = false;
274 continue;
275 }
276
277 if in_list {
278 if let Some((path, _)) = parse_nushell_string_literal(trimmed) {
279 list_entries.push(path);
280 }
281 continue;
282 }
283
284 if let Some(prepend_index) = trimmed.find("| prepend ") {
285 let rest = trimmed[prepend_index + "| prepend ".len()..].trim_start();
286 if let Some((path, _)) = parse_nushell_string_literal(rest) {
287 prepend_entries.push(path);
288 }
289 }
290 }
291
292 if list_entries.is_empty() {
293 prepend_entries.reverse();
294 dedupe_preserving_order(prepend_entries)
295 } else {
296 prepend_entries.reverse();
297 list_entries.extend(prepend_entries);
298 dedupe_preserving_order(list_entries)
299 }
300}
301
302#[cfg(unix)]
303fn parse_nushell_string_literal(input: &str) -> Option<(String, usize)> {
304 let mut chars = input.char_indices();
305 let (_, first) = chars.next()?;
306 if first != '"' {
307 return None;
308 }
309
310 let mut output = String::new();
311 let mut escaped = false;
312 for (index, ch) in chars {
313 if escaped {
314 match ch {
315 '"' | '\\' => output.push(ch),
316 other => {
317 output.push('\\');
318 output.push(other);
319 }
320 }
321 escaped = false;
322 continue;
323 }
324
325 match ch {
326 '\\' => escaped = true,
327 '"' => return Some((output, index + ch.len_utf8())),
328 other => output.push(other),
329 }
330 }
331
332 None
333}
334
335#[cfg(unix)]
336fn dedupe_preserving_order(paths: Vec<String>) -> Vec<String> {
337 let mut unique = Vec::new();
338 for path in paths {
339 if !unique.contains(&path) {
340 unique.push(path);
341 }
342 }
343 unique
344}
345
346#[cfg(test)]
347mod tests {
348 #[cfg(unix)]
349 use super::{ShellManager, parse_nushell_paths_file};
350 #[cfg(unix)]
351 use std::path::{Path, PathBuf};
352 #[cfg(unix)]
353 use std::time::{SystemTime, UNIX_EPOCH};
354 #[cfg(unix)]
355 use std::{fs, io};
356
357 #[cfg(unix)]
358 fn temp_root(name: &str) -> PathBuf {
359 let nanos = SystemTime::now()
360 .duration_since(UNIX_EPOCH)
361 .map(|d| d.as_nanos())
362 .unwrap_or(0);
363 std::env::temp_dir().join(format!("upstream-shell-test-{name}-{nanos}"))
364 }
365
366 #[cfg(unix)]
367 fn cleanup(path: &Path) -> io::Result<()> {
368 fs::remove_dir_all(path)
369 }
370
371 #[cfg(unix)]
372 #[test]
373 fn add_to_paths_is_idempotent_and_escapes_special_characters() {
374 let root = temp_root("add-idempotent");
375 let install_path = root.join("tool\"dir$");
376 let paths_file = root.join("paths.sh");
377 let paths_nu_file = root.join("paths.nu");
378 fs::create_dir_all(&install_path).expect("create install dir");
379 fs::write(&paths_file, "#!/usr/bin/env sh\n").expect("create paths file");
380 fs::write(&paths_nu_file, "# Upstream managed PATH additions\n").expect("create paths.nu");
381 let manager = ShellManager::new(&paths_file);
382
383 manager.add_to_paths(&install_path).expect("first add");
384 manager.add_to_paths(&install_path).expect("second add");
385
386 let content = fs::read_to_string(&paths_file).expect("read paths file");
387 assert_eq!(content.matches("export PATH=").count(), 1);
388 assert!(content.contains("\\\""));
389 assert!(content.contains("\\$"));
390
391 let nushell_content = fs::read_to_string(&paths_nu_file).expect("read paths.nu");
392 assert!(nushell_content.contains("let upstream_paths = ["));
393 assert!(nushell_content.contains("$env.PATH = ($upstream_paths ++ $env.PATH)"));
394 assert_eq!(
395 parse_nushell_paths_file(&nushell_content),
396 vec![install_path.to_string_lossy().to_string()]
397 );
398 assert!(nushell_content.contains("\\\""));
399 assert!(nushell_content.contains("$"));
400
401 cleanup(&root).expect("cleanup");
402 }
403
404 #[cfg(unix)]
405 #[test]
406 fn remove_from_paths_removes_existing_export_line() {
407 let root = temp_root("remove");
408 let install_path = root.join("pkg/bin");
409 let paths_file = root.join("paths.sh");
410 let paths_nu_file = root.join("paths.nu");
411 fs::create_dir_all(&install_path).expect("create install dir");
412 fs::write(&paths_file, "").expect("create paths file");
413 fs::write(&paths_nu_file, "# Upstream managed PATH additions\n").expect("create paths.nu");
414 let manager = ShellManager::new(&paths_file);
415
416 manager.add_to_paths(&install_path).expect("add path");
417 manager
418 .remove_from_paths(&install_path)
419 .expect("remove path");
420
421 let content = fs::read_to_string(&paths_file).expect("read paths file");
422 assert!(!content.contains("export PATH="));
423
424 let nushell_content = fs::read_to_string(&paths_nu_file).expect("read paths.nu");
425 assert!(parse_nushell_paths_file(&nushell_content).is_empty());
426
427 cleanup(&root).expect("cleanup");
428 }
429
430 #[cfg(unix)]
431 #[test]
432 fn add_to_paths_migrates_old_nushell_prepend_lines_to_managed_list() {
433 let root = temp_root("migrate-nu");
434 let first_path = root.join("first/bin");
435 let second_path = root.join("second/bin");
436 let new_path = root.join("new/bin");
437 let paths_file = root.join("paths.sh");
438 let paths_nu_file = root.join("paths.nu");
439 fs::create_dir_all(&first_path).expect("create first dir");
440 fs::create_dir_all(&second_path).expect("create second dir");
441 fs::create_dir_all(&new_path).expect("create new dir");
442 fs::write(&paths_file, "#!/usr/bin/env sh\n").expect("create paths file");
443 fs::write(
444 &paths_nu_file,
445 format!(
446 "# Upstream managed PATH additions\n\
447 $env.PATH = ($env.PATH | prepend \"{}\")\n\
448 $env.PATH = ($env.PATH | prepend \"{}\")\n",
449 first_path.display(),
450 second_path.display()
451 ),
452 )
453 .expect("create old paths.nu");
454 let manager = ShellManager::new(&paths_file);
455
456 manager.add_to_paths(&new_path).expect("add path");
457
458 let nushell_content = fs::read_to_string(&paths_nu_file).expect("read paths.nu");
459 assert!(!nushell_content.contains(" | prepend "));
460 assert_eq!(
461 parse_nushell_paths_file(&nushell_content),
462 vec![
463 new_path.to_string_lossy().to_string(),
464 second_path.to_string_lossy().to_string(),
465 first_path.to_string_lossy().to_string(),
466 ]
467 );
468
469 cleanup(&root).expect("cleanup");
470 }
471}