Skip to main content

aion_package/declared_command/
contract.rs

1//! The emitted form of a declared command, and the render that turns it plus
2//! a parameter set into a process invocation.
3//!
4//! # Why the wire carries a template and not a string
5//!
6//! A command declared in AWL (`command <name>(…)` with `program`, `arg`,
7//! `env`, `cwd`, `hardening` and `timeout` clauses) is a typed argument LIST.
8//! The defect family the whole surface exists to close is the one where a
9//! command is carried as text and re-split by whatever runs it: a value
10//! containing a space becomes two arguments, a value beginning with `-`
11//! becomes an option, a value containing a quote becomes anything at all.
12//!
13//! So the wire form is the emitter's own shapes with the parameter values
14//! still absent: literal program words, one entry per argv slot, and a fill
15//! template per slot whose holes name declared parameters. Nothing here is
16//! ever joined into a command line, and nothing that reads it re-splits one.
17//!
18//! # One render, three callers
19//!
20//! [`DeclaredCommandContract::render`] is the ONLY place a declaration and a
21//! parameter set become an argv. The AWL emitter calls it (mapping its
22//! refusals back onto source spans), the server's declared-body dispatcher
23//! calls it, and the `aion worker awl` executor calls it. A second
24//! implementation anywhere would be a second answer to "what does this
25//! command actually run".
26
27use std::collections::BTreeMap;
28
29use serde::{Deserialize, Serialize};
30
31use super::error::{RenderError, shape_word};
32use super::template::{ArgumentValue, FillTemplate};
33
34/// The end-of-options marker the language knows. The bytes are the contract.
35pub const END_OF_OPTIONS_MARKER: &str = "--";
36
37/// One declared parameter of a command, as the emitter sees it.
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub struct CommandParameterContract {
40    /// The parameter's name — the only thing a hole may name.
41    pub name: String,
42    /// Whether the declared type is a list, and so whether a supplied value
43    /// must be one.
44    pub list: bool,
45    /// The default this parameter takes when a parameter set omits it.
46    ///
47    /// A default is itself a template and may read parameters declared
48    /// BEFORE it, which is why binding walks the declared order. Absence is a
49    /// legal declared state: a parameter with neither a supplied value nor a
50    /// default has nothing for this surface to invent, and refuses.
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub default: Option<FillTemplate>,
53}
54
55/// One argv slot: the template that fills it and whether it may begin with a
56/// dash.
57#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
58pub struct ArgvSlot {
59    /// The fill that produces this slot's element (or elements, for the one
60    /// list-typed tail).
61    pub fill: FillTemplate,
62    /// How a refusal names this slot: the argument's declared name, or its
63    /// literal text.
64    pub label: String,
65    /// Whether an element beginning with `-` is admitted here.
66    ///
67    /// `false` means the receiving program is still reading options at this
68    /// position and no `--` stands before it, so leading-dash bytes could not
69    /// be told from an option. The condition is computed from the ARGUMENT
70    /// LIST at compile time and travels here as a fact, never as a keyword an
71    /// author could write to switch the refusal off.
72    pub admits_leading_dash: bool,
73}
74
75/// One declared environment binding.
76#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
77pub struct EnvBindingContract {
78    /// The variable name.
79    pub name: String,
80    /// The fill that produces its value.
81    pub value: FillTemplate,
82}
83
84/// The emitted, executable form of one declared command.
85#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
86pub struct DeclaredCommandContract {
87    /// The declaration's own name, carried so a refusal can say which command
88    /// it is talking about.
89    pub name: String,
90    /// The declared parameters, in declared order.
91    #[serde(default, skip_serializing_if = "Vec::is_empty")]
92    pub parameters: Vec<CommandParameterContract>,
93    /// The invocation: the program and the subcommand words that qualify it,
94    /// as literal argv elements. Never a fill — a caller-supplied value must
95    /// never choose which program executes.
96    pub program: Vec<String>,
97    /// The argument slots, in declared order, which IS the argv order.
98    #[serde(default, skip_serializing_if = "Vec::is_empty")]
99    pub args: Vec<ArgvSlot>,
100    /// The declared environment bindings, in declared order.
101    #[serde(default, skip_serializing_if = "Vec::is_empty")]
102    pub env: Vec<EnvBindingContract>,
103    /// The declared working directory, verbatim — any `{workspace_root}`
104    /// placeholder unexpanded, because expanding it is the executing side's
105    /// act against ITS workspace.
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub cwd: Option<String>,
108    /// The hardened `PATH`, if the declaration states one.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub hardened_path: Option<FillTemplate>,
111    /// The declared timeout in milliseconds, if the declaration states one.
112    ///
113    /// `None` means the declaration stated NONE, and that is a legal declared
114    /// state. Nothing here substitutes a ceiling: a number this surface
115    /// invented would be indistinguishable from one an author chose.
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub timeout_ms: Option<i64>,
118    /// Who owns the declared timeout, when there is one.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub timeout_owner: Option<String>,
121}
122
123/// A resolved command line: the argv, the environment, the working directory
124/// and the declared ceilings.
125#[derive(Clone, Debug, PartialEq, Eq)]
126pub struct RenderedCommand {
127    /// The argv, one whole element per entry. Never joined into a string.
128    pub argv: Vec<String>,
129    /// The environment bindings, in declared order.
130    pub env: Vec<(String, String)>,
131    /// The declared working directory, verbatim.
132    pub cwd: Option<String>,
133    /// The hardened `PATH`, if the declaration states one.
134    pub hardened_path: Option<String>,
135    /// The declared timeout in milliseconds, if the declaration states one.
136    pub timeout_ms: Option<i64>,
137    /// Who owns the declared timeout, when there is one.
138    pub timeout_owner: Option<String>,
139}
140
141impl DeclaredCommandContract {
142    /// Every parameter name this command declares, in declared order.
143    #[must_use]
144    pub fn parameter_names(&self) -> Vec<&str> {
145        self.parameters
146            .iter()
147            .map(|parameter| parameter.name.as_str())
148            .collect()
149    }
150
151    /// Resolve this command against `supplied`.
152    ///
153    /// `supplied` must name only parameters this command declares — a name it
154    /// does not is refused rather than ignored, because a value nothing
155    /// consumes is a value nobody reviewed. A caller holding a WIDER set (an
156    /// action's parameters, say, of which the command uses some) narrows it
157    /// first with [`Self::parameter_names`].
158    ///
159    /// # Errors
160    ///
161    /// Returns the first refusal the resolution earns: an undeclared or
162    /// missing argument, a shape mismatch against a declared type, or a
163    /// leading-dash operand standing where the program still reads options.
164    pub fn render(
165        &self,
166        supplied: &BTreeMap<String, ArgumentValue>,
167    ) -> Result<RenderedCommand, RenderError> {
168        let bound = self.bind(supplied)?;
169
170        let mut argv = self.program.clone();
171        for slot in &self.args {
172            for element in resolve(&slot.fill, &bound) {
173                // THE CURE, at the bytes. Read off the slot's recorded
174                // position, never off a declared property, so a declaration
175                // cannot exempt itself.
176                if !slot.admits_leading_dash && element.starts_with('-') {
177                    return Err(RenderError::LeadingDashOperand {
178                        command: self.name.clone(),
179                        argument: slot.label.clone(),
180                        element,
181                        marker: END_OF_OPTIONS_MARKER,
182                    });
183                }
184                argv.push(element);
185            }
186        }
187
188        Ok(RenderedCommand {
189            argv,
190            env: self
191                .env
192                .iter()
193                .map(|binding| (binding.name.clone(), join(&resolve(&binding.value, &bound))))
194                .collect(),
195            cwd: self.cwd.clone(),
196            hardened_path: self
197                .hardened_path
198                .as_ref()
199                .map(|path| join(&resolve(path, &bound))),
200            timeout_ms: self.timeout_ms,
201            timeout_owner: self.timeout_owner.clone(),
202        })
203    }
204
205    /// Every declared parameter's value: supplied, or its declared default.
206    fn bind(
207        &self,
208        supplied: &BTreeMap<String, ArgumentValue>,
209    ) -> Result<BTreeMap<String, ArgumentValue>, RenderError> {
210        for name in supplied.keys() {
211            if !self.parameters.iter().any(|item| item.name == *name) {
212                return Err(RenderError::ArgumentUndeclared {
213                    command: self.name.clone(),
214                    parameter: name.clone(),
215                });
216            }
217        }
218
219        let mut bound: BTreeMap<String, ArgumentValue> = BTreeMap::new();
220        for parameter in &self.parameters {
221            // No third branch, deliberately: a parameter with neither a
222            // supplied value nor a declared default has nothing for this
223            // surface to invent.
224            let value = if let Some(value) = supplied.get(&parameter.name) {
225                if value.is_list() != parameter.list {
226                    return Err(RenderError::ArgumentTypeMismatch {
227                        command: self.name.clone(),
228                        parameter: parameter.name.clone(),
229                        observed: value.describe(),
230                        declared: shape_word(parameter.list),
231                        supplied: shape_word(value.is_list()),
232                    });
233                }
234                value.clone()
235            } else {
236                let Some(default) = &parameter.default else {
237                    return Err(RenderError::ArgumentMissing {
238                        command: self.name.clone(),
239                        parameter: parameter.name.clone(),
240                    });
241                };
242                ArgumentValue::Scalar(join(&resolve(default, &bound)))
243            };
244            bound.insert(parameter.name.clone(), value);
245        }
246        Ok(bound)
247    }
248}
249
250/// A fill resolved against bound values, as whole argv elements.
251///
252/// One element for every shape but the variadic tail: a fill that is exactly
253/// one list-bound hole becomes one element per item, which is the only way a
254/// list reaches an argv. Every other fill is one element, whatever it
255/// contains — a space inside a value is a space inside that one element and
256/// never a new argument, because nothing re-splits it.
257fn resolve(fill: &FillTemplate, bound: &BTreeMap<String, ArgumentValue>) -> Vec<String> {
258    if let Some(name) = fill.sole_hole()
259        && let Some(ArgumentValue::List(items)) = bound.get(name)
260    {
261        return items.clone();
262    }
263    let mut element = String::new();
264    for piece in &fill.pieces {
265        match piece {
266            super::template::FillPiece::Literal { text } => element.push_str(text),
267            super::template::FillPiece::Hole { parameter } => match bound.get(parameter.as_str()) {
268                Some(ArgumentValue::Scalar(value)) => element.push_str(value),
269                // A list written into a larger word is refused at the AWL
270                // check layer, so this arm is reached only by a caller that
271                // skipped the checker; joining is then the least surprising
272                // answer and is never silent, because the check refusal
273                // already named it.
274                Some(ArgumentValue::List(items)) => element.push_str(&items.join(" ")),
275                None => {}
276            },
277        }
278    }
279    vec![element]
280}
281
282/// One string from resolved elements, for the places an argv element is not
283/// what is wanted: an environment value and a `PATH` are each one string.
284fn join(elements: &[String]) -> String {
285    elements.join(" ")
286}