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    /// The path is a string that does not match a literal regular expression.
150    ///
151    /// This is narrower than the logical complement of
152    /// [`Guard::MatchesPattern`], which also includes every non-string.
153    /// Stringified predicate results use this guard for their sound
154    /// raw-string mismatch subset.
155    NotMatchesPattern {
156        /// Values path subjected to the pattern test.
157        path: String,
158        /// Literal regular expression excluded by the branch.
159        pattern: String,
160    },
161    /// A destructured range key starts with a literal prefix. The path names
162    /// the ranged collection; the predicate applies to its matching entries,
163    /// not to the collection value itself.
164    RangeKeyPrefix {
165        /// Values path of the ranged collection.
166        path: String,
167        /// Literal prefix required of the current key.
168        prefix: String,
169    },
170    /// A destructured range key equals a literal (`if eq $key "name"`). The
171    /// path names the ranged collection; the predicate selects exactly the
172    /// entry with that key. Document-level lowering may only use the
173    /// POSITIVE form (the key exists in the collection); the negation runs
174    /// for every OTHER member and has no key-presence encoding.
175    RangeKeyEquals {
176        /// Values path of the ranged collection.
177        path: String,
178        /// Literal key selected by the branch.
179        key: String,
180    },
181    /// A destructured range key matches a literal regular expression
182    /// (`if regexMatch "[A-Z]" $name`). The path names the ranged
183    /// collection; the predicate applies per key, so lowering targets the
184    /// collection's key domain (traefik's uppercase `ingressRoute` gate).
185    RangeKeyMatches {
186        /// Values path of the ranged collection.
187        path: String,
188        /// Regular expression required of the current key.
189        pattern: String,
190    },
191    /// Disjunction: `if or .Values.A .Values.B`
192    Or {
193        /// Values paths whose truthiness forms the disjunction.
194        paths: Vec<String>,
195    },
196    /// Disjunction whose arms may each contain a conjunction of typed guards.
197    ///
198    /// This preserves structural forms such as
199    /// `or (and .Values.A .Values.B) (eq .Values.mode "prod")` without
200    /// degrading them into truthiness checks for every mentioned path.
201    AnyOf {
202        /// Guard conjunctions that form the disjunction's alternatives.
203        alternatives: Vec<Vec<Guard>>,
204    },
205    /// Body of `range .Values.X` / `range .foo` block. The referenced path is
206    /// being iterated as a collection, not interpreted as a boolean-valued
207    /// scalar. This should not contribute a boolean type hint downstream.
208    Range {
209        /// Values path used as the range source.
210        path: String,
211    },
212    /// Body of `with .Values.X` block. This distinguishes header binding from
213    /// `if`-style truthy checks. The bound path is null-tolerant by
214    /// construction because `with nil` skips the body.
215    With {
216        /// Values path selected as the branch context.
217        path: String,
218    },
219    /// Rendered via a `default ... <path>` fallback, either in prefix form
220    /// (`default "x" .Values.X`) or pipeline form (`.Values.X | default "x"`).
221    ///
222    /// This is stronger than a plain truthy guard: the template explicitly
223    /// substitutes a fallback when the path is empty/nil, so `null` is an
224    /// accepted chart input for that render site even when `values.yaml` ships
225    /// a non-null default.
226    Default {
227        /// Values path protected by a fallback.
228        path: String,
229    },
230    /// A `typeIs "<json type>" <path>` check in template logic.
231    ///
232    /// This is not a truthiness guard. It is a structural type declaration:
233    /// helpers such as Bitnami's `common.tplvalues.render` explicitly branch on
234    /// `typeIs "string" .value`, so callers may supply that values path as a
235    /// string even when another branch renders it as a YAML object fragment.
236    TypeIs {
237        /// Values path subjected to the type test.
238        path: String,
239        /// JSON Schema type name selected by the branch.
240        schema_type: String,
241    },
242    /// The complement of [`Guard::TypeIs`]: the `else` arm of a type
243    /// dispatch (`if typeIs "string" x … else …`).
244    ///
245    /// Rows need this as a first-class variant because dropping the
246    /// complement collapses a type-switch partition: member reads and
247    /// structural placements under the `else` would otherwise apply to
248    /// EVERY type of the dispatched path.
249    NotTypeIs {
250        /// Values path subjected to the type test.
251        path: String,
252        /// JSON Schema type name excluded by the branch.
253        schema_type: String,
254    },
255    /// The path's RAW value is a JSON integer strictly greater than `bound`.
256    ///
257    /// This deliberately claims less than the Sprig coercion it stands in
258    /// for: `gt (int64 .Values.x) N` also holds for numeric strings and
259    /// `true`, so this guard is a SOUND SUBSET usable only where firing
260    /// less often is safe (a fail-arm condition), never as an exact branch
261    /// condition whose negation must also hold.
262    IntGt {
263        /// Values path subjected to the integer comparison.
264        path: String,
265        /// Exclusive lower bound.
266        bound: i64,
267    },
268    /// The path's RAW value is a JSON integer strictly less than `bound`.
269    ///
270    /// The mirror of [`Guard::IntGt`], with the same sound-subset contract:
271    /// `lt (int .Values.x) N` also holds for coercible non-integers, so
272    /// this guard may only strengthen positive-polarity consumers.
273    IntLt {
274        /// Values path subjected to the integer comparison.
275        path: String,
276        /// Exclusive upper bound.
277        bound: i64,
278    },
279    /// The collection at `path` has at most one entry.
280    ///
281    /// A sound SUBSET stand-in for loop-carried conditions that provably
282    /// hold on a range's FIRST iteration (an empty-initialized dedup
283    /// accumulator cannot shadow anything yet): with at most one member,
284    /// every iteration is the first. Like [`Guard::IntGt`], it may only
285    /// strengthen positive-polarity consumers.
286    AtMostOneMember {
287        /// Values path expected to hold the bounded collection.
288        path: String,
289    },
290    /// The value at `path` is a mapping with at least `bound` members —
291    /// the exact meaning of `gt (keys X | len) N` (`keys` aborts on
292    /// non-maps, so the render reaches the body only for maps).
293    MinMembers {
294        /// Values path expected to hold the mapping.
295        path: String,
296        /// Inclusive minimum number of mapping members.
297        bound: i64,
298    },
299    /// The mapping at `path` contains `key` as a literal member — Sprig
300    /// `hasKey`/`dig` observability, where a present nil member IS present
301    /// (cilium's removed-option guards abort on the truthy `"<nil>"`
302    /// rendering of an explicit null). Contrast [`Guard::Absent`], which
303    /// counts explicit null as absent for the nil-safe selector lanes.
304    HasKey {
305        /// Values path expected to hold a mapping.
306        path: String,
307        /// Literal mapping key whose presence selects the branch.
308        key: String,
309    },
310    /// The mapping at `path` does not contain `key`.
311    ///
312    /// This is the exact logical complement of [`Guard::HasKey`].
313    NotHasKey {
314        /// Values path expected to hold a mapping.
315        path: String,
316        /// Literal mapping key whose absence selects the branch.
317        key: String,
318    },
319    /// SOME item of the list at `path` deep-equals the scalar literal —
320    /// Sprig `has LITERAL .Values.list`, the dual of the literal-list
321    /// membership (`has .Values.x (list …)`). `has` returns false on a
322    /// nil haystack and aborts rendering on non-lists, so the guard holds
323    /// exactly for arrays carrying the literal (oauth2-proxy gates its
324    /// secret keys on `has "cookie-secret" .Values.config.requiredSecretKeys`).
325    ContainsEquals {
326        /// Values path expected to hold a list.
327        path: String,
328        /// Literal that at least one list item must equal.
329        value: GuardValue,
330    },
331    /// Some iterated item of the collection at `path` has `member` equal to
332    /// `value`. This is the quantified result of a monotone Boolean local
333    /// set inside a range under an equality test.
334    ContainsMemberEquals {
335        /// Values path expected to hold the iterated collection.
336        path: String,
337        /// Member name compared within each collection item.
338        member: String,
339        /// Literal that at least one member must equal.
340        value: GuardValue,
341    },
342    /// Some iterated item of the collection at `path` has a Helm-truthy
343    /// `member`. This is the quantified result of a monotone Boolean local
344    /// set inside a range under a truthiness test.
345    ContainsTruthyMember {
346        /// Values path expected to hold the iterated collection.
347        path: String,
348        /// Member whose truthiness selects the sentinel state.
349        member: String,
350    },
351}
352
353impl Guard {
354    pub(crate) fn canonicalize_all(guards: &mut Vec<Self>) {
355        for guard in guards.iter_mut() {
356            guard.canonicalize();
357        }
358        guards.sort();
359        guards.dedup();
360    }
361
362    fn canonicalize(&mut self) {
363        match self {
364            Self::Or { paths } => {
365                paths.sort();
366                paths.dedup();
367            }
368            Self::AnyOf { alternatives } => {
369                for guards in alternatives.iter_mut() {
370                    Self::canonicalize_all(guards);
371                }
372                alternatives.sort();
373                alternatives.dedup();
374            }
375            Self::Truthy { .. }
376            | Self::Not { .. }
377            | Self::Eq { .. }
378            | Self::NotEq { .. }
379            | Self::Absent { .. }
380            | Self::MatchesPattern { .. }
381            | Self::NotMatchesPattern { .. }
382            | Self::RangeKeyPrefix { .. }
383            | Self::RangeKeyEquals { .. }
384            | Self::RangeKeyMatches { .. }
385            | Self::Range { .. }
386            | Self::With { .. }
387            | Self::Default { .. }
388            | Self::TypeIs { .. }
389            | Self::NotTypeIs { .. }
390            | Self::IntGt { .. }
391            | Self::IntLt { .. }
392            | Self::AtMostOneMember { .. }
393            | Self::MinMembers { .. }
394            | Self::HasKey { .. }
395            | Self::NotHasKey { .. }
396            | Self::ContainsEquals { .. }
397            | Self::ContainsMemberEquals { .. }
398            | Self::ContainsTruthyMember { .. } => {}
399        }
400    }
401
402    /// Return all `.Values.*` paths referenced by this guard.
403    #[must_use]
404    pub fn value_paths(&self) -> Vec<&str> {
405        match self {
406            Guard::Truthy { path }
407            | Guard::Not { path }
408            | Guard::Eq { path, .. }
409            | Guard::NotEq { path, .. }
410            | Guard::Absent { path }
411            | Guard::MatchesPattern { path, .. }
412            | Guard::NotMatchesPattern { path, .. }
413            | Guard::RangeKeyPrefix { path, .. }
414            | Guard::RangeKeyEquals { path, .. }
415            | Guard::RangeKeyMatches { path, .. }
416            | Guard::Range { path }
417            | Guard::With { path }
418            | Guard::Default { path }
419            | Guard::TypeIs { path, .. }
420            | Guard::NotTypeIs { path, .. }
421            | Guard::IntGt { path, .. }
422            | Guard::IntLt { path, .. }
423            | Guard::AtMostOneMember { path }
424            | Guard::MinMembers { path, .. }
425            | Guard::HasKey { path, .. }
426            | Guard::NotHasKey { path, .. }
427            | Guard::ContainsEquals { path, .. }
428            | Guard::ContainsMemberEquals { path, .. }
429            | Guard::ContainsTruthyMember { path, .. } => {
430                vec![path.as_str()]
431            }
432            Guard::Or { paths } => paths.iter().map(std::string::String::as_str).collect(),
433            Guard::AnyOf { alternatives } => alternatives
434                .iter()
435                .flat_map(|alternative| alternative.iter().flat_map(Guard::value_paths))
436                .collect(),
437        }
438    }
439
440    /// Rewrite value paths carried by this guard.
441    #[must_use]
442    pub fn map_value_paths<F>(self, map: &mut F) -> Self
443    where
444        F: FnMut(&str) -> String,
445    {
446        match self {
447            Guard::Truthy { path } => Guard::Truthy { path: map(&path) },
448            Guard::Not { path } => Guard::Not { path: map(&path) },
449            Guard::Eq { path, value } => Guard::Eq {
450                path: map(&path),
451                value,
452            },
453            Guard::NotEq { path, value } => Guard::NotEq {
454                path: map(&path),
455                value,
456            },
457            Guard::Absent { path } => Guard::Absent { path: map(&path) },
458            Guard::MatchesPattern {
459                path,
460                pattern,
461                templated,
462            } => Guard::MatchesPattern {
463                path: map(&path),
464                pattern,
465                templated,
466            },
467            Guard::NotMatchesPattern { path, pattern } => Guard::NotMatchesPattern {
468                path: map(&path),
469                pattern,
470            },
471            Guard::RangeKeyEquals { path, key } => Guard::RangeKeyEquals {
472                path: map(&path),
473                key,
474            },
475            Guard::RangeKeyPrefix { path, prefix } => Guard::RangeKeyPrefix {
476                path: map(&path),
477                prefix,
478            },
479            Guard::RangeKeyMatches { path, pattern } => Guard::RangeKeyMatches {
480                path: map(&path),
481                pattern,
482            },
483            Guard::Or { paths } => Guard::Or {
484                paths: paths.into_iter().map(|path| map(&path)).collect(),
485            },
486            Guard::AnyOf { alternatives } => Guard::AnyOf {
487                alternatives: map_guard_alternatives(alternatives, map),
488            },
489            Guard::Range { path } => Guard::Range { path: map(&path) },
490            Guard::With { path } => Guard::With { path: map(&path) },
491            Guard::Default { path } => Guard::Default { path: map(&path) },
492            Guard::TypeIs { path, schema_type } => Guard::TypeIs {
493                path: map(&path),
494                schema_type,
495            },
496            Guard::NotTypeIs { path, schema_type } => Guard::NotTypeIs {
497                path: map(&path),
498                schema_type,
499            },
500            Guard::IntGt { path, bound } => Guard::IntGt {
501                path: map(&path),
502                bound,
503            },
504            Guard::IntLt { path, bound } => Guard::IntLt {
505                path: map(&path),
506                bound,
507            },
508            Guard::AtMostOneMember { path } => Guard::AtMostOneMember { path: map(&path) },
509            Guard::MinMembers { path, bound } => Guard::MinMembers {
510                path: map(&path),
511                bound,
512            },
513            Guard::HasKey { path, key } => Guard::HasKey {
514                path: map(&path),
515                key,
516            },
517            Guard::NotHasKey { path, key } => Guard::NotHasKey {
518                path: map(&path),
519                key,
520            },
521            Guard::ContainsEquals { path, value } => Guard::ContainsEquals {
522                path: map(&path),
523                value,
524            },
525            Guard::ContainsMemberEquals {
526                path,
527                member,
528                value,
529            } => Guard::ContainsMemberEquals {
530                path: map(&path),
531                member,
532                value,
533            },
534            Guard::ContainsTruthyMember { path, member } => Guard::ContainsTruthyMember {
535                path: map(&path),
536                member,
537            },
538        }
539    }
540}
541
542fn map_guard_alternatives<F>(alternatives: Vec<Vec<Guard>>, map: &mut F) -> Vec<Vec<Guard>>
543where
544    F: FnMut(&str) -> String,
545{
546    alternatives
547        .into_iter()
548        .map(|alternative| {
549            alternative
550                .into_iter()
551                .map(|guard| guard.map_value_paths(map))
552                .collect()
553        })
554        .collect()
555}