use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use leviath_tools::ShellExecutor;
use tokio::process::Command as TokioCommand;
use crate::daemon::sandbox_manager::SandboxManager;
use crate::daemon::script_host::{
cap_script_io, combine_shell_output, default_shell, host_shell_command,
};
pub type SeedCommandRunner =
Arc<dyn Fn(&str, &Path, Duration) -> Result<String, String> + Send + Sync>;
#[derive(Clone)]
pub struct SeedCommandPolicy {
pub allowed: bool,
pub timeout: Duration,
pub runner: SeedCommandRunner,
}
impl SeedCommandPolicy {
pub fn new(allowed: bool, timeout: Duration, sandbox: Option<Arc<SandboxManager>>) -> Self {
Self {
allowed,
timeout,
runner: seed_command_runner(sandbox),
}
}
pub fn disabled() -> Self {
Self {
allowed: false,
timeout: Duration::from_secs(0),
runner: Arc::new(|_, _, _| Err("command seeds are disabled".to_string())),
}
}
pub fn run(&self, command: &str, workdir: &Path) -> Result<String, String> {
(self.runner)(command, workdir, self.timeout)
}
}
impl std::fmt::Debug for SeedCommandPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SeedCommandPolicy")
.field("allowed", &self.allowed)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
fn seed_command_runner(sandbox: Option<Arc<SandboxManager>>) -> SeedCommandRunner {
Arc::new(move |command, workdir, timeout| {
run_seed_command(
build_seed_command(sandbox.as_deref(), command, workdir),
timeout,
)
})
}
fn build_seed_command(
sandbox: Option<&SandboxManager>,
command: &str,
workdir: &Path,
) -> TokioCommand {
let (shell, flag) = default_shell();
match sandbox {
Some(sb) => sb.build_command(shell, flag, command, workdir),
None => host_shell_command(shell, flag, command, workdir),
}
}
fn run_seed_command(mut cmd: TokioCommand, timeout: Duration) -> Result<String, String> {
cmd.kill_on_drop(true);
std::thread::spawn(move || {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("current-thread runtime for a seed command always builds");
rt.block_on(async move {
match tokio::time::timeout(timeout, cmd.output()).await {
Ok(Ok(output)) => {
let combined =
cap_script_io(combine_shell_output(&output.stdout, &output.stderr));
if output.status.success() {
Ok(combined)
} else {
Err(format!(
"seed command exited with {}: {}",
output.status,
combined.trim()
))
}
}
Ok(Err(e)) => Err(format!("failed to spawn seed command: {e}")),
Err(_) => Err(format!(
"seed command timed out after {}s",
timeout.as_secs()
)),
}
})
})
.join()
.expect("seed command thread does not panic")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn disabled_policy_refuses_to_run() {
let policy = SeedCommandPolicy::disabled();
assert!(!policy.allowed);
let err = policy.run("echo hi", Path::new(".")).unwrap_err();
assert!(err.contains("disabled"), "got: {err}");
}
#[test]
fn debug_impl_reports_the_switches() {
let policy = SeedCommandPolicy::new(true, Duration::from_secs(7), None);
let rendered = format!("{policy:?}");
assert!(rendered.contains("allowed: true"), "got: {rendered}");
assert!(rendered.contains('7'), "got: {rendered}");
}
#[test]
fn injected_runner_is_used_and_receives_the_policy_timeout() {
let policy = SeedCommandPolicy {
allowed: true,
timeout: Duration::from_secs(3),
runner: Arc::new(|command, workdir, timeout| {
Ok(format!(
"{command}|{}|{}",
workdir.display(),
timeout.as_secs()
))
}),
};
assert_eq!(
policy.run("ls", Path::new("/w")).unwrap(),
"ls|/w|3".to_string()
);
}
#[test]
fn real_runner_captures_stdout() {
let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
let out = policy
.run("echo leviath-seed-ok", &std::env::temp_dir())
.unwrap();
assert!(out.contains("leviath-seed-ok"), "got: {out}");
}
#[test]
fn real_runner_treats_a_non_zero_exit_as_an_error() {
let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
let err = policy
.run("echo before-failure && exit 3", &std::env::temp_dir())
.unwrap_err();
assert!(err.contains("exited with"), "got: {err}");
assert!(err.contains("before-failure"), "got: {err}");
}
#[test]
fn real_runner_rejects_git_ls_files_outside_a_repository() {
let outside = tempfile::tempdir().unwrap();
let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
let err = policy
.run(
"git --git-dir=./definitely-not-a-repo ls-files",
outside.path(),
)
.unwrap_err();
assert!(err.contains("exited with"), "got: {err}");
}
#[test]
fn real_runner_times_out_a_long_command() {
let policy = SeedCommandPolicy::new(true, Duration::from_millis(150), None);
#[cfg(windows)]
let long = "ping -n 30 127.0.0.1 > NUL";
#[cfg(not(windows))]
let long = "sleep 30";
let err = policy.run(long, &std::env::temp_dir()).unwrap_err();
assert!(err.contains("timed out"), "got: {err}");
}
#[test]
fn real_runner_surfaces_a_missing_program() {
let policy = SeedCommandPolicy::new(true, Duration::from_secs(30), None);
let err = policy
.run("leviath-no-such-program-xyz", &std::env::temp_dir())
.unwrap_err();
assert!(err.contains("exited with"), "got: {err}");
}
#[test]
fn an_unsandboxed_seed_command_uses_the_platform_shell() {
let cmd = build_seed_command(None, "echo hi", Path::new("/w"));
assert_eq!(cmd.as_std().get_program(), default_shell().0);
}
#[test]
fn a_sandboxed_seed_command_is_built_through_the_manager() {
let by_index = vec![leviath_core::ToolSandboxConfig {
kind: leviath_core::SandboxKind::Namespace,
on_unavailable: leviath_core::OnUnavailable::Warn,
..Default::default()
}];
let manager = SandboxManager::build("seed-test", by_index, "/w", 0)
.expect("a warn-fallback namespace sandbox always builds")
.expect("an active sandbox config yields a manager");
let cmd = build_seed_command(Some(&manager), "echo hi", Path::new("/w"));
assert!(!cmd.as_std().get_program().is_empty());
}
#[test]
fn run_seed_command_reports_a_spawn_failure() {
let mut cmd = TokioCommand::new("leviath-definitely-not-a-shell-xyz");
cmd.arg("-c").arg("echo hi");
let err = run_seed_command(cmd, Duration::from_secs(5)).unwrap_err();
assert!(err.contains("failed to spawn seed command"), "got: {err}");
}
}