Skip to main content

helm_schema_core/
guard.rs

1use std::fmt;
2
3use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use serde_json::Number;
5
6/// Scalar literal used by values-decidable guard comparisons.
7///
8/// Helm `eq` / `ne` conditions can compare against strings, booleans, numbers,
9/// and nil. Keeping the literal typed prevents static analysis from degrading
10/// `eq .Values.enabled false` into a misleading truthiness guard.
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
12pub enum GuardValue {
13    /// UTF-8 string literal.
14    String(String),
15    /// Boolean literal.
16    Bool(bool),
17    /// Signed integer literal.
18    Int(i64),
19    /// Finite floating-point literal stored in canonical textual form.
20    Float(String),
21    /// Explicit null literal.
22    Null,
23}
24
25impl GuardValue {
26    /// Creates a string guard literal.
27    #[must_use]
28    pub fn string(value: impl Into<String>) -> Self {
29        Self::String(value.into())
30    }
31
32    /// Creates a finite floating-point guard literal, rejecting NaN and infinity.
33    #[must_use]
34    pub fn float(value: f64) -> Option<Self> {
35        value.is_finite().then(|| Self::Float(value.to_string()))
36    }
37}
38
39impl Serialize for GuardValue {
40    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
41    where
42        S: Serializer,
43    {
44        match self {
45            Self::String(value) => serializer.serialize_str(value),
46            Self::Bool(value) => serializer.serialize_bool(*value),
47            Self::Int(value) => serializer.serialize_i64(*value),
48            Self::Float(value) => {
49                let number = value
50                    .parse::<f64>()
51                    .ok()
52                    .and_then(Number::from_f64)
53                    .ok_or_else(|| serde::ser::Error::custom("invalid float guard value"))?;
54                number.serialize(serializer)
55            }
56            Self::Null => serializer.serialize_none(),
57        }
58    }
59}
60
61impl<'de> Deserialize<'de> for GuardValue {
62    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
63    where
64        D: Deserializer<'de>,
65    {
66        let value = serde_json::Value::deserialize(deserializer)?;
67        match value {
68            serde_json::Value::String(value) => Ok(Self::String(value)),
69            serde_json::Value::Bool(value) => Ok(Self::Bool(value)),
70            serde_json::Value::Number(value) => {
71                if let Some(value) = value.as_i64() {
72                    Ok(Self::Int(value))
73                } else {
74                    Ok(Self::Float(value.to_string()))
75                }
76            }
77            serde_json::Value::Null => Ok(Self::Null),
78            _ => Err(serde::de::Error::custom(
79                "guard comparison value must be a scalar literal",
80            )),
81        }
82    }
83}
84
85impl fmt::Display for GuardValue {
86    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
87        match self {
88            Self::String(value) => value.fmt(f),
89            Self::Bool(value) => value.fmt(f),
90            Self::Int(value) => value.fmt(f),
91            #[expect(
92                clippy::match_same_arms,
93                reason = "string and float variants require distinct bindings despite identical formatting"
94            )]
95            Self::Float(value) => value.fmt(f),
96            Self::Null => f.write_str("null"),
97        }
98    }
99}
100
101/// A guard condition from an `if`, `with`, or `range` block.
102#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
103#[serde(tag = "type", rename_all = "snake_case")]
104pub enum Guard {
105    /// Simple truthy check: `if .Values.X`
106    Truthy {
107        /// Values path tested for truthiness.
108        path: String,
109    },
110    /// Negated truthy check: `if not .Values.X`
111    Not {
112        /// Values path tested for falsiness.
113        path: String,
114    },
115    /// Equality check: `if eq .Values.X "value"` / `if eq .Values.X false`.
116    Eq {
117        /// Values path compared with the literal.
118        path: String,
119        /// Literal required at the path.
120        value: GuardValue,
121    },
122    /// Inequality check: `if ne .Values.X "value"` / `if ne .Values.X false`.
123    NotEq {
124        /// Values path compared with the literal.
125        path: String,
126        /// Literal excluded at the path.
127        value: GuardValue,
128    },
129    /// Path absence check, used for structural rules where missing values are
130    /// semantically distinct from false values.
131    Absent {
132        /// Values path whose absence selects the branch.
133        path: String,
134    },
135    /// The path's string value matches a literal regular expression:
136    /// `if regexMatch "…" .Values.X`. `regexMatch` type-asserts a string
137    /// subject, so the guard holding implies string-ness as well. When
138    /// `templated` is set the subject reached the match through `tpl`, so
139    /// the pattern constrains the rendered OUTPUT: a raw value carrying a
140    /// template action is admitted regardless (its render may match).
141    MatchesPattern {
142        /// Values path subjected to the pattern test.
143        path: String,
144        /// Literal regular expression required by the branch.
145        pattern: String,
146        /// Whether matching occurs after rendering the value through `tpl`.
147        templated: bool,
148    },
149    /// A destructured range key starts with a literal prefix. The path names
150    /// the ranged collection; the predicate applies to its matching entries,
151    /// not to the collection value itself.
152    RangeKeyPrefix {
153        /// Values path of the ranged collection.
154        path: String,
155        /// Literal prefix required of the current key.
156        prefix: String,
157    },
158    /// A destructured range key equals a literal (`if eq $key "name"`). The
159    /// path names the ranged collection; the predicate selects exactly the
160    /// entry with that key. Document-level lowering may only use the
161    /// POSITIVE form (the key exists in the collection); the negation runs
162    /// for every OTHER member and has no key-presence encoding.
163    RangeKeyEquals {
164        /// Values path of the ranged collection.
165        path: String,
166        /// Literal key selected by the branch.
167        key: String,
168    },
169    /// A destructured range key matches a literal regular expression
170    /// (`if regexMatch "[A-Z]" $name`). The path names the ranged
171    /// collection; the predicate applies per key, so lowering targets the
172    /// collection's key domain (traefik's uppercase `ingressRoute` gate).
173    RangeKeyMatches {
174        /// Values path of the ranged collection.
175        path: String,
176        /// Regular expression required of the current key.
177        pattern: String,
178    },
179    /// Disjunction: `if or .Values.A .Values.B`
180    Or {
181        /// Values paths whose truthiness forms the disjunction.
182        paths: Vec<String>,
183    },
184    /// Disjunction whose arms may each contain a conjunction of typed guards.
185    ///
186    /// This preserves structural forms such as
187    /// `or (and .Values.A .Values.B) (eq .Values.mode "prod")` without
188    /// degrading them into truthiness checks for every mentioned path.
189    AnyOf {
190        /// Guard conjunctions that form the disjunction's alternatives.
191        alternatives: Vec<Vec<Guard>>,
192    },
193    /// Body of `range .Values.X` / `range .foo` block. The referenced path is
194    /// being iterated as a collection, not interpreted as a boolean-valued
195    /// scalar. This should not contribute a boolean type hint downstream.
196    Range {
197        /// Values path used as the range source.
198        path: String,
199    },
200    /// Body of `with .Values.X` block. This distinguishes header binding from
201    /// `if`-style truthy checks. The bound path is null-tolerant by
202    /// construction because `with nil` skips the body.
203    With {
204        /// Values path selected as the branch context.
205        path: String,
206    },
207    /// Rendered via a `default ... <path>` fallback, either in prefix form
208    /// (`default "x" .Values.X`) or pipeline form (`.Values.X | default "x"`).
209    ///
210    /// This is stronger than a plain truthy guard: the template explicitly
211    /// substitutes a fallback when the path is empty/nil, so `null` is an
212    /// accepted chart input for that render site even when `values.yaml` ships
213    /// a non-null default.
214    Default {
215        /// Values path protected by a fallback.
216        path: String,
217    },
218    /// A `typeIs "<json type>" <path>` check in template logic.
219    ///
220    /// This is not a truthiness guard. It is a structural type declaration:
221    /// helpers such as Bitnami's `common.tplvalues.render` explicitly branch on
222    /// `typeIs "string" .value`, so callers may supply that values path as a
223    /// string even when another branch renders it as a YAML object fragment.
224    TypeIs {
225        /// Values path subjected to the type test.
226        path: String,
227        /// JSON Schema type name selected by the branch.
228        schema_type: String,
229    },
230    /// The complement of [`Guard::TypeIs`]: the `else` arm of a type
231    /// dispatch (`if typeIs "string" x … else …`).
232    ///
233    /// Rows need this as a first-class variant because dropping the
234    /// complement collapses a type-switch partition: member reads and
235    /// structural placements under the `else` would otherwise apply to
236    /// EVERY type of the dispatched path.
237    NotTypeIs {
238        /// Values path subjected to the type test.
239        path: String,
240        /// JSON Schema type name excluded by the branch.
241        schema_type: String,
242    },
243    /// The path's RAW value is a JSON integer strictly greater than `bound`.
244    ///
245    /// This deliberately claims less than the Sprig coercion it stands in
246    /// for: `gt (int64 .Values.x) N` also holds for numeric strings and
247    /// `true`, so this guard is a SOUND SUBSET usable only where firing
248    /// less often is safe (a fail-arm condition), never as an exact branch
249    /// condition whose negation must also hold.
250    IntGt {
251        /// Values path subjected to the integer comparison.
252        path: String,
253        /// Exclusive lower bound.
254        bound: i64,
255    },
256    /// The path's RAW value is a JSON integer strictly less than `bound`.
257    ///
258    /// The mirror of [`Guard::IntGt`], with the same sound-subset contract:
259    /// `lt (int .Values.x) N` also holds for coercible non-integers, so
260    /// this guard may only strengthen positive-polarity consumers.
261    IntLt {
262        /// Values path subjected to the integer comparison.
263        path: String,
264        /// Exclusive upper bound.
265        bound: i64,
266    },
267    /// The collection at `path` has at most one entry.
268    ///
269    /// A sound SUBSET stand-in for loop-carried conditions that provably
270    /// hold on a range's FIRST iteration (an empty-initialized dedup
271    /// accumulator cannot shadow anything yet): with at most one member,
272    /// every iteration is the first. Like [`Guard::IntGt`], it may only
273    /// strengthen positive-polarity consumers.
274    AtMostOneMember {
275        /// Values path expected to hold the bounded collection.
276        path: String,
277    },
278    /// The value at `path` is a mapping with at least `bound` members —
279    /// the exact meaning of `gt (keys X | len) N` (`keys` aborts on
280    /// non-maps, so the render reaches the body only for maps).
281    MinMembers {
282        /// Values path expected to hold the mapping.
283        path: String,
284        /// Inclusive minimum number of mapping members.
285        bound: i64,
286    },
287    /// The mapping at `path` contains `key` as a literal member — Sprig
288    /// `hasKey`/`dig` observability, where a present nil member IS present
289    /// (cilium's removed-option guards abort on the truthy `"<nil>"`
290    /// rendering of an explicit null). Contrast [`Guard::Absent`], which
291    /// counts explicit null as absent for the nil-safe selector lanes.
292    HasKey {
293        /// Values path expected to hold a mapping.
294        path: String,
295        /// Literal mapping key whose presence selects the branch.
296        key: String,
297    },
298    /// SOME item of the list at `path` deep-equals the scalar literal —
299    /// Sprig `has LITERAL .Values.list`, the dual of the literal-list
300    /// membership (`has .Values.x (list …)`). `has` returns false on a
301    /// nil haystack and aborts rendering on non-lists, so the guard holds
302    /// exactly for arrays carrying the literal (oauth2-proxy gates its
303    /// secret keys on `has "cookie-secret" .Values.config.requiredSecretKeys`).
304    ContainsEquals {
305        /// Values path expected to hold a list.
306        path: String,
307        /// Literal that at least one list item must equal.
308        value: GuardValue,
309    },
310}
311
312impl Guard {
313    pub(crate) fn canonicalize_all(guards: &mut Vec<Self>) {
314        for guard in guards.iter_mut() {
315            guard.canonicalize();
316        }
317        guards.sort();
318        guards.dedup();
319    }
320
321    fn canonicalize(&mut self) {
322        match self {
323            Self::Or { paths } => {
324                paths.sort();
325                paths.dedup();
326            }
327            Self::AnyOf { alternatives } => {
328                for guards in alternatives.iter_mut() {
329                    Self::canonicalize_all(guards);
330                }
331                alternatives.sort();
332                alternatives.dedup();
333            }
334            Self::Truthy { .. }
335            | Self::Not { .. }
336            | Self::Eq { .. }
337            | Self::NotEq { .. }
338            | Self::Absent { .. }
339            | Self::MatchesPattern { .. }
340            | Self::RangeKeyPrefix { .. }
341            | Self::RangeKeyEquals { .. }
342            | Self::RangeKeyMatches { .. }
343            | Self::Range { .. }
344            | Self::With { .. }
345            | Self::Default { .. }
346            | Self::TypeIs { .. }
347            | Self::NotTypeIs { .. }
348            | Self::IntGt { .. }
349            | Self::IntLt { .. }
350            | Self::AtMostOneMember { .. }
351            | Self::MinMembers { .. }
352            | Self::HasKey { .. }
353            | Self::ContainsEquals { .. } => {}
354        }
355    }
356
357    /// Return all `.Values.*` paths referenced by this guard.
358    #[must_use]
359    pub fn value_paths(&self) -> Vec<&str> {
360        match self {
361            Guard::Truthy { path }
362            | Guard::Not { path }
363            | Guard::Eq { path, .. }
364            | Guard::NotEq { path, .. }
365            | Guard::Absent { path }
366            | Guard::MatchesPattern { path, .. }
367            | Guard::RangeKeyPrefix { path, .. }
368            | Guard::RangeKeyEquals { path, .. }
369            | Guard::RangeKeyMatches { path, .. }
370            | Guard::Range { path }
371            | Guard::With { path }
372            | Guard::Default { path }
373            | Guard::TypeIs { path, .. }
374            | Guard::NotTypeIs { path, .. }
375            | Guard::IntGt { path, .. }
376            | Guard::IntLt { path, .. }
377            | Guard::AtMostOneMember { path }
378            | Guard::MinMembers { path, .. }
379            | Guard::HasKey { path, .. }
380            | Guard::ContainsEquals { path, .. } => {
381                vec![path.as_str()]
382            }
383            Guard::Or { paths } => paths.iter().map(std::string::String::as_str).collect(),
384            Guard::AnyOf { alternatives } => alternatives
385                .iter()
386                .flat_map(|alternative| alternative.iter().flat_map(Guard::value_paths))
387                .collect(),
388        }
389    }
390
391    /// Rewrite value paths carried by this guard.
392    #[must_use]
393    pub fn map_value_paths<F>(self, map: &mut F) -> Self
394    where
395        F: FnMut(&str) -> String,
396    {
397        match self {
398            Guard::Truthy { path } => Guard::Truthy { path: map(&path) },
399            Guard::Not { path } => Guard::Not { path: map(&path) },
400            Guard::Eq { path, value } => Guard::Eq {
401                path: map(&path),
402                value,
403            },
404            Guard::NotEq { path, value } => Guard::NotEq {
405                path: map(&path),
406                value,
407            },
408            Guard::Absent { path } => Guard::Absent { path: map(&path) },
409            Guard::MatchesPattern {
410                path,
411                pattern,
412                templated,
413            } => Guard::MatchesPattern {
414                path: map(&path),
415                pattern,
416                templated,
417            },
418            Guard::RangeKeyEquals { path, key } => Guard::RangeKeyEquals {
419                path: map(&path),
420                key,
421            },
422            Guard::RangeKeyPrefix { path, prefix } => Guard::RangeKeyPrefix {
423                path: map(&path),
424                prefix,
425            },
426            Guard::RangeKeyMatches { path, pattern } => Guard::RangeKeyMatches {
427                path: map(&path),
428                pattern,
429            },
430            Guard::Or { paths } => Guard::Or {
431                paths: paths.into_iter().map(|path| map(&path)).collect(),
432            },
433            Guard::AnyOf { alternatives } => Guard::AnyOf {
434                alternatives: alternatives
435                    .into_iter()
436                    .map(|alternative| {
437                        alternative
438                            .into_iter()
439                            .map(|guard| guard.map_value_paths(map))
440                            .collect()
441                    })
442                    .collect(),
443            },
444            Guard::Range { path } => Guard::Range { path: map(&path) },
445            Guard::With { path } => Guard::With { path: map(&path) },
446            Guard::Default { path } => Guard::Default { path: map(&path) },
447            Guard::TypeIs { path, schema_type } => Guard::TypeIs {
448                path: map(&path),
449                schema_type,
450            },
451            Guard::NotTypeIs { path, schema_type } => Guard::NotTypeIs {
452                path: map(&path),
453                schema_type,
454            },
455            Guard::IntGt { path, bound } => Guard::IntGt {
456                path: map(&path),
457                bound,
458            },
459            Guard::IntLt { path, bound } => Guard::IntLt {
460                path: map(&path),
461                bound,
462            },
463            Guard::AtMostOneMember { path } => Guard::AtMostOneMember { path: map(&path) },
464            Guard::MinMembers { path, bound } => Guard::MinMembers {
465                path: map(&path),
466                bound,
467            },
468            Guard::HasKey { path, key } => Guard::HasKey {
469                path: map(&path),
470                key,
471            },
472            Guard::ContainsEquals { path, value } => Guard::ContainsEquals {
473                path: map(&path),
474                value,
475            },
476        }
477    }
478}