Skip to main content

stackless_cloud/
credential.rs

1//! Shared cloud credential resolution (§4): env var → resolved secrets →
2//! scoped key file. Each cloud substrate names its own env var and key file and
3//! maps the neutral [`CredentialMissing`] to its own fault, so per-provider
4//! error codes and remediation stay distinct (ARCHITECTURE.md §2).
5
6use std::collections::BTreeMap;
7use std::path::Path;
8
9/// No credential in any source. The provider maps this to its own
10/// `ApiKeyMissing`-style fault (whose remediation names the provider).
11#[derive(Debug, Clone)]
12pub struct CredentialMissing {
13    pub env_var: String,
14    pub key_file: String,
15}
16
17/// Resolve a credential from the environment, the resolved secrets, or a scoped
18/// key file next to the definition. The env var wins so CI can inject without a
19/// file; the secrets map is the project's canonical store; the file is a scoped
20/// fallback.
21pub fn resolve(
22    env_var: &str,
23    key_file_name: &str,
24    definition_dir: &Path,
25    secrets: &BTreeMap<String, String>,
26) -> Result<String, CredentialMissing> {
27    if let Ok(value) = std::env::var(env_var) {
28        let value = value.trim().to_owned();
29        if !value.is_empty() {
30            return Ok(value);
31        }
32    }
33    if let Some(value) = secrets.get(env_var) {
34        let value = value.trim().to_owned();
35        if !value.is_empty() {
36            return Ok(value);
37        }
38    }
39    let key_file = definition_dir.join(key_file_name);
40    if let Ok(contents) = std::fs::read_to_string(&key_file) {
41        let value = contents.trim().to_owned();
42        if !value.is_empty() {
43            return Ok(value);
44        }
45    }
46    Err(CredentialMissing {
47        env_var: env_var.to_owned(),
48        key_file: key_file.display().to_string(),
49    })
50}
51
52#[cfg(test)]
53mod tests {
54    use super::*;
55
56    const ENV: &str = "STACKLESS_CLOUD_TEST_KEY";
57    const FILE: &str = ".cloud-test-key";
58
59    fn secret(key: &str, value: &str) -> BTreeMap<String, String> {
60        let mut map = BTreeMap::new();
61        map.insert(key.to_owned(), value.to_owned());
62        map
63    }
64
65    #[test]
66    fn resolves_from_secret() {
67        let dir = tempfile::tempdir().unwrap();
68        let key = resolve(ENV, FILE, dir.path(), &secret(ENV, "from_secrets")).unwrap();
69        assert_eq!(key, "from_secrets");
70    }
71
72    #[test]
73    fn key_file_is_a_fallback_when_secret_absent() {
74        let dir = tempfile::tempdir().unwrap();
75        std::fs::write(dir.path().join(FILE), "from_file\n").unwrap();
76        let key = resolve(ENV, FILE, dir.path(), &BTreeMap::new()).unwrap();
77        assert_eq!(key, "from_file");
78    }
79
80    #[test]
81    fn missing_everywhere_names_both_sources() {
82        let dir = tempfile::tempdir().unwrap();
83        let err = resolve(ENV, FILE, dir.path(), &BTreeMap::new()).unwrap_err();
84        assert_eq!(err.env_var, ENV);
85        assert!(err.key_file.ends_with(FILE));
86    }
87}