Skip to main content

vtcode_core/tools/
shell_snapshot.rs

1//! Shell environment snapshot for avoiding repeated login script execution.
2//!
3//! This module provides a mechanism to capture a fully-initialized shell environment
4//! (after login scripts have run) and reuse it for subsequent command executions,
5//! significantly reducing command startup time.
6
7use 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
20/// Environment variables that should not be captured in snapshots.
21/// These are volatile or session-specific and would cause issues if reused.
22const 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
69/// Markers used to delimit the environment dump in shell output.
70const ENV_BEGIN_MARKER: &str = "__VTCODE_ENV_BEGIN__";
71const ENV_END_MARKER: &str = "__VTCODE_ENV_END__";
72
73/// Default snapshot TTL (24 hours).
74const DEFAULT_SNAPSHOT_TTL: Duration = Duration::from_secs(24 * 60 * 60);
75
76/// Maximum age before considering refresh (5 minutes for development).
77const REFRESH_CHECK_INTERVAL: Duration = Duration::from_secs(5 * 60);
78
79/// Detected shell kind for platform-specific behavior.
80#[derive(Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ShellKind {
82    Bash,
83    Zsh,
84    Sh,
85    Fish,
86    Unknown,
87}
88
89impl ShellKind {
90    /// Detect shell kind from the shell path.
91    pub fn from_path(shell_path: &str) -> Self {
92        let shell_name = Path::new(shell_path).file_name().and_then(|s| s.to_str()).unwrap_or("");
93
94        match shell_name {
95            name if name.contains("bash") => ShellKind::Bash,
96            name if name.contains("zsh") => ShellKind::Zsh,
97            name if name.contains("fish") => ShellKind::Fish,
98            "sh" | "dash" | "ash" => ShellKind::Sh,
99            _ => ShellKind::Unknown,
100        }
101    }
102
103    /// Get the login shell configuration files to monitor for changes.
104    pub fn config_files(&self) -> Vec<PathBuf> {
105        let home = dirs::home_dir().unwrap_or_default();
106        match self {
107            ShellKind::Bash => vec![
108                PathBuf::from("/etc/profile"),
109                home.join(".bash_profile"),
110                home.join(".bash_login"),
111                home.join(".profile"),
112                home.join(".bashrc"),
113            ],
114            ShellKind::Zsh => vec![
115                PathBuf::from("/etc/zshenv"),
116                PathBuf::from("/etc/zprofile"),
117                PathBuf::from("/etc/zshrc"),
118                PathBuf::from("/etc/zlogin"),
119                home.join(".zshenv"),
120                home.join(".zprofile"),
121                home.join(".zshrc"),
122                home.join(".zlogin"),
123            ],
124            ShellKind::Fish => vec![
125                PathBuf::from("/etc/fish/config.fish"),
126                home.join(".config/fish/config.fish"),
127            ],
128            ShellKind::Sh | ShellKind::Unknown => {
129                vec![PathBuf::from("/etc/profile"), home.join(".profile")]
130            }
131        }
132    }
133}
134
135/// Fingerprint of a configuration file for change detection.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct FileFingerprint {
138    pub path: PathBuf,
139    pub mtime: Option<SystemTime>,
140    pub size: Option<u64>,
141}
142
143impl FileFingerprint {
144    /// Create a fingerprint for a file.
145    pub fn from_path(path: PathBuf) -> Self {
146        let (mtime, size) = std::fs::metadata(&path)
147            .ok()
148            .map(|m| (m.modified().ok(), Some(m.len())))
149            .unwrap_or((None, None));
150
151        Self { path, mtime, size }
152    }
153
154    /// Check if the file has changed since this fingerprint was taken.
155    pub fn has_changed(&self) -> bool {
156        let current = Self::from_path(self.path.clone());
157        self.mtime != current.mtime || self.size != current.size
158    }
159}
160
161/// A captured shell environment snapshot.
162#[derive(Debug, Clone)]
163pub struct ShellSnapshot {
164    /// The shell path used for this snapshot.
165    pub shell_path: String,
166    /// Detected shell kind.
167    pub shell_kind: ShellKind,
168    /// Captured environment variables.
169    pub env: HashMap<String, String>,
170    /// When this snapshot was captured.
171    pub captured_at: Instant,
172    /// Fingerprints of configuration files at capture time.
173    pub config_fingerprints: Vec<FileFingerprint>,
174}
175
176impl ShellSnapshot {
177    /// Check if this snapshot is still valid.
178    pub fn is_valid(&self, shell_path: &str, ttl: Duration) -> bool {
179        if self.shell_path != shell_path {
180            debug!("Snapshot invalid: shell path changed");
181            return false;
182        }
183
184        if self.captured_at.elapsed() > ttl {
185            debug!("Snapshot invalid: TTL expired");
186            return false;
187        }
188
189        for fp in &self.config_fingerprints {
190            if fp.has_changed() {
191                debug!("Snapshot invalid: config file changed: {:?}", fp.path);
192                return false;
193            }
194        }
195
196        true
197    }
198
199    /// Get the PATH from the snapshot.
200    pub fn path(&self) -> Option<&str> {
201        self.env.get("PATH").map(|s| s.as_str())
202    }
203}
204
205/// Manager for shell environment snapshots.
206pub struct ShellSnapshotManager {
207    /// The current snapshot (if any).
208    snapshot: RwLock<Option<Arc<ShellSnapshot>>>,
209    /// Lock for capture operations to prevent stampedes.
210    capture_lock: TokioMutex<()>,
211    /// Snapshot TTL.
212    ttl: Duration,
213    /// Last time we checked for refresh.
214    last_refresh_check: RwLock<Instant>,
215}
216
217impl Default for ShellSnapshotManager {
218    fn default() -> Self {
219        Self::new()
220    }
221}
222
223impl ShellSnapshotManager {
224    /// Create a new snapshot manager.
225    pub fn new() -> Self {
226        Self {
227            snapshot: RwLock::new(None),
228            capture_lock: TokioMutex::new(()),
229            ttl: DEFAULT_SNAPSHOT_TTL,
230            last_refresh_check: RwLock::new(Instant::now()),
231        }
232    }
233
234    /// Create a snapshot manager with a custom TTL.
235    pub fn with_ttl(ttl: Duration) -> Self {
236        Self {
237            snapshot: RwLock::new(None),
238            capture_lock: TokioMutex::new(()),
239            ttl,
240            last_refresh_check: RwLock::new(Instant::now()),
241        }
242    }
243
244    /// Get an existing valid snapshot or capture a new one.
245    pub async fn get_or_capture(&self) -> Result<Arc<ShellSnapshot>> {
246        let shell_path = resolve_fallback_shell();
247
248        let snapshot = self.snapshot.read().as_ref().map(Arc::clone);
249        if let Some(snap) = snapshot {
250            if snapshot_is_valid(Arc::clone(&snap), shell_path.clone(), self.ttl).await? {
251                return Ok(snap);
252            }
253        }
254
255        let _guard = self.capture_lock.lock().await;
256
257        let snapshot = self.snapshot.read().as_ref().map(Arc::clone);
258        if let Some(snap) = snapshot {
259            if snapshot_is_valid(Arc::clone(&snap), shell_path.clone(), self.ttl).await? {
260                return Ok(snap);
261            }
262        }
263
264        let new_snapshot = Arc::new(capture_shell_snapshot(&shell_path).await?);
265
266        {
267            let mut snapshot = self.snapshot.write();
268            *snapshot = Some(Arc::clone(&new_snapshot));
269        }
270
271        info!("Captured shell environment snapshot ({} variables)", new_snapshot.env.len());
272        Ok(new_snapshot)
273    }
274
275    /// Get the current snapshot if valid, without capturing.
276    pub fn get_if_valid(&self) -> Option<Arc<ShellSnapshot>> {
277        let shell_path = resolve_fallback_shell();
278        let snapshot = self.snapshot.read();
279        snapshot.as_ref().and_then(|snap| {
280            if snap.is_valid(&shell_path, self.ttl) {
281                Some(Arc::clone(snap))
282            } else {
283                None
284            }
285        })
286    }
287
288    /// Invalidate the current snapshot.
289    pub fn invalidate(&self) {
290        let mut snapshot = self.snapshot.write();
291        *snapshot = None;
292        debug!("Shell snapshot invalidated");
293    }
294
295    /// Check if refresh is needed (rate-limited).
296    pub fn should_refresh(&self) -> bool {
297        let mut last_check = self.last_refresh_check.write();
298        if last_check.elapsed() < REFRESH_CHECK_INTERVAL {
299            return false;
300        }
301        *last_check = Instant::now();
302
303        let shell_path = resolve_fallback_shell();
304        let snapshot = self.snapshot.read();
305        match &*snapshot {
306            Some(snap) => !snap.is_valid(&shell_path, self.ttl),
307            None => true,
308        }
309    }
310
311    /// Get snapshot statistics for diagnostics.
312    pub fn stats(&self) -> SnapshotStats {
313        let snapshot = self.snapshot.read();
314        match &*snapshot {
315            Some(snap) => SnapshotStats {
316                has_snapshot: true,
317                shell_path: Some(snap.shell_path.clone()),
318                shell_kind: Some(snap.shell_kind),
319                env_count: snap.env.len(),
320                age_secs: snap.captured_at.elapsed().as_secs(),
321                config_files_monitored: snap.config_fingerprints.len(),
322            },
323            None => SnapshotStats {
324                has_snapshot: false,
325                shell_path: None,
326                shell_kind: None,
327                env_count: 0,
328                age_secs: 0,
329                config_files_monitored: 0,
330            },
331        }
332    }
333}
334
335async fn snapshot_is_valid(snapshot: Arc<ShellSnapshot>, shell_path: String, ttl: Duration) -> Result<bool> {
336    tokio::task::spawn_blocking(move || snapshot.is_valid(&shell_path, ttl))
337        .await
338        .context("shell snapshot validation task failed")
339}
340
341/// Statistics about the current snapshot state.
342#[derive(Debug, Clone)]
343pub struct SnapshotStats {
344    pub has_snapshot: bool,
345    pub shell_path: Option<String>,
346    pub shell_kind: Option<ShellKind>,
347    pub env_count: usize,
348    pub age_secs: u64,
349    pub config_files_monitored: usize,
350}
351
352/// Capture a shell environment snapshot by running a login shell.
353async fn capture_shell_snapshot(shell_path: &str) -> Result<ShellSnapshot> {
354    let shell_kind = ShellKind::from_path(shell_path);
355
356    let capture_script = format!("printf '{ENV_BEGIN_MARKER}\\n'; env -0; printf '\\n{ENV_END_MARKER}\\n'");
357
358    let output = Command::new(shell_path)
359        .args(["-lc", &capture_script])
360        .output()
361        .await
362        .with_context(|| format!("Failed to run login shell: {shell_path}"))?;
363
364    if !output.status.success() {
365        let stderr = String::from_utf8_lossy(&output.stderr);
366        return Err(anyhow!(
367            "Login shell exited with status {}: {}",
368            output.status.code().unwrap_or(-1),
369            stderr.trim()
370        ));
371    }
372
373    let stdout = String::from_utf8_lossy(&output.stdout);
374    let env = parse_env_output(&stdout)?;
375
376    let config_fingerprints: Vec<FileFingerprint> = shell_kind
377        .config_files()
378        .into_iter()
379        .filter(|p| p.exists())
380        .map(FileFingerprint::from_path)
381        .collect();
382
383    Ok(ShellSnapshot {
384        shell_path: shell_path.to_string(),
385        shell_kind,
386        env,
387        captured_at: Instant::now(),
388        config_fingerprints,
389    })
390}
391
392/// Parse the environment output from the capture script.
393fn parse_env_output(output: &str) -> Result<HashMap<String, String>> {
394    let begin_idx = output
395        .find(ENV_BEGIN_MARKER)
396        .ok_or_else(|| anyhow!("Missing begin marker in env output"))?;
397    let end_idx = output
398        .find(ENV_END_MARKER)
399        .ok_or_else(|| anyhow!("Missing end marker in env output"))?;
400
401    if end_idx <= begin_idx {
402        return Err(anyhow!("Invalid marker positions in env output"));
403    }
404
405    let env_section = &output[begin_idx + ENV_BEGIN_MARKER.len()..end_idx];
406    let env_section = env_section.trim();
407
408    let mut env = HashMap::new();
409    for entry in env_section.split('\0') {
410        let entry = entry.trim();
411        if entry.is_empty() {
412            continue;
413        }
414
415        if let Some(eq_pos) = entry.find('=') {
416            let key = &entry[..eq_pos];
417            let value = &entry[eq_pos + 1..];
418
419            if !should_exclude_env_var(key) {
420                env.insert(key.to_string(), value.to_string());
421            }
422        }
423    }
424
425    if env.is_empty() {
426        return Err(anyhow!("No environment variables captured"));
427    }
428
429    Ok(env)
430}
431
432/// Check if an environment variable should be excluded from the snapshot.
433fn should_exclude_env_var(key: &str) -> bool {
434    if EXCLUDED_ENV_VARS.contains(&key) {
435        return true;
436    }
437
438    if key.starts_with("BASH_") && key != "BASH_VERSION" {
439        return true;
440    }
441    if key.starts_with("ZSH_") {
442        return true;
443    }
444
445    false
446}
447
448/// Global singleton for the shell snapshot manager.
449static GLOBAL_SNAPSHOT_MANAGER: once_cell::sync::Lazy<ShellSnapshotManager> =
450    once_cell::sync::Lazy::new(ShellSnapshotManager::new);
451
452/// Get the global shell snapshot manager.
453pub fn global_snapshot_manager() -> &'static ShellSnapshotManager {
454    &GLOBAL_SNAPSHOT_MANAGER
455}
456
457/// Apply a snapshot's environment to a command builder.
458pub fn apply_snapshot_env(command: &mut Command, snapshot: &ShellSnapshot, clear_env: bool) {
459    if clear_env {
460        command.env_clear();
461    }
462
463    for (key, value) in &snapshot.env {
464        command.env(key, value);
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    #[test]
473    fn test_shell_kind_detection() {
474        assert_eq!(ShellKind::from_path("/bin/bash"), ShellKind::Bash);
475        assert_eq!(ShellKind::from_path("/usr/bin/zsh"), ShellKind::Zsh);
476        assert_eq!(ShellKind::from_path("/bin/sh"), ShellKind::Sh);
477        assert_eq!(ShellKind::from_path("/usr/local/bin/fish"), ShellKind::Fish);
478        assert_eq!(ShellKind::from_path("/unknown/shell"), ShellKind::Unknown);
479    }
480
481    #[test]
482    fn test_parse_env_output() {
483        let output = format!(
484            "some noise\n{ENV_BEGIN_MARKER}\nHOME=/home/user\0PATH=/usr/bin\0EXCLUDED=yes\0\n{ENV_END_MARKER}\nmore noise"
485        );
486        let env = parse_env_output(&output).unwrap();
487        assert_eq!(env.get("HOME"), Some(&"/home/user".to_string()));
488        assert_eq!(env.get("PATH"), Some(&"/usr/bin".to_string()));
489    }
490
491    #[test]
492    fn test_excluded_env_vars() {
493        assert!(should_exclude_env_var("PWD"));
494        assert!(should_exclude_env_var("SHLVL"));
495        assert!(should_exclude_env_var("BASH_COMMAND"));
496        assert!(!should_exclude_env_var("BASH_VERSION"));
497        assert!(!should_exclude_env_var("HOME"));
498        assert!(!should_exclude_env_var("PATH"));
499    }
500
501    #[test]
502    fn test_file_fingerprint() {
503        let fp = FileFingerprint::from_path(PathBuf::from("/nonexistent/file"));
504        assert!(fp.mtime.is_none());
505        assert!(fp.size.is_none());
506    }
507
508    #[test]
509    fn test_snapshot_manager_stats() {
510        let manager = ShellSnapshotManager::new();
511        let stats = manager.stats();
512        assert!(!stats.has_snapshot);
513        assert_eq!(stats.env_count, 0);
514    }
515}