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).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 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#[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 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 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#[derive(Debug, Clone)]
163pub struct ShellSnapshot {
164 pub shell_path: String,
166 pub shell_kind: ShellKind,
168 pub env: HashMap<String, String>,
170 pub captured_at: Instant,
172 pub config_fingerprints: Vec<FileFingerprint>,
174}
175
176impl ShellSnapshot {
177 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 pub fn path(&self) -> Option<&str> {
201 self.env.get("PATH").map(|s| s.as_str())
202 }
203}
204
205pub struct ShellSnapshotManager {
207 snapshot: RwLock<Option<Arc<ShellSnapshot>>>,
209 capture_lock: TokioMutex<()>,
211 ttl: Duration,
213 last_refresh_check: RwLock<Instant>,
215}
216
217impl Default for ShellSnapshotManager {
218 fn default() -> Self {
219 Self::new()
220 }
221}
222
223impl ShellSnapshotManager {
224 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 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 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 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 pub fn invalidate(&self) {
290 let mut snapshot = self.snapshot.write();
291 *snapshot = None;
292 debug!("Shell snapshot invalidated");
293 }
294
295 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 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#[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
352async 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
392fn 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
432fn 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
448static GLOBAL_SNAPSHOT_MANAGER: once_cell::sync::Lazy<ShellSnapshotManager> =
450 once_cell::sync::Lazy::new(ShellSnapshotManager::new);
451
452pub fn global_snapshot_manager() -> &'static ShellSnapshotManager {
454 &GLOBAL_SNAPSHOT_MANAGER
455}
456
457pub 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}