Skip to main content

harn_cli/package/
credential_environment.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::fmt;
3
4use super::{
5    ConnectorCredentialEnvironmentManifest, ConnectorSetupConfigurationField, ProviderSetupManifest,
6};
7
8const MAX_ENTRIES: usize = 16;
9const MAX_NAMES_PER_SECRET: usize = 8;
10const MAX_NAME_BYTES: usize = 128;
11
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub enum CredentialEnvironmentIssue {
14    TooManyEntries,
15    SecretNotRequired {
16        secret: String,
17    },
18    DuplicateSecret {
19        secret: String,
20    },
21    NoNames {
22        secret: String,
23    },
24    TooManyNames {
25        secret: String,
26    },
27    InvalidName {
28        name: String,
29    },
30    DuplicateName {
31        secret: String,
32        name: String,
33    },
34    NameAssignedToSeveralSecrets {
35        name: String,
36        first_secret: String,
37        second_secret: String,
38    },
39}
40
41impl fmt::Display for CredentialEnvironmentIssue {
42    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            Self::TooManyEntries => write!(formatter, "accepts at most {MAX_ENTRIES} entries"),
45            Self::SecretNotRequired { secret } => {
46                write!(
47                    formatter,
48                    "secret '{secret}' must also appear in required_secrets"
49                )
50            }
51            Self::DuplicateSecret { secret } => write!(formatter, "repeats secret '{secret}'"),
52            Self::NoNames { secret } => {
53                write!(
54                    formatter,
55                    "secret '{secret}' must declare at least one environment name"
56                )
57            }
58            Self::TooManyNames { secret } => write!(
59                formatter,
60                "secret '{secret}' accepts at most {MAX_NAMES_PER_SECRET} environment names"
61            ),
62            Self::InvalidName { name } => write!(
63                formatter,
64                "name '{name}' must use uppercase letters, digits, and underscores"
65            ),
66            Self::DuplicateName { secret, name } => {
67                write!(
68                    formatter,
69                    "secret '{secret}' repeats environment name '{name}'"
70                )
71            }
72            Self::NameAssignedToSeveralSecrets {
73                name,
74                first_secret,
75                second_secret,
76            } => write!(
77                formatter,
78                "name '{name}' is assigned to both '{first_secret}' and '{second_secret}'"
79            ),
80        }
81    }
82}
83
84pub fn credential_environment_issues(
85    setup: &ProviderSetupManifest,
86) -> Vec<CredentialEnvironmentIssue> {
87    let mut issues = Vec::new();
88    if setup.credential_environment.len() > MAX_ENTRIES {
89        issues.push(CredentialEnvironmentIssue::TooManyEntries);
90    }
91    let required = setup.required_secrets.iter().collect::<BTreeSet<_>>();
92    let mut declared_secrets = BTreeSet::new();
93    let mut environment_owners = BTreeMap::new();
94    for source in &setup.credential_environment {
95        if !required.contains(&source.secret) {
96            issues.push(CredentialEnvironmentIssue::SecretNotRequired {
97                secret: source.secret.clone(),
98            });
99        }
100        if !declared_secrets.insert(source.secret.as_str()) {
101            issues.push(CredentialEnvironmentIssue::DuplicateSecret {
102                secret: source.secret.clone(),
103            });
104        }
105        if source.environment_names.is_empty() {
106            issues.push(CredentialEnvironmentIssue::NoNames {
107                secret: source.secret.clone(),
108            });
109        }
110        if source.environment_names.len() > MAX_NAMES_PER_SECRET {
111            issues.push(CredentialEnvironmentIssue::TooManyNames {
112                secret: source.secret.clone(),
113            });
114        }
115        let mut declared_names = BTreeSet::new();
116        for name in &source.environment_names {
117            if !environment_name_is_valid(name) {
118                issues.push(CredentialEnvironmentIssue::InvalidName { name: name.clone() });
119            }
120            if !declared_names.insert(name.as_str()) {
121                issues.push(CredentialEnvironmentIssue::DuplicateName {
122                    secret: source.secret.clone(),
123                    name: name.clone(),
124                });
125            }
126            if let Some(first_secret) =
127                environment_owners.insert(name.as_str(), source.secret.as_str())
128            {
129                if first_secret != source.secret {
130                    issues.push(CredentialEnvironmentIssue::NameAssignedToSeveralSecrets {
131                        name: name.clone(),
132                        first_secret: first_secret.to_string(),
133                        second_secret: source.secret.clone(),
134                    });
135                }
136            }
137        }
138    }
139    issues
140}
141
142/// Validate the allowlisted environment names for non-secret setup fields.
143/// This is intentionally separate from credential sources: a public OAuth
144/// client id must never be mistaken for an access token or stored in the
145/// credential index.
146pub fn configuration_environment_issues(setup: &ProviderSetupManifest) -> Vec<String> {
147    let sources = &setup.configuration_environment;
148    let mut issues = Vec::new();
149    if sources.len() > MAX_ENTRIES {
150        issues.push(format!("must include at most {MAX_ENTRIES} entries"));
151    }
152    let mut fields = BTreeSet::new();
153    let mut names = BTreeSet::new();
154    for source in sources {
155        if !fields.insert(source.field) {
156            issues.push(format!("repeats field '{}'", source.field.as_str()));
157        }
158        if source.environment_names.is_empty() {
159            issues.push(format!(
160                "field '{}' must declare at least one environment name",
161                source.field.as_str()
162            ));
163        }
164        if source.environment_names.len() > MAX_NAMES_PER_SECRET {
165            issues.push(format!(
166                "field '{}' must declare at most {MAX_NAMES_PER_SECRET} environment names",
167                source.field.as_str()
168            ));
169        }
170        let mut field_names = BTreeSet::new();
171        for name in &source.environment_names {
172            if !environment_name_is_valid(name) {
173                issues.push(format!("environment name '{name}' is invalid"));
174            }
175            if !field_names.insert(name.as_str()) {
176                issues.push(format!(
177                    "field '{}' repeats environment name '{name}'",
178                    source.field.as_str()
179                ));
180            }
181            if !names.insert(name.as_str()) {
182                issues.push(format!(
183                    "environment name '{name}' is assigned to several configuration fields"
184                ));
185            }
186        }
187    }
188    issues
189}
190
191/// Resolve one non-secret setup value only from names explicitly declared by
192/// the connector manifest. The value is returned to the setup adapter and must
193/// not be included in status, plan, event, or diagnostic output.
194pub fn process_configuration_environment_value(
195    setup: &ProviderSetupManifest,
196    field: ConnectorSetupConfigurationField,
197) -> Option<String> {
198    setup
199        .configuration_environment
200        .iter()
201        .filter(|source| source.field == field)
202        .flat_map(|source| source.environment_names.iter())
203        .find_map(|name| {
204            std::env::var(name)
205                .ok()
206                .filter(|value| !value.trim().is_empty())
207        })
208}
209
210pub fn available_process_credential_environment_name<'a>(
211    sources: &'a [ConnectorCredentialEnvironmentManifest],
212    secret: &str,
213) -> Option<&'a str> {
214    available_credential_environment_name(sources, secret, |name| {
215        std::env::var(name)
216            .ok()
217            .is_some_and(|value| !value.trim().is_empty())
218    })
219}
220
221pub fn available_credential_environment_name<'a>(
222    sources: &'a [ConnectorCredentialEnvironmentManifest],
223    secret: &str,
224    mut is_present: impl FnMut(&str) -> bool,
225) -> Option<&'a str> {
226    sources
227        .iter()
228        .filter(|source| source.secret == secret)
229        .flat_map(|source| source.environment_names.iter())
230        .find(|name| is_present(name))
231        .map(String::as_str)
232}
233
234pub fn credential_environment_names(setup: &ProviderSetupManifest) -> Vec<String> {
235    let mut names = setup
236        .credential_environment
237        .iter()
238        .flat_map(|source| source.environment_names.iter().cloned())
239        .collect::<Vec<_>>();
240    names.sort();
241    names.dedup();
242    names
243}
244
245pub fn configuration_environment_names(setup: &ProviderSetupManifest) -> Vec<String> {
246    let mut names = setup
247        .configuration_environment
248        .iter()
249        .flat_map(|source| source.environment_names.iter().cloned())
250        .collect::<Vec<_>>();
251    names.sort();
252    names.dedup();
253    names
254}
255
256fn environment_name_is_valid(name: &str) -> bool {
257    !name.is_empty()
258        && name.len() <= MAX_NAME_BYTES
259        && name
260            .bytes()
261            .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit() || byte == b'_')
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    #[test]
269    fn lookup_uses_only_declared_nonempty_sources() {
270        let sources = [ConnectorCredentialEnvironmentManifest {
271            secret: "duffel/test-access-token".to_string(),
272            environment_names: vec!["DUFFEL_TEST_KEY".to_string(), "DUFFEL_BACKUP".to_string()],
273        }];
274        let found =
275            available_credential_environment_name(&sources, "duffel/test-access-token", |name| {
276                name == "DUFFEL_BACKUP"
277            });
278        assert_eq!(found, Some("DUFFEL_BACKUP"));
279        assert_eq!(
280            available_credential_environment_name(&sources, "duffel/live-token", |_| true),
281            None
282        );
283    }
284
285    #[test]
286    fn manifest_shape_decodes_logical_secret_environment_sources() {
287        let setup: ProviderSetupManifest = toml::from_str(
288            r#"
289auth_type = "api-key"
290required_secrets = ["duffel/test-access-token"]
291credential_environment = [
292  { secret = "duffel/test-access-token", environment_names = ["DUFFEL_TEST_KEY"] },
293]
294"#,
295        )
296        .expect("setup manifest");
297        assert_eq!(
298            setup.credential_environment,
299            [ConnectorCredentialEnvironmentManifest {
300                secret: "duffel/test-access-token".to_string(),
301                environment_names: vec!["DUFFEL_TEST_KEY".to_string()],
302            }]
303        );
304    }
305
306    #[test]
307    fn configuration_environment_is_allowlisted_and_value_safe() {
308        const NAME: &str = "HARN_TEST_OAUTH_CLIENT_ID_6615";
309        let _environment = crate::env_guard::ScopedEnvVar::set(NAME, "fixture-client-id");
310        let setup: ProviderSetupManifest = toml::from_str(&format!(
311            r#"
312auth_type = "oauth2"
313configuration_environment = [
314  {{ field = "oauth_client_id", environment_names = ["{NAME}"] }},
315]
316"#,
317        ))
318        .expect("setup manifest");
319        assert!(configuration_environment_issues(&setup).is_empty());
320        assert_eq!(
321            process_configuration_environment_value(
322                &setup,
323                ConnectorSetupConfigurationField::OAuthClientId,
324            )
325            .as_deref(),
326            Some("fixture-client-id")
327        );
328        let encoded = serde_json::to_string(&setup.configuration_environment).unwrap();
329        assert!(encoded.contains(NAME));
330        assert!(!encoded.contains("fixture-client-id"));
331    }
332
333    #[test]
334    fn configuration_environment_rejects_ambiguous_or_unsafe_names() {
335        let setup: ProviderSetupManifest = toml::from_str(
336            r#"
337configuration_environment = [
338  { field = "oauth_client_id", environment_names = ["bad-name", "SHARED_ID"] },
339  { field = "oauth_client_id", environment_names = ["SHARED_ID"] },
340]
341"#,
342        )
343        .expect("setup manifest");
344        let issues = configuration_environment_issues(&setup).join("\n");
345        assert!(issues.contains("repeats field"));
346        assert!(issues.contains("'bad-name' is invalid"));
347        assert!(issues.contains("assigned to several configuration fields"));
348    }
349}