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: every `alist-get` consumer of the record is
57//! unaffected, and nothing in the tree asserted the record's length.
58//!
59//! **The record's readers are native as of 2026-09-15** — `status-of`,
60//! `stdout-of`, `stderr-of`, installed by `install_capture_readers` below.
61//! Before that, this paragraph named them as consumers while they were NOT
62//! builtins, and a script trusting it died with `unbound symbol` at runtime.
63//! A doc comment that names a symbol is not a binding; see that function for
64//! the 40-file measurement that licensed making them one.
65//!
66//! Canonical technique: pleme-io/docs/controlled-subprocess.md (rung 2).
67
68use std::process::{Command, Stdio};
69use std::sync::Arc;
70
71use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
72
73use crate::script_ctx::ScriptCtx;
74use crate::stdlib::env::str_arg;
75
76pub fn install(interp: &mut Interpreter<ScriptCtx>) {
77    interp.register_fn(
78        "exec-check",
79        Arity::AtLeast(1),
80        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
81            let (cmd, rest) = split_cmd(args, "exec-check", sp)?;
82            let status = Command::new(&*cmd)
83                .args(rest.iter().map(|s| s.as_ref()))
84                .stdin(Stdio::inherit())
85                .stdout(Stdio::inherit())
86                .stderr(Stdio::inherit())
87                .status()
88                .map_err(|e| EvalError::native_fn("exec-check", e.to_string(), sp))?;
89            Ok(Value::Int(status.code().unwrap_or(-1) as i64))
90        },
91    );
92
93    interp.register_fn(
94        "exec-ok?",
95        Arity::AtLeast(1),
96        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
97            let (cmd, rest) = split_cmd(args, "exec-ok?", sp)?;
98            let status = Command::new(&*cmd)
99                .args(rest.iter().map(|s| s.as_ref()))
100                .stdin(Stdio::null())
101                .stdout(Stdio::null())
102                .stderr(Stdio::null())
103                .status()
104                .map_err(|e| EvalError::native_fn("exec-ok?", e.to_string(), sp))?;
105            Ok(Value::Bool(status.success()))
106        },
107    );
108
109    interp.register_fn(
110        "exec-capture",
111        Arity::AtLeast(1),
112        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
113            let (cmd, rest) = split_cmd(args, "exec-capture", sp)?;
114            let started = std::time::Instant::now();
115            let out = Command::new(&*cmd)
116                .args(rest.iter().map(|s| s.as_ref()))
117                .stdin(Stdio::null())
118                .output()
119                .map_err(|e| EvalError::native_fn("exec-capture", e.to_string(), sp))?;
120            let inv = Invocation::new(
121                &cmd,
122                rest.iter().map(|s| s.as_ref()).collect(),
123                started.elapsed().as_millis(),
124            );
125            Ok(capture_result(&inv, &out))
126        },
127    );
128
129    interp.register_fn(
130        "exec-with-stdin",
131        Arity::AtLeast(2),
132        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
133            use std::io::Write;
134            let payload = str_arg(&args[0], "exec-with-stdin", sp)?;
135            let (cmd, rest) = split_cmd(&args[1..], "exec-with-stdin", sp)?;
136            let started = std::time::Instant::now();
137            let mut child = Command::new(&*cmd)
138                .args(rest.iter().map(|s| s.as_ref()))
139                .stdin(Stdio::piped())
140                .stdout(Stdio::piped())
141                .stderr(Stdio::piped())
142                .spawn()
143                .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
144            // `take()` so the pipe is dropped before we wait — a tool reading
145            // stdin to EOF deadlocks otherwise.
146            if let Some(mut sink) = child.stdin.take() {
147                // A child that exits or closes stdin without reading all of it
148                // (`true`, a tool that ignores stdin) makes this write fail with
149                // EPIPE. That is the child's choice, not our error: its exit
150                // status and output below are the answer. Treating it as an
151                // error made `neither_sink_puts_the_value_in_argv` fail whenever
152                // the child won the race (auto-release run 36634926576).
153                match sink.write_all(payload.as_bytes()) {
154                    Err(e) if e.kind() != std::io::ErrorKind::BrokenPipe => {
155                        return Err(EvalError::native_fn("exec-with-stdin", e.to_string(), sp));
156                    }
157                    _ => {}
158                }
159            }
160            let out = child
161                .wait_with_output()
162                .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
163            let inv = Invocation::new(
164                &cmd,
165                rest.iter().map(|s| s.as_ref()).collect(),
166                started.elapsed().as_millis(),
167            );
168            Ok(capture_result(&inv, &out))
169        },
170    );
171
172    interp.register_fn(
173        "exec-with-env",
174        Arity::AtLeast(2),
175        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
176            let pairs = env_pairs(&args[0], "exec-with-env", sp)?;
177            let (cmd, rest) = split_cmd(&args[1..], "exec-with-env", sp)?;
178            let mut c = Command::new(&*cmd);
179            c.args(rest.iter().map(|s| s.as_ref()))
180                .stdin(Stdio::null())
181                .stdout(Stdio::piped())
182                .stderr(Stdio::piped());
183            for (k, v) in &pairs {
184                c.env(k.as_ref(), v.as_ref());
185            }
186            let started = std::time::Instant::now();
187            let out = c
188                .output()
189                .map_err(|e| EvalError::native_fn("exec-with-env", e.to_string(), sp))?;
190            let inv = Invocation::new(
191                &cmd,
192                rest.iter().map(|s| s.as_ref()).collect(),
193                started.elapsed().as_millis(),
194            );
195            Ok(capture_result(&inv, &out))
196        },
197    );
198
199    interp.register_fn(
200        "sh-exec",
201        Arity::Exact(1),
202        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
203            let script = str_arg(&args[0], "sh-exec", sp)?;
204            let started = std::time::Instant::now();
205            let out = Command::new("sh")
206                .arg("-c")
207                .arg(&*script)
208                .stdin(Stdio::null())
209                .output()
210                .map_err(|e| EvalError::native_fn("sh-exec", e.to_string(), sp))?;
211            // argv is recorded as the REAL three-element form `sh -c <script>`,
212            // not as the script text alone. That is the honest record: sh-exec
213            // is the one primitive that hands a string to a shell, and the
214            // record should show that a shell was involved.
215            let inv = Invocation::new("sh", vec!["-c", &script], started.elapsed().as_millis());
216            Ok(capture_result(&inv, &out))
217        },
218    );
219
220    install_capture_readers(interp);
221}
222
223/// `status-of` / `stdout-of` / `stderr-of` — the readers of the record
224/// [`capture_result`] produces, installed beside the producer.
225///
226/// ── WHY THESE ARE NATIVE ───────────────────────────────────────────────
227/// They were never builtins, yet this module's own header named them as
228/// consumers of the record, so a reader (human or agent) took them to exist.
229/// A script calling one died at runtime with `unbound symbol`.
230///
231/// `tatara-script lint` DOES catch that statically — its `unbound-symbol`
232/// rule reports `status-of is not bound by the interpreter or this program`
233/// (verified 2026-09-15 against the 0.3.55 binary). So the class was caught
234/// at lint time, in any repo that runs the lint. The scripts that shipped the
235/// call were ones where it did not run; the defect was gate coverage, not
236/// a missing check.
237///
238/// The fleet answered by hand-rolling them. Measured 2026-09-15 with `rg`
239/// across every `.tlisp` in pleme-io: **40 files in 8 repos** define them
240/// locally — nix 29, actions 3, substrate 3, and five `caixa-tlisp-*` — and
241/// `caixa-tlisp-core/stdlib.tlisp:148-152` is an extraction with ZERO
242/// consumers, because standalone `tatara-script file.tlisp` cannot load it.
243/// Every copy has the SAME body. That is convergent evidence, not duplication:
244/// independent authors kept arriving at one shape because the record forces it.
245///
246/// ── THE DEFAULTS ARE THE PRODUCER'S, NOT A CHOICE ───────────────────────
247/// `-1` for status is what [`capture_result`] itself writes when a child is
248/// killed by a signal (`out.status.code().unwrap_or(-1)`, and `(:status N)`
249/// is documented as "exit code, or -1 when killed by a signal"). Every local
250/// copy picked `-1` independently. Using anything else here would make a
251/// signal-killed child and a missing key report differently for no reason.
252///
253/// ── NON-BREAKING ────────────────────────────────────────────────────────
254/// A script-level `(define (status-of r) ...)` SHADOWS a native binding, so
255/// all 40 existing local definitions keep working unchanged. They can be
256/// deleted at leisure; nothing forces a migration.
257fn install_capture_readers(interp: &mut Interpreter<ScriptCtx>) {
258    use crate::stdlib::json::alist_lookup;
259
260    for (name, key, default) in [
261        ("status-of", "status", Value::Int(-1)),
262        ("stdout-of", "stdout", Value::Str(Arc::from(""))),
263        ("stderr-of", "stderr", Value::Str(Arc::from(""))),
264    ] {
265        interp.register_fn(
266            name,
267            Arity::Exact(1),
268            move |args: &[Value], _ctx: &mut ScriptCtx, _sp| {
269                Ok(alist_lookup(&args[0], key).unwrap_or_else(|| default.clone()))
270            },
271        );
272    }
273}
274
275fn split_cmd(
276    args: &[Value],
277    fname: &'static str,
278    sp: tatara_lisp::Span,
279) -> Result<(Arc<str>, Vec<Arc<str>>), EvalError> {
280    let mut it = args.iter();
281    let cmd = str_arg(
282        it.next().ok_or_else(|| {
283            EvalError::native_fn(fname, "expected at least 1 argument".to_string(), sp)
284        })?,
285        fname,
286        sp,
287    )?;
288    let rest = it
289        .map(|v| str_arg(v, fname, sp))
290        .collect::<Result<Vec<_>, _>>()?;
291    Ok((cmd, rest))
292}
293
294/// Read an alist of `(KEY VALUE)` string pairs.
295///
296/// Rejects a malformed entry rather than skipping it: a silently-dropped pair
297/// would run the child WITHOUT the credential and report success, which is a
298/// worse failure than an error.
299fn env_pairs(
300    v: &Value,
301    fname: &'static str,
302    sp: tatara_lisp::Span,
303) -> Result<Vec<(Arc<str>, Arc<str>)>, EvalError> {
304    let items = match v {
305        Value::List(items) => items,
306        _ => {
307            return Err(EvalError::native_fn(
308                fname,
309                "first argument must be an alist of (KEY VALUE) pairs".to_string(),
310                sp,
311            ));
312        }
313    };
314    let mut out = Vec::with_capacity(items.len());
315    for it in items.iter() {
316        match it {
317            Value::List(kv) if kv.len() == 2 => {
318                out.push((str_arg(&kv[0], fname, sp)?, str_arg(&kv[1], fname, sp)?));
319            }
320            _ => {
321                return Err(EvalError::native_fn(
322                    fname,
323                    "each env entry must be a 2-element (KEY VALUE) list".to_string(),
324                    sp,
325                ));
326            }
327        }
328    }
329    Ok(out)
330}
331
332/// What was actually invoked, carried alongside the output so the record can
333/// answer WHICH binary ran and WHERE — not just what it printed.
334///
335/// ── ★ WHY THE RECORD NEEDED WIDENING ─────────────────────────────────────
336/// The record used to be `{status, stdout, stderr}`: three fields built from
337/// `std::process::Output` alone, so argv, the resolved binary and the cwd were
338/// not omitted by choice — [`capture_result`] never received them.
339///
340/// That gap is what makes a deshellify port's correctness argument uncheckable.
341/// The argument is always "the new thing does what the old thing did", and that
342/// is a COMPARISON; you cannot compare an invocation you did not record. Two
343/// concrete failure classes it left invisible:
344///
345///   * WHICH BINARY. `Command::new("kubectl")` runs whatever `PATH` resolved
346///     first, so a verdict gets attributed to a binary nobody declared — the
347///     silent-PATH-fallback class. With no resolved-path field, "did the right
348///     tool run" was unanswerable from the record and could only be settled by
349///     reading source. Measured in pleme-io/forge: 218 bare-literal spawns.
350///   * WHERE. A bump that targets a flake's read-only `/nix/store` source copy
351///     instead of the work tree fails with a bare `Permission denied (os error
352///     13)`; the cwd is the field that says which one it was.
353///
354/// `duration-ms` separates "failed" from "hung", which a status alone cannot.
355struct Invocation<'a> {
356    program: &'a str,
357    args: Vec<&'a str>,
358    elapsed_ms: u128,
359}
360
361impl<'a> Invocation<'a> {
362    fn new(program: &'a str, args: Vec<&'a str>, elapsed_ms: u128) -> Self {
363        Self {
364            program,
365            args,
366            elapsed_ms,
367        }
368    }
369}
370
371/// Resolve `program` the way the OS just did, so the record names the binary
372/// that actually ran rather than the string we asked for.
373///
374/// A path-bearing program is already unambiguous and is returned as-is. A bare
375/// name is resolved through `PATH`; when resolution fails the field is the empty
376/// string — which is a FINDING (the spawn succeeded, so something ran, and we
377/// could not name it) and deliberately not the bare name again, because that
378/// would render "resolved" and "unresolved" as the same bytes.
379fn resolved_program(program: &str) -> String {
380    if program.contains(std::path::MAIN_SEPARATOR) {
381        return program.to_string();
382    }
383    which::which(program)
384        .map(|p| p.display().to_string())
385        .unwrap_or_default()
386}
387
388fn capture_result(inv: &Invocation<'_>, out: &std::process::Output) -> Value {
389    // argv as a LIST, never a joined string: re-quoting a command changes it,
390    // so a single string cannot be compared against what was run.
391    let mut argv = vec![Value::Str(Arc::from(inv.program))];
392    argv.extend(inv.args.iter().map(|a| Value::Str(Arc::from(*a))));
393
394    let cwd = std::env::current_dir()
395        .map(|p| p.display().to_string())
396        .unwrap_or_default();
397
398    Value::list(vec![
399        Value::list(vec![
400            Value::Keyword(Arc::from("status")),
401            Value::Int(out.status.code().unwrap_or(-1) as i64),
402        ]),
403        Value::list(vec![
404            Value::Keyword(Arc::from("stdout")),
405            Value::Str(Arc::from(String::from_utf8_lossy(&out.stdout).as_ref())),
406        ]),
407        Value::list(vec![
408            Value::Keyword(Arc::from("stderr")),
409            Value::Str(Arc::from(String::from_utf8_lossy(&out.stderr).as_ref())),
410        ]),
411        Value::list(vec![Value::Keyword(Arc::from("argv")), Value::list(argv)]),
412        Value::list(vec![
413            Value::Keyword(Arc::from("program")),
414            Value::Str(Arc::from(inv.program)),
415        ]),
416        Value::list(vec![
417            Value::Keyword(Arc::from("resolved")),
418            Value::Str(Arc::from(resolved_program(inv.program).as_str())),
419        ]),
420        Value::list(vec![
421            Value::Keyword(Arc::from("cwd")),
422            Value::Str(Arc::from(cwd.as_str())),
423        ]),
424        Value::list(vec![
425            Value::Keyword(Arc::from("duration-ms")),
426            Value::Int(inv.elapsed_ms.min(i64::MAX as u128) as i64),
427        ]),
428    ])
429}