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}