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)
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    /// Get the login shell configuration files to monitor for changes.
107    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/// Fingerprint of a configuration file for change detection.
139#[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    /// Create a fingerprint for a file.
148    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    /// Check if the file has changed since this fingerprint was taken.
158    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/// A captured shell environment snapshot.
165#[derive(Debug, Clone)]
166pub struct ShellSnapshot {
167    /// The shell path used for this snapshot.
168    pub shell_path: String,
169    /// Detected shell kind.
170    pub shell_kind: ShellKind,
171    /// Captured environment variables.
172    pub env: HashMap<String, String>,
173    /// When this snapshot was captured.
174    pub captured_at: Instant,
175    /// Fingerprints of configuration files at capture time.
176    pub config_fingerprints: Vec<FileFingerprint>,
177}
178
179impl ShellSnapshot {
180    /// Check if this snapshot is still valid.
181    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    /// Get the PATH from the snapshot.
203    pub fn path(&self) -> Option<&str> {
204        self.env.get("PATH").map(|s| s.as_str())
205    }
206}
207
208/// Manager for shell environment snapshots.
209pub struct ShellSnapshotManager {
210    /// The current snapshot (if any).
211    snapshot: RwLock<Option<Arc<ShellSnapshot>>>,
212    /// Lock for capture operations to prevent stampedes.
213    capture_lock: TokioMutex<()>,
214    /// Snapshot TTL.
215    ttl: Duration,
216    /// Last time we checked for refresh.
217    last_refresh_check: RwLock<Instant>,
218}
219
220impl Default for ShellSnapshotManager {
221    fn default() -> Self {
222        Self::new()
223    }
224}
225
226impl ShellSnapshotManager {
227    /// Create a new snapshot manager.
228    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    /// Create a snapshot manager with a custom TTL.
238    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    /// Get an existing valid snapshot or capture a new one.
248    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    /// Get the current snapshot if valid, without capturing.
286    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    /// Invalidate the current snapshot.
299    pub fn invalidate(&self) {
300        let mut snapshot = self.snapshot.write();
301        *snapshot = None;
302        debug!("Shell snapshot invalidated");
303    }
304
305    /// Check if refresh is needed (rate-limited).
306    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    /// Get snapshot statistics for diagnostics.
322    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/// Statistics about the current snapshot state.
346#[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
356/// Capture a shell environment snapshot by running a login shell.
357async 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
399/// Parse the environment output from the capture script.
400fn 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
439/// Check if an environment variable should be excluded from the snapshot.
440fn 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
455/// Global singleton for the shell snapshot manager.
456static GLOBAL_SNAPSHOT_MANAGER: once_cell::sync::Lazy<ShellSnapshotManager> =
457    once_cell::sync::Lazy::new(ShellSnapshotManager::new);
458
459/// Get the global shell snapshot manager.
460pub fn global_snapshot_manager() -> &'static ShellSnapshotManager {
461    &GLOBAL_SNAPSHOT_MANAGER
462}
463
464/// Apply a snapshot's environment to a command builder.
465pub 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}