use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::thread;
use std::time::{Duration, Instant, SystemTime};
use ignore::WalkBuilder;
use sa3p_engine::{
EngineError, FileHash, NodeKind, ProcessSignal, Result, TerminalExecution, TerminalProvider,
TreeNode, VirtualFileSystem,
};
use sha2::{Digest, Sha256};
const DEFAULT_TERMINAL_OUTPUT_LIMIT: usize = 256 * 1024;
pub struct LocalVfs {
root: PathBuf,
recent_window: Duration,
}
impl LocalVfs {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self {
root: root.into(),
recent_window: Duration::from_secs(60 * 60 * 24),
}
}
pub fn with_recent_window(mut self, recent_window: Duration) -> Self {
self.recent_window = recent_window;
self
}
fn resolve(&self, path: &Path) -> PathBuf {
if path.is_absolute() {
path.to_path_buf()
} else {
self.root.join(path)
}
}
fn to_relative(&self, path: &Path) -> PathBuf {
path.strip_prefix(&self.root)
.map(PathBuf::from)
.unwrap_or_else(|_| path.to_path_buf())
}
fn walk_builder(&self, root: &Path) -> WalkBuilder {
let mut builder = WalkBuilder::new(root);
builder.hidden(false);
builder.git_ignore(true);
builder.git_exclude(true);
builder.ignore(true);
builder.add_custom_ignore_filename(".sa3pignore");
builder
}
}
impl VirtualFileSystem for LocalVfs {
fn read(&self, path: &Path) -> Result<Vec<u8>> {
let absolute = self.resolve(path);
fs::read(&absolute)
.map_err(|err| EngineError::Vfs(format!("read {} failed: {err}", absolute.display())))
}
fn write_atomic(&self, path: &Path, bytes: &[u8]) -> Result<()> {
let absolute = self.resolve(path);
let parent = absolute.parent().ok_or_else(|| {
EngineError::Vfs(format!(
"cannot determine parent directory for {}",
absolute.display()
))
})?;
fs::create_dir_all(parent).map_err(|err| {
EngineError::Vfs(format!("mkdir -p {} failed: {err}", parent.display()))
})?;
let tmp_name = format!(
".sa3p.{}.{}.tmp",
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|err| EngineError::Vfs(format!("system clock error: {err}")))?
.as_nanos()
);
let tmp_path = parent.join(tmp_name);
{
let mut tmp_file = fs::File::create(&tmp_path).map_err(|err| {
EngineError::Vfs(format!("create {} failed: {err}", tmp_path.display()))
})?;
tmp_file.write_all(bytes).map_err(|err| {
EngineError::Vfs(format!("write {} failed: {err}", tmp_path.display()))
})?;
tmp_file.sync_all().map_err(|err| {
EngineError::Vfs(format!("sync {} failed: {err}", tmp_path.display()))
})?;
}
fs::rename(&tmp_path, &absolute).map_err(|err| {
EngineError::Vfs(format!(
"rename {} -> {} failed: {err}",
tmp_path.display(),
absolute.display()
))
})?;
if should_be_executable(path, bytes) {
make_executable(&absolute)?;
}
Ok(())
}
fn hash(&self, path: &Path) -> Result<String> {
let bytes = self.read(path)?;
Ok(sha256_hex(&bytes))
}
fn cwd(&self) -> Result<PathBuf> {
Ok(self.root.clone())
}
fn list_tree(&self, path: &Path) -> Result<Vec<TreeNode>> {
let target = self.resolve(path);
if !target.exists() {
return Err(EngineError::Vfs(format!(
"list root {} does not exist",
target.display()
)));
}
let mut files = Vec::<PathBuf>::new();
let mut dirs = HashMap::<PathBuf, bool>::new();
let now = SystemTime::now();
for entry in self.walk_builder(&target).build() {
let entry = entry.map_err(|err| EngineError::Vfs(format!("walk failed: {err}")))?;
let absolute = entry.path();
if absolute == target {
continue;
}
let file_type = entry
.file_type()
.ok_or_else(|| EngineError::Vfs("missing file type during walk".to_string()))?;
let metadata = entry
.metadata()
.map_err(|err| EngineError::Vfs(format!("metadata failed: {err}")))?;
let modified_recently = metadata
.modified()
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age <= self.recent_window);
let rel = self.to_relative(absolute);
if file_type.is_file() {
files.push(rel.clone());
} else if file_type.is_dir() {
dirs.insert(rel.clone(), modified_recently);
}
}
let mut descendant_counts = HashMap::<PathBuf, usize>::new();
for file in &files {
let mut parent = file.parent();
while let Some(dir) = parent {
if dir.as_os_str().is_empty() {
break;
}
*descendant_counts.entry(dir.to_path_buf()).or_default() += 1;
parent = dir.parent();
}
}
let mut nodes = Vec::new();
for (dir, modified_recently) in dirs {
nodes.push(TreeNode {
path: dir.clone(),
kind: NodeKind::Directory,
descendant_file_count: *descendant_counts.get(&dir).unwrap_or(&0),
modified_recently,
});
}
for file in files {
let absolute = self.resolve(&file);
let modified_recently = fs::metadata(&absolute)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| now.duration_since(modified).ok())
.is_some_and(|age| age <= self.recent_window);
nodes.push(TreeNode {
path: file,
kind: NodeKind::File,
descendant_file_count: 0,
modified_recently,
});
}
Ok(nodes)
}
fn recent_file_hashes(&self, limit: usize) -> Result<Vec<FileHash>> {
let mut candidates = Vec::<(PathBuf, SystemTime)>::new();
for entry in self.walk_builder(&self.root).build() {
let entry = entry.map_err(|err| EngineError::Vfs(format!("walk failed: {err}")))?;
let file_type = match entry.file_type() {
Some(file_type) => file_type,
None => continue,
};
if !file_type.is_file() {
continue;
}
let metadata = entry
.metadata()
.map_err(|err| EngineError::Vfs(format!("metadata failed: {err}")))?;
let modified = metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH);
candidates.push((entry.path().to_path_buf(), modified));
}
candidates.sort_by(|a, b| b.1.cmp(&a.1));
candidates.truncate(limit);
let mut out = Vec::new();
for (absolute, _) in candidates {
let bytes = fs::read(&absolute).map_err(|err| {
EngineError::Vfs(format!("read {} failed: {err}", absolute.display()))
})?;
out.push(FileHash {
path: self.to_relative(&absolute),
sha256: sha256_hex(&bytes),
});
}
Ok(out)
}
}
pub struct LocalTerminal {
cwd: PathBuf,
shell: String,
background: HashMap<u32, BackgroundProcess>,
output_byte_limit: usize,
}
#[derive(Debug, Clone)]
struct CapturePaths {
stdout: PathBuf,
stderr: PathBuf,
}
#[derive(Debug)]
struct BackgroundProcess {
child: Child,
capture: CapturePaths,
}
impl LocalTerminal {
pub fn new(cwd: impl Into<PathBuf>) -> Self {
Self {
cwd: cwd.into(),
shell: "zsh".to_string(),
background: HashMap::new(),
output_byte_limit: DEFAULT_TERMINAL_OUTPUT_LIMIT,
}
}
pub fn with_shell(mut self, shell: impl Into<String>) -> Self {
self.shell = shell.into();
self
}
pub fn with_output_byte_limit(mut self, output_byte_limit: usize) -> Self {
self.output_byte_limit = output_byte_limit.max(1);
self
}
fn reap_background(&mut self) {
let pids: Vec<u32> = self.background.keys().copied().collect();
for pid in pids {
let Some(process) = self.background.get_mut(&pid) else {
continue;
};
if process.child.try_wait().ok().flatten().is_some() {
if let Some(process) = self.background.remove(&pid) {
cleanup_capture_files(&process.capture);
}
}
}
}
fn prepare_capture_files(&self) -> Result<(CapturePaths, fs::File, fs::File)> {
let capture_root = std::env::temp_dir().join("sa3p-terminal-capture");
fs::create_dir_all(&capture_root).map_err(|err| {
EngineError::Terminal(format!(
"create capture dir {} failed: {err}",
capture_root.display()
))
})?;
let unique = format!(
"{}-{}",
std::process::id(),
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map_err(|err| EngineError::Terminal(format!("system clock error: {err}")))?
.as_nanos()
);
let capture = CapturePaths {
stdout: capture_root.join(format!("{unique}.stdout.log")),
stderr: capture_root.join(format!("{unique}.stderr.log")),
};
let stdout_file = fs::File::create(&capture.stdout).map_err(|err| {
EngineError::Terminal(format!(
"create stdout capture {} failed: {err}",
capture.stdout.display()
))
})?;
let stderr_file = fs::File::create(&capture.stderr).map_err(|err| {
EngineError::Terminal(format!(
"create stderr capture {} failed: {err}",
capture.stderr.display()
))
})?;
Ok((capture, stdout_file, stderr_file))
}
fn render_captured_output(&self, capture: &CapturePaths) -> Result<String> {
let (stdout_bytes, stdout_total, stdout_truncated) =
read_capture_with_limit(&capture.stdout, self.output_byte_limit)?;
let (stderr_bytes, stderr_total, stderr_truncated) =
read_capture_with_limit(&capture.stderr, self.output_byte_limit)?;
let mut output = String::new();
output.push_str(&String::from_utf8_lossy(&stdout_bytes));
output.push_str(&String::from_utf8_lossy(&stderr_bytes));
if stdout_truncated || stderr_truncated {
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&format!(
"[OUTPUT_TRUNCATED: stdout {stdout_total} bytes, stderr {stderr_total} bytes; showing first {} bytes per stream]",
self.output_byte_limit
));
}
Ok(output)
}
}
impl TerminalProvider for LocalTerminal {
fn run(&mut self, command: &str, timeout: Duration) -> Result<TerminalExecution> {
if command.trim().is_empty() {
return Err(EngineError::Terminal("empty terminal command".to_string()));
}
self.reap_background();
let (capture, stdout_file, stderr_file) = self.prepare_capture_files()?;
let mut child = Command::new(&self.shell)
.arg("-lc")
.arg(command)
.current_dir(&self.cwd)
.stdout(Stdio::from(stdout_file))
.stderr(Stdio::from(stderr_file))
.spawn()
.map_err(|err| {
cleanup_capture_files(&capture);
EngineError::Terminal(format!("spawn failed: {err}"))
})?;
let started = Instant::now();
loop {
if let Some(status) = child
.try_wait()
.map_err(|err| EngineError::Terminal(format!("wait failed: {err}")))?
{
let mut output = self.render_captured_output(&capture)?;
cleanup_capture_files(&capture);
if !output.is_empty() && !output.ends_with('\n') {
output.push('\n');
}
output.push_str(&format!(
"[EXIT_CODE: {} | CWD: {}]",
status.code().unwrap_or(-1),
self.cwd.display()
));
return Ok(TerminalExecution {
output,
exit_code: status.code(),
cwd: self.cwd.clone(),
detached_pid: None,
});
}
if started.elapsed() >= timeout {
let pid = child.id();
self.background.insert(
pid,
BackgroundProcess {
child,
capture: capture.clone(),
},
);
return Ok(TerminalExecution {
output: format!(
"[PROCESS DETACHED: PID {}. Running in background | CWD: {} | OUTPUT_CAPTURE: {}, {}]",
pid,
self.cwd.display(),
capture.stdout.display(),
capture.stderr.display(),
),
exit_code: None,
cwd: self.cwd.clone(),
detached_pid: Some(pid),
});
}
thread::sleep(Duration::from_millis(25));
}
}
fn signal(&mut self, pid: u32, signal: ProcessSignal) -> Result<()> {
self.reap_background();
let signal_code = match signal {
ProcessSignal::SigInt => libc::SIGINT,
ProcessSignal::SigTerm => libc::SIGTERM,
ProcessSignal::SigKill => libc::SIGKILL,
};
#[cfg(unix)]
{
let rc = unsafe { libc::kill(pid as i32, signal_code) };
if rc != 0 {
return Err(EngineError::Terminal(format!(
"failed to signal pid {} with {}",
pid, signal_code
)));
}
}
#[cfg(not(unix))]
{
let _ = signal_code;
return Err(EngineError::Terminal(
"terminal signaling is only implemented on unix hosts".to_string(),
));
}
if let Some(mut process) = self.background.remove(&pid) {
let _ = process.child.try_wait();
cleanup_capture_files(&process.capture);
}
Ok(())
}
fn active_pids(&self) -> Vec<u32> {
let mut pids: Vec<u32> = self.background.keys().copied().collect();
pids.sort_unstable();
pids
}
}
fn read_capture_with_limit(path: &Path, limit: usize) -> Result<(Vec<u8>, usize, bool)> {
let mut bytes = fs::read(path).map_err(|err| {
EngineError::Terminal(format!("read capture {} failed: {err}", path.display()))
})?;
let total = bytes.len();
let truncated = total > limit;
if truncated {
bytes.truncate(limit);
}
Ok((bytes, total, truncated))
}
fn cleanup_capture_files(capture: &CapturePaths) {
let _ = fs::remove_file(&capture.stdout);
let _ = fs::remove_file(&capture.stderr);
}
fn should_be_executable(path: &Path, bytes: &[u8]) -> bool {
bytes.starts_with(b"#!") || path.components().any(|c| c.as_os_str() == "bin")
}
fn make_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let metadata = fs::metadata(path).map_err(|err| {
EngineError::Vfs(format!("metadata {} failed: {err}", path.display()))
})?;
let mut perms = metadata.permissions();
perms.set_mode(perms.mode() | 0o111);
fs::set_permissions(path, perms).map_err(|err| {
EngineError::Vfs(format!("chmod +x {} failed: {err}", path.display()))
})?;
}
Ok(())
}
fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("{:x}", hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt;
use tempfile::tempdir;
#[test]
fn write_atomic_creates_parents_and_writes_content() {
let dir = tempdir().expect("tmpdir");
let vfs = LocalVfs::new(dir.path());
vfs.write_atomic(Path::new("src/main.rs"), b"fn main() {}")
.expect("write should succeed");
let body = fs::read_to_string(dir.path().join("src/main.rs")).expect("read file");
assert_eq!(body, "fn main() {}");
}
#[test]
fn write_atomic_auto_chmods_shebang_files() {
let dir = tempdir().expect("tmpdir");
let vfs = LocalVfs::new(dir.path());
vfs.write_atomic(
Path::new("scripts/run.sh"),
b"#!/usr/bin/env bash\necho hi\n",
)
.expect("write should succeed");
let mode = fs::metadata(dir.path().join("scripts/run.sh"))
.expect("metadata")
.permissions()
.mode();
assert_ne!(mode & 0o111, 0);
}
#[test]
fn list_tree_respects_sa3pignore() {
let dir = tempdir().expect("tmpdir");
fs::write(dir.path().join(".sa3pignore"), "ignored_dir/\n*.tmp\n").expect("write ignore");
fs::create_dir_all(dir.path().join("ignored_dir")).expect("mkdir");
fs::write(dir.path().join("ignored_dir/secret.txt"), "x").expect("write");
fs::create_dir_all(dir.path().join("src")).expect("mkdir");
fs::write(dir.path().join("src/lib.rs"), "pub fn x() {}\n").expect("write");
fs::write(dir.path().join("notes.tmp"), "temp").expect("write");
let vfs = LocalVfs::new(dir.path());
let nodes = vfs.list_tree(Path::new(".")).expect("list tree");
assert!(nodes.iter().any(|n| n.path == Path::new("src/lib.rs")));
assert!(
!nodes
.iter()
.any(|n| n.path.to_string_lossy().contains("ignored_dir"))
);
assert!(
!nodes
.iter()
.any(|n| n.path.to_string_lossy().contains("notes.tmp"))
);
}
#[test]
fn recent_hashes_returns_paths_and_hashes() {
let dir = tempdir().expect("tmpdir");
fs::create_dir_all(dir.path().join("src")).expect("mkdir");
fs::write(dir.path().join("src/a.rs"), "a").expect("write");
fs::write(dir.path().join("src/b.rs"), "b").expect("write");
let vfs = LocalVfs::new(dir.path());
let hashes = vfs.recent_file_hashes(10).expect("recent hashes");
assert!(
hashes
.iter()
.any(|entry| entry.path == Path::new("src/a.rs"))
);
assert!(hashes.iter().all(|entry| !entry.sha256.is_empty()));
}
#[test]
fn terminal_run_completes_with_exit_code() {
let dir = tempdir().expect("tmpdir");
let mut terminal = LocalTerminal::new(dir.path());
let result = terminal
.run("echo hello", Duration::from_secs(2))
.expect("terminal run");
assert!(result.output.contains("hello"));
assert_eq!(result.exit_code, Some(0));
assert!(result.detached_pid.is_none());
}
#[test]
fn terminal_detaches_and_can_be_signaled() {
let dir = tempdir().expect("tmpdir");
let mut terminal = LocalTerminal::new(dir.path());
let result = terminal
.run("sleep 5", Duration::from_millis(50))
.expect("terminal run");
let pid = result.detached_pid.expect("expected detached pid");
assert!(terminal.active_pids().contains(&pid));
terminal
.signal(pid, ProcessSignal::SigTerm)
.expect("signal should succeed");
}
#[test]
fn terminal_truncates_large_output_with_notice() {
let dir = tempdir().expect("tmpdir");
let mut terminal = LocalTerminal::new(dir.path()).with_output_byte_limit(1024);
let result = terminal
.run("yes x | head -c 20000", Duration::from_secs(2))
.expect("terminal run");
assert_eq!(result.exit_code, Some(0));
assert!(result.output.contains("[OUTPUT_TRUNCATED:"));
}
}