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
142pub 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
191pub 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}