use crate::builtins::{Keep, TreeError};
use crate::safety;
use crate::value::Value;
use anyhow::Result;
use std::collections::BTreeMap;
use std::net::{IpAddr, Ipv4Addr};
use std::path::Path;
use std::time::{Duration, Instant};
const IPERF3: &str = "iperf3_server";
const ENTR: &str = "entr_watch";
const WATCHEXEC: &str = "watchexec_run";
const NODEMON: &str = "nodemon_run";
const MAX_ARGV: usize = 64;
const MAX_ARG_BYTES: usize = 4096;
const MAX_PATHS: usize = 64;
const OUTPUT_KEEP: usize = 65_536;
fn bad(name: &str, message: impl Into<String>) -> anyhow::Error {
safety::arg_err(format!("{name}: {}", message.into()))
}
fn over_limit(name: &str, message: impl Into<String>, hint: &str) -> anyhow::Error {
safety::budget_exceeded(name, message.into(), hint)
}
fn check_shape(
name: &str,
args: &[Value],
input: &Option<Value>,
lo: usize,
hi: usize,
) -> Result<()> {
if input.is_some() {
return Err(bad(
name,
"direct call only; pipeline input is not accepted",
));
}
if !(lo..=hi).contains(&args.len()) {
return Err(bad(
name,
format!("expected {lo} to {hi} arguments, got {}", args.len()),
));
}
Ok(())
}
fn refuse_in_transaction(name: &str) -> Result<()> {
if crate::tx::is_active() {
return Err(safety::unimplemented(
name,
"running an external tool cannot be rolled back",
"finish the transaction first",
));
}
Ok(())
}
fn read_options(
name: &str,
value: Option<&Value>,
allowed: &[&str],
) -> Result<BTreeMap<String, Value>> {
let Some(value) = value else {
return Ok(BTreeMap::new());
};
let Value::Record(map) = value else {
return Err(bad(name, "options must be a Record"));
};
for key in map.keys() {
if !allowed.contains(&key.as_str()) {
return Err(bad(
name,
format!("unknown option {key}; expected {}", allowed.join(", ")),
));
}
}
Ok(map.clone())
}
fn int_in(name: &str, key: &str, value: &Value, lo: i64, hi: i64) -> Result<i64> {
let Value::Int(n) = value else {
return Err(bad(name, format!("option {key} must be an Int")));
};
if *n < lo || *n > hi {
return Err(bad(
name,
format!("option {key} must be in {lo}..={hi}, got {n}"),
));
}
Ok(*n)
}
fn timeout_of(
name: &str,
opts: &BTreeMap<String, Value>,
lo: i64,
hi: i64,
default: i64,
) -> Result<Duration> {
let ms = match opts.get("timeout_ms") {
Some(v) => int_in(name, "timeout_ms", v, lo, hi)?,
None => default,
};
safety::bounded_io_timeout(name, Duration::from_millis(ms as u64))
}
fn command(name: &str, value: &Value) -> Result<Vec<String>> {
let Value::Array(items) = value else {
return Err(bad(name, "command must be an Array of String"));
};
if items.is_empty() {
return Err(bad(name, "command must name a program"));
}
if items.len() > MAX_ARGV {
return Err(over_limit(
name,
format!("command has more than {MAX_ARGV} elements"),
"pass a shorter argv",
));
}
let mut out = Vec::with_capacity(items.len());
for item in items {
let Value::Str(text) = item else {
return Err(bad(name, "command elements must be String"));
};
if text.contains('\0') {
return Err(bad(name, "command elements must not contain NUL"));
}
if text.len() > MAX_ARG_BYTES {
return Err(over_limit(
name,
format!("a command element exceeds {MAX_ARG_BYTES} bytes"),
"pass shorter arguments",
));
}
out.push(text.clone());
}
let program = &out[0];
if program.starts_with('-') {
return Err(bad(
name,
format!("command program must not start with '-': {program}"),
));
}
let path = Path::new(program);
if path.is_absolute() {
if !path.is_file() {
return Err(bad(
name,
format!("command program does not exist: {program}"),
));
}
} else if program.contains('/') || program.contains('\\') {
return Err(bad(
name,
format!("command program must be an absolute path or a plain name, got {program}"),
));
}
Ok(out)
}
fn paths(name: &str, value: &Value, as_argv: bool) -> Result<Vec<String>> {
let Value::Array(items) = value else {
return Err(bad(name, "paths must be an Array of String"));
};
if items.is_empty() {
return Err(bad(name, "paths must name at least one file or directory"));
}
if items.len() > MAX_PATHS {
return Err(over_limit(
name,
format!("paths has more than {MAX_PATHS} entries"),
"watch fewer paths",
));
}
let mut out = Vec::with_capacity(items.len());
for item in items {
let Value::Str(text) = item else {
return Err(bad(name, "paths elements must be String"));
};
if text.is_empty() || text.contains('\0') || text.contains('\n') || text.contains('\r') {
return Err(bad(
name,
"paths elements must be non-empty and free of NUL and line breaks",
));
}
if std::fs::symlink_metadata(text).is_err() {
return Err(bad(name, format!("path does not exist: {text}")));
}
out.push(text.clone());
}
if as_argv {
safety::reject_option_like(name, &out)?;
}
Ok(out)
}
pub(super) struct Finished {
exit_code: Option<i32>,
timed_out: bool,
duration_ms: i64,
stdout: String,
stderr: String,
}
impl Finished {
fn into_fields(self) -> BTreeMap<String, Value> {
let exit_code = match self.exit_code {
Some(code) if !self.timed_out => Value::Int(i64::from(code)),
_ => Value::Null,
};
let mut m = BTreeMap::new();
m.insert("exit_code".to_string(), exit_code);
m.insert("timed_out".to_string(), Value::Bool(self.timed_out));
m.insert("duration_ms".to_string(), Value::Int(self.duration_ms));
m.insert("stdout".to_string(), Value::Str(self.stdout));
m.insert("stderr".to_string(), Value::Str(self.stderr));
m
}
}
pub(super) fn run_bounded(
name: &str,
argv: &[String],
stdin: Option<String>,
limit: Duration,
) -> Result<Finished> {
let started = Instant::now();
let args: Vec<&str> = argv[1..].iter().map(String::as_str).collect();
let run = crate::builtins::run_tree(
&argv[0],
&args,
|_| {},
stdin.map(String::into_bytes),
limit,
Keep::Tail(OUTPUT_KEEP),
Keep::Tail(OUTPUT_KEEP),
)
.map_err(|e| match e {
TreeError::Spawn(e) => safety::spawn_error(name, &argv[0], &e),
TreeError::Wait(e) => e.into(),
})?;
let duration_ms = i64::try_from(started.elapsed().as_millis()).unwrap_or(i64::MAX);
Ok(Finished {
exit_code: run.code,
timed_out: run.timed_out,
duration_ms,
stdout: String::from_utf8_lossy(&run.stdout).into_owned(),
stderr: String::from_utf8_lossy(&run.stderr).into_owned(),
})
}
fn check_exit(name: &str, label: &str, finished: &Finished) -> Result<()> {
if finished.timed_out || finished.exit_code == Some(0) {
return Ok(());
}
Err(safety::tool_failed(
name,
label,
finished.exit_code,
&finished.stderr,
))
}
fn record(fields: BTreeMap<String, Value>) -> Value {
Value::Record(fields)
}
pub(super) fn iperf3_server(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
check_shape(IPERF3, &args, &input, 0, 1)?;
let opts = read_options(IPERF3, args.first(), &["bind", "port", "timeout_ms"])?;
let mut bind: IpAddr = Ipv4Addr::LOCALHOST.into();
if let Some(v) = opts.get("bind") {
let Value::Str(text) = v else {
return Err(bad(IPERF3, "option bind must be a String"));
};
bind = text.parse().map_err(|_| {
bad(
IPERF3,
"option bind must be a literal IPv4 or IPv6 address; resolve hostnames separately",
)
})?;
}
let port = match opts.get("port") {
Some(v) => int_in(IPERF3, "port", v, 1024, 65535)?,
None => 5201,
};
let timeout = timeout_of(IPERF3, &opts, 1000, 600_000, 60_000)?;
refuse_in_transaction(IPERF3)?;
let argv = vec![
"iperf3".to_string(),
"-s".to_string(),
"-1".to_string(),
"-J".to_string(),
"-p".to_string(),
port.to_string(),
"-B".to_string(),
bind.to_string(),
];
let finished = run_bounded(IPERF3, &argv, None, timeout)?;
check_exit(IPERF3, "iperf3 -s -1 -J", &finished)?;
let timed_out = finished.timed_out;
let stdout = finished.stdout.clone();
let mut fields = finished.into_fields();
if timed_out {
for key in ["received_bytes", "seconds", "bits_per_second"] {
fields.insert(key.to_string(), Value::Null);
}
return Ok(record(fields));
}
let json: serde_json::Value = serde_json::from_str(stdout.trim()).map_err(|e| {
safety::tool_failed(
IPERF3,
"iperf3 -s -1 -J",
Some(0),
&format!("output was not iperf3 JSON ({e})"),
)
})?;
let sum = match json.pointer("/end/sum_received") {
Some(sum) => sum,
None => {
let reason = json
.get("error")
.and_then(|e| e.as_str())
.unwrap_or("no end.sum_received in the output");
return Err(safety::tool_failed(
IPERF3,
"iperf3 -s -1 -J",
Some(0),
reason,
));
}
};
let bytes = sum.get("bytes").and_then(|v| v.as_i64());
let seconds = sum.get("seconds").and_then(|v| v.as_f64());
let bps = sum.get("bits_per_second").and_then(|v| v.as_f64());
let (Some(bytes), Some(seconds), Some(bps)) = (bytes, seconds, bps) else {
return Err(safety::tool_failed(
IPERF3,
"iperf3 -s -1 -J",
Some(0),
"end.sum_received lacks bytes, seconds or bits_per_second",
));
};
fields.insert("received_bytes".to_string(), Value::Int(bytes));
fields.insert("seconds".to_string(), Value::Float(seconds));
fields.insert("bits_per_second".to_string(), Value::Float(bps));
Ok(record(fields))
}
pub(super) fn entr_watch(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
check_shape(ENTR, &args, &input, 2, 3)?;
let watched = paths(ENTR, &args[0], false)?;
let argv_cmd = command(ENTR, &args[1])?;
let opts = read_options(ENTR, args.get(2), &["timeout_ms"])?;
let timeout = timeout_of(ENTR, &opts, 100, 600_000, 30_000)?;
refuse_in_transaction(ENTR)?;
let mut argv = vec!["entr".to_string(), "-n".to_string(), "--".to_string()];
argv.extend(argv_cmd);
let stdin = format!("{}\n", watched.join("\n"));
let finished = run_bounded(ENTR, &argv, Some(stdin), timeout)?;
check_exit(ENTR, "entr", &finished)?;
Ok(record(finished.into_fields()))
}
pub(super) fn watchexec_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
check_shape(WATCHEXEC, &args, &input, 2, 3)?;
let watched = paths(WATCHEXEC, &args[0], true)?;
let argv_cmd = command(WATCHEXEC, &args[1])?;
let opts = read_options(WATCHEXEC, args.get(2), &["timeout_ms"])?;
let timeout = timeout_of(WATCHEXEC, &opts, 100, 600_000, 30_000)?;
refuse_in_transaction(WATCHEXEC)?;
let mut argv = vec!["watchexec".to_string(), "--shell=none".to_string()];
for path in &watched {
argv.push("-w".to_string());
argv.push(path.clone());
}
argv.push("--".to_string());
argv.extend(argv_cmd);
let finished = run_bounded(WATCHEXEC, &argv, None, timeout)?;
check_exit(WATCHEXEC, "watchexec", &finished)?;
Ok(record(finished.into_fields()))
}
pub(super) fn nodemon_run(args: Vec<Value>, input: Option<Value>) -> Result<Value> {
check_shape(NODEMON, &args, &input, 1, 2)?;
let Value::Str(script) = &args[0] else {
return Err(bad(NODEMON, "script must be a String"));
};
if script.is_empty() || script.contains('\0') {
return Err(bad(NODEMON, "script must be a non-empty path without NUL"));
}
safety::reject_option_like(NODEMON, std::slice::from_ref(script))?;
let path = Path::new(script);
if !path.is_file() {
return Err(bad(
NODEMON,
format!("script does not exist as a file: {script}"),
));
}
let is_js = path
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case("js"));
if !is_js {
return Err(bad(NODEMON, "script must be a .js file"));
}
let opts = read_options(NODEMON, args.get(1), &["timeout_ms"])?;
let timeout = timeout_of(NODEMON, &opts, 100, 600_000, 30_000)?;
refuse_in_transaction(NODEMON)?;
let dir = path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.map(|p| p.display().to_string())
.unwrap_or_else(|| ".".to_string());
let argv = vec![
"nodemon".to_string(),
"--exitcrash".to_string(),
"--watch".to_string(),
dir,
script.clone(),
];
let finished = run_bounded(NODEMON, &argv, None, timeout)?;
check_exit(NODEMON, "nodemon", &finished)?;
Ok(record(finished.into_fields()))
}