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