Skip to main content

tatara_lisp_script/stdlib/
process.rs

1//! Process / shell integration.
2//!
3//!   (exec-check CMD ARG…)        → 0 on success, non-zero exit code otherwise
4//!                                   Streams stdin/stdout/stderr to the parent.
5//!   (exec-capture CMD ARG…)      → the CAPTURE RECORD (below)
6//!                                   Captures stdout + stderr, exposes exit code.
7//!   (exec-ok? CMD ARG…)          → bool; true iff exit code is 0
8//!   (sh-exec STR)                → convenience: run STR through `sh -c`
9//!                                   returning the capture-form result
10//!
11//! Credential-carrying variants. Use these instead of putting a secret in an
12//! argument: argv is world-readable from the process table, and in CI it is
13//! readable by co-tenant steps and sibling containers.
14//!
15//!   (exec-with-stdin IN CMD ARG…)  → capture form; IN is written to the
16//!                                   child's stdin. The `--password-stdin`
17//!                                   shape that docker / helm / skopeo / gh
18//!                                   already support. Preferred.
19//!   (exec-with-env ENV CMD ARG…)   → capture form; ENV is an alist of
20//!                                   (KEY VALUE) pairs set for the child only.
21//!                                   For tools with no stdin form. Weaker than
22//!                                   stdin — the environment is readable by the
23//!                                   same uid — but far stronger than argv.
24//!
25//! No implicit shell interpolation. Arguments are passed literally to
26//! the underlying process; no glob / word-splitting / $VAR substitution.
27//! Scripts that want shell features use `sh-exec` explicitly.
28//!
29//! ── THE CAPTURE RECORD ───────────────────────────────────────────────────
30//! Every capture-form primitive above returns the SAME eight-field alist, so a
31//! caller never has to know which form produced it:
32//!
33//!   (:status N)          exit code, or -1 when killed by a signal
34//!   (:stdout "…")        captured stdout
35//!   (:stderr "…")        captured stderr
36//!   (:argv ("cmd" "a"))  the argv LIST, exact and unsplit
37//!   (:program "cmd")     the program as ASKED FOR
38//!   (:resolved "/nix/…") the program as RESOLVED — "" when PATH lookup failed
39//!   (:cwd "/…")          the directory the child inherited
40//!   (:duration-ms N)     wall-clock milliseconds
41//!
42//! The last five landed 2026-08-17 and are not decoration. A deshellify port's
43//! correctness argument is always "the new thing does what the old thing did",
44//! which is a COMPARISON — and you cannot compare an invocation you did not
45//! record. Concretely: `:resolved` answers "did the RIGHT tool run" (the
46//! silent-PATH-fallback class — `Command::new("kubectl")` runs whatever PATH
47//! found first, and 218 such bare spawns were measured in pleme-io/forge);
48//! `:cwd` distinguishes a write to a flake's read-only /nix/store source copy
49//! from one to the work tree, which otherwise surfaces only as a bare
50//! `Permission denied (os error 13)`; `:duration-ms` separates "failed" from
51//! "hung", which a status alone cannot.
52//!
53//! `:argv` is a LIST and never a joined string, because re-quoting a command
54//! changes it — a single string cannot be compared against what was run.
55//!
56//! ADDITIVE by construction: `status-of` / `stdout-of` / `stderr-of` and every
57//! other `alist-get` consumer are unaffected, and nothing in the tree asserted
58//! the record's length.
59//!
60//! Canonical technique: pleme-io/docs/controlled-subprocess.md (rung 2).
61
62use std::process::{Command, Stdio};
63use std::sync::Arc;
64
65use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
66
67use crate::script_ctx::ScriptCtx;
68use crate::stdlib::env::str_arg;
69
70pub fn install(interp: &mut Interpreter<ScriptCtx>) {
71    interp.register_fn(
72        "exec-check",
73        Arity::AtLeast(1),
74        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
75            let (cmd, rest) = split_cmd(args, "exec-check", sp)?;
76            let status = Command::new(&*cmd)
77                .args(rest.iter().map(|s| s.as_ref()))
78                .stdin(Stdio::inherit())
79                .stdout(Stdio::inherit())
80                .stderr(Stdio::inherit())
81                .status()
82                .map_err(|e| EvalError::native_fn("exec-check", e.to_string(), sp))?;
83            Ok(Value::Int(status.code().unwrap_or(-1) as i64))
84        },
85    );
86
87    interp.register_fn(
88        "exec-ok?",
89        Arity::AtLeast(1),
90        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
91            let (cmd, rest) = split_cmd(args, "exec-ok?", sp)?;
92            let status = Command::new(&*cmd)
93                .args(rest.iter().map(|s| s.as_ref()))
94                .stdin(Stdio::null())
95                .stdout(Stdio::null())
96                .stderr(Stdio::null())
97                .status()
98                .map_err(|e| EvalError::native_fn("exec-ok?", e.to_string(), sp))?;
99            Ok(Value::Bool(status.success()))
100        },
101    );
102
103    interp.register_fn(
104        "exec-capture",
105        Arity::AtLeast(1),
106        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
107            let (cmd, rest) = split_cmd(args, "exec-capture", sp)?;
108            let started = std::time::Instant::now();
109            let out = Command::new(&*cmd)
110                .args(rest.iter().map(|s| s.as_ref()))
111                .stdin(Stdio::null())
112                .output()
113                .map_err(|e| EvalError::native_fn("exec-capture", e.to_string(), sp))?;
114            let inv = Invocation::new(
115                &cmd,
116                rest.iter().map(|s| s.as_ref()).collect(),
117                started.elapsed().as_millis(),
118            );
119            Ok(capture_result(&inv, &out))
120        },
121    );
122
123    interp.register_fn(
124        "exec-with-stdin",
125        Arity::AtLeast(2),
126        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
127            use std::io::Write;
128            let payload = str_arg(&args[0], "exec-with-stdin", sp)?;
129            let (cmd, rest) = split_cmd(&args[1..], "exec-with-stdin", sp)?;
130            let started = std::time::Instant::now();
131            let mut child = Command::new(&*cmd)
132                .args(rest.iter().map(|s| s.as_ref()))
133                .stdin(Stdio::piped())
134                .stdout(Stdio::piped())
135                .stderr(Stdio::piped())
136                .spawn()
137                .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
138            // `take()` so the pipe is dropped before we wait — a tool reading
139            // stdin to EOF deadlocks otherwise.
140            if let Some(mut sink) = child.stdin.take() {
141                sink.write_all(payload.as_bytes())
142                    .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
143            }
144            let out = child
145                .wait_with_output()
146                .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
147            let inv = Invocation::new(
148                &cmd,
149                rest.iter().map(|s| s.as_ref()).collect(),
150                started.elapsed().as_millis(),
151            );
152            Ok(capture_result(&inv, &out))
153        },
154    );
155
156    interp.register_fn(
157        "exec-with-env",
158        Arity::AtLeast(2),
159        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
160            let pairs = env_pairs(&args[0], "exec-with-env", sp)?;
161            let (cmd, rest) = split_cmd(&args[1..], "exec-with-env", sp)?;
162            let mut c = Command::new(&*cmd);
163            c.args(rest.iter().map(|s| s.as_ref()))
164                .stdin(Stdio::null())
165                .stdout(Stdio::piped())
166                .stderr(Stdio::piped());
167            for (k, v) in &pairs {
168                c.env(k.as_ref(), v.as_ref());
169            }
170            let started = std::time::Instant::now();
171            let out = c
172                .output()
173                .map_err(|e| EvalError::native_fn("exec-with-env", e.to_string(), sp))?;
174            let inv = Invocation::new(
175                &cmd,
176                rest.iter().map(|s| s.as_ref()).collect(),
177                started.elapsed().as_millis(),
178            );
179            Ok(capture_result(&inv, &out))
180        },
181    );
182
183    interp.register_fn(
184        "sh-exec",
185        Arity::Exact(1),
186        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
187            let script = str_arg(&args[0], "sh-exec", sp)?;
188            let started = std::time::Instant::now();
189            let out = Command::new("sh")
190                .arg("-c")
191                .arg(&*script)
192                .stdin(Stdio::null())
193                .output()
194                .map_err(|e| EvalError::native_fn("sh-exec", e.to_string(), sp))?;
195            // argv is recorded as the REAL three-element form `sh -c <script>`,
196            // not as the script text alone. That is the honest record: sh-exec
197            // is the one primitive that hands a string to a shell, and the
198            // record should show that a shell was involved.
199            let inv = Invocation::new("sh", vec!["-c", &script], started.elapsed().as_millis());
200            Ok(capture_result(&inv, &out))
201        },
202    );
203}
204
205fn split_cmd(
206    args: &[Value],
207    fname: &'static str,
208    sp: tatara_lisp::Span,
209) -> Result<(Arc<str>, Vec<Arc<str>>), EvalError> {
210    let mut it = args.iter();
211    let cmd = str_arg(
212        it.next().ok_or_else(|| {
213            EvalError::native_fn(fname, "expected at least 1 argument".to_string(), sp)
214        })?,
215        fname,
216        sp,
217    )?;
218    let rest = it
219        .map(|v| str_arg(v, fname, sp))
220        .collect::<Result<Vec<_>, _>>()?;
221    Ok((cmd, rest))
222}
223
224/// Read an alist of `(KEY VALUE)` string pairs.
225///
226/// Rejects a malformed entry rather than skipping it: a silently-dropped pair
227/// would run the child WITHOUT the credential and report success, which is a
228/// worse failure than an error.
229fn env_pairs(
230    v: &Value,
231    fname: &'static str,
232    sp: tatara_lisp::Span,
233) -> Result<Vec<(Arc<str>, Arc<str>)>, EvalError> {
234    let items = match v {
235        Value::List(items) => items,
236        _ => {
237            return Err(EvalError::native_fn(
238                fname,
239                "first argument must be an alist of (KEY VALUE) pairs".to_string(),
240                sp,
241            ));
242        }
243    };
244    let mut out = Vec::with_capacity(items.len());
245    for it in items.iter() {
246        match it {
247            Value::List(kv) if kv.len() == 2 => {
248                out.push((str_arg(&kv[0], fname, sp)?, str_arg(&kv[1], fname, sp)?));
249            }
250            _ => {
251                return Err(EvalError::native_fn(
252                    fname,
253                    "each env entry must be a 2-element (KEY VALUE) list".to_string(),
254                    sp,
255                ));
256            }
257        }
258    }
259    Ok(out)
260}
261
262/// What was actually invoked, carried alongside the output so the record can
263/// answer WHICH binary ran and WHERE — not just what it printed.
264///
265/// ── ★ WHY THE RECORD NEEDED WIDENING ─────────────────────────────────────
266/// The record used to be `{status, stdout, stderr}`: three fields built from
267/// `std::process::Output` alone, so argv, the resolved binary and the cwd were
268/// not omitted by choice — [`capture_result`] never received them.
269///
270/// That gap is what makes a deshellify port's correctness argument uncheckable.
271/// The argument is always "the new thing does what the old thing did", and that
272/// is a COMPARISON; you cannot compare an invocation you did not record. Two
273/// concrete failure classes it left invisible:
274///
275///   * WHICH BINARY. `Command::new("kubectl")` runs whatever `PATH` resolved
276///     first, so a verdict gets attributed to a binary nobody declared — the
277///     silent-PATH-fallback class. With no resolved-path field, "did the right
278///     tool run" was unanswerable from the record and could only be settled by
279///     reading source. Measured in pleme-io/forge: 218 bare-literal spawns.
280///   * WHERE. A bump that targets a flake's read-only `/nix/store` source copy
281///     instead of the work tree fails with a bare `Permission denied (os error
282///     13)`; the cwd is the field that says which one it was.
283///
284/// `duration-ms` separates "failed" from "hung", which a status alone cannot.
285struct Invocation<'a> {
286    program: &'a str,
287    args: Vec<&'a str>,
288    elapsed_ms: u128,
289}
290
291impl<'a> Invocation<'a> {
292    fn new(program: &'a str, args: Vec<&'a str>, elapsed_ms: u128) -> Self {
293        Self {
294            program,
295            args,
296            elapsed_ms,
297        }
298    }
299}
300
301/// Resolve `program` the way the OS just did, so the record names the binary
302/// that actually ran rather than the string we asked for.
303///
304/// A path-bearing program is already unambiguous and is returned as-is. A bare
305/// name is resolved through `PATH`; when resolution fails the field is the empty
306/// string — which is a FINDING (the spawn succeeded, so something ran, and we
307/// could not name it) and deliberately not the bare name again, because that
308/// would render "resolved" and "unresolved" as the same bytes.
309fn resolved_program(program: &str) -> String {
310    if program.contains(std::path::MAIN_SEPARATOR) {
311        return program.to_string();
312    }
313    which::which(program)
314        .map(|p| p.display().to_string())
315        .unwrap_or_default()
316}
317
318fn capture_result(inv: &Invocation<'_>, out: &std::process::Output) -> Value {
319    // argv as a LIST, never a joined string: re-quoting a command changes it,
320    // so a single string cannot be compared against what was run.
321    let mut argv = vec![Value::Str(Arc::from(inv.program))];
322    argv.extend(inv.args.iter().map(|a| Value::Str(Arc::from(*a))));
323
324    let cwd = std::env::current_dir()
325        .map(|p| p.display().to_string())
326        .unwrap_or_default();
327
328    Value::list(vec![
329        Value::list(vec![
330            Value::Keyword(Arc::from("status")),
331            Value::Int(out.status.code().unwrap_or(-1) as i64),
332        ]),
333        Value::list(vec![
334            Value::Keyword(Arc::from("stdout")),
335            Value::Str(Arc::from(String::from_utf8_lossy(&out.stdout).as_ref())),
336        ]),
337        Value::list(vec![
338            Value::Keyword(Arc::from("stderr")),
339            Value::Str(Arc::from(String::from_utf8_lossy(&out.stderr).as_ref())),
340        ]),
341        Value::list(vec![Value::Keyword(Arc::from("argv")), Value::list(argv)]),
342        Value::list(vec![
343            Value::Keyword(Arc::from("program")),
344            Value::Str(Arc::from(inv.program)),
345        ]),
346        Value::list(vec![
347            Value::Keyword(Arc::from("resolved")),
348            Value::Str(Arc::from(resolved_program(inv.program).as_str())),
349        ]),
350        Value::list(vec![
351            Value::Keyword(Arc::from("cwd")),
352            Value::Str(Arc::from(cwd.as_str())),
353        ]),
354        Value::list(vec![
355            Value::Keyword(Arc::from("duration-ms")),
356            Value::Int(inv.elapsed_ms.min(i64::MAX as u128) as i64),
357        ]),
358    ])
359}