Skip to main content

isb_core/
flex.rs

1//! Lenient scalar deserializers.
2//!
3//! Interpolation runs on the YAML tree before typing, so `cpus: "${CPUS:-8}"`
4//! reaches serde as the string `"8"`. These accept either the native scalar or a
5//! string that parses as one, the way compose does.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Deserializer, de::Error as _};
9
10#[derive(Deserialize, JsonSchema)]
11#[serde(untagged)]
12#[allow(dead_code)]
13pub(crate) enum BoolOrString {
14    Bool(bool),
15    String(String),
16}
17
18#[derive(Deserialize, JsonSchema)]
19#[serde(untagged)]
20#[allow(dead_code)]
21pub(crate) enum IntOrString {
22    Int(u64),
23    String(String),
24}
25
26fn parse_bool(s: &str) -> Option<bool> {
27    match s.trim().to_ascii_lowercase().as_str() {
28        "true" | "yes" | "on" | "1" => Some(true),
29        "false" | "no" | "off" | "0" | "" => Some(false),
30        _ => None,
31    }
32}
33
34pub(crate) fn bool<'de, D: Deserializer<'de>>(d: D) -> Result<bool, D::Error> {
35    match BoolOrString::deserialize(d)? {
36        BoolOrString::Bool(b) => Ok(b),
37        BoolOrString::String(s) => {
38            parse_bool(&s).ok_or_else(|| D::Error::custom(format!("expected a boolean, got {s:?}")))
39        }
40    }
41}
42
43pub(crate) fn opt_bool<'de, D: Deserializer<'de>>(d: D) -> Result<Option<bool>, D::Error> {
44    bool(d).map(Some)
45}
46
47/// A required string that may be written as a number (`connect: 5173`).
48pub(crate) fn string<'de, D: Deserializer<'de>>(d: D) -> Result<String, D::Error> {
49    Ok(match IntOrString::deserialize(d)? {
50        IntOrString::Int(n) => n.to_string(),
51        IntOrString::String(s) => s,
52    })
53}
54
55pub(crate) fn opt_string<'de, D: Deserializer<'de>>(d: D) -> Result<Option<String>, D::Error> {
56    Ok(Some(match IntOrString::deserialize(d)? {
57        IntOrString::Int(n) => n.to_string(),
58        IntOrString::String(s) => s,
59    }))
60}
61
62/// A scalar (string, number or boolean) read as a string.
63#[derive(serde::Serialize, Deserialize, JsonSchema)]
64#[serde(untagged)]
65#[allow(dead_code)]
66pub(crate) enum Scalar {
67    String(String),
68    Bool(bool),
69    Int(i64),
70    Float(f64),
71}
72
73impl Scalar {
74    pub(crate) fn into_string(self) -> String {
75        match self {
76            Scalar::String(s) => s,
77            Scalar::Bool(b) => b.to_string(),
78            Scalar::Int(i) => i.to_string(),
79            Scalar::Float(f) => f.to_string(),
80        }
81    }
82}
83
84/// A string map whose values may be written as unquoted scalars
85/// (`env: {DEBUG: 1}`, `raw_config: {security.nesting: true}`).
86pub(crate) fn string_map<'de, D: Deserializer<'de>>(
87    d: D,
88) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
89    let m = std::collections::BTreeMap::<String, Scalar>::deserialize(d)?;
90    Ok(m.into_iter().map(|(k, v)| (k, v.into_string())).collect())
91}
92
93/// One `environment` value: a scalar, or a top-level secret delivered as the
94/// variable.
95#[derive(Deserialize, JsonSchema)]
96#[serde(untagged, deny_unknown_fields)]
97pub(crate) enum EnvValue {
98    Scalar(Scalar),
99    Secret {
100        /// A top-level secret's key.
101        secret: String,
102    },
103}
104
105/// An environment: a map (a value may be `{secret: NAME}`), or docker's
106/// list of `KEY=VALUE` strings.
107#[derive(Deserialize, JsonSchema)]
108#[serde(untagged)]
109pub(crate) enum EnvMapOrList {
110    Map(std::collections::BTreeMap<String, EnvValue>),
111    List(Vec<String>),
112}
113
114/// A map, or docker's list of `KEY=VALUE` strings.
115#[derive(Deserialize, JsonSchema)]
116#[serde(untagged)]
117#[allow(dead_code)]
118pub(crate) enum MapOrList {
119    Map(std::collections::BTreeMap<String, Scalar>),
120    List(Vec<String>),
121}
122
123fn map_or_list<'de, D: Deserializer<'de>>(
124    d: D,
125    bare: impl Fn(&str) -> Result<String, String>,
126) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
127    match MapOrList::deserialize(d)? {
128        MapOrList::Map(m) => Ok(m.into_iter().map(|(k, v)| (k, v.into_string())).collect()),
129        MapOrList::List(l) => l
130            .into_iter()
131            .map(|item| match item.split_once('=') {
132                Some((k, v)) => Ok((k.to_string(), v.to_string())),
133                None => bare(&item).map(|v| (item.clone(), v)),
134            })
135            .collect::<Result<_, _>>()
136            .map_err(D::Error::custom),
137    }
138}
139
140/// Labels: a map or a list of `KEY=VALUE`; a bare `KEY` is an empty label,
141/// as in docker.
142pub(crate) fn string_map_or_list<'de, D: Deserializer<'de>>(
143    d: D,
144) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
145    map_or_list(d, |_| Ok(String::new()))
146}
147
148/// An environment: a map or a list of `KEY=VALUE`. A compose file resolves a
149/// bare `KEY` from the environment before this sees it, as docker does; here,
150/// with nothing to resolve it against, it is an error.
151pub(crate) fn env_map_or_list<'de, D: Deserializer<'de>>(
152    d: D,
153) -> Result<std::collections::BTreeMap<String, String>, D::Error> {
154    map_or_list(d, |k| {
155        Err(format!(
156            "environment entry {k:?} has no value: write {k}=VALUE"
157        ))
158    })
159}
160
161/// A command: argv, or a string split the way a shell splits words.
162#[derive(Deserialize, JsonSchema)]
163#[serde(untagged)]
164#[allow(dead_code)]
165pub(crate) enum Command {
166    String(String),
167    Argv(Vec<Scalar>),
168}
169
170pub(crate) fn opt_command<'de, D: Deserializer<'de>>(
171    d: D,
172) -> Result<Option<Vec<String>>, D::Error> {
173    match Command::deserialize(d)? {
174        Command::Argv(v) => Ok(Some(v.into_iter().map(Scalar::into_string).collect())),
175        Command::String(s) => split_words(&s).map(Some).map_err(D::Error::custom),
176    }
177}
178
179/// Split a command line into words like a POSIX shell, without expanding
180/// anything: whitespace separates, quotes group, backslash escapes. Docker
181/// splits a string `command` the same way.
182pub fn split_words(s: &str) -> Result<Vec<String>, String> {
183    let mut words = Vec::new();
184    let mut cur = String::new();
185    let mut in_word = false;
186    let mut chars = s.chars();
187    while let Some(c) = chars.next() {
188        match c {
189            c if c.is_whitespace() => {
190                if in_word {
191                    words.push(std::mem::take(&mut cur));
192                    in_word = false;
193                }
194            }
195            '\'' => {
196                in_word = true;
197                loop {
198                    match chars.next() {
199                        Some('\'') => break,
200                        Some(c) => cur.push(c),
201                        None => return Err(format!("unterminated ' in {s:?}")),
202                    }
203                }
204            }
205            '"' => {
206                in_word = true;
207                loop {
208                    match chars.next() {
209                        Some('"') => break,
210                        Some('\\') => match chars.next() {
211                            Some(c @ ('"' | '\\' | '$' | '`')) => cur.push(c),
212                            Some('\n') => {}
213                            Some(c) => {
214                                cur.push('\\');
215                                cur.push(c);
216                            }
217                            None => return Err(format!("unterminated \" in {s:?}")),
218                        },
219                        Some(c) => cur.push(c),
220                        None => return Err(format!("unterminated \" in {s:?}")),
221                    }
222                }
223            }
224            '\\' => {
225                in_word = true;
226                match chars.next() {
227                    Some('\n') => {}
228                    Some(c) => cur.push(c),
229                    None => return Err(format!("trailing \\ in {s:?}")),
230                }
231            }
232            c => {
233                in_word = true;
234                cur.push(c);
235            }
236        }
237    }
238    if in_word {
239        words.push(cur);
240    }
241    if words.is_empty() {
242        return Err("command is empty".into());
243    }
244    Ok(words)
245}
246
247pub(crate) fn string_map_map<'de, D: Deserializer<'de>>(
248    d: D,
249) -> Result<std::collections::BTreeMap<String, std::collections::BTreeMap<String, String>>, D::Error>
250{
251    let m = std::collections::BTreeMap::<String, std::collections::BTreeMap<String, Scalar>>::deserialize(d)?;
252    Ok(m.into_iter()
253        .map(|(k, v)| {
254            (
255                k,
256                v.into_iter().map(|(a, b)| (a, b.into_string())).collect(),
257            )
258        })
259        .collect())
260}
261
262/// Parse `90`, `90s`, `5m`, `1h`, `90d`, `1500ms` into a duration.
263pub fn parse_duration(s: &str) -> Result<std::time::Duration, String> {
264    let s = s.trim();
265    let (num, unit) = match s.find(|c: char| !c.is_ascii_digit() && c != '.') {
266        Some(i) => (&s[..i], s[i..].trim()),
267        None => (s, "s"),
268    };
269    let n: f64 = num
270        .parse()
271        .map_err(|_| format!("invalid duration {s:?} (use e.g. 90s, 5m, 1h)"))?;
272    let secs = match unit {
273        "ms" => n / 1000.0,
274        "s" | "sec" | "secs" => n,
275        "m" | "min" | "mins" => n * 60.0,
276        "h" => n * 3600.0,
277        "d" => n * 86400.0,
278        _ => return Err(format!("invalid duration unit in {s:?} (ms, s, m, h, d)")),
279    };
280    Ok(std::time::Duration::from_secs_f64(secs))
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286    use std::time::Duration;
287
288    #[test]
289    fn durations() {
290        assert_eq!(parse_duration("90").unwrap(), Duration::from_secs(90));
291        assert_eq!(parse_duration("90s").unwrap(), Duration::from_secs(90));
292        assert_eq!(parse_duration("5m").unwrap(), Duration::from_secs(300));
293        assert_eq!(parse_duration("1h").unwrap(), Duration::from_secs(3600));
294        assert_eq!(
295            parse_duration("90d").unwrap(),
296            Duration::from_secs(90 * 86400)
297        );
298        assert_eq!(
299            parse_duration("1500ms").unwrap(),
300            Duration::from_millis(1500)
301        );
302        assert!(parse_duration("5 parsecs").is_err());
303    }
304
305    #[test]
306    fn words() {
307        assert_eq!(
308            split_words(r#"sh -c 'bun install && exec bun run dev'"#).unwrap(),
309            ["sh", "-c", "bun install && exec bun run dev"]
310        );
311        assert_eq!(
312            split_words(r#"echo "a \"b\" $X" c\ d ''"#).unwrap(),
313            ["echo", r#"a "b" $X"#, "c d", ""]
314        );
315        assert!(split_words("echo 'oops").is_err());
316        assert!(split_words("   ").is_err());
317    }
318
319    #[test]
320    fn bools() {
321        assert_eq!(parse_bool("TRUE"), Some(true));
322        assert_eq!(parse_bool("off"), Some(false));
323        assert_eq!(parse_bool("maybe"), None);
324    }
325}