Skip to main content

secrets_provider_tests/
env.rs

1use crate::retry::parse_bool_env;
2use serde_json::json;
3use std::env;
4use std::fmt::Write as _;
5use std::time::{SystemTime, UNIX_EPOCH};
6
7/// Parsed test environment configuration.
8#[derive(Debug, Clone)]
9pub struct TestEnv {
10    pub prefix: TestPrefix,
11    pub cleanup: bool,
12}
13
14impl TestEnv {
15    pub fn from_env(provider: &str) -> Self {
16        let prefix = TestPrefix::from_env(provider);
17
18        let keep = parse_bool_env("GREENTIC_TEST_KEEP");
19        let cleanup = if keep {
20            false
21        } else if let Ok(value) = env::var("GREENTIC_TEST_CLEANUP") {
22            parse_bool_env_value(&value, true)
23        } else {
24            true
25        };
26
27        Self { prefix, cleanup }
28    }
29}
30
31#[derive(Debug, Clone)]
32pub struct TestPrefix {
33    provider: String,
34    base: String,
35    counter: std::sync::Arc<std::sync::atomic::AtomicU64>,
36}
37
38impl TestPrefix {
39    pub fn from_env(provider: &str) -> Self {
40        if let Ok(explicit) = env::var("GREENTIC_TEST_PREFIX") {
41            return Self::new(provider, explicit);
42        }
43
44        let run_id = env::var("GITHUB_RUN_ID").ok();
45        let run_attempt = env::var("GITHUB_RUN_ATTEMPT").ok();
46        let repo = env::var("GITHUB_REPOSITORY").unwrap_or_else(|_| "local".to_string());
47
48        if let (Some(id), Some(attempt)) = (run_id, run_attempt) {
49            let base = format!("ci/{provider}/{repo}/{id}/{attempt}");
50            return Self::new(provider, base);
51        }
52
53        let now = SystemTime::now()
54            .duration_since(UNIX_EPOCH)
55            .unwrap_or_default()
56            .as_secs();
57        let pid = std::process::id();
58        let mut base = String::from("local/");
59        let _ = write!(&mut base, "{provider}/{now}/{pid}");
60        Self::new(provider, base)
61    }
62
63    fn new(provider: &str, base: String) -> Self {
64        // The base flows directly into provider keys, which providers turn into
65        // SecretUri components (validated against `[a-z0-9._-]+`). Sanitize once
66        // here so every provider's wrapper sees an already-valid identifier.
67        let base = sanitize_segment(&base);
68        Self {
69            provider: provider.to_string(),
70            base,
71            counter: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
72        }
73    }
74
75    /// Returns a unique key prefix for the current test run.
76    pub fn base(&self) -> String {
77        self.base.clone()
78    }
79
80    /// Derive a unique secret key for a test case.
81    ///
82    /// Uses `-` rather than `/` between segments so keys are valid as
83    /// `SecretUri` name components without further provider-side sanitization.
84    pub fn key(&self, suffix: &str) -> String {
85        let next = self
86            .counter
87            .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
88        let suffix = sanitize_segment(suffix);
89        format!("{}-{suffix}-{next}", self.base)
90    }
91
92    /// Minimal JSON metadata used in debugging output.
93    pub fn to_metadata(&self) -> serde_json::Value {
94        json!({
95            "provider": self.provider,
96            "prefix": self.base,
97        })
98    }
99}
100
101/// Replace any character outside the SecretUri component charset
102/// (`[a-z0-9._-]`) with `-`, lowercasing ASCII letters along the way.
103fn sanitize_segment(value: &str) -> String {
104    let mut out = String::with_capacity(value.len());
105    for ch in value.chars() {
106        match ch {
107            'a'..='z' | '0'..='9' | '-' | '_' | '.' => out.push(ch),
108            'A'..='Z' => out.push(ch.to_ascii_lowercase()),
109            _ => out.push('-'),
110        }
111    }
112    out
113}
114
115fn parse_bool_env_value(value: &str, default_true: bool) -> bool {
116    match value {
117        "" => default_true,
118        v if v.eq_ignore_ascii_case("1") || v.eq_ignore_ascii_case("true") => true,
119        v if v.eq_ignore_ascii_case("0") || v.eq_ignore_ascii_case("false") => false,
120        _ => default_true,
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    #[test]
129    fn builds_local_prefix_when_no_ci_env() {
130        let prefix = TestPrefix::new("dev", "local/test/123".to_string());
131        // Slashes get normalised to dashes so the base is a valid SecretUri segment.
132        assert_eq!(prefix.base(), "local-test-123");
133        let k1 = prefix.key("a");
134        let k2 = prefix.key("a");
135        assert_ne!(k1, k2);
136        // Keys must contain only chars accepted by SecretUri name validation.
137        assert!(
138            k1.chars()
139                .all(|c| matches!(c, 'a'..='z' | '0'..='9' | '-' | '_' | '.')),
140            "key contains invalid chars: {k1}"
141        );
142    }
143}