Skip to main content

cljrs_stdlib/
process.rs

1//! Native implementation of `clojure.rust.process`: run a host program and
2//! capture its result. The substrate under `clojure.java.shell/sh`.
3//!
4//! `(run argv)` / `(run argv {:in "..." :dir "..." :env {"NAME" "value"} :out-bytes true})`
5//! returns `{:exit n :out "..." :err "..."}`. A non-zero exit is data, not an
6//! error. `:env` replaces the child environment (JVM `sh` semantics); the
7//! program is still found on the parent's PATH. A child killed by signal N
8//! reports exit 128+N. The program is started by argv, never through a shell.
9//! stdout/stderr are decoded as UTF-8, lossily; `:out-bytes true` returns
10//! stdout as a byte array instead.
11//!
12//! Strata: `collect` reads a `Request` value out of the Clojure arguments,
13//! `command` and `exit_code` / `result_value` are pure promotions, `execute`
14//! is the only boundary that touches the OS, `builtin_run` composes them.
15
16use std::io::Write;
17use std::process::{Command, Output, Stdio};
18use std::sync::{Arc, Mutex};
19
20use cljrs_gc::GcPtr;
21use cljrs_runtime::env::env::GlobalEnv;
22use cljrs_value::{Arity, Keyword, MapValue, NativeFn, Value, ValueError, ValueResult};
23
24/// The native fn's name, which is also its entry in the transaction policy
25/// denylist (`cljrs_runtime::env::policy::check_native`).
26pub const RUN_NAME: &str = "clojure.rust.process/run";
27
28pub fn register(globals: &Arc<GlobalEnv>, ns: &str) {
29    let nf = NativeFn::new(RUN_NAME, Arity::Variadic { min: 1 }, builtin_run);
30    globals.intern(ns, Arc::from("run"), Value::NativeFunction(GcPtr::new(nf)));
31}
32
33// ── Value objects ───────────────────────────────────────────────────────────────
34
35/// One program invocation, independent of how it was asked for.
36#[derive(Debug, Clone, PartialEq)]
37pub struct Request {
38    pub argv: Vec<String>,
39    pub stdin: Option<String>,
40    pub dir: Option<String>,
41    pub env: Option<Vec<(String, String)>>,
42    pub out_bytes: bool,
43}
44
45// ── Collect: Clojure arguments -> Request ───────────────────────────────────────
46
47fn kw(name: &str) -> Value {
48    Value::keyword(Keyword::simple(name))
49}
50
51fn arg_error(detail: &str) -> ValueError {
52    ValueError::Other(format!("clojure.rust.process/run: {detail}"))
53}
54
55/// A variable name `Command::env` accepts: non-empty, no `=`, no NUL.
56fn env_name(k: &Value) -> Option<String> {
57    let name = match k {
58        Value::Str(s) => s.get().clone(),
59        Value::Keyword(k) => k.get().name.to_string(),
60        _ => return None,
61    };
62    let valid = !name.is_empty() && !name.contains('=') && !name.contains('\0');
63    valid.then_some(name)
64}
65
66fn opt_string(opts: &MapValue, key: &str) -> ValueResult<Option<String>> {
67    match opts.get(&kw(key)) {
68        None | Some(Value::Nil) => Ok(None),
69        Some(Value::Str(s)) => Ok(Some(s.get().clone())),
70        Some(_) => Err(arg_error(&format!("the :{key} option must be a string"))),
71    }
72}
73
74fn collect_argv(v: &Value) -> ValueResult<Vec<String>> {
75    let bad = || arg_error("the command must be a non-empty vector of strings");
76    let argv: Vec<String> = match v {
77        Value::Vector(v) => v
78            .get()
79            .iter()
80            .map(|e| match e {
81                Value::Str(s) => Ok(s.get().clone()),
82                _ => Err(arg_error("every command element must be a string")),
83            })
84            .collect::<ValueResult<_>>()?,
85        _ => return Err(bad()),
86    };
87    if argv.is_empty() {
88        Err(bad())
89    } else {
90        Ok(argv)
91    }
92}
93
94fn collect_env(opts: &MapValue) -> ValueResult<Option<Vec<(String, String)>>> {
95    match opts.get(&kw("env")) {
96        None | Some(Value::Nil) => Ok(None),
97        Some(Value::Map(m)) => m
98            .iter()
99            .map(|(k, v)| match (env_name(k), v) {
100                // Command::env panics on a NUL in a value (argv and :dir
101                // surface it as a spawn error instead), so refuse it here.
102                (Some(name), Value::Str(s)) => {
103                    let v = s.get().clone();
104                    if v.contains('\0') {
105                        Err(arg_error("an :env value must not contain a NUL character"))
106                    } else {
107                        Ok((name, v))
108                    }
109                }
110                _ => Err(arg_error(
111                    "every :env key must be a valid variable name (string/keyword) and every value a string",
112                )),
113            })
114            .collect::<ValueResult<Vec<_>>>()
115            .map(Some),
116        Some(_) => Err(arg_error("the :env option must be a map of name to string value")),
117    }
118}
119
120/// Read a `Request` out of `(run argv)` / `(run argv opts)`.
121pub fn collect(args: &[Value]) -> ValueResult<Request> {
122    if args.len() > 2 {
123        return Err(arg_error("expects (run argv) or (run argv opts)"));
124    }
125    let argv = collect_argv(&args[0])?;
126    let opts = match args.get(1) {
127        None | Some(Value::Nil) => {
128            return Ok(Request {
129                argv,
130                stdin: None,
131                dir: None,
132                env: None,
133                out_bytes: false,
134            });
135        }
136        Some(Value::Map(m)) => m,
137        Some(_) => return Err(arg_error("the options argument must be a map")),
138    };
139    Ok(Request {
140        argv,
141        stdin: opt_string(opts, "in")?,
142        dir: opt_string(opts, "dir")?,
143        env: collect_env(opts)?,
144        out_bytes: matches!(opts.get(&kw("out-bytes")), Some(Value::Bool(true))),
145    })
146}
147
148// ── Promote: pure derivations ───────────────────────────────────────────────────
149
150/// The `Command` a request describes. Builds, never spawns.
151pub fn command(req: &Request) -> Command {
152    let mut cmd = Command::new(&req.argv[0]);
153    cmd.args(&req.argv[1..])
154        .stdin(if req.stdin.is_some() {
155            Stdio::piped()
156        } else {
157            Stdio::null()
158        })
159        .stdout(Stdio::piped())
160        .stderr(Stdio::piped());
161    if let Some(dir) = &req.dir {
162        cmd.current_dir(dir);
163    }
164    if let Some(env) = &req.env {
165        cmd.env_clear()
166            .envs(env.iter().map(|(k, v)| (k.as_str(), v.as_str())));
167    }
168    cmd
169}
170
171/// A process status as the integer `sh` reports: the exit code, or 128+N for
172/// a child killed by signal N.
173#[cfg(unix)]
174pub fn exit_code(status: std::process::ExitStatus) -> i64 {
175    use std::os::unix::process::ExitStatusExt;
176    match (status.code(), status.signal()) {
177        (Some(code), _) => code as i64,
178        (None, Some(sig)) => 128 + sig as i64,
179        (None, None) => -1,
180    }
181}
182
183#[cfg(not(unix))]
184pub fn exit_code(status: std::process::ExitStatus) -> i64 {
185    status.code().map(|c| c as i64).unwrap_or(-1)
186}
187
188/// The `{:exit :out :err}` map for a finished process.
189pub fn result_value(output: &Output, out_bytes: bool) -> Value {
190    let out = if out_bytes {
191        let bytes: Vec<i8> = output.stdout.iter().map(|b| *b as i8).collect();
192        Value::ByteArray(GcPtr::new(Mutex::new(bytes)))
193    } else {
194        Value::string(String::from_utf8_lossy(&output.stdout).into_owned())
195    };
196    Value::Map(MapValue::from_pairs(vec![
197        (kw("exit"), Value::Long(exit_code(output.status))),
198        (kw("out"), out),
199        (
200            kw("err"),
201            Value::string(String::from_utf8_lossy(&output.stderr).into_owned()),
202        ),
203    ]))
204}
205
206// ── Boundary: the only code that touches the OS ─────────────────────────────────
207
208/// Spawn `cmd`, feed `stdin`, drain stdout/stderr, wait.
209///
210/// stdin is written from its own thread while `wait_with_output` drains, so a
211/// child that answers before it finishes reading cannot deadlock the pipes. A
212/// child that exits unread makes the write fail with EPIPE (Rust ignores
213/// SIGPIPE); that is the child's choice, not an error of the call.
214pub fn execute(mut cmd: Command, program: &str, stdin: Option<String>) -> ValueResult<Output> {
215    let mut child = cmd.spawn().map_err(|e| {
216        ValueError::Other(format!(
217            "clojure.rust.process/run: cannot run program '{program}' ({e})"
218        ))
219    })?;
220    let feeder = match (stdin, child.stdin.take()) {
221        (Some(bytes), Some(mut pipe)) => Some(std::thread::spawn(move || {
222            let _ = pipe.write_all(bytes.as_bytes());
223        })),
224        _ => None,
225    };
226    let output = child.wait_with_output().map_err(|e| {
227        ValueError::Other(format!(
228            "clojure.rust.process/run: waiting for '{program}' failed ({e})"
229        ))
230    })?;
231    if let Some(handle) = feeder {
232        let _ = handle.join();
233    }
234    Ok(output)
235}
236
237// ── Facade ──────────────────────────────────────────────────────────────────────
238
239fn builtin_run(args: &[Value]) -> ValueResult<Value> {
240    let req = collect(args)?;
241    let output = execute(command(&req), &req.argv[0], req.stdin.clone())?;
242    Ok(result_value(&output, req.out_bytes))
243}