Skip to main content

kaish_kernel/ast/
plan.rs

1//! The statement plan: an AST rendered back to shell text, **unexpanded**.
2//!
3//! A plan is parse information. It is built after validation and before
4//! execution, so `${HOME}` and `$(...)` appear exactly as written — an
5//! embedder judges what was asked, not what it resolved to, and the
6//! substitution that would resolve them has not run.
7//!
8//! Two products, one AST walk each: [`render_stmt`] produces the text, and
9//! [`planned_commands`] produces one [`PlannedCommand`] per command the
10//! statement contains — control-structure bodies, `if` conditions, and
11//! command substitutions included, because every one of them is a command
12//! this statement would run.
13//!
14//! The same walk collects the statement's variables: the names it reads
15//! (`free_variables`) and the names it writes (`bound_variables`). A name
16//! that is both lands bound, never free.
17//!
18//! The collection walk also lifts out any literal `--confirm=<key>` the
19//! statement's argv carries ([`StatementPlan::presented_keys`]) — the same
20//! spellings the rendering redacts. One predicate decides all three of lift,
21//! redact, and render, so they cannot disagree about what the statement
22//! presented.
23//!
24//! Redaction is the `--confirm=<key>` flag spelling and nothing else — that
25//! spelling carries a confirmation credential, and a credential must never
26//! ride into a stored plan. kaish ships no secret detector — a shell cannot
27//! define what a secret is — so an embedder that wants more redacts the
28//! plans it holds.
29
30use std::collections::BTreeSet;
31
32use kaish_types::plan::{Plan, PlannedCommand, PlannedRedirect, PlannedValue, PLAN_RENDER_LIMIT};
33use kaish_types::Value;
34
35use super::types::{
36    Arg, Assignment, BinaryOp, CaseStmt, Command, Expr, ForLoop, IfStmt, ListElem, Pipeline,
37    RecordKey, Redirect, Stmt, StringPart, TestExpr, ToolDef, VarPath, VarSegment, WhileLoop,
38};
39
40/// One statement's plan, plus the redemption credentials its argv presented.
41pub struct StatementPlan {
42    /// What the statement was asked to run, with every credential redacted.
43    pub plan: Plan,
44    /// Every literal `--confirm=<key>` (or `confirm=<key>`) the statement's
45    /// argv carries, in source order.
46    ///
47    /// **Literal only.** A plan is unexpanded, so `--confirm=${key}` reads as
48    /// `${key}` here and nothing is lifted — what the plan cannot see, the
49    /// plan cannot leak, and nothing is stripped from the argv that executes.
50    pub presented_keys: Vec<String>,
51}
52
53/// One statement of a planned program: its [`Plan`] and where it sits in the
54/// parsed source.
55///
56/// `index` is the statement's position in the parsed program, so an embedder
57/// can name which statement it is talking about.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct PlannedStatement {
60    /// The statement's position in the parsed program.
61    pub index: usize,
62    /// What the statement was asked to run, with every credential redacted.
63    pub plan: Plan,
64}
65
66/// Plan every statement of `source` without executing anything.
67///
68/// A plan is parse information: `${HOME}` and `$(...)` appear exactly as
69/// written, no substitution has run, and no filesystem has been touched. That
70/// is the point — an embedder judges what the statement *asked for*, before
71/// anything it names can happen.
72///
73/// Each plan carries the statement's rendered text, every command it would
74/// run (control-structure bodies, `if` conditions, and `$(...)` bodies
75/// included), the variables it reads ([`Plan::free_variables`]) and the ones
76/// it writes ([`Plan::bound_variables`]). Reading live session state for the
77/// free set with [`Kernel::get_var`](crate::Kernel::get_var) closes the loop:
78/// plan a statement, look up what it depends on, and decide with the values
79/// in hand.
80///
81/// Every literal `--confirm=<key>` is redacted from the plans and **not
82/// returned**: the caller holds `source` and can read its own credentials;
83/// this function adds no second copy.
84///
85/// # Errors
86///
87/// Returns the parse errors when `source` does not parse. Each error's
88/// [`format`](crate::parser::ParseError::format) renders a diagnostic against
89/// the source.
90pub fn plan_program(
91    source: &str,
92) -> Result<Vec<PlannedStatement>, Vec<crate::parser::ParseError>> {
93    let program = crate::parser::parse(source)?;
94    Ok(program
95        .statements
96        .iter()
97        .enumerate()
98        // An empty statement runs nothing and plans nothing; skipping it here
99        // is why `index` is carried explicitly instead of implied by position.
100        .filter(|(_, stmt)| !matches!(stmt, Stmt::Empty))
101        .map(|(index, stmt)| PlannedStatement {
102            index,
103            plan: plan_statement(stmt).plan,
104        })
105        .collect())
106}
107
108/// Build the plan for one top-level statement. Every value is
109/// [`PlannedValue::Plain`] except a presented confirm key, which the kernel
110/// redacts unconditionally.
111pub fn plan_statement(stmt: &Stmt) -> StatementPlan {
112    let collected = collect(stmt);
113    // Free = read and never written in-statement. A name that is both read
114    // and written lands in `bound` — the safe direction: an embedder that
115    // skips peeking it loses one lookup; one that peeked it would judge the
116    // statement against a value the statement itself replaces.
117    let free: Vec<String> = collected
118        .reads
119        .difference(&collected.binds)
120        .cloned()
121        .collect();
122    let bound: Vec<String> = collected.binds.into_iter().collect();
123    StatementPlan {
124        plan: Plan::new(
125            truncate_rendering(render_stmt(stmt)),
126            stmt.kind_name(),
127            collected.commands,
128        )
129        .with_variables(free, bound),
130        presented_keys: collected.keys,
131    }
132}
133
134/// Remove every `--confirm=` (or `confirm=`) token from rendered plan text,
135/// whatever it carries.
136///
137/// An embedder computing a content identity over rendered text (see
138/// `PlanDigest` in kaish-types) wants the identity to cover the operation,
139/// not any credential presented with it — `rm x` and
140/// `rm --confirm=<confirm-key> x` should digest the same.
141///
142/// Unlike [`redact_keys`], this does not need to know the key: it removes the
143/// whole token whether it carries a literal credential, the `<confirm-key>`
144/// marker a rendered plan shows, or an unexpanded `${key}` the plan could not
145/// lift.
146pub fn strip_confirm_tokens(rendered: &str) -> String {
147    rendered
148        .split_whitespace()
149        .filter(|word| {
150            !word.starts_with(&format!("--{CONFIRM_KEY}=")) && !word.starts_with(&format!("{CONFIRM_KEY}="))
151        })
152        .collect::<Vec<_>>()
153        .join(" ")
154}
155
156/// Remove every one of `keys` from captured source text — for an embedder
157/// storing source alongside plans, so the stored text never carries a
158/// credential. The whole `--confirm=<key>` token goes, not just its value,
159/// so re-running the stored text cannot re-present a spent key.
160pub fn redact_keys(source: &str, keys: &[String]) -> String {
161    let mut out = source.to_string();
162    for key in keys {
163        for spelling in [format!("--{CONFIRM_KEY}={key}"), format!("{CONFIRM_KEY}={key}")] {
164            // Take the separating space with the token so the surrounding
165            // words stay one space apart; fall back to the bare token for a
166            // spelling that opens its line.
167            out = out.replace(&format!(" {spelling}"), "");
168            out = out.replace(&spelling, "");
169        }
170    }
171    out
172}
173
174/// Cut a rendering to [`PLAN_RENDER_LIMIT`] bytes, naming the cut.
175///
176/// The marker is loud and states the number, because a classifier reading a
177/// silently shortened line would judge a statement it cannot see the end of.
178/// The structure is not lost with the text — [`Plan::commands`] still names
179/// every command.
180fn truncate_rendering(rendered: String) -> String {
181    if rendered.len() <= PLAN_RENDER_LIMIT {
182        return rendered;
183    }
184    // Back up to a character boundary so the marker lands on valid UTF-8.
185    let mut cut = PLAN_RENDER_LIMIT;
186    while cut > 0 && !rendered.is_char_boundary(cut) {
187        cut -= 1;
188    }
189    let mut out = rendered[..cut].to_string();
190    out.push_str(&format!(
191        "… [rendering truncated at {PLAN_RENDER_LIMIT} bytes]"
192    ));
193    out
194}
195
196// ───────────────────────── Command collection ─────────────────────────
197
198/// What one collection walk produces: the statement's commands, the
199/// credentials their argv presented, and its variable analysis.
200#[derive(Default)]
201struct Collected {
202    commands: Vec<PlannedCommand>,
203    keys: Vec<String>,
204    /// Every variable name the statement reads, anywhere — `${x}`, a
205    /// `"${x}"` interpolation, `${#x}`, a `[$k]` dynamic subscript, an
206    /// identifier inside `$((…))`. kaish has no `eval` and no indirect
207    /// expansion, so this set is complete by construction.
208    reads: BTreeSet<String>,
209    /// Every name the statement writes or binds — an assignment target, a
210    /// `for` variable, an env-prefix name, a tool-def parameter.
211    binds: BTreeSet<String>,
212}
213
214impl Collected {
215    /// Record every read a variable path performs: its root name, plus any
216    /// `[$k]` dynamic-subscript variable along the path.
217    fn read_path(&mut self, path: &VarPath) {
218        for (i, segment) in path.segments.iter().enumerate() {
219            match segment {
220                VarSegment::Field(name) if i == 0 => {
221                    self.reads.insert(name.clone());
222                }
223                VarSegment::Dynamic(v) => {
224                    self.reads.insert(v.clone());
225                }
226                _ => {}
227            }
228        }
229    }
230
231    /// Record the name an assignment path writes (its root), plus the reads
232    /// its dynamic subscripts perform — `x[$k]=v` writes `x` and reads `k`.
233    fn bind_path(&mut self, path: &VarPath) {
234        if let Some(VarSegment::Field(name)) = path.segments.first() {
235            self.binds.insert(name.clone());
236        }
237        for segment in path.segments.iter().skip(1) {
238            if let VarSegment::Dynamic(v) = segment {
239                self.reads.insert(v.clone());
240            }
241        }
242    }
243
244    /// Record every identifier in an arithmetic expression as a read.
245    /// kaish arithmetic is numbers, variables (bare or `${name}`), and
246    /// operators — an identifier token is always a variable.
247    fn read_arithmetic(&mut self, expr: &str) {
248        let mut name = String::new();
249        for c in expr.chars() {
250            if c == '_' || c.is_ascii_alphabetic() || (!name.is_empty() && c.is_ascii_digit()) {
251                name.push(c);
252            } else if !name.is_empty() {
253                self.reads.insert(std::mem::take(&mut name));
254            }
255        }
256        if !name.is_empty() {
257            self.reads.insert(name);
258        }
259    }
260}
261
262/// Every command the statement contains, in source order, plus any literal
263/// redemption key its argv carries.
264///
265/// A `for` body's commands, an `if` condition's command, and a `$(…)`
266/// substitution's commands are all in here: each is a command this statement
267/// would run, so each is a `cmd` resource a standing grant has to cover.
268fn collect(stmt: &Stmt) -> Collected {
269    let mut out = Collected::default();
270    collect_stmt(stmt, false, &mut out);
271    out
272}
273
274/// Every command the statement contains, in source order.
275pub fn planned_commands(stmt: &Stmt) -> Vec<PlannedCommand> {
276    collect(stmt).commands
277}
278
279fn collect_stmt(stmt: &Stmt, background: bool, out: &mut Collected) {
280    match stmt {
281        Stmt::Assignment(a) => {
282            out.bind_path(&a.path);
283            collect_expr(&a.value, background, out)
284        }
285        Stmt::Command(cmd) => collect_command(cmd, background, out),
286        Stmt::Pipeline(p) => {
287            for cmd in &p.commands {
288                collect_command(cmd, background || p.background, out);
289            }
290        }
291        Stmt::If(s) => {
292            collect_expr(&s.condition, background, out);
293            collect_block(&s.then_branch, background, out);
294            if let Some(else_branch) = &s.else_branch {
295                collect_block(else_branch, background, out);
296            }
297        }
298        Stmt::For(s) => {
299            out.binds.insert(s.variable.clone());
300            for item in &s.items {
301                collect_expr(item, background, out);
302            }
303            collect_block(&s.body, background, out);
304        }
305        Stmt::While(s) => {
306            collect_expr(&s.condition, background, out);
307            collect_block(&s.body, background, out);
308        }
309        Stmt::Case(s) => {
310            collect_expr(&s.expr, background, out);
311            for branch in &s.branches {
312                collect_block(&branch.body, background, out);
313            }
314        }
315        Stmt::Return(e) | Stmt::Exit(e) => {
316            if let Some(e) = e {
317                collect_expr(e, background, out);
318            }
319        }
320        Stmt::ToolDef(def) => {
321            for param in &def.params {
322                out.binds.insert(param.name.clone());
323                if let Some(default) = &param.default {
324                    collect_expr(default, background, out);
325                }
326            }
327            collect_block(&def.body, background, out)
328        }
329        Stmt::Test(t) => collect_test(t, background, out),
330        Stmt::AndChain { left, right } | Stmt::OrChain { left, right } => {
331            collect_stmt(left, background, out);
332            collect_stmt(right, background, out);
333        }
334        Stmt::EnvScoped { assignments, body } => {
335            for a in assignments {
336                out.bind_path(&a.path);
337                collect_expr(&a.value, background, out);
338            }
339            collect_stmt(body, background, out);
340        }
341        Stmt::Break(_) | Stmt::Continue(_) | Stmt::Empty => {}
342    }
343}
344
345fn collect_block(stmts: &[Stmt], background: bool, out: &mut Collected) {
346    for stmt in stmts {
347        collect_stmt(stmt, background, out);
348    }
349}
350
351fn collect_command(cmd: &Command, background: bool, out: &mut Collected) {
352    let args: Vec<PlannedValue> = cmd.args.iter().map(|arg| plan_arg(arg).1).collect();
353    let redirects = cmd
354        .redirects
355        .iter()
356        .map(|r| PlannedRedirect::new(r.kind.to_string(), plan_redirect_target(&r.target)))
357        .collect();
358    out.commands.push(PlannedCommand::new(
359        cmd.name.clone(),
360        args,
361        redirects,
362        background,
363    ));
364    // Lift any literal credential out of the argv on the same pass that
365    // redacts it from the rendering — one walk, one truth about what this
366    // statement presented.
367    for arg in &cmd.args {
368        if let Some(key) = presented_key(arg) {
369            out.keys.push(key);
370        }
371    }
372    // Substitutions nested inside this command's own arguments and redirect
373    // targets are commands too, and they run before it does.
374    for arg in &cmd.args {
375        match arg {
376            Arg::Positional(e) => collect_expr(e, background, out),
377            Arg::Named { value, .. } | Arg::WordAssign { value, .. } => {
378                collect_expr(value, background, out)
379            }
380            Arg::ShortFlag(_) | Arg::LongFlag(_) | Arg::DoubleDash => {}
381        }
382    }
383    for redirect in &cmd.redirects {
384        collect_expr(&redirect.target, background, out);
385    }
386}
387
388fn collect_expr(expr: &Expr, background: bool, out: &mut Collected) {
389    match expr {
390        Expr::Command(cmd) => collect_command(cmd, background, out),
391        Expr::CommandSubst(stmts) => collect_block(stmts, background, out),
392        Expr::BinaryOp { left, right, .. } => {
393            collect_expr(left, background, out);
394            collect_expr(right, background, out);
395        }
396        Expr::Interpolated(parts) => collect_parts(parts, background, out),
397        Expr::HereDocBody { parts, .. } => {
398            for part in parts {
399                collect_part(&part.part, background, out);
400            }
401        }
402        Expr::Test(t) => collect_test(t, background, out),
403        Expr::VarWithDefault { path, default } => {
404            out.read_path(path);
405            collect_parts(default, background, out)
406        }
407        Expr::ListLiteral(elems) => {
408            for elem in elems {
409                match elem {
410                    ListElem::Item(e) | ListElem::Spread(e) => collect_expr(e, background, out),
411                }
412            }
413        }
414        Expr::RecordLiteral(entries) => {
415            for entry in entries {
416                if let RecordKey::Interpolated(parts) = &entry.key {
417                    collect_parts(parts, background, out);
418                }
419                collect_expr(&entry.value, background, out);
420            }
421        }
422        Expr::VarRef(path) | Expr::VarLength(path) => out.read_path(path),
423        Expr::Arithmetic(e) => out.read_arithmetic(e),
424        // Special forms ($1, $@, $#, $?, $$) are not session variables; an
425        // embedder cannot peek them with `get_var`, so they are not listed.
426        Expr::Literal(_)
427        | Expr::Positional(_)
428        | Expr::AllArgs
429        | Expr::ArgCount
430        | Expr::LastExitCode
431        | Expr::CurrentPid
432        | Expr::GlobPattern(_) => {}
433    }
434}
435
436fn collect_parts(parts: &[StringPart], background: bool, out: &mut Collected) {
437    for part in parts {
438        collect_part(part, background, out);
439    }
440}
441
442fn collect_part(part: &StringPart, background: bool, out: &mut Collected) {
443    match part {
444        StringPart::CommandSubst(stmts) => collect_block(stmts, background, out),
445        StringPart::VarWithDefault { path, default } => {
446            out.read_path(path);
447            collect_parts(default, background, out)
448        }
449        StringPart::Var(path) | StringPart::VarLength(path) => out.read_path(path),
450        StringPart::Arithmetic(e) => out.read_arithmetic(e),
451        // See the identical special-forms note in `collect_expr`.
452        StringPart::Literal(_)
453        | StringPart::Positional(_)
454        | StringPart::AllArgs
455        | StringPart::ArgCount
456        | StringPart::LastExitCode
457        | StringPart::CurrentPid => {}
458    }
459}
460
461fn collect_test(test: &TestExpr, background: bool, out: &mut Collected) {
462    match test {
463        TestExpr::FileTest { path, .. } => collect_expr(path, background, out),
464        TestExpr::StringTest { value, .. } => collect_expr(value, background, out),
465        TestExpr::Comparison { left, right, .. }
466        | TestExpr::In { left, right }
467        | TestExpr::NotIn { left, right } => {
468            collect_expr(left, background, out);
469            collect_expr(right, background, out);
470        }
471        TestExpr::And { left, right } | TestExpr::Or { left, right } => {
472            collect_test(left, background, out);
473            collect_test(right, background, out);
474        }
475        TestExpr::Not { expr } => collect_test(expr, background, out),
476    }
477}
478
479// ───────────────────────── Rendering ─────────────────────────
480
481/// Render one statement back to shell text, unexpanded.
482pub fn render_stmt(stmt: &Stmt) -> String {
483    match stmt {
484        Stmt::Assignment(a) => render_assignment(a),
485        Stmt::Command(cmd) => render_command(cmd),
486        Stmt::Pipeline(p) => render_pipeline(p),
487        Stmt::If(s) => render_if(s),
488        Stmt::For(s) => render_for(s),
489        Stmt::While(s) => render_while(s),
490        Stmt::Case(s) => render_case(s),
491        Stmt::Break(n) => render_keyword("break", n.map(|n| n.to_string())),
492        Stmt::Continue(n) => render_keyword("continue", n.map(|n| n.to_string())),
493        Stmt::Return(e) => render_keyword("return", e.as_ref().map(|e| render_expr(e))),
494        Stmt::Exit(e) => render_keyword("exit", e.as_ref().map(|e| render_expr(e))),
495        Stmt::ToolDef(def) => render_tooldef(def),
496        Stmt::Test(t) => format!("[[ {} ]]", render_test(t)),
497        Stmt::AndChain { left, right } => {
498            format!("{} && {}", render_stmt(left), render_stmt(right))
499        }
500        Stmt::OrChain { left, right } => {
501            format!("{} || {}", render_stmt(left), render_stmt(right))
502        }
503        Stmt::EnvScoped { assignments, body } => {
504            let prefix: Vec<String> = assignments.iter().map(render_assignment).collect();
505            format!("{} {}", prefix.join(" "), render_stmt(body))
506        }
507        Stmt::Empty => String::new(),
508    }
509}
510
511fn render_keyword(word: &str, operand: Option<String>) -> String {
512    match operand {
513        Some(operand) => format!("{word} {operand}"),
514        None => word.to_string(),
515    }
516}
517
518fn render_block(stmts: &[Stmt]) -> String {
519    stmts
520        .iter()
521        .filter(|s| !matches!(s, Stmt::Empty))
522        .map(render_stmt)
523        .collect::<Vec<_>>()
524        .join("; ")
525}
526
527fn render_assignment(a: &Assignment) -> String {
528    let path = render_varpath(&a.path);
529    if a.local {
530        format!("local {} = {}", path, render_expr(&a.value))
531    } else {
532        format!("{}={}", path, render_expr(&a.value))
533    }
534}
535
536/// Render one command: argv0, every argument form, and every redirect.
537pub fn render_command(cmd: &Command) -> String {
538    let mut parts = vec![cmd.name.clone()];
539    for arg in &cmd.args {
540        parts.push(plan_arg(arg).0);
541    }
542    for redirect in &cmd.redirects {
543        parts.push(render_redirect(redirect));
544    }
545    parts.join(" ")
546}
547
548/// The one argument whose value never reaches a plan unredacted:
549/// `--confirm=<token>` carries a confirmation credential, and a plan is
550/// built to be stored and shown. This is kaish's own, unconditional
551/// redaction — possible exactly because the flag spelling is kaish's
552/// convention and needs no secret detector to recognize.
553const CONFIRM_KEY: &str = "confirm";
554
555/// The [`PlannedValue::Redacted`] `kind` the kernel's confirm-key redaction
556/// marks a value with, so an auditor reading `Plan::commands` can tell the
557/// kernel's own redaction apart from an embedder's.
558const CONFIRM_KEY_KIND: &str = "confirm-key";
559
560/// The literal credential this argument presents, if it is a `confirm`
561/// argument carrying one.
562///
563/// A non-literal value (`--confirm=${key}`, `--confirm=$(cat key)`) yields
564/// `None`: the plan is unexpanded, so the value is not knowable here. That
565/// costs nothing — what the plan cannot see, it cannot leak, and the argv
566/// that executes is untouched.
567fn presented_key(arg: &Arg) -> Option<String> {
568    let value = match arg {
569        Arg::Named { key, value } | Arg::WordAssign { key, value } if key == CONFIRM_KEY => value,
570        _ => return None,
571    };
572    match value {
573        Expr::Literal(Value::String(s)) => Some(s.clone()),
574        _ => None,
575    }
576}
577
578/// Plan one argument: its flat text (for [`render_command`]) and its
579/// [`PlannedValue`] (for [`PlannedCommand::args`]), derived together so the
580/// two representations cannot disagree about what this argument was.
581///
582/// A `confirm` argument carrying a *literal* is a credential and is redacted
583/// unconditionally; every other value's flag/key prefix (if any) stays
584/// visible even when its value is redacted, so the plan still shows *that* a
585/// value was presented at that flag, never *what*.
586fn plan_arg(arg: &Arg) -> (String, PlannedValue) {
587    if presented_key(arg).is_some() {
588        let value = PlannedValue::redacted(CONFIRM_KEY_KIND, None);
589        let text = match arg {
590            // `dd` takes its operands as bare `key=value`, so `confirm=<key>`
591            // is a second spelling of the same credential.
592            Arg::WordAssign { key, .. } => format!("{key}={}", value.display()),
593            _ => format!("--{CONFIRM_KEY}={}", value.display()),
594        };
595        return (text, value);
596    }
597    let (text, value) = match arg {
598        Arg::Positional(e) => {
599            let value = PlannedValue::Plain(render_expr(e));
600            (value.display(), value)
601        }
602        Arg::Named { key, value: e } => {
603            let value = PlannedValue::Plain(render_expr(e));
604            (format!("--{key}={}", value.display()), value)
605        }
606        Arg::WordAssign { key, value: e } => {
607            let value = PlannedValue::Plain(render_expr(e));
608            (format!("{key}={}", value.display()), value)
609        }
610        Arg::ShortFlag(f) => {
611            let text = format!("-{f}");
612            (text.clone(), PlannedValue::Plain(text))
613        }
614        Arg::LongFlag(f) => {
615            let text = format!("--{f}");
616            (text.clone(), PlannedValue::Plain(text))
617        }
618        Arg::DoubleDash => ("--".to_string(), PlannedValue::Plain("--".to_string())),
619    };
620    // A redacted `--key=value` loses its `key=` prefix in the *structured*
621    // value (`PlannedValue::Redacted` has nowhere to put one) but keeps it
622    // in the flat text above; a plain value keeps the full composed text in
623    // both, so `PlannedCommand::args` round-trips into `render_command`'s
624    // output unless something was actually judged secret.
625    let structured = if value.is_redacted() {
626        value
627    } else {
628        PlannedValue::Plain(text.clone())
629    };
630    (text, structured)
631}
632
633/// Plan one redirect's target: rendered unexpanded, always plain — a
634/// redirect target is never the kernel's confirm key.
635fn plan_redirect_target(target: &Expr) -> PlannedValue {
636    PlannedValue::Plain(render_expr(target))
637}
638
639fn render_redirect(redirect: &Redirect) -> String {
640    // A merge redirect (`2>&1`, `1>&2`) is the whole operator: its target
641    // expression is a placeholder, and printing it would invent a filename.
642    match redirect.kind {
643        super::types::RedirectKind::MergeStderr | super::types::RedirectKind::MergeStdout => {
644            redirect.kind.to_string()
645        }
646        _ => format!(
647            "{} {}",
648            redirect.kind,
649            plan_redirect_target(&redirect.target).display()
650        ),
651    }
652}
653
654fn render_pipeline(p: &Pipeline) -> String {
655    let body = p
656        .commands
657        .iter()
658        .map(render_command)
659        .collect::<Vec<_>>()
660        .join(" | ");
661    if p.background {
662        format!("{body} &")
663    } else {
664        body
665    }
666}
667
668fn render_if(s: &IfStmt) -> String {
669    let mut out = format!(
670        "if {}; then {}",
671        render_expr(&s.condition),
672        render_block(&s.then_branch)
673    );
674    if let Some(else_branch) = &s.else_branch {
675        let rendered = render_block(else_branch);
676        if !rendered.is_empty() {
677            out.push_str(&format!("; else {rendered}"));
678        }
679    }
680    out.push_str("; fi");
681    out
682}
683
684fn render_for(s: &ForLoop) -> String {
685    let items: Vec<String> = s.items.iter().map(render_expr).collect();
686    format!(
687        "for {} in {}; do {}; done",
688        s.variable,
689        items.join(" "),
690        render_block(&s.body)
691    )
692}
693
694fn render_while(s: &WhileLoop) -> String {
695    format!(
696        "while {}; do {}; done",
697        render_expr(&s.condition),
698        render_block(&s.body)
699    )
700}
701
702fn render_case(s: &CaseStmt) -> String {
703    let branches: Vec<String> = s
704        .branches
705        .iter()
706        .map(|b| format!("{}) {} ;;", b.patterns.join("|"), render_block(&b.body)))
707        .collect();
708    format!("case {} in {} esac", render_expr(&s.expr), branches.join(" "))
709}
710
711fn render_tooldef(def: &ToolDef) -> String {
712    let params: Vec<String> = def
713        .params
714        .iter()
715        .map(|p| match &p.default {
716            Some(default) => format!("{}={}", p.name, render_expr(default)),
717            None => p.name.clone(),
718        })
719        .collect();
720    format!(
721        "tool {}({}) {{ {} }}",
722        def.name,
723        params.join(", "),
724        render_block(&def.body)
725    )
726}
727
728/// Render one expression back to shell text, unexpanded: a variable
729/// reference stays `${NAME}` and a substitution stays `$(…)`.
730pub fn render_expr(expr: &Expr) -> String {
731    match expr {
732        Expr::Literal(v) => render_literal(v),
733        Expr::VarRef(path) => format!("${{{}}}", render_varpath(path)),
734        Expr::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
735        Expr::HereDocBody { parts, strip_tabs } => {
736            let dash = if *strip_tabs { "-" } else { "" };
737            let body: Vec<String> = parts.iter().map(|sp| render_part(&sp.part)).collect();
738            format!("<<{dash}EOF\n{}\nEOF", body.join(""))
739        }
740        Expr::BinaryOp { left, op, right } => {
741            let op = match op {
742                BinaryOp::And => "&&",
743                BinaryOp::Or => "||",
744            };
745            format!("{} {} {}", render_expr(left), op, render_expr(right))
746        }
747        Expr::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
748        Expr::Test(t) => format!("[[ {} ]]", render_test(t)),
749        Expr::Positional(n) => format!("${n}"),
750        Expr::AllArgs => "$@".to_string(),
751        Expr::ArgCount => "$#".to_string(),
752        Expr::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
753        Expr::VarWithDefault { path, default } => {
754            format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
755        }
756        Expr::Arithmetic(e) => format!("$(({e}))"),
757        Expr::Command(cmd) => render_command(cmd),
758        Expr::LastExitCode => "$?".to_string(),
759        Expr::CurrentPid => "$$".to_string(),
760        Expr::GlobPattern(p) => p.clone(),
761        Expr::ListLiteral(elems) => {
762            let parts: Vec<String> = elems
763                .iter()
764                .map(|e| match e {
765                    ListElem::Item(e) => render_expr(e),
766                    ListElem::Spread(e) => format!("...{}", render_expr(e)),
767                })
768                .collect();
769            format!("[{}]", parts.join(" "))
770        }
771        Expr::RecordLiteral(entries) => {
772            let parts: Vec<String> = entries
773                .iter()
774                .map(|entry| {
775                    let key = match &entry.key {
776                        RecordKey::Bare(k) => k.clone(),
777                        RecordKey::Quoted(k) => format!("\"{k}\""),
778                        RecordKey::Interpolated(parts) => format!("\"{}\"", render_parts(parts)),
779                    };
780                    format!("{key}: {}", render_expr(&entry.value))
781                })
782                .collect();
783            format!("{{{}}}", parts.join(", "))
784        }
785    }
786}
787
788/// A literal, quoted only where a shell reader would need the quotes.
789fn render_literal(value: &Value) -> String {
790    match value {
791        Value::String(s) => quote_word(s),
792        Value::Int(i) => i.to_string(),
793        Value::Float(f) => f.to_string(),
794        Value::Bool(b) => b.to_string(),
795        Value::Null => "null".to_string(),
796        Value::Json(j) => j.to_string(),
797        // Binary reaches a plan only through `execute_argv`, which takes
798        // typed values. Naming the length is honest; printing the bytes
799        // would put unreadable data in a stored plan.
800        Value::Bytes(b) => format!("<bytes len={}>", b.len()),
801    }
802}
803
804/// Single-quote a word that a shell reader could not take literally.
805fn quote_word(s: &str) -> String {
806    let needs_quotes = s.is_empty()
807        || s.chars()
808            .any(|c| c.is_whitespace() || "\"'$`&|;<>(){}[]*?#!~\\".contains(c));
809    if !needs_quotes {
810        return s.to_string();
811    }
812    // `'\''` is the one portable way to put a single quote inside a
813    // single-quoted word.
814    format!("'{}'", s.replace('\'', "'\\''"))
815}
816
817fn render_parts(parts: &[StringPart]) -> String {
818    parts.iter().map(render_part).collect::<Vec<_>>().join("")
819}
820
821fn render_part(part: &StringPart) -> String {
822    match part {
823        StringPart::Literal(s) => s.replace('\\', "\\\\").replace('"', "\\\""),
824        StringPart::Var(path) => format!("${{{}}}", render_varpath(path)),
825        StringPart::VarWithDefault { path, default } => {
826            format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
827        }
828        StringPart::VarLength(path) => format!("${{#{}}}", render_varpath(path)),
829        StringPart::Positional(n) => format!("${n}"),
830        StringPart::AllArgs => "$@".to_string(),
831        StringPart::ArgCount => "$#".to_string(),
832        StringPart::Arithmetic(e) => format!("$(({e}))"),
833        StringPart::CommandSubst(stmts) => format!("$({})", render_block(stmts)),
834        StringPart::LastExitCode => "$?".to_string(),
835        StringPart::CurrentPid => "$$".to_string(),
836    }
837}
838
839fn render_test(test: &TestExpr) -> String {
840    match test {
841        TestExpr::FileTest { op, path } => format!("{} {}", op, render_expr(path)),
842        TestExpr::StringTest { op, value } => format!("{} {}", op, render_expr(value)),
843        TestExpr::Comparison { left, op, right } => {
844            format!("{} {} {}", render_expr(left), op, render_expr(right))
845        }
846        TestExpr::And { left, right } => {
847            format!("{} && {}", render_test(left), render_test(right))
848        }
849        TestExpr::Or { left, right } => {
850            format!("{} || {}", render_test(left), render_test(right))
851        }
852        TestExpr::Not { expr } => format!("! {}", render_test(expr)),
853        TestExpr::In { left, right } => {
854            format!("{} in {}", render_expr(left), render_expr(right))
855        }
856        TestExpr::NotIn { left, right } => {
857            format!("{} not in {}", render_expr(left), render_expr(right))
858        }
859    }
860}
861
862/// Render a variable path in its source form: the root name, then bracket
863/// subscripts. Never dotted — kaish access is brackets-only.
864fn render_varpath(path: &VarPath) -> String {
865    let mut out = String::new();
866    for (i, segment) in path.segments.iter().enumerate() {
867        match segment {
868            VarSegment::Field(name) => {
869                if i > 0 {
870                    out.push('.');
871                }
872                out.push_str(name);
873            }
874            VarSegment::Index(idx) => out.push_str(&format!("[{idx}]")),
875            VarSegment::Key(k) => out.push_str(&format!("[{k}]")),
876            VarSegment::Dynamic(v) => out.push_str(&format!("[${v}]")),
877            VarSegment::Slice(a, b) => out.push_str(&format!(
878                "[{}:{}]",
879                a.map(|n| n.to_string()).unwrap_or_default(),
880                b.map(|n| n.to_string()).unwrap_or_default()
881            )),
882        }
883    }
884    out
885}
886
887#[cfg(test)]
888#[allow(clippy::unwrap_used, clippy::expect_used)]
889mod tests {
890    use super::*;
891    use crate::parser::parse;
892
893    fn planned_of(source: &str) -> StatementPlan {
894        let program = parse(source).expect("the fixture parses");
895        let stmt = program
896            .statements
897            .into_iter()
898            .find(|s| !matches!(s, Stmt::Empty))
899            .expect("one statement");
900        plan_statement(&stmt)
901    }
902
903    fn plan_of(source: &str) -> Plan {
904        planned_of(source).plan
905    }
906
907    #[test]
908    fn a_variable_renders_unexpanded() {
909        let plan = plan_of("rm -r \"${HOME}/build\"");
910        assert!(
911            plan.rendered.contains("${HOME}"),
912            "the plan must keep the variable as written: {}",
913            plan.rendered
914        );
915    }
916
917    #[test]
918    fn a_substitution_renders_unexpanded_and_plans_its_own_command() {
919        let plan = plan_of("rm $(cat list.txt)");
920        assert!(
921            plan.rendered.contains("$(cat list.txt)"),
922            "got: {}",
923            plan.rendered
924        );
925        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
926        assert_eq!(names, vec!["rm", "cat"], "the substitution runs too");
927    }
928
929    #[test]
930    fn a_loop_body_belongs_to_the_enclosing_statement() {
931        let plan = plan_of("for f in a b; do rm $f; done");
932        assert_eq!(plan.statement_kind, "for");
933        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
934        assert_eq!(names, vec!["rm"]);
935        assert!(plan.rendered.starts_with("for f in a b; do rm"));
936    }
937
938    #[test]
939    fn every_redirect_form_renders() {
940        let plan = plan_of("cmd > out.txt 2> err.txt < in.txt");
941        let kinds: Vec<&str> = plan.commands[0]
942            .redirects
943            .iter()
944            .map(|r| r.kind.as_str())
945            .collect();
946        assert_eq!(kinds, vec![">", "2>", "<"]);
947        assert_eq!(
948            plan.commands[0].redirects[0].target,
949            PlannedValue::Plain("out.txt".to_string())
950        );
951        assert!(plan.rendered.contains("> out.txt"), "got: {}", plan.rendered);
952    }
953
954    #[test]
955    fn a_redirect_target_stays_unexpanded() {
956        let plan = plan_of("echo hi > ${LOG}");
957        assert_eq!(
958            plan.commands[0].redirects[0].target,
959            PlannedValue::Plain("${LOG}".to_string())
960        );
961    }
962
963    #[test]
964    fn a_merge_redirect_renders_as_its_operator_alone() {
965        let plan = plan_of("cmd 2>&1");
966        assert!(
967            plan.rendered.ends_with("2>&1"),
968            "a merge redirect has no filename: {}",
969            plan.rendered
970        );
971    }
972
973    #[test]
974    fn every_argument_form_renders() {
975        let plan = plan_of("tool -v --force --key=value word -- --after");
976        let args = &plan.commands[0].args;
977        assert_eq!(
978            args,
979            &vec![
980                PlannedValue::Plain("-v".to_string()),
981                PlannedValue::Plain("--force".to_string()),
982                PlannedValue::Plain("--key=value".to_string()),
983                PlannedValue::Plain("word".to_string()),
984                PlannedValue::Plain("--".to_string()),
985                PlannedValue::Plain("--after".to_string()),
986            ]
987        );
988    }
989
990    #[test]
991    fn a_backgrounded_pipeline_marks_every_command() {
992        let plan = plan_of("a | b &");
993        assert!(plan.commands.iter().all(|c| c.background));
994        assert!(plan.rendered.ends_with('&'), "got: {}", plan.rendered);
995    }
996
997    #[test]
998    fn a_pipeline_renders_every_stage() {
999        let plan = plan_of("cat f | grep x | wc -l");
1000        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1001        assert_eq!(names, vec!["cat", "grep", "wc"]);
1002        assert_eq!(plan.rendered, "cat f | grep x | wc -l");
1003    }
1004
1005    #[test]
1006    fn an_and_chain_plans_both_sides() {
1007        let plan = plan_of("mkdir d && rm -r d");
1008        assert_eq!(plan.statement_kind, "and_chain");
1009        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1010        assert_eq!(names, vec!["mkdir", "rm"]);
1011    }
1012
1013    #[test]
1014    fn an_if_plans_its_condition_and_both_branches() {
1015        let plan = plan_of("if grep -q x f; then echo hit; else echo miss; fi");
1016        let names: Vec<&str> = plan.commands.iter().map(|c| c.name.as_str()).collect();
1017        assert_eq!(names, vec!["grep", "echo", "echo"]);
1018    }
1019
1020    #[test]
1021    fn a_quoted_word_keeps_its_spaces_inside_quotes() {
1022        let plan = plan_of("echo 'two words'");
1023        assert_eq!(plan.rendered, "echo 'two words'");
1024    }
1025
1026    #[test]
1027    fn an_interpolated_string_keeps_its_variables() {
1028        let plan = plan_of("echo \"hello ${NAME}\"");
1029        assert_eq!(plan.rendered, "echo \"hello ${NAME}\"");
1030    }
1031
1032    #[test]
1033    fn a_bracket_path_renders_with_brackets_not_dots() {
1034        let plan = plan_of("echo ${servers[web]}");
1035        assert_eq!(plan.rendered, "echo ${servers[web]}");
1036    }
1037
1038    #[test]
1039    fn rendering_truncates_at_the_limit_with_a_loud_marker() {
1040        let long = "x".repeat(PLAN_RENDER_LIMIT * 2);
1041        let plan = plan_of(&format!("echo {long}"));
1042        assert!(
1043            plan.rendered.contains("[rendering truncated at 8192 bytes]"),
1044            "expected the marker, got {} bytes ending in {:?}",
1045            plan.rendered.len(),
1046            &plan.rendered[plan.rendered.len().saturating_sub(48)..]
1047        );
1048        // The structure survives the cut — that is what a classifier reads.
1049        assert_eq!(plan.commands.len(), 1);
1050        assert_eq!(plan.commands[0].name, "echo");
1051    }
1052
1053    #[test]
1054    fn a_short_rendering_carries_no_marker() {
1055        let plan = plan_of("echo hi");
1056        assert_eq!(plan.rendered, "echo hi");
1057    }
1058
1059    // ── The presented credential (spec §A.2, §C.6) ──
1060
1061    #[test]
1062    fn a_literal_key_is_lifted_and_redacted() {
1063        let planned = planned_of("rm --confirm=deadbeef target.txt");
1064        assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1065        assert_eq!(planned.plan.rendered, "rm --confirm=<confirm-key> target.txt");
1066        assert_eq!(
1067            planned.plan.commands[0].args,
1068            vec![
1069                PlannedValue::redacted("confirm-key", None),
1070                PlannedValue::Plain("target.txt".to_string()),
1071            ]
1072        );
1073    }
1074
1075    #[test]
1076    fn the_bare_word_assign_spelling_is_lifted_too() {
1077        // `dd` takes its operands as `key=value`, so this is the same
1078        // credential wearing the other spelling.
1079        let planned = planned_of("dd if=a of=b confirm=deadbeef");
1080        assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1081        assert!(
1082            planned.plan.rendered.ends_with("confirm=<confirm-key>"),
1083            "got: {}",
1084            planned.plan.rendered
1085        );
1086    }
1087
1088    #[test]
1089    fn a_variable_carried_key_is_neither_lifted_nor_redacted() {
1090        // Nothing to lift and nothing to leak: an unexpanded plan never held
1091        // the value, so it renders as written like any other variable.
1092        let planned = planned_of("rm --confirm=${key} target.txt");
1093        assert!(planned.presented_keys.is_empty());
1094        assert_eq!(planned.plan.rendered, "rm --confirm=${key} target.txt");
1095    }
1096
1097    #[test]
1098    fn a_key_inside_a_loop_body_is_still_lifted() {
1099        let planned = planned_of("for f in a b; do rm --confirm=deadbeef $f; done");
1100        assert_eq!(planned.presented_keys, vec!["deadbeef".to_string()]);
1101    }
1102
1103    #[test]
1104    fn redaction_takes_the_whole_token_and_leaves_one_space() {
1105        let source = "rm --confirm=deadbeef target.txt";
1106        assert_eq!(
1107            redact_keys(source, &["deadbeef".to_string()]),
1108            "rm target.txt"
1109        );
1110    }
1111
1112    #[test]
1113    fn redaction_leaves_a_source_that_never_presented_a_key_alone() {
1114        let source = "rm target.txt";
1115        assert_eq!(redact_keys(source, &[]), source);
1116        assert_eq!(redact_keys(source, &["deadbeef".to_string()]), source);
1117    }
1118
1119    // ── Variable analysis ──
1120
1121    #[test]
1122    fn reads_cover_interpolation_length_subscript_and_arithmetic() {
1123        let plan = plan_of(
1124            "echo \"${greeting} ${#items} ${servers[$env]}\" $((base + offset))",
1125        );
1126        assert_eq!(
1127            plan.free_variables,
1128            vec!["base", "env", "greeting", "items", "offset", "servers"],
1129            "every lexical read is listed, sorted"
1130        );
1131        assert!(plan.bound_variables.is_empty());
1132    }
1133
1134    #[test]
1135    fn a_subscripted_assignment_binds_the_root_and_reads_the_subscript() {
1136        let plan = plan_of("counts[$key]=1");
1137        assert_eq!(plan.free_variables, vec!["key"]);
1138        assert_eq!(plan.bound_variables, vec!["counts"]);
1139    }
1140
1141    #[test]
1142    fn an_env_prefix_binds_its_name_for_the_one_command() {
1143        let plan = plan_of("MODE=fast deploy ${TARGET}");
1144        assert_eq!(plan.free_variables, vec!["TARGET"]);
1145        assert_eq!(plan.bound_variables, vec!["MODE"]);
1146    }
1147
1148    // ── plan_program: the program-level surface ──
1149
1150    #[test]
1151    fn plan_program_indexes_agree_with_the_parsed_program() {
1152        let source = "echo one\n\n# a comment\necho two && echo three\nX=5";
1153        let program = parse(source).expect("the fixture parses");
1154        let expected: Vec<(usize, String)> = program
1155            .statements
1156            .iter()
1157            .enumerate()
1158            .filter(|(_, s)| !matches!(s, Stmt::Empty))
1159            .map(|(i, s)| (i, s.kind_name().to_string()))
1160            .collect();
1161        let plans = plan_program(source).expect("the fixture parses");
1162        assert_eq!(
1163            plans
1164                .iter()
1165                .map(|p| (p.index, p.plan.statement_kind.clone()))
1166                .collect::<Vec<_>>(),
1167            expected,
1168            "an index here must be usable against Capture::Statement's index"
1169        );
1170    }
1171
1172    #[test]
1173    fn plan_program_redacts_a_presented_key_and_returns_no_copy_of_it() {
1174        // `PlannedStatement` has no key field by design — the caller holds
1175        // the source. What must hold is that the plan itself is redacted.
1176        let plans = plan_program("rm --confirm=deadbeef x.txt").expect("parses");
1177        assert_eq!(plans[0].plan.rendered, "rm --confirm=<confirm-key> x.txt");
1178    }
1179
1180    #[test]
1181    fn plan_program_returns_the_parse_errors_for_a_broken_source() {
1182        let errors = plan_program("echo 'unclosed").expect_err("must not parse");
1183        assert!(!errors.is_empty());
1184    }
1185
1186    #[test]
1187    fn redaction_covers_both_spellings_across_a_multi_statement_source() {
1188        let source = "echo one\ndd if=a confirm=deadbeef\nrm --confirm=deadbeef x";
1189        let redacted = redact_keys(source, &["deadbeef".to_string()]);
1190        assert!(!redacted.contains("deadbeef"), "got: {redacted}");
1191        assert_eq!(redacted, "echo one\ndd if=a\nrm x");
1192    }
1193}