1use std::path::{Path, PathBuf};
2use std::process::{Command, Stdio};
3use std::sync::{Arc, Mutex, OnceLock};
4use std::time::{Duration, SystemTime};
5use tracing::{debug, info, warn};
6
7use crate::config::ShimConfig;
8use crate::downloader::Downloader;
9use crate::error::{Result, ShimError};
10use crate::updater::ShimUpdater;
11use crate::utils::merge_env_vars;
12
13#[derive(Debug, Clone)]
15struct ValidationCacheEntry {
16 is_valid: bool,
17 last_checked: SystemTime,
18 file_modified: SystemTime,
19}
20
21#[derive(Debug, Clone)]
23struct ExecutableCache {
24 cache: Arc<Mutex<std::collections::HashMap<PathBuf, ValidationCacheEntry>>>,
25 ttl: Duration,
26}
27
28impl ExecutableCache {
29 fn new(ttl: Duration) -> Self {
30 Self {
31 cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
32 ttl,
33 }
34 }
35
36 fn is_valid(&self, path: &Path) -> Option<bool> {
37 let now = SystemTime::now();
38 if let Ok(cache) = self.cache.lock() {
39 if let Some(entry) = cache.get(path) {
40 if now
42 .duration_since(entry.last_checked)
43 .unwrap_or(Duration::MAX)
44 < self.ttl
45 {
46 if let Ok(metadata) = std::fs::metadata(path) {
48 if let Ok(modified) = metadata.modified() {
49 if modified <= entry.file_modified {
50 return Some(entry.is_valid);
51 }
52 }
53 }
54 }
55 }
56 }
57 None
58 }
59
60 fn set_valid(&self, path: &Path, is_valid: bool) {
61 let now = SystemTime::now();
62 let file_modified = std::fs::metadata(path)
63 .and_then(|m| m.modified())
64 .unwrap_or(now);
65
66 if let Ok(mut cache) = self.cache.lock() {
67 cache.insert(
68 path.to_path_buf(),
69 ValidationCacheEntry {
70 is_valid,
71 last_checked: now,
72 file_modified,
73 },
74 );
75 }
76 }
77}
78
79static EXECUTABLE_CACHE: OnceLock<ExecutableCache> = OnceLock::new();
81
82fn get_executable_cache() -> &'static ExecutableCache {
83 EXECUTABLE_CACHE.get_or_init(|| ExecutableCache::new(Duration::from_secs(30)))
84}
85
86pub struct ShimRunner {
88 config: ShimConfig,
89 shim_file_path: Option<PathBuf>,
90}
91
92impl ShimRunner {
93 pub fn from_file<P: AsRef<Path>>(shim_file: P) -> Result<Self> {
95 let mut config = ShimConfig::from_file(&shim_file)?;
96 config.expand_env_vars()?;
97
98 Ok(Self {
99 config,
100 shim_file_path: Some(shim_file.as_ref().to_path_buf()),
101 })
102 }
103
104 pub fn from_config(mut config: ShimConfig) -> Result<Self> {
106 config.expand_env_vars()?;
107 Ok(Self {
108 config,
109 shim_file_path: None,
110 })
111 }
112
113 pub fn execute(&self, additional_args: &[String]) -> Result<i32> {
115 let start_time = SystemTime::now();
116
117 if let Some(ref auto_update) = self.config.auto_update {
119 if let Some(ref shim_file_path) = self.shim_file_path {
120 self.check_and_update(auto_update, shim_file_path)?;
121 }
122 }
123
124 self.ensure_executable_available()?;
126
127 let executable_path = self.config.get_executable_path()?;
128
129 let cache = get_executable_cache();
131 if let Some(is_valid) = cache.is_valid(&executable_path) {
132 if !is_valid {
133 return Err(ShimError::ExecutableNotFound(
134 executable_path.to_string_lossy().to_string(),
135 ));
136 }
137 } else {
138 let is_valid = self.validate_executable_fast(&executable_path);
140 cache.set_valid(&executable_path, is_valid);
141 if !is_valid {
142 return Err(ShimError::ExecutableNotFound(
143 executable_path.to_string_lossy().to_string(),
144 ));
145 }
146 }
147
148 debug!("Executing: {:?}", executable_path);
149 debug!("Default args: {:?}", self.config.shim.args);
150 debug!("Additional args: {:?}", additional_args);
151
152 let mut cmd = Command::new(&executable_path);
154
155 cmd.args(&self.config.shim.args);
157
158 cmd.args(additional_args);
160
161 if let Some(ref cwd) = self.config.shim.cwd {
163 cmd.current_dir(cwd);
164 }
165
166 if !self.config.env.is_empty() {
168 let env_vars = merge_env_vars(&self.config.env);
169 for (key, value) in env_vars {
170 cmd.env(key, value);
171 }
172 }
173
174 cmd.stdin(Stdio::inherit())
176 .stdout(Stdio::inherit())
177 .stderr(Stdio::inherit());
178
179 info!(
180 "Executing shim '{}' -> {:?}",
181 self.config.shim.name, executable_path
182 );
183
184 let result = match cmd.status() {
186 Ok(status) => {
187 let exit_code = status.code().unwrap_or(-1);
188 debug!("Process exited with code: {}", exit_code);
189 Ok(exit_code)
190 }
191 Err(e) => {
192 warn!("Failed to execute process: {}", e);
193 Err(ShimError::ProcessExecution(e.to_string()))
194 }
195 };
196
197 if let Ok(elapsed) = start_time.elapsed() {
199 debug!("Shim execution took: {:?}", elapsed);
200 }
201
202 result
203 }
204
205 fn validate_executable_fast(&self, path: &Path) -> bool {
207 path.exists() && path.is_file()
208 }
209
210 pub fn config(&self) -> &ShimConfig {
212 &self.config
213 }
214
215 pub fn validate(&self) -> Result<()> {
217 let executable_path = self.config.get_executable_path()?;
218
219 let cache = get_executable_cache();
221 if let Some(is_valid) = cache.is_valid(&executable_path) {
222 if is_valid {
223 return Ok(());
224 } else {
225 return Err(ShimError::ExecutableNotFound(
226 executable_path.to_string_lossy().to_string(),
227 ));
228 }
229 }
230
231 let validation_result = self.validate_executable_full(&executable_path);
233 let is_valid = validation_result.is_ok();
234 cache.set_valid(&executable_path, is_valid);
235 validation_result
236 }
237
238 fn validate_executable_full(&self, executable_path: &Path) -> Result<()> {
240 if !executable_path.exists() {
241 return Err(ShimError::ExecutableNotFound(
242 executable_path.to_string_lossy().to_string(),
243 ));
244 }
245
246 if !executable_path.is_file() {
248 return Err(ShimError::Config(format!(
249 "Path is not a file: {}",
250 executable_path.display()
251 )));
252 }
253
254 #[cfg(unix)]
256 {
257 use std::os::unix::fs::PermissionsExt;
258 let metadata = executable_path.metadata().map_err(ShimError::Io)?;
259 let permissions = metadata.permissions();
260
261 if permissions.mode() & 0o111 == 0 {
262 return Err(ShimError::PermissionDenied(format!(
263 "File is not executable: {}",
264 executable_path.display()
265 )));
266 }
267 }
268
269 Ok(())
270 }
271
272 fn check_and_update(
274 &self,
275 auto_update: &crate::config::AutoUpdate,
276 shim_file_path: &Path,
277 ) -> Result<()> {
278 let executable_path = self.config.get_executable_path()?;
279 let updater = ShimUpdater::new(
280 auto_update.clone(),
281 shim_file_path.to_path_buf(),
282 executable_path,
283 );
284
285 let rt = tokio::runtime::Runtime::new().map_err(|e| {
288 ShimError::ProcessExecution(format!("Failed to create async runtime: {}", e))
289 })?;
290
291 rt.block_on(async {
292 match updater.check_update_needed().await {
293 Ok(Some(version)) => {
294 info!("Auto-update available: {}", version);
295 if let Err(e) = updater.update_to_version(&version).await {
296 warn!("Auto-update failed: {}", e);
297 }
298 }
299 Ok(None) => {
300 debug!("No update needed");
301 }
302 Err(e) => {
303 warn!("Update check failed: {}", e);
304 }
305 }
306 });
307
308 Ok(())
309 }
310
311 fn ensure_executable_available(&self) -> Result<()> {
313 if let Some(download_url) = self.config.get_download_url() {
315 let executable_path = match self.config.get_executable_path() {
317 Ok(path) => path,
318 Err(_) => {
319 return self.download_executable_from_url(download_url);
321 }
322 };
323
324 if !executable_path.exists() {
326 return self.download_executable_from_url(download_url);
327 }
328 } else if Downloader::is_url(&self.config.shim.path) {
329 let executable_path = match self.config.get_executable_path() {
331 Ok(path) => path,
332 Err(_) => {
333 return self.download_executable_from_url(&self.config.shim.path);
335 }
336 };
337
338 if !executable_path.exists() {
340 return self.download_executable_from_url(&self.config.shim.path);
341 }
342 }
343 Ok(())
344 }
345
346 fn download_executable_from_url(&self, url: &str) -> Result<()> {
348 let filename = Downloader::extract_filename_from_url(url).ok_or_else(|| {
350 ShimError::Config(format!("Could not extract filename from URL: {}", url))
351 })?;
352
353 let download_dir = if let Some(ref shim_file_path) = self.shim_file_path {
355 shim_file_path
357 .parent()
358 .ok_or_else(|| {
359 ShimError::Config("Could not determine shim file directory".to_string())
360 })?
361 .join(&self.config.shim.name)
362 .join("bin")
363 } else {
364 dirs::home_dir()
366 .ok_or_else(|| ShimError::Config("Could not determine home directory".to_string()))?
367 .join(".shimexe")
368 .join(&self.config.shim.name)
369 .join("bin")
370 };
371
372 let download_path = download_dir.join(&filename);
373
374 let rt = tokio::runtime::Runtime::new().map_err(|e| {
376 ShimError::ProcessExecution(format!("Failed to create async runtime: {}", e))
377 })?;
378
379 rt.block_on(async {
380 let downloader = Downloader::new();
381 downloader
382 .download_if_missing(url, &download_path)
383 .await
384 .map_err(|e| {
385 ShimError::ProcessExecution(format!("Failed to download executable: {}", e))
386 })
387 })?;
388
389 info!("Downloaded executable to: {}", download_path.display());
390 Ok(())
391 }
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397 use std::io::Write;
398 use tempfile::NamedTempFile;
399
400 #[test]
401 fn test_runner_from_config() {
402 let config = ShimConfig {
403 shim: crate::config::ShimCore {
404 name: "test".to_string(),
405 path: "echo".to_string(),
406 args: vec!["hello".to_string()],
407 cwd: None,
408 download_url: None,
409 source_type: crate::config::SourceType::File,
410 extracted_executables: vec![],
411 },
412 args: Default::default(),
413 env: std::collections::HashMap::new(),
414 metadata: Default::default(),
415 auto_update: None,
416 };
417
418 let runner = ShimRunner::from_config(config).unwrap();
419 assert_eq!(runner.config().shim.name, "test");
420 }
421
422 #[test]
423 fn test_runner_from_file() {
424 let mut temp_file = NamedTempFile::new().unwrap();
425 writeln!(
426 temp_file,
427 r#"
428[shim]
429name = "test"
430path = "echo"
431args = ["hello"]
432
433[env]
434TEST_VAR = "test_value"
435 "#
436 )
437 .unwrap();
438
439 let runner = ShimRunner::from_file(temp_file.path()).unwrap();
440 assert_eq!(runner.config().shim.name, "test");
441 assert_eq!(
442 runner.config().env.get("TEST_VAR"),
443 Some(&"test_value".to_string())
444 );
445 }
446}