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}