Skip to main content

anodizer_core/config/
string_or_bool.rs

1use schemars::JsonSchema;
2use serde::{Deserialize, Deserializer, Serialize};
3
4// ---------------------------------------------------------------------------
5// StringOrBool — accepts bool or template string in YAML
6// ---------------------------------------------------------------------------
7
8/// A value that can be either a bool or a template string.
9/// Used by `skip`, `skip_upload`, and similar fields across multiple config
10/// structs to support both `skip: true` and template conditionals like
11/// `skip: "{{ if .IsSnapshot }}true{{ endif }}"`.
12#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)]
13#[serde(untagged)]
14pub enum StringOrBool {
15    Bool(bool),
16    String(String),
17}
18
19impl StringOrBool {
20    /// Evaluate this value to a bool. If it's a string, treat "true" / "1" as true,
21    /// everything else as false.
22    pub fn as_bool(&self) -> bool {
23        match self {
24            StringOrBool::Bool(b) => *b,
25            StringOrBool::String(s) => matches!(s.trim(), "true" | "1"),
26        }
27    }
28
29    /// Return the raw string value for template rendering, or the bool as a string.
30    pub fn as_str(&self) -> &str {
31        match self {
32            StringOrBool::Bool(true) => "true",
33            StringOrBool::Bool(false) => "false",
34            StringOrBool::String(s) => s,
35        }
36    }
37
38    /// Whether this value contains a template expression that needs rendering.
39    pub fn is_template(&self) -> bool {
40        matches!(self, StringOrBool::String(s) if s.contains('{'))
41    }
42
43    /// Evaluate whether this value resolves to `true`.
44    ///
45    /// The value is always run through `render` (Tera leaves plain literals
46    /// unchanged, so this is a no-op for non-templated values). The rendered
47    /// result is then compared to `"true"` / `"1"` after trimming. A `Bool`
48    /// variant short-circuits without rendering.
49    ///
50    /// Always-rendering keeps this helper consistent with sibling
51    /// `should_skip_upload` (which always renders) — a literal `"{{ broken"`
52    /// surfaces as an `Err` instead of being silently treated as a false-y
53    /// non-template string.
54    ///
55    /// Used for both `skip:` evaluation (most callers) and `output:` / `sbom:`
56    /// bool-or-template fields — there is no separate alias; call this directly.
57    pub fn try_evaluates_to_true(
58        &self,
59        render: impl Fn(&str) -> anyhow::Result<String>,
60    ) -> anyhow::Result<bool> {
61        match self {
62            StringOrBool::Bool(b) => Ok(*b),
63            StringOrBool::String(s) => {
64                let rendered = render(s)?;
65                Ok(matches!(rendered.trim(), "true" | "1"))
66            }
67        }
68    }
69}
70
71impl Default for StringOrBool {
72    fn default() -> Self {
73        StringOrBool::Bool(false)
74    }
75}
76
77/// Evaluate an `if:` conditional template.
78///
79/// Returns `Ok(true)` when the caller should proceed with the resource and
80/// `Ok(false)` when the resource must be skipped. Mirrors the contract every
81/// existing `if_condition` consumer in anodizer applies:
82///
83/// - `None` → proceed (no gate set).
84/// - `Some("")` → proceed (empty literal is a no-op gate; the
85///   "no `if:` = always run" behavior — keeps round-tripping clean for
86///   configs that emit empty strings).
87/// - Template render failure → hard `Err` (matches every existing
88///   `if_condition` site; silent-skip on a typo'd template was the W1
89///   release-resilience footgun and is intentionally NOT replicated).
90/// - Rendered value (trimmed) equal to `"false"`, `"0"`, `"no"`, or empty
91///   → skip.
92/// - Any other rendered value → proceed.
93///
94/// `label` is the resource-identifying string woven into the error context
95/// chain (e.g. `"publisher 'upload-artifacts'"` or `"blob 's3-cache'"`).
96pub fn evaluate_if_condition(
97    condition: Option<&str>,
98    label: &str,
99    render: impl Fn(&str) -> anyhow::Result<String>,
100) -> anyhow::Result<bool> {
101    use anyhow::Context as _;
102    let Some(template) = condition else {
103        return Ok(true);
104    };
105    if template.is_empty() {
106        return Ok(true);
107    }
108    let rendered = render(template).with_context(|| {
109        format!("{label}: `if` template render failed (expression: {template})")
110    })?;
111    let trimmed = rendered.trim();
112    let falsy = matches!(trimmed, "" | "false" | "0" | "no");
113    Ok(!falsy)
114}
115
116/// Custom deserializer for `Option<StringOrBool>`.
117pub(crate) fn deserialize_string_or_bool_opt<'de, D>(
118    deserializer: D,
119) -> Result<Option<StringOrBool>, D::Error>
120where
121    D: Deserializer<'de>,
122{
123    use serde::de::{self, Visitor};
124
125    struct StringOrBoolVisitor;
126
127    impl<'de> Visitor<'de> for StringOrBoolVisitor {
128        type Value = Option<StringOrBool>;
129
130        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131            f.write_str("a bool, a string, or null")
132        }
133
134        fn visit_bool<E: de::Error>(self, v: bool) -> Result<Self::Value, E> {
135            Ok(Some(StringOrBool::Bool(v)))
136        }
137
138        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
139            Ok(Some(StringOrBool::String(v.to_owned())))
140        }
141
142        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
143            Ok(Some(StringOrBool::String(v)))
144        }
145
146        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
147            Ok(None)
148        }
149
150        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
151            Ok(None)
152        }
153    }
154
155    deserializer.deserialize_any(StringOrBoolVisitor)
156}
157
158/// A typed duration value parsed from a humantime-style string in YAML.
159///
160/// Accepts `"10m"`, `"15s"`, `"1h30m"`, `"500ms"`, etc. Used by notarize
161/// timeouts so the schema is typed and validation catches malformed values
162/// at config-load time instead of during the notarize stage.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)]
164pub struct HumanDuration(
165    #[serde(serialize_with = "serialize_human_duration")] pub std::time::Duration,
166);
167
168impl HumanDuration {
169    /// Get the underlying `Duration` value.
170    pub fn duration(&self) -> std::time::Duration {
171        self.0
172    }
173
174    /// Format the duration back to its canonical string form (`{seconds}s` or
175    /// `{minutes}m{seconds}s` depending on whole-minute alignment). Matches
176    /// the form `xcrun notarytool --timeout` accepts (a unit-suffixed integer).
177    pub fn as_humantime_string(&self) -> String {
178        let total_secs = self.0.as_secs();
179        if total_secs == 0 {
180            // Sub-second; fall back to ms.
181            return format!("{}ms", self.0.as_millis());
182        }
183        let hours = total_secs / 3600;
184        let mins = (total_secs % 3600) / 60;
185        let secs = total_secs % 60;
186        let mut out = String::new();
187        if hours > 0 {
188            out.push_str(&format!("{hours}h"));
189        }
190        if mins > 0 {
191            out.push_str(&format!("{mins}m"));
192        }
193        if secs > 0 || out.is_empty() {
194            out.push_str(&format!("{secs}s"));
195        }
196        out
197    }
198}
199
200impl<'de> Deserialize<'de> for HumanDuration {
201    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
202    where
203        D: Deserializer<'de>,
204    {
205        use serde::de::{self, Visitor};
206
207        struct DurVisitor;
208
209        impl<'de> Visitor<'de> for DurVisitor {
210            type Value = HumanDuration;
211
212            fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
213                f.write_str(
214                    "a duration string with unit suffix (e.g. \"10m\", \"15s\", \"1h30m\", \"500ms\")",
215                )
216            }
217
218            fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
219                parse_humantime_duration(v)
220                    .map(HumanDuration)
221                    .map_err(E::custom)
222            }
223
224            fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
225                self.visit_str(&v)
226            }
227        }
228
229        deserializer.deserialize_str(DurVisitor)
230    }
231}
232
233fn serialize_human_duration<S: serde::Serializer>(
234    d: &std::time::Duration,
235    serializer: S,
236) -> Result<S::Ok, S::Error> {
237    serializer.serialize_str(&HumanDuration(*d).as_humantime_string())
238}
239
240/// Parse a humantime-style duration string. Recognizes `ms`, `s`, `m`, `h`,
241/// `d` units and concatenated forms like `"1h30m"`. Whitespace between
242/// components is tolerated.
243pub(super) fn parse_humantime_duration(input: &str) -> Result<std::time::Duration, String> {
244    let s = input.trim();
245    if s.is_empty() {
246        return Err("empty duration string".to_string());
247    }
248    let mut total = std::time::Duration::ZERO;
249    let mut number_buf = String::new();
250    let mut had_any = false;
251    let mut iter = s.chars().peekable();
252    while let Some(&c) = iter.peek() {
253        if c.is_whitespace() {
254            iter.next();
255            continue;
256        }
257        if c.is_ascii_digit() {
258            number_buf.push(c);
259            iter.next();
260            continue;
261        }
262        if number_buf.is_empty() {
263            return Err(format!("expected digit before unit in '{input}'"));
264        }
265        // Read unit (1 or 2 chars: ms, s, m, h, d).
266        let mut unit = String::new();
267        unit.push(c);
268        iter.next();
269        if let Some(&next) = iter.peek()
270            && unit == "m"
271            && next == 's'
272        {
273            unit.push('s');
274            iter.next();
275        }
276        let n: u64 = number_buf
277            .parse()
278            .map_err(|e| format!("invalid number '{number_buf}' in '{input}': {e}"))?;
279        let segment = match unit.as_str() {
280            "ms" => std::time::Duration::from_millis(n),
281            "s" => std::time::Duration::from_secs(n),
282            "m" => std::time::Duration::from_secs(n * 60),
283            "h" => std::time::Duration::from_secs(n * 3600),
284            "d" => std::time::Duration::from_secs(n * 86_400),
285            other => return Err(format!("unknown duration unit '{other}' in '{input}'")),
286        };
287        total += segment;
288        number_buf.clear();
289        had_any = true;
290    }
291    if !number_buf.is_empty() {
292        return Err(format!(
293            "trailing number '{number_buf}' without a unit in '{input}'"
294        ));
295    }
296    if !had_any {
297        return Err(format!("no duration components found in '{input}'"));
298    }
299    Ok(total)
300}
301
302/// A value that can be either a `u32` or a string parsed as octal/decimal.
303///
304/// Used by `NfpmConfig.umask` (and any future field specified
305/// as `int OR string` in YAML — the parser canonicalizes both forms to a
306/// `u32`). Accepts: `0o022`, `"0o022"`, `"022"`, `"18"`, `18`. Bare numeric
307/// YAML values are interpreted as decimal; YAML-string forms accept the
308/// `0o`/`0O` prefix to spell octal explicitly.
309#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
310#[serde(transparent)]
311pub struct StringOrU32(#[serde(deserialize_with = "deserialize_u32_from_string_or_int")] pub u32);
312
313impl StringOrU32 {
314    /// Get the underlying `u32` value.
315    pub fn value(&self) -> u32 {
316        self.0
317    }
318}
319
320/// Deserialize a `u32` from either a YAML int or a string in octal/decimal.
321fn deserialize_u32_from_string_or_int<'de, D>(deserializer: D) -> Result<u32, D::Error>
322where
323    D: Deserializer<'de>,
324{
325    use serde::de::{self, Visitor};
326
327    struct U32Visitor;
328
329    impl<'de> Visitor<'de> for U32Visitor {
330        type Value = u32;
331
332        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
333            f.write_str("a u32 integer or a string parseable as octal/decimal (e.g. 18, \"0o022\", \"022\")")
334        }
335
336        fn visit_u64<E: de::Error>(self, v: u64) -> Result<Self::Value, E> {
337            u32::try_from(v).map_err(|_| E::custom(format!("value {v} does not fit in u32")))
338        }
339
340        fn visit_i64<E: de::Error>(self, v: i64) -> Result<Self::Value, E> {
341            u32::try_from(v).map_err(|_| E::custom(format!("value {v} does not fit in u32")))
342        }
343
344        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
345            let trimmed = v.trim();
346            if let Some(rest) = trimmed
347                .strip_prefix("0o")
348                .or_else(|| trimmed.strip_prefix("0O"))
349            {
350                return u32::from_str_radix(rest, 8)
351                    .map_err(|e| E::custom(format!("invalid octal '{v}': {e}")));
352            }
353            // Bare leading-zero strings (e.g. "022") are octal — match the
354            // typical convention for unix file mode strings.
355            if trimmed.starts_with('0') && trimmed.len() > 1 {
356                return u32::from_str_radix(trimmed, 8)
357                    .map_err(|e| E::custom(format!("invalid octal '{v}': {e}")));
358            }
359            trimmed
360                .parse::<u32>()
361                .map_err(|e| E::custom(format!("invalid u32 '{v}': {e}")))
362        }
363
364        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
365            self.visit_str(&v)
366        }
367    }
368
369    deserializer.deserialize_any(U32Visitor)
370}
371
372/// Custom deserializer for `Option<Vec<String>>` that accepts either a single
373/// string or an array of strings. Used by `BlobConfig.cache_control`.
374pub(super) fn deserialize_string_or_vec_opt<'de, D>(
375    deserializer: D,
376) -> Result<Option<Vec<String>>, D::Error>
377where
378    D: Deserializer<'de>,
379{
380    use serde::de::{self, Visitor};
381
382    struct StringOrVecVisitor;
383
384    impl<'de> Visitor<'de> for StringOrVecVisitor {
385        type Value = Option<Vec<String>>;
386
387        fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
388            f.write_str("a string, a list of strings, or null")
389        }
390
391        fn visit_str<E: de::Error>(self, v: &str) -> Result<Self::Value, E> {
392            Ok(Some(vec![v.to_owned()]))
393        }
394
395        fn visit_string<E: de::Error>(self, v: String) -> Result<Self::Value, E> {
396            Ok(Some(vec![v]))
397        }
398
399        fn visit_seq<A: de::SeqAccess<'de>>(self, mut seq: A) -> Result<Self::Value, A::Error> {
400            let mut items = Vec::new();
401            while let Some(item) = seq.next_element::<String>()? {
402                items.push(item);
403            }
404            Ok(Some(items))
405        }
406
407        fn visit_none<E: de::Error>(self) -> Result<Self::Value, E> {
408            Ok(None)
409        }
410
411        fn visit_unit<E: de::Error>(self) -> Result<Self::Value, E> {
412            Ok(None)
413        }
414    }
415
416    deserializer.deserialize_any(StringOrVecVisitor)
417}