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…)      → ((:status N) (:stdout "…") (:stderr "…"))
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
29use std::process::{Command, Stdio};
30use std::sync::Arc;
31
32use tatara_lisp_eval::{Arity, EvalError, Interpreter, Value};
33
34use crate::script_ctx::ScriptCtx;
35use crate::stdlib::env::str_arg;
36
37pub fn install(interp: &mut Interpreter<ScriptCtx>) {
38    interp.register_fn(
39        "exec-check",
40        Arity::AtLeast(1),
41        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
42            let (cmd, rest) = split_cmd(args, "exec-check", sp)?;
43            let status = Command::new(&*cmd)
44                .args(rest.iter().map(|s| s.as_ref()))
45                .stdin(Stdio::inherit())
46                .stdout(Stdio::inherit())
47                .stderr(Stdio::inherit())
48                .status()
49                .map_err(|e| EvalError::native_fn("exec-check", e.to_string(), sp))?;
50            Ok(Value::Int(status.code().unwrap_or(-1) as i64))
51        },
52    );
53
54    interp.register_fn(
55        "exec-ok?",
56        Arity::AtLeast(1),
57        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
58            let (cmd, rest) = split_cmd(args, "exec-ok?", sp)?;
59            let status = Command::new(&*cmd)
60                .args(rest.iter().map(|s| s.as_ref()))
61                .stdin(Stdio::null())
62                .stdout(Stdio::null())
63                .stderr(Stdio::null())
64                .status()
65                .map_err(|e| EvalError::native_fn("exec-ok?", e.to_string(), sp))?;
66            Ok(Value::Bool(status.success()))
67        },
68    );
69
70    interp.register_fn(
71        "exec-capture",
72        Arity::AtLeast(1),
73        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
74            let (cmd, rest) = split_cmd(args, "exec-capture", sp)?;
75            let out = Command::new(&*cmd)
76                .args(rest.iter().map(|s| s.as_ref()))
77                .stdin(Stdio::null())
78                .output()
79                .map_err(|e| EvalError::native_fn("exec-capture", e.to_string(), sp))?;
80            Ok(capture_result(&out))
81        },
82    );
83
84    interp.register_fn(
85        "exec-with-stdin",
86        Arity::AtLeast(2),
87        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
88            use std::io::Write;
89            let payload = str_arg(&args[0], "exec-with-stdin", sp)?;
90            let (cmd, rest) = split_cmd(&args[1..], "exec-with-stdin", sp)?;
91            let mut child = Command::new(&*cmd)
92                .args(rest.iter().map(|s| s.as_ref()))
93                .stdin(Stdio::piped())
94                .stdout(Stdio::piped())
95                .stderr(Stdio::piped())
96                .spawn()
97                .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
98            // `take()` so the pipe is dropped before we wait — a tool reading
99            // stdin to EOF deadlocks otherwise.
100            if let Some(mut sink) = child.stdin.take() {
101                sink.write_all(payload.as_bytes())
102                    .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
103            }
104            let out = child
105                .wait_with_output()
106                .map_err(|e| EvalError::native_fn("exec-with-stdin", e.to_string(), sp))?;
107            Ok(capture_result(&out))
108        },
109    );
110
111    interp.register_fn(
112        "exec-with-env",
113        Arity::AtLeast(2),
114        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
115            let pairs = env_pairs(&args[0], "exec-with-env", sp)?;
116            let (cmd, rest) = split_cmd(&args[1..], "exec-with-env", sp)?;
117            let mut c = Command::new(&*cmd);
118            c.args(rest.iter().map(|s| s.as_ref()))
119                .stdin(Stdio::null())
120                .stdout(Stdio::piped())
121                .stderr(Stdio::piped());
122            for (k, v) in &pairs {
123                c.env(k.as_ref(), v.as_ref());
124            }
125            let out = c
126                .output()
127                .map_err(|e| EvalError::native_fn("exec-with-env", e.to_string(), sp))?;
128            Ok(capture_result(&out))
129        },
130    );
131
132    interp.register_fn(
133        "sh-exec",
134        Arity::Exact(1),
135        |args: &[Value], _ctx: &mut ScriptCtx, sp| {
136            let script = str_arg(&args[0], "sh-exec", sp)?;
137            let out = Command::new("sh")
138                .arg("-c")
139                .arg(&*script)
140                .stdin(Stdio::null())
141                .output()
142                .map_err(|e| EvalError::native_fn("sh-exec", e.to_string(), sp))?;
143            Ok(capture_result(&out))
144        },
145    );
146}
147
148fn split_cmd(
149    args: &[Value],
150    fname: &'static str,
151    sp: tatara_lisp::Span,
152) -> Result<(Arc<str>, Vec<Arc<str>>), EvalError> {
153    let mut it = args.iter();
154    let cmd = str_arg(
155        it.next().ok_or_else(|| {
156            EvalError::native_fn(fname, "expected at least 1 argument".to_string(), sp)
157        })?,
158        fname,
159        sp,
160    )?;
161    let rest = it
162        .map(|v| str_arg(v, fname, sp))
163        .collect::<Result<Vec<_>, _>>()?;
164    Ok((cmd, rest))
165}
166
167/// Read an alist of `(KEY VALUE)` string pairs.
168///
169/// Rejects a malformed entry rather than skipping it: a silently-dropped pair
170/// would run the child WITHOUT the credential and report success, which is a
171/// worse failure than an error.
172fn env_pairs(
173    v: &Value,
174    fname: &'static str,
175    sp: tatara_lisp::Span,
176) -> Result<Vec<(Arc<str>, Arc<str>)>, EvalError> {
177    let items = match v {
178        Value::List(items) => items,
179        _ => {
180            return Err(EvalError::native_fn(
181                fname,
182                "first argument must be an alist of (KEY VALUE) pairs".to_string(),
183                sp,
184            ));
185        }
186    };
187    let mut out = Vec::with_capacity(items.len());
188    for it in items.iter() {
189        match it {
190            Value::List(kv) if kv.len() == 2 => {
191                out.push((str_arg(&kv[0], fname, sp)?, str_arg(&kv[1], fname, sp)?));
192            }
193            _ => {
194                return Err(EvalError::native_fn(
195                    fname,
196                    "each env entry must be a 2-element (KEY VALUE) list".to_string(),
197                    sp,
198                ));
199            }
200        }
201    }
202    Ok(out)
203}
204
205fn capture_result(out: &std::process::Output) -> Value {
206    Value::list(vec![
207        Value::list(vec![
208            Value::Keyword(Arc::from("status")),
209            Value::Int(out.status.code().unwrap_or(-1) as i64),
210        ]),
211        Value::list(vec![
212            Value::Keyword(Arc::from("stdout")),
213            Value::Str(Arc::from(String::from_utf8_lossy(&out.stdout).as_ref())),
214        ]),
215        Value::list(vec![
216            Value::Keyword(Arc::from("stderr")),
217            Value::Str(Arc::from(String::from_utf8_lossy(&out.stderr).as_ref())),
218        ]),
219    ])
220}