1use crate::error::{Result, SkillError};
31use async_trait::async_trait;
32use std::path::{Path, PathBuf};
33use std::process::Stdio;
34use std::time::{Duration, Instant};
35use tokio::io::AsyncReadExt;
36use tokio::process::Command;
37
38#[derive(Debug, Clone)]
45pub struct PathValidator {
46 base_dir: PathBuf,
47 allow_symlinks: bool,
48}
49
50impl PathValidator {
51 pub fn new(base_dir: impl Into<PathBuf>) -> Self {
57 Self {
58 base_dir: base_dir.into(),
59 allow_symlinks: false,
60 }
61 }
62
63 #[must_use]
68 pub fn allow_symlinks(mut self, allow: bool) -> Self {
69 self.allow_symlinks = allow;
70 self
71 }
72
73 pub fn validate(&self, path: &Path) -> Result<PathBuf> {
84 if !path.exists() {
86 return Err(SkillError::ScriptExecution(format!(
87 "Script path does not exist: {}",
88 path.display()
89 )));
90 }
91
92 if !path.is_file() {
94 return Err(SkillError::ScriptExecution(format!(
95 "Script path is not a regular file: {}",
96 path.display()
97 )));
98 }
99
100 if path.is_symlink() && !self.allow_symlinks {
102 return Err(SkillError::ScriptExecution(format!(
103 "Symlinks are not allowed: {}",
104 path.display()
105 )));
106 }
107
108 let canonical_path = path.canonicalize().map_err(|e| {
110 SkillError::ScriptExecution(format!("Failed to canonicalize script path: {e}"))
111 })?;
112
113 let canonical_base = self.base_dir.canonicalize().map_err(|e| {
114 SkillError::ScriptExecution(format!("Failed to canonicalize base directory: {e}"))
115 })?;
116
117 if !canonical_path.starts_with(&canonical_base) {
119 return Err(SkillError::ScriptExecution(format!(
120 "Script path is outside allowed directory: {}",
121 path.display()
122 )));
123 }
124
125 Ok(canonical_path)
126 }
127}
128
129#[derive(Debug, Clone)]
134pub struct ScriptOutput {
135 pub exit_code: i32,
137
138 pub stdout: String,
140
141 pub stderr: String,
143
144 pub duration: Duration,
146
147 pub timed_out: bool,
149}
150
151impl ScriptOutput {
152 #[must_use]
156 pub fn success(&self) -> bool {
157 self.exit_code == 0 && !self.timed_out
158 }
159}
160
161#[async_trait]
166pub trait ScriptExecutor: Send + Sync {
167 async fn execute(&self, path: &Path, args: &[&str], timeout: Duration) -> Result<ScriptOutput>;
183
184 fn can_execute(&self, path: &Path) -> bool;
188}
189
190pub struct PythonExecutor {
194 python_path: String,
196 path_validator: Option<PathValidator>,
198}
199
200impl PythonExecutor {
201 #[must_use]
203 pub fn new() -> Self {
204 Self {
205 python_path: "python3".to_string(),
206 path_validator: None,
207 }
208 }
209
210 #[must_use]
220 pub fn with_path(python_path: impl Into<String>) -> Self {
221 Self {
222 python_path: python_path.into(),
223 path_validator: None,
224 }
225 }
226
227 #[must_use]
242 pub fn with_validator(mut self, validator: PathValidator) -> Self {
243 self.path_validator = Some(validator);
244 self
245 }
246}
247
248impl Default for PythonExecutor {
249 fn default() -> Self {
250 Self::new()
251 }
252}
253
254#[async_trait]
255impl ScriptExecutor for PythonExecutor {
256 async fn execute(
257 &self,
258 path: &Path,
259 args: &[&str],
260 timeout_duration: Duration,
261 ) -> Result<ScriptOutput> {
262 let start = Instant::now();
263
264 if let Some(validator) = &self.path_validator {
266 validator.validate(path)?;
267 }
268
269 let mut cmd = Command::new(&self.python_path);
271 cmd.arg(path);
272 cmd.args(args);
273 cmd.stdout(Stdio::piped());
274 cmd.stderr(Stdio::piped());
275
276 let mut child = cmd
278 .kill_on_drop(true)
279 .spawn()
280 .map_err(|e| SkillError::ScriptExecution(format!("Failed to spawn Python: {e}")))?;
281
282 let child_id = child.id();
283
284 let mut stdout_handle = child.stdout.take().unwrap();
287 let mut stderr_handle = child.stderr.take().unwrap();
288
289 let stdout_task = tokio::spawn(async move {
290 let mut buf = Vec::new();
291 stdout_handle.read_to_end(&mut buf).await.ok();
292 buf
293 });
294
295 let stderr_task = tokio::spawn(async move {
296 let mut buf = Vec::new();
297 stderr_handle.read_to_end(&mut buf).await.ok();
298 buf
299 });
300
301 let result = tokio::select! {
303 status_result = child.wait() => {
304 let duration = start.elapsed();
305 match status_result {
306 Ok(status) => {
307 let stdout_buf = stdout_task.await.unwrap_or_default();
309 let stderr_buf = stderr_task.await.unwrap_or_default();
310
311 Ok(ScriptOutput {
312 exit_code: status.code().unwrap_or(-1),
313 stdout: String::from_utf8_lossy(&stdout_buf).to_string(),
314 stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
315 duration,
316 timed_out: false,
317 })
318 }
319 Err(e) => Err(SkillError::ScriptExecution(format!(
320 "Python execution failed: {e}"
321 ))),
322 }
323 }
324
325 () = tokio::time::sleep(timeout_duration) => {
326 if let Err(e) = child.kill().await {
328 tracing::warn!(
329 "Failed to kill timed-out Python process {}: {}",
330 child_id.unwrap_or(0),
331 e
332 );
333 }
334
335 stdout_task.abort();
337 stderr_task.abort();
338
339 let duration = start.elapsed();
340 Ok(ScriptOutput {
341 exit_code: -1,
342 stdout: String::new(),
343 stderr: format!("Script timed out after {timeout_duration:?}"),
344 duration,
345 timed_out: true,
346 })
347 }
348 };
349
350 result
351 }
352
353 fn can_execute(&self, path: &Path) -> bool {
354 path.extension()
355 .and_then(|ext| ext.to_str())
356 .is_some_and(|ext| ext == "py")
357 }
358}
359
360pub struct BashExecutor {
364 bash_path: String,
366 path_validator: Option<PathValidator>,
368}
369
370impl BashExecutor {
371 #[must_use]
373 pub fn new() -> Self {
374 Self {
375 bash_path: "bash".to_string(),
376 path_validator: None,
377 }
378 }
379
380 #[must_use]
390 pub fn with_path(bash_path: impl Into<String>) -> Self {
391 Self {
392 bash_path: bash_path.into(),
393 path_validator: None,
394 }
395 }
396
397 #[must_use]
412 pub fn with_validator(mut self, validator: PathValidator) -> Self {
413 self.path_validator = Some(validator);
414 self
415 }
416}
417
418impl Default for BashExecutor {
419 fn default() -> Self {
420 Self::new()
421 }
422}
423
424#[async_trait]
425impl ScriptExecutor for BashExecutor {
426 async fn execute(
427 &self,
428 path: &Path,
429 args: &[&str],
430 timeout_duration: Duration,
431 ) -> Result<ScriptOutput> {
432 let start = Instant::now();
433
434 if let Some(validator) = &self.path_validator {
436 validator.validate(path)?;
437 }
438
439 let mut cmd = Command::new(&self.bash_path);
441 cmd.arg(path);
442 cmd.args(args);
443 cmd.stdout(Stdio::piped());
444 cmd.stderr(Stdio::piped());
445
446 let mut child = cmd
448 .kill_on_drop(true)
449 .spawn()
450 .map_err(|e| SkillError::ScriptExecution(format!("Failed to spawn Bash: {e}")))?;
451
452 let child_id = child.id();
453
454 let mut stdout_handle = child.stdout.take().unwrap();
457 let mut stderr_handle = child.stderr.take().unwrap();
458
459 let stdout_task = tokio::spawn(async move {
460 let mut buf = Vec::new();
461 stdout_handle.read_to_end(&mut buf).await.ok();
462 buf
463 });
464
465 let stderr_task = tokio::spawn(async move {
466 let mut buf = Vec::new();
467 stderr_handle.read_to_end(&mut buf).await.ok();
468 buf
469 });
470
471 let result = tokio::select! {
473 status_result = child.wait() => {
474 let duration = start.elapsed();
475 match status_result {
476 Ok(status) => {
477 let stdout_buf = stdout_task.await.unwrap_or_default();
479 let stderr_buf = stderr_task.await.unwrap_or_default();
480
481 Ok(ScriptOutput {
482 exit_code: status.code().unwrap_or(-1),
483 stdout: String::from_utf8_lossy(&stdout_buf).to_string(),
484 stderr: String::from_utf8_lossy(&stderr_buf).to_string(),
485 duration,
486 timed_out: false,
487 })
488 }
489 Err(e) => Err(SkillError::ScriptExecution(format!(
490 "Bash execution failed: {e}"
491 ))),
492 }
493 }
494
495 () = tokio::time::sleep(timeout_duration) => {
496 if let Err(e) = child.kill().await {
498 tracing::warn!(
499 "Failed to kill timed-out Bash process {}: {}",
500 child_id.unwrap_or(0),
501 e
502 );
503 }
504
505 stdout_task.abort();
507 stderr_task.abort();
508
509 let duration = start.elapsed();
510 Ok(ScriptOutput {
511 exit_code: -1,
512 stdout: String::new(),
513 stderr: format!("Script timed out after {timeout_duration:?}"),
514 duration,
515 timed_out: true,
516 })
517 }
518 };
519
520 result
521 }
522
523 fn can_execute(&self, path: &Path) -> bool {
524 path.extension()
525 .and_then(|ext| ext.to_str())
526 .is_some_and(|ext| ext == "sh")
527 }
528}
529
530pub struct CompositeExecutor {
535 executors: Vec<Box<dyn ScriptExecutor>>,
536}
537
538impl CompositeExecutor {
539 #[must_use]
545 pub fn new() -> Self {
546 Self {
547 executors: vec![
548 Box::new(PythonExecutor::new()),
549 Box::new(BashExecutor::new()),
550 ],
551 }
552 }
553
554 #[must_use]
567 pub fn with_executors(executors: Vec<Box<dyn ScriptExecutor>>) -> Self {
568 Self { executors }
569 }
570}
571
572impl Default for CompositeExecutor {
573 fn default() -> Self {
574 Self::new()
575 }
576}
577
578#[async_trait]
579impl ScriptExecutor for CompositeExecutor {
580 async fn execute(&self, path: &Path, args: &[&str], timeout: Duration) -> Result<ScriptOutput> {
581 for executor in &self.executors {
583 if executor.can_execute(path) {
584 return executor.execute(path, args, timeout).await;
585 }
586 }
587
588 Err(SkillError::UnsupportedScriptType(
589 path.extension()
590 .and_then(|ext| ext.to_str())
591 .unwrap_or("unknown")
592 .to_string(),
593 ))
594 }
595
596 fn can_execute(&self, path: &Path) -> bool {
597 self.executors.iter().any(|e| e.can_execute(path))
598 }
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604
605 #[test]
606 fn test_path_validator_valid_path() {
607 let temp_dir = tempfile::tempdir().unwrap();
608 let script_file = temp_dir.path().join("script.py");
609 std::fs::write(&script_file, "print('hello')").unwrap();
610
611 let validator = PathValidator::new(temp_dir.path());
612 let result = validator.validate(&script_file);
613 assert!(result.is_ok());
614 }
615
616 #[test]
617 fn test_path_validator_nonexistent_path() {
618 let temp_dir = tempfile::tempdir().unwrap();
619 let nonexistent = temp_dir.path().join("nonexistent.py");
620
621 let validator = PathValidator::new(temp_dir.path());
622 let result = validator.validate(&nonexistent);
623 assert!(result.is_err());
624 }
625
626 #[test]
627 fn test_path_validator_directory() {
628 let temp_dir = tempfile::tempdir().unwrap();
629 let subdir = temp_dir.path().join("subdir");
630 std::fs::create_dir(&subdir).unwrap();
631
632 let validator = PathValidator::new(temp_dir.path());
633 let result = validator.validate(&subdir);
634 assert!(result.is_err());
635 }
636
637 #[test]
638 fn test_path_validator_traversal_attempt() {
639 let temp_dir = tempfile::tempdir().unwrap();
640 let scripts_dir = temp_dir.path().join("scripts");
641 std::fs::create_dir(&scripts_dir).unwrap();
642
643 let script_file = scripts_dir.join("script.py");
644 std::fs::write(&script_file, "print('hello')").unwrap();
645
646 let parent_dir = temp_dir.path().join("other");
647 std::fs::create_dir(&parent_dir).unwrap();
648
649 let validator = PathValidator::new(&scripts_dir);
650 let traversal_path = scripts_dir.join("../other");
652 let result = validator.validate(&traversal_path);
653 assert!(result.is_err());
655 }
656
657 #[test]
658 fn test_python_executor_can_execute() {
659 let executor = PythonExecutor::new();
660 assert!(executor.can_execute(Path::new("script.py")));
661 assert!(!executor.can_execute(Path::new("script.sh")));
662 assert!(!executor.can_execute(Path::new("script.txt")));
663 }
664
665 #[test]
666 fn test_bash_executor_can_execute() {
667 let executor = BashExecutor::new();
668 assert!(executor.can_execute(Path::new("script.sh")));
669 assert!(!executor.can_execute(Path::new("script.py")));
670 assert!(!executor.can_execute(Path::new("script.txt")));
671 }
672
673 #[test]
674 fn test_composite_executor_can_execute() {
675 let executor = CompositeExecutor::new();
676 assert!(executor.can_execute(Path::new("script.py")));
677 assert!(executor.can_execute(Path::new("script.sh")));
678 assert!(!executor.can_execute(Path::new("script.txt")));
679 }
680
681 #[test]
682 fn test_script_output_success() {
683 let output = ScriptOutput {
684 exit_code: 0,
685 stdout: "Success".to_string(),
686 stderr: String::new(),
687 duration: Duration::from_millis(100),
688 timed_out: false,
689 };
690 assert!(output.success());
691
692 let failed = ScriptOutput {
693 exit_code: 1,
694 stdout: String::new(),
695 stderr: "Error".to_string(),
696 duration: Duration::from_millis(100),
697 timed_out: false,
698 };
699 assert!(!failed.success());
700
701 let timeout = ScriptOutput {
702 exit_code: 0,
703 stdout: String::new(),
704 stderr: String::new(),
705 duration: Duration::from_secs(30),
706 timed_out: true,
707 };
708 assert!(!timeout.success());
709 }
710
711 #[test]
712 fn test_python_executor_with_path() {
713 let executor = PythonExecutor::with_path("/usr/local/bin/python3.11");
714 assert_eq!(executor.python_path, "/usr/local/bin/python3.11");
715 }
716
717 #[test]
718 fn test_bash_executor_with_path() {
719 let executor = BashExecutor::with_path("/bin/bash");
720 assert_eq!(executor.bash_path, "/bin/bash");
721 }
722}