1use 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}