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}