1use std::collections::BTreeMap;
16use std::fmt;
17
18use serde::de::{self, MapAccess, SeqAccess, Visitor};
19use serde::{Deserialize, Deserializer, Serialize};
20
21#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25#[serde(untagged)]
26pub enum CommandRun {
27 Shell(String),
28 Argv(Vec<String>),
29}
30
31impl<'de> Deserialize<'de> for CommandRun {
32 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
33 struct V;
34 impl<'de> Visitor<'de> for V {
35 type Value = CommandRun;
36 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
37 f.write_str("a shell string or an argv array of strings")
38 }
39 fn visit_str<E: de::Error>(self, v: &str) -> Result<CommandRun, E> {
40 Ok(CommandRun::Shell(v.to_owned()))
41 }
42 fn visit_string<E: de::Error>(self, v: String) -> Result<CommandRun, E> {
43 Ok(CommandRun::Shell(v))
44 }
45 fn visit_seq<A: SeqAccess<'de>>(self, mut s: A) -> Result<CommandRun, A::Error> {
46 let mut out = Vec::new();
47 while let Some(e) = s.next_element::<String>()? {
48 out.push(e);
49 }
50 if out.is_empty() {
51 return Err(de::Error::custom("argv array must have at least one element"));
52 }
53 Ok(CommandRun::Argv(out))
54 }
55 }
56 d.deserialize_any(V)
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
62#[serde(rename_all = "lowercase")]
63pub enum CommandOutput {
64 #[default]
66 Text,
67 Json,
69 Lines,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
76#[serde(rename_all = "camelCase")]
77pub struct CommandSpec {
78 pub run: CommandRun,
79 #[serde(skip_serializing_if = "Option::is_none")]
81 pub timeout_ms: Option<u64>,
82 #[serde(skip_serializing_if = "Vec::is_empty")]
86 pub env: Vec<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
90 pub cwd: Option<String>,
91 pub output: CommandOutput,
92 #[serde(skip_serializing_if = "std::ops::Not::not")]
97 pub persistent: bool,
98}
99
100impl<'de> Deserialize<'de> for CommandSpec {
101 fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
102 struct V;
103 impl<'de> Visitor<'de> for V {
104 type Value = CommandSpec;
105 fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
106 f.write_str("a shell-command string or a command spec object")
107 }
108 fn visit_str<E: de::Error>(self, v: &str) -> Result<CommandSpec, E> {
109 Ok(CommandSpec::shell(v))
110 }
111 fn visit_string<E: de::Error>(self, v: String) -> Result<CommandSpec, E> {
112 Ok(CommandSpec::shell(&v))
113 }
114 fn visit_map<A: MapAccess<'de>>(self, mut m: A) -> Result<CommandSpec, A::Error> {
115 let mut run: Option<CommandRun> = None;
116 let mut timeout_ms: Option<u64> = None;
117 let mut env: Vec<String> = Vec::new();
118 let mut cwd: Option<String> = None;
119 let mut output = CommandOutput::Text;
120 let mut persistent = false;
121 while let Some(k) = m.next_key::<String>()? {
122 match k.as_str() {
123 "run" => run = Some(m.next_value()?),
124 "timeoutMs" | "timeout_ms" => timeout_ms = m.next_value()?,
125 "env" => env = m.next_value()?,
126 "cwd" => cwd = m.next_value()?,
127 "output" => output = m.next_value()?,
128 "persistent" => persistent = m.next_value()?,
129 _ => {
130 let _: de::IgnoredAny = m.next_value()?;
131 },
132 }
133 }
134 let run = run.ok_or_else(|| de::Error::missing_field("run"))?;
135 Ok(CommandSpec {
136 run,
137 timeout_ms,
138 env,
139 cwd,
140 output,
141 persistent,
142 })
143 }
144 }
145 d.deserialize_any(V)
146 }
147}
148
149#[derive(Debug, Clone, PartialEq, Eq)]
151pub enum ResolvedExec {
152 Shell(String),
154 Argv(Vec<String>),
156}
157
158#[derive(Debug, Clone)]
159pub struct ResolvedCommand {
160 pub exec: ResolvedExec,
161 pub timeout_ms: Option<u64>,
162 pub env: Vec<String>,
163 pub cwd: Option<String>,
164 pub output: CommandOutput,
165 pub persistent: bool,
166}
167
168impl CommandSpec {
169 fn shell(s: &str) -> Self {
170 Self {
171 run: CommandRun::Shell(s.to_owned()),
172 timeout_ms: None,
173 env: Vec::new(),
174 cwd: None,
175 output: CommandOutput::Text,
176 persistent: false,
177 }
178 }
179
180 pub fn resolve(&self, vars: &BTreeMap<String, serde_json::Value>) -> Result<ResolvedCommand, String> {
187 let scalar = |k: &str| -> Result<String, String> {
188 match vars.get(k) {
189 None => Err(format!("missing value for `${{{k}}}`")),
190 Some(serde_json::Value::String(s)) => Ok(s.clone()),
191 Some(serde_json::Value::Number(n)) => Ok(n.to_string()),
192 Some(serde_json::Value::Bool(b)) => Ok(b.to_string()),
193 Some(_) => Err(format!("value for `${{{k}}}` must be a string, number, or boolean")),
194 }
195 };
196
197 let exec = match &self.run {
198 CommandRun::Shell(tpl) => ResolvedExec::Shell(subst(tpl, |k| scalar(k).map(|v| shell_single_quote(&v)))?),
199 CommandRun::Argv(args) => {
200 let mut out = Vec::with_capacity(args.len());
201 for a in args {
202 out.push(subst(a, &scalar)?);
203 }
204 ResolvedExec::Argv(out)
205 },
206 };
207 Ok(ResolvedCommand {
208 exec,
209 timeout_ms: self.timeout_ms,
210 env: self.env.clone(),
211 cwd: self.cwd.clone(),
212 output: self.output,
213 persistent: self.persistent,
214 })
215 }
216}
217
218fn subst(tpl: &str, mut repl: impl FnMut(&str) -> Result<String, String>) -> Result<String, String> {
223 let mut out = String::with_capacity(tpl.len());
224 let b = tpl.as_bytes();
225 let mut i = 0;
226 while i < b.len() {
227 if b[i] == b'$' && i + 1 < b.len() && b[i + 1] == b'{' {
228 if let Some(end_rel) = tpl[i + 2..].find('}') {
229 let name = &tpl[i + 2..i + 2 + end_rel];
230 out.push_str(&repl(name)?);
231 i = i + 2 + end_rel + 1;
232 continue;
233 }
234 }
235 let ch = tpl[i..].chars().next().unwrap_or('\u{FFFD}');
237 out.push(ch);
238 i += ch.len_utf8();
239 }
240 Ok(out)
241}
242
243fn shell_single_quote(s: &str) -> String {
244 format!("'{}'", s.replace('\'', r"'\''"))
245}
246
247#[cfg(test)]
248mod tests {
249 use super::*;
250
251 fn vars(pairs: &[(&str, serde_json::Value)]) -> BTreeMap<String, serde_json::Value> {
252 pairs.iter().map(|(k, v)| ((*k).to_string(), v.clone())).collect()
253 }
254
255 #[test]
256 fn shorthand_string_is_a_shell_oneshot() {
257 let s: CommandSpec = serde_json::from_str(r#""git status""#).unwrap();
258 assert_eq!(s.run, CommandRun::Shell("git status".into()));
259 assert!(!s.persistent);
260 assert_eq!(s.output, CommandOutput::Text);
261 }
262
263 #[test]
264 fn object_with_argv_and_policy() {
265 let s: CommandSpec = serde_json::from_str(
266 r#"{"run":["git","-C","${repo}","rev-parse","HEAD"],"timeoutMs":5000,"env":["HOME"],"output":"json"}"#,
267 )
268 .unwrap();
269 assert_eq!(s.timeout_ms, Some(5000));
270 assert_eq!(s.env, vec!["HOME"]);
271 assert_eq!(s.output, CommandOutput::Json);
272 let r = s.resolve(&vars(&[("repo", serde_json::json!("/srv/app"))])).unwrap();
273 assert_eq!(
274 r.exec,
275 ResolvedExec::Argv(vec![
276 "git".into(),
277 "-C".into(),
278 "/srv/app".into(),
279 "rev-parse".into(),
280 "HEAD".into()
281 ])
282 );
283 }
284
285 #[test]
286 fn argv_substitution_is_not_shell_escaped() {
287 let s: CommandSpec = serde_json::from_str(r#"{"run":["echo","${msg}"]}"#).unwrap();
288 let r = s.resolve(&vars(&[("msg", serde_json::json!("a; rm -rf /"))])).unwrap();
289 assert_eq!(r.exec, ResolvedExec::Argv(vec!["echo".into(), "a; rm -rf /".into()]));
291 }
292
293 #[test]
294 fn shell_substitution_is_single_quoted() {
295 let s = CommandSpec::shell("echo ${msg}");
296 let r = s
297 .resolve(&vars(&[("msg", serde_json::json!("a'b; rm -rf /"))]))
298 .unwrap();
299 assert_eq!(r.exec, ResolvedExec::Shell(r"echo 'a'\''b; rm -rf /'".to_string()));
300 }
301
302 #[test]
303 fn missing_placeholder_is_an_error() {
304 let s = CommandSpec::shell("deploy ${env} ${tag}");
305 let e = s.resolve(&vars(&[("env", serde_json::json!("prod"))])).unwrap_err();
306 assert!(e.contains("${tag}"), "{e}");
307 }
308
309 #[test]
310 fn non_scalar_value_is_an_error() {
311 let s = CommandSpec::shell("x ${o}");
312 let e = s.resolve(&vars(&[("o", serde_json::json!({"a":1}))])).unwrap_err();
313 assert!(e.contains("must be a string, number, or boolean"), "{e}");
314 }
315
316 #[test]
317 fn persistent_flag_round_trips() {
318 let s: CommandSpec = serde_json::from_str(r#"{"run":"node server.js","persistent":true}"#).unwrap();
319 assert!(s.persistent);
320 }
321}