k8s_expand/
lib.rs

1//! A library to expand variables in the style of Kubernetes pod manifests.
2
3use std::{cell::RefCell, collections::HashMap};
4
5const OPERATOR: char = '$';
6const REFERENCE_OPENER: char = '(';
7const REFERENCE_CLOSER: char = ')';
8
9fn syntax_wrap(input: &str) -> String {
10    format!(
11        "{}{}{}{}",
12        OPERATOR, REFERENCE_OPENER, input, REFERENCE_CLOSER
13    )
14}
15
16pub fn mapping_func_for<'a>(
17    context: &'a [&'a HashMap<String, RefCell<String>>],
18) -> impl Fn(&str) -> String + 'a {
19    move |input: &str| -> String {
20        for vars in context.iter() {
21            if let Some(val) = vars.get(input) {
22                return val.borrow().clone();
23            }
24        }
25        syntax_wrap(input)
26    }
27}
28
29pub fn expand(input: &str, mapping: impl Fn(&str) -> String) -> String {
30    let mut buf = String::with_capacity(16);
31    let mut checkpoint = 0;
32    let mut cursor = 0;
33    let mut chars = input.chars();
34    let mut chars_cursor = 0;
35
36    while cursor < input.len() {
37        if chars.nth(chars_cursor) == Some(OPERATOR) && cursor + 1 < input.len() {
38            buf.push_str(&input[checkpoint..cursor]);
39
40            let (read, is_var, advance) = try_read_variable_name(&input[cursor + 1..]);
41            if is_var {
42                buf.push_str(&mapping(&read));
43            } else {
44                buf.push_str(&read);
45            }
46
47            chars_cursor = advance;
48            cursor += advance;
49            checkpoint = cursor + 1;
50        } else {
51            chars_cursor = 0;
52        }
53        cursor += 1;
54    }
55
56    buf.push_str(&input[checkpoint..]);
57    buf
58}
59
60fn try_read_variable_name(input: &str) -> (String, bool, usize) {
61    let mut chars = input.chars();
62    let next = chars.next().unwrap();
63    match next {
64        OPERATOR => (OPERATOR.into(), false, 1),
65        REFERENCE_OPENER => {
66            for (i, c) in chars.enumerate() {
67                if c == REFERENCE_CLOSER {
68                    return (input[1..i + 1].into(), true, i + 2);
69                }
70            }
71            (format!("{}{}", OPERATOR, REFERENCE_OPENER), false, 1)
72        }
73        _ => (format!("{}{}", OPERATOR, next), false, 1),
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use std::cell::RefCell;
80
81    use super::*;
82    use pretty_assertions::assert_eq;
83
84    struct NameValue<'a> {
85        name: &'a str,
86        value: &'a str,
87    }
88
89    #[test]
90    fn test_map_reference() {
91        let envs = vec![
92            NameValue {
93                name: "FOO",
94                value: "bar",
95            },
96            NameValue {
97                name: "ZOO",
98                value: "$(FOO)-1",
99            },
100            NameValue {
101                name: "BLU",
102                value: "$(ZOO)-2",
103            },
104        ];
105
106        let declared_env: HashMap<String, RefCell<String>> = HashMap::from_iter(
107            vec![("FOO", "bar"), ("ZOO", "$(FOO)-1"), ("BLU", "$(ZOO)-2")]
108                .into_iter()
109                .map(|(k, v)| (k.to_string(), v.to_string().into())),
110        );
111
112        let maps = vec![&declared_env];
113        let mapping = mapping_func_for(&maps);
114
115        for entry in envs {
116            let vrc = declared_env.get(entry.name).unwrap();
117            *vrc.borrow_mut() = expand(entry.value.into(), &mapping);
118        }
119
120        let expected_env: HashMap<String, String> = HashMap::from_iter(
121            vec![("FOO", "bar"), ("ZOO", "bar-1"), ("BLU", "bar-1-2")]
122                .into_iter()
123                .map(|(k, v)| (k.to_string(), v.to_string())),
124        );
125
126        for (k, v) in expected_env {
127            let expanded = declared_env.get(&k).unwrap().borrow();
128            assert_eq!(*expanded, v);
129        }
130    }
131
132    #[test]
133    fn test_mapping() {
134        let context: HashMap<String, RefCell<String>> = HashMap::from_iter(
135            vec![
136                ("VAR_A", "A"),
137                ("VAR_B", "B"),
138                ("VAR_C", "C"),
139                ("VAR_REF", "$(VAR_A)"),
140                ("VAR_EMPTY", ""),
141            ]
142            .into_iter()
143            .map(|(k, v)| (k.to_string(), v.to_string().into())),
144        );
145        let maps = vec![&context];
146        let mapping = mapping_func_for(&maps);
147
148        do_expansion_test(mapping)
149    }
150
151    #[test]
152    fn test_mapping_dual() {
153        let context: HashMap<String, RefCell<String>> = HashMap::from_iter(
154            vec![("VAR_A", "A"), ("VAR_EMPTY", "")]
155                .into_iter()
156                .map(|(k, v)| (k.to_string(), v.to_string().into())),
157        );
158        let context2: HashMap<String, RefCell<String>> = HashMap::from_iter(
159            vec![("VAR_B", "B"), ("VAR_C", "C"), ("VAR_REF", "$(VAR_A)")]
160                .into_iter()
161                .map(|(k, v)| (k.to_string(), v.to_string().into())),
162        );
163        let maps = vec![&context, &context2];
164        let mapping = mapping_func_for(&maps);
165
166        do_expansion_test(mapping)
167    }
168
169    fn do_expansion_test(mapping: impl Fn(&str) -> String) {
170        struct Case<'a> {
171            name: &'a str,
172            input: &'a str,
173            expected: &'a str,
174        }
175        let cases = vec![
176            Case {
177                name: "whole string",
178                input: "$(VAR_A)",
179                expected: "A",
180            },
181            Case {
182                name: "repeat",
183                input: "$(VAR_A)-$(VAR_A)",
184                expected: "A-A",
185            },
186            Case {
187                name: "beginning",
188                input: "$(VAR_A)-1",
189                expected: "A-1",
190            },
191            Case {
192                name: "middle",
193                input: "___$(VAR_B)___",
194                expected: "___B___",
195            },
196            Case {
197                name: "end",
198                input: "___$(VAR_C)",
199                expected: "___C",
200            },
201            Case {
202                name: "compound",
203                input: "$(VAR_A)_$(VAR_B)_$(VAR_C)",
204                expected: "A_B_C",
205            },
206            Case {
207                name: "escape & expand",
208                input: "$$(VAR_B)_$(VAR_A)",
209                expected: "$(VAR_B)_A",
210            },
211            Case {
212                name: "compound escape",
213                input: "$$(VAR_A)_$$(VAR_B)",
214                expected: "$(VAR_A)_$(VAR_B)",
215            },
216            Case {
217                name: "mixed in escapes",
218                input: "f000-$$VAR_A",
219                expected: "f000-$VAR_A",
220            },
221            Case {
222                name: "backslash escape ignored",
223                input: "foo\\$(VAR_C)bar",
224                expected: "foo\\Cbar",
225            },
226            Case {
227                name: "backslash escape ignored",
228                input: "foo\\\\$(VAR_C)bar",
229                expected: "foo\\\\Cbar",
230            },
231            Case {
232                name: "lots of backslashes",
233                input: "foo\\\\\\\\$(VAR_A)bar",
234                expected: "foo\\\\\\\\Abar",
235            },
236            Case {
237                name: "nested var references",
238                input: "$(VAR_A$(VAR_B))",
239                expected: "$(VAR_A$(VAR_B))",
240            },
241            Case {
242                name: "nested var references second type",
243                input: "$(VAR_A$(VAR_B)",
244                expected: "$(VAR_A$(VAR_B)",
245            },
246            Case {
247                name: "value is a reference",
248                input: "$(VAR_REF)",
249                expected: "$(VAR_A)",
250            },
251            Case {
252                name: "value is a reference x 2",
253                input: "%%$(VAR_REF)--$(VAR_REF)%%",
254                expected: "%%$(VAR_A)--$(VAR_A)%%",
255            },
256            Case {
257                name: "empty var",
258                input: "foo$(VAR_EMPTY)bar",
259                expected: "foobar",
260            },
261            Case {
262                name: "unterminated expression",
263                input: "foo$(VAR_Awhoops!",
264                expected: "foo$(VAR_Awhoops!",
265            },
266            Case {
267                name: "expression without operator",
268                input: "f00__(VAR_A)__",
269                expected: "f00__(VAR_A)__",
270            },
271            Case {
272                name: "shell special vars pass through",
273                input: "$?_boo_$!",
274                expected: "$?_boo_$!",
275            },
276            Case {
277                name: "bare operators are ignored",
278                input: "$VAR_A",
279                expected: "$VAR_A",
280            },
281            Case {
282                name: "undefined vars are passed through",
283                input: "$(VAR_DNE)",
284                expected: "$(VAR_DNE)",
285            },
286            Case {
287                name: "multiple (even) operators, var undefined",
288                input: "$$$$$$(BIG_MONEY)",
289                expected: "$$$(BIG_MONEY)",
290            },
291            Case {
292                name: "multiple (even) operators, var defined",
293                input: "$$$$$$(VAR_A)",
294                expected: "$$$(VAR_A)",
295            },
296            Case {
297                name: "multiple (odd) operators, var undefined",
298                input: "$$$$$$$(GOOD_ODDS)",
299                expected: "$$$$(GOOD_ODDS)",
300            },
301            Case {
302                name: "multiple (odd) operators, var defined",
303                input: "$$$$$$$(VAR_A)",
304                expected: "$$$A",
305            },
306            Case {
307                name: "missing open expression",
308                input: "$VAR_A)",
309                expected: "$VAR_A)",
310            },
311            Case {
312                name: "shell syntax ignored",
313                input: "${VAR_A}",
314                expected: "${VAR_A}",
315            },
316            Case {
317                name: "trailing incomplete expression not consumed",
318                input: "$(VAR_B)_______$(A",
319                expected: "B_______$(A",
320            },
321            Case {
322                name: "trailing incomplete expression, no content, is not consumed",
323                input: "$(VAR_C)_______$(",
324                expected: "C_______$(",
325            },
326            Case {
327                name: "operator at end of input string is preserved",
328                input: "$(VAR_A)foobarzab$",
329                expected: "Afoobarzab$",
330            },
331            Case {
332                name: "shell escaped incomplete expr",
333                input: "foo-\\$(VAR_A",
334                expected: "foo-\\$(VAR_A",
335            },
336            Case {
337                name: "lots of $( in middle",
338                input: "--$($($($($--",
339                expected: "--$($($($($--",
340            },
341            Case {
342                name: "lots of $( in beginning",
343                input: "$($($($($--foo$(",
344                expected: "$($($($($--foo$(",
345            },
346            Case {
347                name: "lots of $( at end",
348                input: "foo0--$($($($(",
349                expected: "foo0--$($($($(",
350            },
351            Case {
352                name: "escaped operators in variable names are not escaped",
353                input: "$(foo$$var)",
354                expected: "$(foo$$var)",
355            },
356            Case {
357                name: "newline not expanded",
358                input: "\n",
359                expected: "\n",
360            },
361        ];
362
363        for case in cases {
364            let expanded = expand(case.input.into(), &mapping);
365            assert_eq!(expanded, case.expected, "{}", case.name);
366        }
367    }
368}