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