Skip to main content

anodizer_core/
env.rs

1//! `KEY=VAL` env-list helpers.
2//!
3//! Stages and publishers accept `env: Vec<String>` lists where each entry is a
4//! `KEY=VALUE` string. The helpers in this module parse, split, and render
5//! those entries into `(key, value)` pairs preserving declaration order so
6//! chained env applications (sign + sbom + notarize, hook before/after) see
7//! entries in the order the user wrote them.
8//!
9//! Companion to [`crate::env_expand`], which handles `$VAR` / `${VAR}`
10//! shell-style expansion inside individual values.
11
12use anyhow::Context as _;
13
14/// Split a `KEY=VAL` env entry into `(key, value)`.
15///
16/// Returns the original entry as the error message when the line is missing
17/// `=` or has an empty key. Used by stage code that needs to apply env entries
18/// to a child process (sign, sbom, notarize, publishers).
19pub fn split_env_entry(entry: &str) -> Result<(&str, &str), String> {
20    let (k, v) = entry
21        .split_once('=')
22        .ok_or_else(|| format!("env entry must be KEY=VALUE, got: {entry:?}"))?;
23    let key = k.trim();
24    if key.is_empty() {
25        return Err(format!("env entry has empty key: {entry:?}"));
26    }
27    Ok((key, v))
28}
29
30/// Parse a list of `KEY=VAL` env entries into ordered `(key, value)` pairs.
31///
32/// Order is preserved so chained env applications (sign + sbom + notarize)
33/// see entries in user-declared order.
34pub fn parse_env_entries(entries: &[String]) -> anyhow::Result<Vec<(String, String)>> {
35    entries
36        .iter()
37        .map(|e| {
38            split_env_entry(e)
39                .map(|(k, v)| (k.to_string(), v.to_string()))
40                .map_err(anyhow::Error::msg)
41        })
42        .collect()
43}
44
45/// Parse `KEY=VALUE` env entries and render each value through a template closure.
46///
47/// Combines [`parse_env_entries`] with per-value rendering in one pass so call
48/// sites don't duplicate the parse → iterate → render loop.  The `render`
49/// closure is called once per value; any error is propagated with a
50/// descriptive context message so the caller can identify which key failed.
51///
52/// Preserves declaration order — important for chained-env semantics in stages
53/// like sign, sbom, and notarize where later entries may reference env vars set
54/// by earlier ones.
55///
56/// ```ignore
57/// let rendered = render_env_entries(cfg.env.as_deref().unwrap_or(&[]), |v| ctx.render_template(v))?;
58/// for (k, v) in rendered { cmd.env(k, v); }
59/// ```
60pub fn render_env_entries<F>(entries: &[String], render: F) -> anyhow::Result<Vec<(String, String)>>
61where
62    F: Fn(&str) -> anyhow::Result<String>,
63{
64    let parsed = parse_env_entries(entries)?;
65    parsed
66        .into_iter()
67        .map(|(k, v)| {
68            let rendered = render(&v).with_context(|| format!("render env value for '{k}'"))?;
69            Ok((k, rendered))
70        })
71        .collect()
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77
78    #[test]
79    fn test_split_env_entry_basic() {
80        assert_eq!(split_env_entry("KEY=value").unwrap(), ("KEY", "value"));
81    }
82
83    #[test]
84    fn test_split_env_entry_split_on_first_equals() {
85        assert_eq!(
86            split_env_entry("FLAGS=--key=val --other=stuff").unwrap(),
87            ("FLAGS", "--key=val --other=stuff")
88        );
89    }
90
91    #[test]
92    fn test_split_env_entry_no_equals_errors() {
93        let err = split_env_entry("COSIGN_PASSWORD").unwrap_err();
94        assert!(err.contains("must be KEY=VALUE"), "{err}");
95    }
96
97    #[test]
98    fn test_split_env_entry_empty_key_errors() {
99        let err = split_env_entry("=value").unwrap_err();
100        assert!(err.contains("empty key"), "{err}");
101    }
102
103    #[test]
104    fn test_parse_env_entries_preserves_order() {
105        let input = vec![
106            "FIRST=1".to_string(),
107            "SECOND=2".to_string(),
108            "THIRD=3".to_string(),
109        ];
110        let parsed = parse_env_entries(&input).unwrap();
111        assert_eq!(
112            parsed,
113            vec![
114                ("FIRST".to_string(), "1".to_string()),
115                ("SECOND".to_string(), "2".to_string()),
116                ("THIRD".to_string(), "3".to_string()),
117            ]
118        );
119    }
120
121    #[test]
122    fn test_render_env_entries_propagates_render_errors() {
123        let input = vec!["GOOD=ok".to_string(), "BAD=fail".to_string()];
124        let err = render_env_entries(&input, |v| {
125            if v == "fail" {
126                Err(anyhow::anyhow!("render boom"))
127            } else {
128                Ok(v.to_string())
129            }
130        })
131        .unwrap_err();
132        let msg = format!("{err:#}");
133        assert!(msg.contains("BAD"), "error should label key BAD: {msg}");
134        assert!(
135            msg.contains("render boom"),
136            "error chain should include underlying cause: {msg}"
137        );
138    }
139
140    #[test]
141    fn test_render_env_entries_passes_through_when_render_is_identity() {
142        let input = vec!["A=1".to_string(), "B=2".to_string()];
143        let rendered = render_env_entries(&input, |v| Ok(v.to_string())).unwrap();
144        assert_eq!(
145            rendered,
146            vec![("A".into(), "1".into()), ("B".into(), "2".into())]
147        );
148    }
149}