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(¶meter.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) = ¶meter.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}