1use hashbrown::HashMap;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::{Duration, Instant, SystemTime};
11
12use anyhow::{Context, Result, anyhow};
13use parking_lot::RwLock;
14use tokio::process::Command;
15use tokio::sync::Mutex as TokioMutex;
16use tracing::{debug, info};
17
18use super::shell::resolve_fallback_shell;
19
20const EXCLUDED_ENV_VARS: &[&str] = &[
23 "PWD",
24 "OLDPWD",
25 "SHLVL",
26 "_",
27 "TERM",
28 "TERM_PROGRAM",
29 "TERM_SESSION_ID",
30 "SHELL_SESSION_ID",
31 "TERM_PROGRAM_VERSION",
32 "COLUMNS",
33 "LINES",
34 "WINDOWID",
35 "DISPLAY",
36 "SSH_CLIENT",
37 "SSH_CONNECTION",
38 "SSH_TTY",
39 "STY",
40 "TMUX",
41 "TMUX_PANE",
42 "ITERM_SESSION_ID",
43 "ITERM_PROFILE",
44 "KONSOLE_DBUS_SERVICE",
45 "KONSOLE_DBUS_SESSION",
46 "KONSOLE_VERSION",
47 "GNOME_TERMINAL_SCREEN",
48 "GNOME_TERMINAL_SERVICE",
49 "VTE_VERSION",
50 "COLORTERM",
51 "WT_SESSION",
52 "WT_PROFILE_ID",
53 "LC_TERMINAL",
54 "LC_TERMINAL_VERSION",
55 "SECURITYSESSIONID",
56 "Apple_PubSub_Socket_Render",
57 "LaunchInstanceID",
58 "RANDOM",
59 "LINENO",
60 "SECONDS",
61 "EPOCHREALTIME",
62 "EPOCHSECONDS",
63 "BASHPID",
64 "PPID",
65 "BASH_COMMAND",
66 "BASH_SUBSHELL",
67];
68
69const ENV_BEGIN_MARKER: &str = "__VTCODE_ENV_BEGIN__";
71const ENV_END_MARKER: &str = "__VTCODE_ENV_END__";
72
73const DEFAULT_SNAPSHOT_TTL: Duration = Duration::from_secs(24 * 60 * 60);
75
76const REFRESH_CHECK_INTERVAL: Duration = Duration::from_secs(5 * 60);
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ShellKind {
82 Bash,
83 Zsh,
84 Sh,
85 Fish,
86 Unknown,
87}
88
89impl ShellKind {
90 pub fn from_path(shell_path: &str) -> Self {
92 let shell_name = Path::new(shell_path)
93 .file_name()
94 .and_then(|s| s.to_str())
95 .unwrap_or("");
96
97 match shell_name {
98 name if name.contains("bash") => ShellKind::Bash,
99 name if name.contains("zsh") => ShellKind::Zsh,
100 name if name.contains("fish") => ShellKind::Fish,
101 "sh" | "dash" | "ash" => ShellKind::Sh,
102 _ => ShellKind::Unknown,
103 }
104 }
105
106 pub fn config_files(&self) -> Vec<PathBuf> {
108 let home = dirs::home_dir().unwrap_or_default();
109 match self {
110 ShellKind::Bash => vec![
111 PathBuf::from("/etc/profile"),
112 home.join(".bash_profile"),
113 home.join(".bash_login"),
114 home.join(".profile"),
115 home.join(".bashrc"),
116 ],
117 ShellKind::Zsh => vec![
118 PathBuf::from("/etc/zshenv"),
119 PathBuf::from("/etc/zprofile"),
120 PathBuf::from("/etc/zshrc"),
121 PathBuf::from("/etc/zlogin"),
122 home.join(".zshenv"),
123 home.join(".zprofile"),
124 home.join(".zshrc"),
125 home.join(".zlogin"),
126 ],
127 ShellKind::Fish => vec![
128 PathBuf::from("/etc/fish/config.fish"),
129 home.join(".config/fish/config.fish"),
130 ],
131 ShellKind::Sh | ShellKind::Unknown => {
132 vec![PathBuf::from("/etc/profile"), home.join(".profile")]
133 }
134 }
135 }
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
140pub struct FileFingerprint {
141 pub path: PathBuf,
142 pub mtime: Option<SystemTime>,
143 pub size: Option<u64>,
144}
145
146impl FileFingerprint {
147 pub fn from_path(path: PathBuf) -> Self {
149 let (mtime, size) = std::fs::metadata(&path)
150 .ok()
151 .map(|m| (m.modified().ok(), Some(m.len())))
152 .unwrap_or((None, None));
153
154 Self { path, mtime, size }
155 }
156
157 pub fn has_changed(&self) -> bool {
159 let current = Self::from_path(self.path.clone());
160 self.mtime != current.mtime || self.size != current.size
161 }
162}
163
164#[derive(Debug, Clone)]
166pub struct ShellSnapshot {
167 pub shell_path: String,
169 pub shell_kind: ShellKind,
171 pub env: HashMap<String, String>,
173 pub captured_at: Instant,
175 pub config_fingerprints: Vec<FileFingerprint>,
177}
178
179impl ShellSnapshot {
180 pub fn is_valid(&self, shell_path: &str, ttl: Duration) -> bool {
182 if self.shell_path != shell_path {
183 debug!("Snapshot invalid: shell path changed");
184 return false;
185 }
186
187 if self.captured_at.elapsed() > ttl {
188 debug!("Snapshot invalid: TTL expired");
189 return false;
190 }
191
192 for fp in &self.config_fingerprints {
193 if fp.has_changed() {
194 debug!("Snapshot invalid: config file changed: {:?}", fp.path);
195 return false;
196 }
197 }
198
199 true
200 }
201
202 pub fn path(&self) -> Option<&str> {
204 self.env.get("PATH").map(|s| s.as_str())
205 }
206}
207
208pub struct ShellSnapshotManager {
210 snapshot: RwLock<Option<Arc<ShellSnapshot>>>,
212 capture_lock: TokioMutex<()>,
214 ttl: Duration,
216 last_refresh_check: RwLock<Instant>,
218}
219
220impl Default for ShellSnapshotManager {
221 fn default() -> Self {
222 Self::new()
223 }
224}
225
226impl ShellSnapshotManager {
227 pub fn new() -> Self {
229 Self {
230 snapshot: RwLock::new(None),
231 capture_lock: TokioMutex::new(()),
232 ttl: DEFAULT_SNAPSHOT_TTL,
233 last_refresh_check: RwLock::new(Instant::now()),
234 }
235 }
236
237 pub fn with_ttl(ttl: Duration) -> Self {
239 Self {
240 snapshot: RwLock::new(None),
241 capture_lock: TokioMutex::new(()),
242 ttl,
243 last_refresh_check: RwLock::new(Instant::now()),
244 }
245 }
246
247 pub async fn get_or_capture(&self) -> Result<Arc<ShellSnapshot>> {
249 let shell_path = resolve_fallback_shell();
250
251 {
252 let snapshot = self.snapshot.read();
253 if let Some(ref snap) = *snapshot
254 && snap.is_valid(&shell_path, self.ttl)
255 {
256 return Ok(Arc::clone(snap));
257 }
258 }
259
260 let _guard = self.capture_lock.lock().await;
261
262 {
263 let snapshot = self.snapshot.read();
264 if let Some(ref snap) = *snapshot
265 && snap.is_valid(&shell_path, self.ttl)
266 {
267 return Ok(Arc::clone(snap));
268 }
269 }
270
271 let new_snapshot = Arc::new(capture_shell_snapshot(&shell_path).await?);
272
273 {
274 let mut snapshot = self.snapshot.write();
275 *snapshot = Some(Arc::clone(&new_snapshot));
276 }
277
278 info!(
279 "Captured shell environment snapshot ({} variables)",
280 new_snapshot.env.len()
281 );
282 Ok(new_snapshot)
283 }
284
285 pub fn get_if_valid(&self) -> Option<Arc<ShellSnapshot>> {
287 let shell_path = resolve_fallback_shell();
288 let snapshot = self.snapshot.read();
289 snapshot.as_ref().and_then(|snap| {
290 if snap.is_valid(&shell_path, self.ttl) {
291 Some(Arc::clone(snap))
292 } else {
293 None
294 }
295 })
296 }
297
298 pub fn invalidate(&self) {
300 let mut snapshot = self.snapshot.write();
301 *snapshot = None;
302 debug!("Shell snapshot invalidated");
303 }
304
305 pub fn should_refresh(&self) -> bool {
307 let mut last_check = self.last_refresh_check.write();
308 if last_check.elapsed() < REFRESH_CHECK_INTERVAL {
309 return false;
310 }
311 *last_check = Instant::now();
312
313 let shell_path = resolve_fallback_shell();
314 let snapshot = self.snapshot.read();
315 match &*snapshot {
316 Some(snap) => !snap.is_valid(&shell_path, self.ttl),
317 None => true,
318 }
319 }
320
321 pub fn stats(&self) -> SnapshotStats {
323 let snapshot = self.snapshot.read();
324 match &*snapshot {
325 Some(snap) => SnapshotStats {
326 has_snapshot: true,
327 shell_path: Some(snap.shell_path.clone()),
328 shell_kind: Some(snap.shell_kind),
329 env_count: snap.env.len(),
330 age_secs: snap.captured_at.elapsed().as_secs(),
331 config_files_monitored: snap.config_fingerprints.len(),
332 },
333 None => SnapshotStats {
334 has_snapshot: false,
335 shell_path: None,
336 shell_kind: None,
337 env_count: 0,
338 age_secs: 0,
339 config_files_monitored: 0,
340 },
341 }
342 }
343}
344
345#[derive(Debug, Clone)]
347pub struct SnapshotStats {
348 pub has_snapshot: bool,
349 pub shell_path: Option<String>,
350 pub shell_kind: Option<ShellKind>,
351 pub env_count: usize,
352 pub age_secs: u64,
353 pub config_files_monitored: usize,
354}
355
356async fn capture_shell_snapshot(shell_path: &str) -> Result<ShellSnapshot> {
358 let shell_kind = ShellKind::from_path(shell_path);
359
360 let capture_script = format!(
361 "printf '{}\\n'; env -0; printf '\\n{}\\n'",
362 ENV_BEGIN_MARKER, ENV_END_MARKER
363 );
364
365 let output = Command::new(shell_path)
366 .args(["-lc", &capture_script])
367 .output()
368 .await
369 .with_context(|| format!("Failed to run login shell: {shell_path}"))?;
370
371 if !output.status.success() {
372 let stderr = String::from_utf8_lossy(&output.stderr);
373 return Err(anyhow!(
374 "Login shell exited with status {}: {}",
375 output.status.code().unwrap_or(-1),
376 stderr.trim()
377 ));
378 }
379
380 let stdout = String::from_utf8_lossy(&output.stdout);
381 let env = parse_env_output(&stdout)?;
382
383 let config_fingerprints: Vec<FileFingerprint> = shell_kind
384 .config_files()
385 .into_iter()
386 .filter(|p| p.exists())
387 .map(FileFingerprint::from_path)
388 .collect();
389
390 Ok(ShellSnapshot {
391 shell_path: shell_path.to_string(),
392 shell_kind,
393 env,
394 captured_at: Instant::now(),
395 config_fingerprints,
396 })
397}
398
399fn parse_env_output(output: &str) -> Result<HashMap<String, String>> {
401 let begin_idx = output
402 .find(ENV_BEGIN_MARKER)
403 .ok_or_else(|| anyhow!("Missing begin marker in env output"))?;
404 let end_idx = output
405 .find(ENV_END_MARKER)
406 .ok_or_else(|| anyhow!("Missing end marker in env output"))?;
407
408 if end_idx <= begin_idx {
409 return Err(anyhow!("Invalid marker positions in env output"));
410 }
411
412 let env_section = &output[begin_idx + ENV_BEGIN_MARKER.len()..end_idx];
413 let env_section = env_section.trim();
414
415 let mut env = HashMap::new();
416 for entry in env_section.split('\0') {
417 let entry = entry.trim();
418 if entry.is_empty() {
419 continue;
420 }
421
422 if let Some(eq_pos) = entry.find('=') {
423 let key = &entry[..eq_pos];
424 let value = &entry[eq_pos + 1..];
425
426 if !should_exclude_env_var(key) {
427 env.insert(key.to_string(), value.to_string());
428 }
429 }
430 }
431
432 if env.is_empty() {
433 return Err(anyhow!("No environment variables captured"));
434 }
435
436 Ok(env)
437}
438
439fn should_exclude_env_var(key: &str) -> bool {
441 if EXCLUDED_ENV_VARS.contains(&key) {
442 return true;
443 }
444
445 if key.starts_with("BASH_") && key != "BASH_VERSION" {
446 return true;
447 }
448 if key.starts_with("ZSH_") {
449 return true;
450 }
451
452 false
453}
454
455static GLOBAL_SNAPSHOT_MANAGER: once_cell::sync::Lazy<ShellSnapshotManager> =
457 once_cell::sync::Lazy::new(ShellSnapshotManager::new);
458
459pub fn global_snapshot_manager() -> &'static ShellSnapshotManager {
461 &GLOBAL_SNAPSHOT_MANAGER
462}
463
464pub fn apply_snapshot_env(command: &mut Command, snapshot: &ShellSnapshot, clear_env: bool) {
466 if clear_env {
467 command.env_clear();
468 }
469
470 for (key, value) in &snapshot.env {
471 command.env(key, value);
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn test_shell_kind_detection() {
481 assert_eq!(ShellKind::from_path("/bin/bash"), ShellKind::Bash);
482 assert_eq!(ShellKind::from_path("/usr/bin/zsh"), ShellKind::Zsh);
483 assert_eq!(ShellKind::from_path("/bin/sh"), ShellKind::Sh);
484 assert_eq!(ShellKind::from_path("/usr/local/bin/fish"), ShellKind::Fish);
485 assert_eq!(ShellKind::from_path("/unknown/shell"), ShellKind::Unknown);
486 }
487
488 #[test]
489 fn test_parse_env_output() {
490 let output = format!(
491 "some noise\n{}\nHOME=/home/user\0PATH=/usr/bin\0EXCLUDED=yes\0\n{}\nmore noise",
492 ENV_BEGIN_MARKER, ENV_END_MARKER
493 );
494 let env = parse_env_output(&output).unwrap();
495 assert_eq!(env.get("HOME"), Some(&"/home/user".to_string()));
496 assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string()));
497 }
498
499 #[test]
500 fn test_excluded_env_vars() {
501 assert!(should_exclude_env_var("PWD"));
502 assert!(should_exclude_env_var("SHLVL"));
503 assert!(should_exclude_env_var("BASH_COMMAND"));
504 assert!(!should_exclude_env_var("BASH_VERSION"));
505 assert!(!should_exclude_env_var("HOME"));
506 assert!(!should_exclude_env_var("PATH"));
507 }
508
509 #[test]
510 fn test_file_fingerprint() {
511 let fp = FileFingerprint::from_path(PathBuf::from("/nonexistent/file"));
512 assert!(fp.mtime.is_none());
513 assert!(fp.size.is_none());
514 }
515
516 #[test]
517 fn test_snapshot_manager_stats() {
518 let manager = ShellSnapshotManager::new();
519 let stats = manager.stats();
520 assert!(!stats.has_snapshot);
521 assert_eq!(stats.env_count, 0);
522 }
523}