Skip to main content

feldera_types/
secret_ref.rs

1use regex::Regex;
2use std::fmt;
3use std::fmt::{Display, Formatter};
4use thiserror::Error as ThisError;
5
6/// RFC 1123 specification for a DNS label, which is also used by Kubernetes.
7pub const PATTERN_RFC_1123_DNS_LABEL: &str = r"^[a-z0-9]+(-[a-z0-9]+)*$";
8
9/// POSIX pattern for an environment variable name.
10pub const PATTERN_ENV_VAR_NAME: &str = r"^[a-zA-Z_][a-zA-Z0-9_]*$";
11
12#[derive(Debug, Clone, PartialEq, Eq, ThisError)]
13pub enum EnvVarNameParseError {
14    #[error("cannot be empty")]
15    Empty,
16    #[error(
17        "must only contain alphanumeric characters and underscores (_), and start with a letter or underscore"
18    )]
19    InvalidFormat,
20}
21
22/// Validates it is a valid POSIX environment variable name.
23pub fn validate_env_var_name(name: &str) -> Result<(), EnvVarNameParseError> {
24    if name.is_empty() {
25        Err(EnvVarNameParseError::Empty)
26    } else {
27        let re = Regex::new(PATTERN_ENV_VAR_NAME).expect("valid regular expression");
28        if re.is_match(name) {
29            Ok(())
30        } else {
31            Err(EnvVarNameParseError::InvalidFormat)
32        }
33    }
34}
35
36#[derive(Debug, Clone, PartialEq, Eq, ThisError)]
37pub enum KubernetesSecretNameParseError {
38    #[error("cannot be empty")]
39    Empty,
40    #[error("length ({name_len}) exceeds 63 characters")]
41    TooLong { name_len: usize },
42    #[error(
43        "must only contain lowercase alphanumeric characters or hyphens (-), and start and end with a lowercase alphanumeric character"
44    )]
45    InvalidFormat,
46}
47
48/// Validates it is a valid Kubernetes `Secret` name (follows RFC 1123 DNS label).
49pub fn validate_kubernetes_secret_name(name: &str) -> Result<(), KubernetesSecretNameParseError> {
50    if name.is_empty() {
51        Err(KubernetesSecretNameParseError::Empty)
52    } else if name.len() > 63 {
53        Err(KubernetesSecretNameParseError::TooLong {
54            name_len: name.len(),
55        })
56    } else {
57        let re = Regex::new(PATTERN_RFC_1123_DNS_LABEL).expect("valid regular expression");
58        if re.is_match(name) {
59            Ok(())
60        } else {
61            Err(KubernetesSecretNameParseError::InvalidFormat)
62        }
63    }
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, ThisError)]
67pub enum KubernetesSecretDataKeyParseError {
68    #[error("cannot be empty")]
69    Empty,
70    #[error("length ({data_key_len}) exceeds 255 characters")]
71    TooLong { data_key_len: usize },
72    #[error(
73        "must only contain lowercase alphanumeric characters and hyphens (-), and start and end with a lowercase alphanumeric character"
74    )]
75    InvalidFormat,
76}
77
78/// Validates it is a valid Kubernetes Secret data key.
79///
80/// YAML itself imposes little restrictions on a quoted string key except
81/// that it should not exceed 1024 Unicode characters.
82///
83/// However, it might be mounted as a file, as such characters such as `/`
84/// are not desirable. For now, we mirror the convention of the secret name,
85/// in which it restricts to lowercase alphanumeric characters and hyphens
86/// (and start/end with lower alphanumeric). In the future, this requirement
87/// might be loosened. The length is limited to 255 characters (which is
88/// stricter than 1024 Unicode characters, but looser than 63 of a DNS label),
89/// as the filename length limit for ext4 is 255.
90pub fn validate_kubernetes_secret_data_key(
91    data_key: &str,
92) -> Result<(), KubernetesSecretDataKeyParseError> {
93    if data_key.is_empty() {
94        Err(KubernetesSecretDataKeyParseError::Empty)
95    } else if data_key.len() > 255 {
96        Err(KubernetesSecretDataKeyParseError::TooLong {
97            data_key_len: data_key.len(),
98        })
99    } else {
100        let re = Regex::new(PATTERN_RFC_1123_DNS_LABEL).expect("valid regular expression");
101        if re.is_match(data_key) {
102            Ok(())
103        } else {
104            Err(KubernetesSecretDataKeyParseError::InvalidFormat)
105        }
106    }
107}
108
109/// Enumeration of possible secret references.
110/// Each variant corresponds to a secret provider.
111#[derive(Debug, Clone, PartialEq, Eq, Ord, PartialOrd)]
112pub enum SecretRef {
113    /// Reference to a data key in a specific Kubernetes `Secret`.
114    Kubernetes {
115        /// Name of the Kubernetes `Secret` object.
116        name: String,
117        /// Key inside the `data:` section of the `Secret` object.
118        data_key: String,
119    },
120    /// Reference to a process environment variable.
121    EnvVar {
122        /// Name of the environment variable.
123        name: String,
124    },
125}
126
127impl Display for SecretRef {
128    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
129        match self {
130            SecretRef::Kubernetes { name, data_key } => {
131                write!(f, "${{secret:kubernetes:{name}/{data_key}}}")
132            }
133            SecretRef::EnvVar { name } => {
134                write!(f, "${{env:{name}}}")
135            }
136        }
137    }
138}
139
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum MaybeSecretRef {
142    String(String),
143    SecretRef(SecretRef),
144}
145
146#[derive(Debug, Clone, PartialEq, Eq, ThisError)]
147pub enum MaybeSecretRefParseError {
148    #[error(
149        "secret reference '{secret_ref_str}' does not specify a valid provider (for example: 'kubernetes:')"
150    )]
151    InvalidProvider { secret_ref_str: String },
152    #[error(
153        "Kubernetes secret reference '{secret_ref_str}' is not valid: does not follow format `<name>/<data key>`"
154    )]
155    InvalidKubernetesSecretFormat { secret_ref_str: String },
156    #[error(
157        "Kubernetes secret reference '{secret_ref_str}' has name '{name}' which is not valid: {e}"
158    )]
159    InvalidKubernetesSecretName {
160        secret_ref_str: String,
161        name: String,
162        e: KubernetesSecretNameParseError,
163    },
164    #[error(
165        "Kubernetes secret reference '{secret_ref_str}' has data key '{data_key}' which is not valid: {e}"
166    )]
167    InvalidKubernetesSecretDataKey {
168        secret_ref_str: String,
169        data_key: String,
170        e: KubernetesSecretDataKeyParseError,
171    },
172    #[error(
173        "environment variable reference '{env_ref_str}' has name '{name}' which is not valid: {e}"
174    )]
175    InvalidEnvVarName {
176        env_ref_str: String,
177        name: String,
178        e: EnvVarNameParseError,
179    },
180    #[error("environment variable reference '{env_ref_str}' is not valid: name cannot be empty")]
181    EmptyEnvVarName { env_ref_str: String },
182}
183
184impl MaybeSecretRef {
185    /// Determines whether a string is just a plain string, a reference to a secret,
186    /// or a reference to a process environment variable.
187    ///
188    /// - Secret reference: any string which starts with `${secret:` and ends with `}`
189    ///   is regarded as an attempt to declare a secret reference
190    /// - Environment variable reference: any string which starts with `${env:` and ends with `}`
191    ///   is regarded as an attempt to declare an environment variable reference
192    /// - Plain string: any other string
193    ///
194    /// A secret reference must follow the following pattern:
195    /// `${secret:<provider>:<identifier>}`
196    ///
197    /// An environment variable reference must follow the following pattern:
198    /// `${env:<name>}`
199    ///
200    /// An error is returned if a string is regarded as a secret or env var reference (see above), but:
201    /// - Specifies a `<provider>` which does not exist (for secret refs)
202    /// - Specifies a `<name>` which does not meet the requirements
203    ///
204    /// Supported providers and their identifier expectations:
205    /// - `${secret:kubernetes:<name>/<data key>}`
206    /// - `${env:<name>}` where `<name>` follows POSIX env var naming rules
207    ///
208    /// Note that here is not checked whether the reference can actually be resolved.
209    pub fn new(value: String) -> Result<MaybeSecretRef, MaybeSecretRefParseError> {
210        let env_prefix = "${env:";
211        if value.starts_with("${secret:") && value.ends_with('}') {
212            // Because the pattern only has ASCII characters, they are encoded as single bytes.
213            // The secret reference is extracted by slicing away the first 9 bytes and the last byte.
214            let from_idx_incl = 9;
215            let till_idx_excl = value.len() - 1;
216            let content = value[from_idx_incl..till_idx_excl].to_string();
217            if let Some(kubernetes_content) = content.strip_prefix("kubernetes:") {
218                if let Some((name, data_key)) = kubernetes_content.split_once("/") {
219                    if let Err(e) = validate_kubernetes_secret_name(name) {
220                        Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
221                            secret_ref_str: value,
222                            name: name.to_string(),
223                            e,
224                        })
225                    } else if let Err(e) = validate_kubernetes_secret_data_key(data_key) {
226                        Err(MaybeSecretRefParseError::InvalidKubernetesSecretDataKey {
227                            secret_ref_str: value,
228                            data_key: data_key.to_string(),
229                            e,
230                        })
231                    } else {
232                        Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
233                            name: name.to_string(),
234                            data_key: data_key.to_string(),
235                        }))
236                    }
237                } else {
238                    Err(MaybeSecretRefParseError::InvalidKubernetesSecretFormat {
239                        secret_ref_str: value,
240                    })
241                }
242            } else {
243                Err(MaybeSecretRefParseError::InvalidProvider {
244                    secret_ref_str: value,
245                })
246            }
247        } else if value.starts_with(env_prefix) && value.ends_with('}') {
248            // Environment variable reference: `${env:<name>}`
249            // The content is extracted by slicing away the first 6 bytes ("${env:") and the last byte ("}").
250            let name = value
251                .trim_start_matches(env_prefix)
252                .trim_end_matches("}")
253                .to_string();
254            if name.is_empty() {
255                Err(MaybeSecretRefParseError::EmptyEnvVarName { env_ref_str: value })
256            } else if let Err(e) = validate_env_var_name(&name) {
257                Err(MaybeSecretRefParseError::InvalidEnvVarName {
258                    env_ref_str: value,
259                    name,
260                    e,
261                })
262            } else {
263                Ok(MaybeSecretRef::SecretRef(SecretRef::EnvVar { name }))
264            }
265        } else {
266            Ok(MaybeSecretRef::String(value))
267        }
268    }
269}
270
271impl Display for MaybeSecretRef {
272    fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
273        match self {
274            MaybeSecretRef::String(plain_str) => {
275                write!(f, "{plain_str}")
276            }
277            MaybeSecretRef::SecretRef(secret_ref) => {
278                write!(f, "{secret_ref}")
279            }
280        }
281    }
282}
283
284#[cfg(test)]
285mod tests {
286    use super::{
287        EnvVarNameParseError, KubernetesSecretDataKeyParseError, KubernetesSecretNameParseError,
288        MaybeSecretRef, validate_env_var_name, validate_kubernetes_secret_data_key,
289        validate_kubernetes_secret_name,
290    };
291    use super::{MaybeSecretRefParseError, SecretRef};
292
293    #[test]
294    #[rustfmt::skip] // Skip formatting to keep it short
295    fn secret_ref_format() {
296        assert_eq!(
297            format!("{}", SecretRef::Kubernetes {
298                name: "example".to_string(),
299                data_key: "value".to_string(),
300            }),
301            "${secret:kubernetes:example/value}"
302        );
303        assert_eq!(
304            format!("{}", SecretRef::EnvVar {
305                name: "MY_VAR".to_string(),
306            }),
307            "${env:MY_VAR}"
308        );
309    }
310
311    #[test]
312    #[rustfmt::skip] // Skip formatting to keep it short
313    fn maybe_secret_ref_format() {
314        assert_eq!(
315            format!("{}", MaybeSecretRef::String("example".to_string())),
316            "example"
317        );
318        assert_eq!(
319            format!("{}", MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
320                name: "example".to_string(),
321                data_key: "value".to_string(),
322            })),
323            "${secret:kubernetes:example/value}"
324        );
325    }
326
327    /// Checks that the parsing of the `MaybeSecretRef` yields the correct result,
328    /// and that the original string is preserved when again formatted.
329    fn test_values_and_expectations(
330        values_and_expectations: Vec<(&str, Result<MaybeSecretRef, MaybeSecretRefParseError>)>,
331    ) {
332        for (value, expectation) in values_and_expectations {
333            let outcome = MaybeSecretRef::new(value.to_string());
334            assert_eq!(outcome, expectation);
335            if let Ok(maybe_secret_ref) = outcome {
336                assert_eq!(maybe_secret_ref.to_string(), value);
337            }
338        }
339    }
340
341    #[test]
342    #[rustfmt::skip] // Skip formatting to keep it short
343    fn maybe_secret_ref_parse_string() {
344        let values_and_expectations = vec![
345            ("", Ok(MaybeSecretRef::String("".to_string()))),
346            ("a", Ok(MaybeSecretRef::String("a".to_string()))),
347            ("1", Ok(MaybeSecretRef::String("1".to_string()))),
348            ("example", Ok(MaybeSecretRef::String("example".to_string()))),
349            ("EXAMPLE", Ok(MaybeSecretRef::String("EXAMPLE".to_string()))),
350            ("123", Ok(MaybeSecretRef::String("123".to_string()))),
351            ("/path/to/file.txt", Ok(MaybeSecretRef::String("/path/to/file.txt".to_string()))),
352            ("$abc", Ok(MaybeSecretRef::String("$abc".to_string()))),
353            ("${secret", Ok(MaybeSecretRef::String("${secret".to_string()))),
354            ("}", Ok(MaybeSecretRef::String("}".to_string()))),
355            ("${secre:}", Ok(MaybeSecretRef::String("${secre:}".to_string()))),
356            // Unicode "Slightly Smiling Face": U+1F642
357            ("\u{1F642}", Ok(MaybeSecretRef::String("\u{1F642}".to_string()))),
358        ];
359        test_values_and_expectations(values_and_expectations);
360    }
361
362    #[test]
363    #[rustfmt::skip] // Skip formatting to keep it short
364    fn maybe_secret_ref_parse_secret_ref_kubernetes() {
365        let secret_ref_name_len_63 = format!("${{secret:kubernetes:{}/b}}", "a".repeat(63));
366        let secret_ref_name_len_64 = format!("${{secret:kubernetes:{}/b}}", "a".repeat(64));
367        let secret_ref_data_key_len_255 = format!("${{secret:kubernetes:a/{}}}", "b".repeat(255));
368        let secret_ref_data_key_len_256 = format!("${{secret:kubernetes:a/{}}}", "b".repeat(256));
369        let values_and_expectations = vec![
370            ("${secret:kubernetes:}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretFormat {
371                secret_ref_str: "${secret:kubernetes:}".to_string()
372            })),
373            ("${secret:kubernetes:ab}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretFormat {
374                secret_ref_str: "${secret:kubernetes:ab}".to_string()
375            })),
376            ("${secret:kubernetes:/}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
377                secret_ref_str: "${secret:kubernetes:/}".to_string(),
378                name: "".to_string(),
379                e: KubernetesSecretNameParseError::Empty
380            })),
381            ("${secret:kubernetes:/b}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
382                secret_ref_str: "${secret:kubernetes:/b}".to_string(),
383                name: "".to_string(),
384                e: KubernetesSecretNameParseError::Empty
385            })),
386            ("${secret:kubernetes:a/}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretDataKey {
387                secret_ref_str: "${secret:kubernetes:a/}".to_string(),
388                data_key: "".to_string(),
389                e: KubernetesSecretDataKeyParseError::Empty
390            })),
391            // Name: character which is not lowercase alphanumeric
392            ("${secret:kubernetes:A/b}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
393                secret_ref_str: "${secret:kubernetes:A/b}".to_string(),
394                name: "A".to_string(),
395                e: KubernetesSecretNameParseError::InvalidFormat
396            })),
397            // Name: cannot start with hyphen
398            ("${secret:kubernetes:-a/b}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
399                secret_ref_str: "${secret:kubernetes:-a/b}".to_string(),
400                name: "-a".to_string(),
401                e: KubernetesSecretNameParseError::InvalidFormat
402            })),
403            // Name: cannot end in hyphen
404            ("${secret:kubernetes:a-/b}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
405                secret_ref_str: "${secret:kubernetes:a-/b}".to_string(),
406                name: "a-".to_string(),
407                e: KubernetesSecretNameParseError::InvalidFormat
408            })),
409            // Data key: character which is not lowercase alphanumeric
410            ("${secret:kubernetes:a/B}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretDataKey {
411                secret_ref_str: "${secret:kubernetes:a/B}".to_string(),
412                data_key: "B".to_string(),
413                e: KubernetesSecretDataKeyParseError::InvalidFormat
414            })),
415            // Data key: cannot start with hyphen
416            ("${secret:kubernetes:a/-b}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretDataKey {
417                secret_ref_str: "${secret:kubernetes:a/-b}".to_string(),
418                data_key: "-b".to_string(),
419                e: KubernetesSecretDataKeyParseError::InvalidFormat
420            })),
421            // Data key: cannot end in hyphen
422            ("${secret:kubernetes:a/b-}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretDataKey {
423                secret_ref_str: "${secret:kubernetes:a/b-}".to_string(),
424                data_key: "b-".to_string(),
425                e: KubernetesSecretDataKeyParseError::InvalidFormat
426            })),
427            // Name: exceeds 63 characters
428            (&secret_ref_name_len_64, Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
429                secret_ref_str: secret_ref_name_len_64.to_string(),
430                name: "a".repeat(64).to_string(),
431                e: KubernetesSecretNameParseError::TooLong {
432                    name_len: 64
433                }
434            })),
435            // Data key: exceeds 255 characters
436            (&secret_ref_data_key_len_256, Err(MaybeSecretRefParseError::InvalidKubernetesSecretDataKey {
437                secret_ref_str: secret_ref_data_key_len_256.to_string(),
438                data_key: "b".repeat(256).to_string(),
439                e: KubernetesSecretDataKeyParseError::TooLong {
440                    data_key_len: 256
441                }
442            })),
443            // Name: Unicode character
444            // Unicode "Slightly Smiling Face": U+1F642
445            ("${secret:kubernetes:\u{1F642}/b}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretName {
446                secret_ref_str: "${secret:kubernetes:\u{1F642}/b}".to_string(),
447                name: "\u{1F642}".to_string(),
448                e: KubernetesSecretNameParseError::InvalidFormat
449            })),
450            // Data key: Unicode character
451            // Unicode "Slightly Smiling Face": U+1F642
452            ("${secret:kubernetes:a/\u{1F642}}", Err(MaybeSecretRefParseError::InvalidKubernetesSecretDataKey {
453                secret_ref_str: "${secret:kubernetes:a/\u{1F642}}".to_string(),
454                data_key: "\u{1F642}".to_string(),
455                e: KubernetesSecretDataKeyParseError::InvalidFormat
456            })),
457            ("${secret:kubernetes:a/b}", Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
458                name: "a".to_string(),
459                data_key: "b".to_string()
460            }))),
461            ("${secret:kubernetes:0/1}", Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
462                name: "0".to_string(),
463                data_key: "1".to_string()
464            }))),
465            ("${secret:kubernetes:a0/b1}", Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
466                name: "a0".to_string(),
467                data_key: "b1".to_string()
468            }))),
469            ("${secret:kubernetes:0a/1b}", Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
470                name: "0a".to_string(),
471                data_key: "1b".to_string()
472            }))),
473            ("${secret:kubernetes:a-b/c-d}", Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
474                name: "a-b".to_string(),
475                data_key: "c-d".to_string()
476            }))),
477            (&secret_ref_name_len_63, Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
478                name: "a".repeat(63),
479                data_key: "b".to_string()
480            }))),
481            (&secret_ref_data_key_len_255, Ok(MaybeSecretRef::SecretRef(SecretRef::Kubernetes {
482                name: "a".to_string(),
483                data_key: "b".repeat(255),
484            }))),
485        ];
486        test_values_and_expectations(values_and_expectations);
487    }
488
489    #[test]
490    #[rustfmt::skip] // Skip formatting to keep it short
491    fn kubernetes_secret_name_validation() {
492        let name_len_63 = "a".repeat(63);
493        let name_len_64 = "a".repeat(64);
494        for (value, expectation) in vec![
495            ("a", Ok(())),
496            ("0", Ok(())),
497            ("a0", Ok(())),
498            ("0a", Ok(())),
499            ("a-0", Ok(())),
500            (&name_len_63, Ok(())),
501            ("", Err(KubernetesSecretNameParseError::Empty)),
502            ("a-", Err(KubernetesSecretNameParseError::InvalidFormat)),
503            ("-a", Err(KubernetesSecretNameParseError::InvalidFormat)),
504            (&name_len_64, Err(KubernetesSecretNameParseError::TooLong {
505                name_len: 64
506            })),
507        ] {
508            assert_eq!(validate_kubernetes_secret_name(value), expectation);
509        }
510    }
511
512    #[test]
513    #[rustfmt::skip] // Skip formatting to keep it short
514    fn kubernetes_secret_data_key_validation() {
515        let data_key_len_255 = "a".repeat(255);
516        let data_key_len_256 = "a".repeat(256);
517        for (value, expectation) in vec![
518            ("a", Ok(())),
519            ("0", Ok(())),
520            ("a0", Ok(())),
521            ("0a", Ok(())),
522            ("a-0", Ok(())),
523            (&data_key_len_255, Ok(())),
524            ("", Err(KubernetesSecretDataKeyParseError::Empty)),
525            ("a-", Err(KubernetesSecretDataKeyParseError::InvalidFormat)),
526            ("-a", Err(KubernetesSecretDataKeyParseError::InvalidFormat)),
527            (&data_key_len_256, Err(KubernetesSecretDataKeyParseError::TooLong {
528                data_key_len: 256
529            })),
530        ] {
531            assert_eq!(validate_kubernetes_secret_data_key(value), expectation);
532        }
533    }
534
535    #[test]
536    #[rustfmt::skip] // Skip formatting to keep it short
537    fn env_var_name_validation() {
538        for (value, expectation) in vec![
539            ("A", Ok(())),
540            ("a", Ok(())),
541            ("_", Ok(())),
542            ("A1", Ok(())),
543            ("MY_VAR", Ok(())),
544            ("_MY_VAR", Ok(())),
545            ("MY_VAR_123", Ok(())),
546            ("", Err(EnvVarNameParseError::Empty)),
547            ("1A", Err(EnvVarNameParseError::InvalidFormat)),
548            ("MY-VAR", Err(EnvVarNameParseError::InvalidFormat)),
549            ("MY VAR", Err(EnvVarNameParseError::InvalidFormat)),
550            ("MY.VAR", Err(EnvVarNameParseError::InvalidFormat)),
551        ] {
552            assert_eq!(validate_env_var_name(value), expectation);
553        }
554    }
555
556    #[test]
557    #[rustfmt::skip] // Skip formatting to keep it short
558    fn maybe_secret_ref_parse_env_var() {
559        let values_and_expectations = vec![
560            // Valid env var references
561            ("${env:A}", Ok(MaybeSecretRef::SecretRef(SecretRef::EnvVar { name: "A".to_string() }))),
562            ("${env:MY_VAR}", Ok(MaybeSecretRef::SecretRef(SecretRef::EnvVar { name: "MY_VAR".to_string() }))),
563            ("${env:_MY_VAR}", Ok(MaybeSecretRef::SecretRef(SecretRef::EnvVar { name: "_MY_VAR".to_string() }))),
564            ("${env:MY_VAR_123}", Ok(MaybeSecretRef::SecretRef(SecretRef::EnvVar { name: "MY_VAR_123".to_string() }))),
565            // Empty name
566            ("${env:}", Err(MaybeSecretRefParseError::EmptyEnvVarName {
567                env_ref_str: "${env:}".to_string()
568            })),
569            // Invalid name: starts with digit
570            ("${env:1VAR}", Err(MaybeSecretRefParseError::InvalidEnvVarName {
571                env_ref_str: "${env:1VAR}".to_string(),
572                name: "1VAR".to_string(),
573                e: EnvVarNameParseError::InvalidFormat
574            })),
575            // Invalid name: contains hyphen
576            ("${env:MY-VAR}", Err(MaybeSecretRefParseError::InvalidEnvVarName {
577                env_ref_str: "${env:MY-VAR}".to_string(),
578                name: "MY-VAR".to_string(),
579                e: EnvVarNameParseError::InvalidFormat
580            })),
581            // Not an env var reference (no closing brace match for opening pattern)
582            ("${env:", Ok(MaybeSecretRef::String("${env:".to_string()))),
583            // Plain strings that look similar but are not env var references
584            ("$env:MY_VAR}", Ok(MaybeSecretRef::String("$env:MY_VAR}".to_string()))),
585        ];
586        test_values_and_expectations(values_and_expectations);
587    }
588}