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