Skip to main content

fluidattacks_blends_domain/content/
helm.rs

1//! Helm-template fixup for templated json/yaml.
2//!
3//! Rewrites `{{ ... }}` into something a parser can accept. Pure business
4//! logic: text plus an `under_templates` hint in, fixed text out; the shell
5//! owns parsing and decides the hint.
6
7use alloc::boxed::Box;
8use alloc::string::String;
9use alloc::vec::Vec;
10use core::str::Chars;
11
12use once_cell::race::OnceBox;
13use regex_automata::meta::Regex;
14use regex_automata::util::captures::Captures;
15
16struct Patterns {
17    helm_expr: Regex,
18    kv: Regex,
19    block_comment: Regex,
20    expr_normalize: Regex,
21    kv_line: Regex,
22    required: [Regex; 3],
23}
24
25static PATTERNS: OnceBox<Patterns> = OnceBox::new();
26
27impl Patterns {
28    fn get() -> Option<&'static Self> {
29        PATTERNS
30            .get_or_try_init(|| Self::new().map(Box::new).ok_or(()))
31            .ok()
32    }
33
34    fn new() -> Option<Self> {
35        Some(Self {
36            helm_expr: Regex::new(r"\{\{-?\s*[^{}]*?\s*-?\}\}").ok()?,
37            kv: Regex::new(
38                r"^(?P<indent>\s*)(?P<dash>- )?(?P<key>(?:\{\{.*?\}\}|[^:])+):\s*(?P<value>.*)$",
39            )
40            .ok()?,
41            block_comment: Regex::new(r"\{\{\s*-?\s*/\*(?:[\s\S]*?)\*/\s*-?\s*\}\}").ok()?,
42            expr_normalize: Regex::new(r"\{\{-?\s*(.*?)\s*-?\}\}").ok()?,
43            kv_line: Regex::new(r"^\s*-?\s*(?:\{\{.*?\}\}|[\w.{}$/-])+\s*:").ok()?,
44            required: [
45                Regex::new(r"\bapiVersion\b\s*:").ok()?,
46                Regex::new(r"\bkind\b\s*:").ok()?,
47                Regex::new(r"\bmetadata\b\s*:").ok()?,
48            ],
49        })
50    }
51}
52
53fn replace_all(
54    re: &Regex,
55    haystack: &str,
56    mut render: impl FnMut(&Captures, &str) -> String,
57) -> String {
58    let mut out = String::new();
59    let mut last = 0_usize;
60    for caps in re.captures_iter(haystack) {
61        let Some(matched) = caps.get_match() else {
62            continue;
63        };
64        if let Some(pre) = haystack.get(last..matched.start()) {
65            out.push_str(pre);
66        }
67        out.push_str(&render(&caps, haystack));
68        last = matched.end();
69    }
70    if let Some(rest) = haystack.get(last..) {
71        out.push_str(rest);
72    }
73    out
74}
75
76fn named<'a>(caps: &Captures, name: &str, haystack: &'a str) -> &'a str {
77    caps.get_group_by_name(name)
78        .and_then(|span| haystack.get(span.range()))
79        .unwrap_or("")
80}
81
82fn validate_required_keys(patterns: &Patterns, content: &str) -> bool {
83    patterns
84        .required
85        .iter()
86        .all(|pattern| pattern.is_match(content))
87}
88
89fn convert_block_comment(patterns: &Patterns, content: &str) -> String {
90    replace_all(&patterns.block_comment, content, |caps, haystack| {
91        let block = caps
92            .get_match()
93            .and_then(|matched| haystack.get(matched.range()))
94            .unwrap_or("");
95        let mut out = String::from("# ");
96        out.push_str(&block.replace('\n', "\n# "));
97        out
98    })
99}
100
101fn normalize_expression(patterns: &Patterns, value: &str) -> String {
102    replace_all(&patterns.expr_normalize, value, |caps, haystack| {
103        let inner = caps
104            .get_group(1)
105            .and_then(|span| haystack.get(span.range()))
106            .unwrap_or("");
107        let mut out = String::from("{{ ");
108        out.push_str(inner);
109        out.push_str(" }}");
110        out
111    })
112}
113
114fn take_until_quote(chars: &mut Chars<'_>) -> (String, bool) {
115    let mut inner = String::new();
116    for next in chars.by_ref() {
117        if next == '"' {
118            return (inner, true);
119        }
120        inner.push(next);
121    }
122    (inner, false)
123}
124
125fn replace_unescaped_double_quotes(input: &str) -> String {
126    let mut out = String::with_capacity(input.len());
127    let mut chars = input.chars();
128    let mut previous: Option<char> = None;
129    while let Some(current) = chars.next() {
130        if current != '"' || previous == Some('\\') {
131            out.push(current);
132            previous = Some(current);
133            continue;
134        }
135        let (inner, closed) = take_until_quote(&mut chars);
136        if closed {
137            out.push('\'');
138            out.push_str(&inner);
139            out.push('\'');
140            previous = Some('"');
141        } else {
142            out.push('"');
143            out.push_str(&inner);
144            previous = inner.chars().last().or(Some('"'));
145        }
146    }
147    out
148}
149
150fn fix_inner_quotes(patterns: &Patterns, value: &str) -> String {
151    replace_all(&patterns.helm_expr, value, |caps, haystack| {
152        let expr = caps
153            .get_match()
154            .and_then(|matched| haystack.get(matched.range()))
155            .unwrap_or("");
156        let inner = expr
157            .strip_prefix("{{")
158            .and_then(|rest| rest.strip_suffix("}}"))
159            .unwrap_or("");
160        let mut out = String::from("{{");
161        out.push_str(&replace_unescaped_double_quotes(inner));
162        out.push_str("}}");
163        out
164    })
165}
166
167fn process_kv_line(patterns: &Patterns, line: &str) -> String {
168    let mut caps = patterns.kv.create_captures();
169    patterns.kv.captures(line, &mut caps);
170    if !caps.is_match() {
171        return String::from(line);
172    }
173
174    let indent = named(&caps, "indent", line);
175    let dash = named(&caps, "dash", line);
176    let key = named(&caps, "key", line).trim();
177    let value = named(&caps, "value", line).trim();
178
179    let key_part = if patterns.helm_expr.is_match(key) {
180        let mut part = String::from("'");
181        part.push_str(&normalize_expression(patterns, key));
182        part.push_str("': ");
183        part
184    } else {
185        let mut part = String::from(key);
186        part.push_str(": ");
187        part
188    };
189    let mut prefix = String::from(indent);
190    prefix.push_str(dash);
191    prefix.push_str(&key_part);
192
193    if value.is_empty() {
194        return prefix;
195    }
196
197    if patterns.helm_expr.is_match(value) {
198        let normalized = normalize_expression(patterns, &fix_inner_quotes(patterns, value));
199        if normalized.starts_with('"') && normalized.ends_with('"') {
200            prefix.push_str(&normalized);
201        } else if normalized.contains('"') {
202            prefix.push('\'');
203            prefix.push_str(&normalized);
204            prefix.push('\'');
205        } else {
206            prefix.push('"');
207            prefix.push_str(&normalized);
208            prefix.push('"');
209        }
210        return prefix;
211    }
212
213    prefix.push_str(value);
214    prefix
215}
216
217fn get_indent(line: &str) -> &str {
218    let trimmed = line.trim_start();
219    line.get(..line.len().saturating_sub(trimmed.len()))
220        .unwrap_or("")
221}
222
223fn preprocess(patterns: &Patterns, text: &str) -> String {
224    let converted = convert_block_comment(patterns, text);
225    let mut lines: Vec<String> = Vec::new();
226    for line in converted.lines() {
227        if patterns.kv_line.is_match(line) {
228            lines.push(process_kv_line(patterns, line));
229        } else if patterns.helm_expr.is_match(line) {
230            let mut commented = String::from(get_indent(line));
231            commented.push_str("# ");
232            commented.push_str(&normalize_expression(patterns, line.trim()));
233            lines.push(commented);
234        } else {
235            lines.push(String::from(line));
236        }
237    }
238    let trimmed: Vec<&str> = lines.iter().map(|line| line.trim_end()).collect();
239    String::from(trimmed.join("\n").trim())
240}
241
242#[derive(Clone, Copy)]
243pub struct HelmInput<'a> {
244    pub text: &'a str,
245    pub under_templates: bool,
246}
247
248#[must_use]
249pub fn fix(input: HelmInput<'_>) -> Option<String> {
250    let patterns = Patterns::get()?;
251    if !(input.under_templates
252        && validate_required_keys(patterns, input.text)
253        && patterns.helm_expr.is_match(input.text))
254    {
255        return None;
256    }
257    Some(preprocess(patterns, input.text))
258}
259
260#[cfg(test)]
261mod tests {
262    use super::{fix, HelmInput};
263
264    const TEMPLATE: &str = concat!(
265        "apiVersion: v1\n",
266        "kind: Deployment\n",
267        "metadata:\n",
268        "  name: test\n",
269        "spec:\n",
270        "  replicas: {{ .Values.replicas }}\n",
271    );
272
273    fn run(text: &str, under_templates: bool) -> Option<String> {
274        fix(HelmInput {
275            text,
276            under_templates,
277        })
278    }
279
280    #[test]
281    fn skips_when_not_under_templates() {
282        assert!(run(TEMPLATE, false).is_none());
283    }
284
285    #[test]
286    fn skips_when_required_keys_missing() {
287        assert!(run("foo: {{ .Values.x }}\n", true).is_none());
288    }
289
290    #[test]
291    fn skips_when_no_helm_expression() {
292        let plain = "apiVersion: v1\nkind: Deployment\nmetadata:\n  name: test\n";
293        assert!(run(plain, true).is_none());
294    }
295
296    #[test]
297    fn quotes_helm_value_expression() {
298        let fixed = run(TEMPLATE, true).expect("template is fixable");
299        assert!(
300            fixed.contains(r#"replicas: "{{ .Values.replicas }}""#),
301            "unexpected output: {fixed}"
302        );
303    }
304
305    #[test]
306    fn comments_standalone_helm_line() {
307        let text = concat!(
308            "apiVersion: v1\n",
309            "kind: Deployment\n",
310            "metadata:\n",
311            "  name: test\n",
312            "{{- if .Values.enabled }}\n",
313        );
314        let fixed = run(text, true).expect("template is fixable");
315        assert!(
316            fixed.contains("# {{ if .Values.enabled }}"),
317            "unexpected output: {fixed}"
318        );
319    }
320}