Skip to main content

sa3p_host_impl/
lib.rs

1//! Local host implementations for `sa3p-engine` traits.
2//!
3//! - `LocalVfs` implements atomic file operations, hashing, and tree listing.
4//! - `LocalTerminal` implements command execution, timeout detachment, and
5//!   process signaling for detached jobs.
6
7use std::collections::HashMap;
8use std::fs;
9use std::io::Write;
10use std::path::{Path, PathBuf};
11use std::process::{Child, Command, Stdio};
12use std::thread;
13use std::time::{Duration, Instant, SystemTime};
14
15use ignore::WalkBuilder;
16use sa3p_engine::{
17    EngineError, FileHash, NodeKind, ProcessSignal, Result, TerminalExecution, TerminalProvider,
18    TreeNode, VirtualFileSystem,
19};
20use sha2::{Digest, Sha256};
21
22const DEFAULT_TERMINAL_OUTPUT_LIMIT: usize = 256 * 1024;
23
24pub struct LocalVfs {
25    root: PathBuf,
26    recent_window: Duration,
27}
28
29impl LocalVfs {
30    pub fn new(root: impl Into<PathBuf>) -> Self {
31        Self {
32            root: root.into(),
33            recent_window: Duration::from_secs(60 * 60 * 24),
34        }
35    }
36
37    pub fn with_recent_window(mut self, recent_window: Duration) -> Self {
38        self.recent_window = recent_window;
39        self
40    }
41
42    fn resolve(&self, path: &Path) -> PathBuf {
43        if path.is_absolute() {
44            path.to_path_buf()
45        } else {
46            self.root.join(path)
47        }
48    }
49
50    fn to_relative(&self, path: &Path) -> PathBuf {
51        path.strip_prefix(&self.root)
52            .map(PathBuf::from)
53            .unwrap_or_else(|_| path.to_path_buf())
54    }
55
56    fn walk_builder(&self, root: &Path) -> WalkBuilder {
57        let mut builder = WalkBuilder::new(root);
58        builder.hidden(false);
59        builder.git_ignore(true);
60        builder.git_exclude(true);
61        builder.ignore(true);
62        builder.add_custom_ignore_filename(".sa3pignore");
63        builder
64    }
65}
66
67impl VirtualFileSystem for LocalVfs {
68    fn read(&self, path: &Path) -> Result<Vec<u8>> {
69        let absolute = self.resolve(path);
70        fs::read(&absolute)
71            .map_err(|err| EngineError::Vfs(format!("read {} failed: {err}", absolute.display())))
72    }
73
74    fn write_atomic(&self, path: &Path, bytes: &[u8]) -> Result<()> {
75        let absolute = self.resolve(path);
76        let parent = absolute.parent().ok_or_else(|| {
77            EngineError::Vfs(format!(
78                "cannot determine parent directory for {}",
79                absolute.display()
80            ))
81        })?;
82        fs::create_dir_all(parent).map_err(|err| {
83            EngineError::Vfs(format!("mkdir -p {} failed: {err}", parent.display()))
84        })?;
85
86        let tmp_name = format!(
87            ".sa3p.{}.{}.tmp",
88            std::process::id(),
89            SystemTime::now()
90                .duration_since(SystemTime::UNIX_EPOCH)
91                .map_err(|err| EngineError::Vfs(format!("system clock error: {err}")))?
92                .as_nanos()
93        );
94        let tmp_path = parent.join(tmp_name);
95
96        {
97            let mut tmp_file = fs::File::create(&tmp_path).map_err(|err| {
98                EngineError::Vfs(format!("create {} failed: {err}", tmp_path.display()))
99            })?;
100            tmp_file.write_all(bytes).map_err(|err| {
101                EngineError::Vfs(format!("write {} failed: {err}", tmp_path.display()))
102            })?;
103            tmp_file.sync_all().map_err(|err| {
104                EngineError::Vfs(format!("sync {} failed: {err}", tmp_path.display()))
105            })?;
106        }
107
108        fs::rename(&tmp_path, &absolute).map_err(|err| {
109            EngineError::Vfs(format!(
110                "rename {} -> {} failed: {err}",
111                tmp_path.display(),
112                absolute.display()
113            ))
114        })?;
115
116        if should_be_executable(path, bytes) {
117            make_executable(&absolute)?;
118        }
119
120        Ok(())
121    }
122
123    fn hash(&self, path: &Path) -> Result<String> {
124        let bytes = self.read(path)?;
125        Ok(sha256_hex(&bytes))
126    }
127
128    fn cwd(&self) -> Result<PathBuf> {
129        Ok(self.root.clone())
130    }
131
132    fn list_tree(&self, path: &Path) -> Result<Vec<TreeNode>> {
133        let target = self.resolve(path);
134        if !target.exists() {
135            return Err(EngineError::Vfs(format!(
136                "list root {} does not exist",
137                target.display()
138            )));
139        }
140
141        let mut files = Vec::<PathBuf>::new();
142        let mut dirs = HashMap::<PathBuf, bool>::new();
143        let now = SystemTime::now();
144
145        for entry in self.walk_builder(&target).build() {
146            let entry = entry.map_err(|err| EngineError::Vfs(format!("walk failed: {err}")))?;
147            let absolute = entry.path();
148            if absolute == target {
149                continue;
150            }
151
152            let file_type = entry
153                .file_type()
154                .ok_or_else(|| EngineError::Vfs("missing file type during walk".to_string()))?;
155            let metadata = entry
156                .metadata()
157                .map_err(|err| EngineError::Vfs(format!("metadata failed: {err}")))?;
158            let modified_recently = metadata
159                .modified()
160                .ok()
161                .and_then(|modified| now.duration_since(modified).ok())
162                .is_some_and(|age| age <= self.recent_window);
163
164            let rel = self.to_relative(absolute);
165            if file_type.is_file() {
166                files.push(rel.clone());
167            } else if file_type.is_dir() {
168                dirs.insert(rel.clone(), modified_recently);
169            }
170        }
171
172        let mut descendant_counts = HashMap::<PathBuf, usize>::new();
173        for file in &files {
174            let mut parent = file.parent();
175            while let Some(dir) = parent {
176                if dir.as_os_str().is_empty() {
177                    break;
178                }
179                *descendant_counts.entry(dir.to_path_buf()).or_default() += 1;
180                parent = dir.parent();
181            }
182        }
183
184        let mut nodes = Vec::new();
185        for (dir, modified_recently) in dirs {
186            nodes.push(TreeNode {
187                path: dir.clone(),
188                kind: NodeKind::Directory,
189                descendant_file_count: *descendant_counts.get(&dir).unwrap_or(&0),
190                modified_recently,
191            });
192        }
193
194        for file in files {
195            let absolute = self.resolve(&file);
196            let modified_recently = fs::metadata(&absolute)
197                .and_then(|meta| meta.modified())
198                .ok()
199                .and_then(|modified| now.duration_since(modified).ok())
200                .is_some_and(|age| age <= self.recent_window);
201            nodes.push(TreeNode {
202                path: file,
203                kind: NodeKind::File,
204                descendant_file_count: 0,
205                modified_recently,
206            });
207        }
208
209        Ok(nodes)
210    }
211
212    fn recent_file_hashes(&self, limit: usize) -> Result<Vec<FileHash>> {
213        let mut candidates = Vec::<(PathBuf, SystemTime)>::new();
214
215        for entry in self.walk_builder(&self.root).build() {
216            let entry = entry.map_err(|err| EngineError::Vfs(format!("walk failed: {err}")))?;
217            let file_type = match entry.file_type() {
218                Some(file_type) => file_type,
219                None => continue,
220            };
221            if !file_type.is_file() {
222                continue;
223            }
224            let metadata = entry
225                .metadata()
226                .map_err(|err| EngineError::Vfs(format!("metadata failed: {err}")))?;
227            let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
228            candidates.push((entry.path().to_path_buf(), modified));
229        }
230
231        candidates.sort_by(|a, b| b.1.cmp(&a.1));
232        candidates.truncate(limit);
233
234        let mut out = Vec::new();
235        for (absolute, _) in candidates {
236            let bytes = fs::read(&absolute).map_err(|err| {
237                EngineError::Vfs(format!("read {} failed: {err}", absolute.display()))
238            })?;
239            out.push(FileHash {
240                path: self.to_relative(&absolute),
241                sha256: sha256_hex(&bytes),
242            });
243        }
244
245        Ok(out)
246    }
247}
248
249pub struct LocalTerminal {
250    cwd: PathBuf,
251    shell: String,
252    background: HashMap<u32, BackgroundProcess>,
253    output_byte_limit: usize,
254}
255
256#[derive(Debug, Clone)]
257struct CapturePaths {
258    stdout: PathBuf,
259    stderr: PathBuf,
260}
261
262#[derive(Debug)]
263struct BackgroundProcess {
264    child: Child,
265    capture: CapturePaths,
266}
267
268impl LocalTerminal {
269    pub fn new(cwd: impl Into<PathBuf>) -> Self {
270        Self {
271            cwd: cwd.into(),
272            shell: "zsh".to_string(),
273            background: HashMap::new(),
274            output_byte_limit: DEFAULT_TERMINAL_OUTPUT_LIMIT,
275        }
276    }
277
278    pub fn with_shell(mut self, shell: impl Into<String>) -> Self {
279        self.shell = shell.into();
280        self
281    }
282
283    pub fn with_output_byte_limit(mut self, output_byte_limit: usize) -> Self {
284        self.output_byte_limit = output_byte_limit.max(1);
285        self
286    }
287
288    fn reap_background(&mut self) {
289        let pids: Vec<u32> = self.background.keys().copied().collect();
290        for pid in pids {
291            let Some(process) = self.background.get_mut(&pid) else {
292                continue;
293            };
294            if process.child.try_wait().ok().flatten().is_some() {
295                if let Some(process) = self.background.remove(&pid) {
296                    cleanup_capture_files(&process.capture);
297                }
298            }
299        }
300    }
301
302    fn prepare_capture_files(&self) -> Result<(CapturePaths, fs::File, fs::File)> {
303        let capture_root = std::env::temp_dir().join("sa3p-terminal-capture");
304        fs::create_dir_all(&capture_root).map_err(|err| {
305            EngineError::Terminal(format!(
306                "create capture dir {} failed: {err}",
307                capture_root.display()
308            ))
309        })?;
310
311        let unique = format!(
312            "{}-{}",
313            std::process::id(),
314            SystemTime::now()
315                .duration_since(SystemTime::UNIX_EPOCH)
316                .map_err(|err| EngineError::Terminal(format!("system clock error: {err}")))?
317                .as_nanos()
318        );
319        let capture = CapturePaths {
320            stdout: capture_root.join(format!("{unique}.stdout.log")),
321            stderr: capture_root.join(format!("{unique}.stderr.log")),
322        };
323
324        let stdout_file = fs::File::create(&capture.stdout).map_err(|err| {
325            EngineError::Terminal(format!(
326                "create stdout capture {} failed: {err}",
327                capture.stdout.display()
328            ))
329        })?;
330        let stderr_file = fs::File::create(&capture.stderr).map_err(|err| {
331            EngineError::Terminal(format!(
332                "create stderr capture {} failed: {err}",
333                capture.stderr.display()
334            ))
335        })?;
336
337        Ok((capture, stdout_file, stderr_file))
338    }
339
340    fn render_captured_output(&self, capture: &CapturePaths) -> Result<String> {
341        let (stdout_bytes, stdout_total, stdout_truncated) =
342            read_capture_with_limit(&capture.stdout, self.output_byte_limit)?;
343        let (stderr_bytes, stderr_total, stderr_truncated) =
344            read_capture_with_limit(&capture.stderr, self.output_byte_limit)?;
345
346        let mut output = String::new();
347        output.push_str(&String::from_utf8_lossy(&stdout_bytes));
348        output.push_str(&String::from_utf8_lossy(&stderr_bytes));
349
350        if stdout_truncated || stderr_truncated {
351            if !output.is_empty() && !output.ends_with('\n') {
352                output.push('\n');
353            }
354            output.push_str(&format!(
355                "[OUTPUT_TRUNCATED: stdout {stdout_total} bytes, stderr {stderr_total} bytes; showing first {} bytes per stream]",
356                self.output_byte_limit
357            ));
358        }
359
360        Ok(output)
361    }
362}
363
364impl TerminalProvider for LocalTerminal {
365    fn run(&mut self, command: &str, timeout: Duration) -> Result<TerminalExecution> {
366        if command.trim().is_empty() {
367            return Err(EngineError::Terminal("empty terminal command".to_string()));
368        }
369
370        self.reap_background();
371
372        let (capture, stdout_file, stderr_file) = self.prepare_capture_files()?;
373        let mut child = Command::new(&self.shell)
374            .arg("-lc")
375            .arg(command)
376            .current_dir(&self.cwd)
377            .stdout(Stdio::from(stdout_file))
378            .stderr(Stdio::from(stderr_file))
379            .spawn()
380            .map_err(|err| {
381                cleanup_capture_files(&capture);
382                EngineError::Terminal(format!("spawn failed: {err}"))
383            })?;
384
385        let started = Instant::now();
386        loop {
387            if let Some(status) = child
388                .try_wait()
389                .map_err(|err| EngineError::Terminal(format!("wait failed: {err}")))?
390            {
391                let mut output = self.render_captured_output(&capture)?;
392                cleanup_capture_files(&capture);
393                if !output.is_empty() && !output.ends_with('\n') {
394                    output.push('\n');
395                }
396                output.push_str(&format!(
397                    "[EXIT_CODE: {} | CWD: {}]",
398                    status.code().unwrap_or(-1),
399                    self.cwd.display()
400                ));
401
402                return Ok(TerminalExecution {
403                    output,
404                    exit_code: status.code(),
405                    cwd: self.cwd.clone(),
406                    detached_pid: None,
407                });
408            }
409
410            if started.elapsed() >= timeout {
411                let pid = child.id();
412                self.background.insert(
413                    pid,
414                    BackgroundProcess {
415                        child,
416                        capture: capture.clone(),
417                    },
418                );
419                return Ok(TerminalExecution {
420                    output: format!(
421                        "[PROCESS DETACHED: PID {}. Running in background | CWD: {} | OUTPUT_CAPTURE: {}, {}]",
422                        pid,
423                        self.cwd.display(),
424                        capture.stdout.display(),
425                        capture.stderr.display(),
426                    ),
427                    exit_code: None,
428                    cwd: self.cwd.clone(),
429                    detached_pid: Some(pid),
430                });
431            }
432
433            thread::sleep(Duration::from_millis(25));
434        }
435    }
436
437    fn signal(&mut self, pid: u32, signal: ProcessSignal) -> Result<()> {
438        self.reap_background();
439
440        let signal_code = match signal {
441            ProcessSignal::SigInt => libc::SIGINT,
442            ProcessSignal::SigTerm => libc::SIGTERM,
443            ProcessSignal::SigKill => libc::SIGKILL,
444        };
445
446        #[cfg(unix)]
447        {
448            let rc = unsafe { libc::kill(pid as i32, signal_code) };
449            if rc != 0 {
450                return Err(EngineError::Terminal(format!(
451                    "failed to signal pid {} with {}",
452                    pid, signal_code
453                )));
454            }
455        }
456
457        #[cfg(not(unix))]
458        {
459            let _ = signal_code;
460            return Err(EngineError::Terminal(
461                "terminal signaling is only implemented on unix hosts".to_string(),
462            ));
463        }
464
465        if let Some(mut process) = self.background.remove(&pid) {
466            let _ = process.child.try_wait();
467            cleanup_capture_files(&process.capture);
468        }
469
470        Ok(())
471    }
472
473    fn active_pids(&self) -> Vec<u32> {
474        let mut pids: Vec<u32> = self.background.keys().copied().collect();
475        pids.sort_unstable();
476        pids
477    }
478}
479
480fn read_capture_with_limit(path: &Path, limit: usize) -> Result<(Vec<u8>, usize, bool)> {
481    let mut bytes = fs::read(path).map_err(|err| {
482        EngineError::Terminal(format!("read capture {} failed: {err}", path.display()))
483    })?;
484    let total = bytes.len();
485    let truncated = total > limit;
486    if truncated {
487        bytes.truncate(limit);
488    }
489    Ok((bytes, total, truncated))
490}
491
492fn cleanup_capture_files(capture: &CapturePaths) {
493    let _ = fs::remove_file(&capture.stdout);
494    let _ = fs::remove_file(&capture.stderr);
495}
496
497fn should_be_executable(path: &Path, bytes: &[u8]) -> bool {
498    bytes.starts_with(b"#!") || path.components().any(|c| c.as_os_str() == "bin")
499}
500
501fn make_executable(path: &Path) -> Result<()> {
502    #[cfg(unix)]
503    {
504        use std::os::unix::fs::PermissionsExt;
505
506        let metadata = fs::metadata(path).map_err(|err| {
507            EngineError::Vfs(format!("metadata {} failed: {err}", path.display()))
508        })?;
509        let mut perms = metadata.permissions();
510        perms.set_mode(perms.mode() | 0o111);
511        fs::set_permissions(path, perms).map_err(|err| {
512            EngineError::Vfs(format!("chmod +x {} failed: {err}", path.display()))
513        })?;
514    }
515    Ok(())
516}
517
518fn sha256_hex(bytes: &[u8]) -> String {
519    let mut hasher = Sha256::new();
520    hasher.update(bytes);
521    format!("{:x}", hasher.finalize())
522}
523
524#[cfg(test)]
525mod tests {
526    use super::*;
527    use std::os::unix::fs::PermissionsExt;
528    use tempfile::tempdir;
529
530    #[test]
531    fn write_atomic_creates_parents_and_writes_content() {
532        let dir = tempdir().expect("tmpdir");
533        let vfs = LocalVfs::new(dir.path());
534
535        vfs.write_atomic(Path::new("src/main.rs"), b"fn main() {}")
536            .expect("write should succeed");
537
538        let body = fs::read_to_string(dir.path().join("src/main.rs")).expect("read file");
539        assert_eq!(body, "fn main() {}");
540    }
541
542    #[test]
543    fn write_atomic_auto_chmods_shebang_files() {
544        let dir = tempdir().expect("tmpdir");
545        let vfs = LocalVfs::new(dir.path());
546
547        vfs.write_atomic(
548            Path::new("scripts/run.sh"),
549            b"#!/usr/bin/env bash\necho hi\n",
550        )
551        .expect("write should succeed");
552
553        let mode = fs::metadata(dir.path().join("scripts/run.sh"))
554            .expect("metadata")
555            .permissions()
556            .mode();
557        assert_ne!(mode & 0o111, 0);
558    }
559
560    #[test]
561    fn list_tree_respects_sa3pignore() {
562        let dir = tempdir().expect("tmpdir");
563        fs::write(dir.path().join(".sa3pignore"), "ignored_dir/\n*.tmp\n").expect("write ignore");
564        fs::create_dir_all(dir.path().join("ignored_dir")).expect("mkdir");
565        fs::write(dir.path().join("ignored_dir/secret.txt"), "x").expect("write");
566        fs::create_dir_all(dir.path().join("src")).expect("mkdir");
567        fs::write(dir.path().join("src/lib.rs"), "pub fn x() {}\n").expect("write");
568        fs::write(dir.path().join("notes.tmp"), "temp").expect("write");
569
570        let vfs = LocalVfs::new(dir.path());
571        let nodes = vfs.list_tree(Path::new(".")).expect("list tree");
572
573        assert!(nodes.iter().any(|n| n.path == Path::new("src/lib.rs")));
574        assert!(
575            !nodes
576                .iter()
577                .any(|n| n.path.to_string_lossy().contains("ignored_dir"))
578        );
579        assert!(
580            !nodes
581                .iter()
582                .any(|n| n.path.to_string_lossy().contains("notes.tmp"))
583        );
584    }
585
586    #[test]
587    fn recent_hashes_returns_paths_and_hashes() {
588        let dir = tempdir().expect("tmpdir");
589        fs::create_dir_all(dir.path().join("src")).expect("mkdir");
590        fs::write(dir.path().join("src/a.rs"), "a").expect("write");
591        fs::write(dir.path().join("src/b.rs"), "b").expect("write");
592
593        let vfs = LocalVfs::new(dir.path());
594        let hashes = vfs.recent_file_hashes(10).expect("recent hashes");
595
596        assert!(
597            hashes
598                .iter()
599                .any(|entry| entry.path == Path::new("src/a.rs"))
600        );
601        assert!(hashes.iter().all(|entry| !entry.sha256.is_empty()));
602    }
603
604    #[test]
605    fn terminal_run_completes_with_exit_code() {
606        let dir = tempdir().expect("tmpdir");
607        let mut terminal = LocalTerminal::new(dir.path());
608
609        let result = terminal
610            .run("echo hello", Duration::from_secs(2))
611            .expect("terminal run");
612
613        assert!(result.output.contains("hello"));
614        assert_eq!(result.exit_code, Some(0));
615        assert!(result.detached_pid.is_none());
616    }
617
618    #[test]
619    fn terminal_detaches_and_can_be_signaled() {
620        let dir = tempdir().expect("tmpdir");
621        let mut terminal = LocalTerminal::new(dir.path());
622
623        let result = terminal
624            .run("sleep 5", Duration::from_millis(50))
625            .expect("terminal run");
626
627        let pid = result.detached_pid.expect("expected detached pid");
628        assert!(terminal.active_pids().contains(&pid));
629
630        terminal
631            .signal(pid, ProcessSignal::SigTerm)
632            .expect("signal should succeed");
633    }
634
635    #[test]
636    fn terminal_truncates_large_output_with_notice() {
637        let dir = tempdir().expect("tmpdir");
638        let mut terminal = LocalTerminal::new(dir.path()).with_output_byte_limit(1024);
639
640        let result = terminal
641            .run("yes x | head -c 20000", Duration::from_secs(2))
642            .expect("terminal run");
643
644        assert_eq!(result.exit_code, Some(0));
645        assert!(result.output.contains("[OUTPUT_TRUNCATED:"));
646    }
647}