laminar_connectors/storage/resolver/
mod.rs1#![allow(clippy::disallowed_types)] use std::collections::HashMap;
15use std::fmt;
16
17use super::provider::StorageProvider;
18use laminar_core::storage_auth::classify_storage_auth_source;
19pub use laminar_core::storage_auth::AuthSource;
20use laminar_core::storage_location::StorageEndpointClass;
21
22const AWS_ENV_MAPPING: &[(&str, &str)] = &[
26 ("aws_access_key_id", "AWS_ACCESS_KEY_ID"),
27 ("aws_secret_access_key", "AWS_SECRET_ACCESS_KEY"),
28 ("aws_region", "AWS_REGION"),
29 ("aws_default_region", "AWS_DEFAULT_REGION"),
30 ("aws_session_token", "AWS_SESSION_TOKEN"),
31 ("aws_endpoint", "AWS_ENDPOINT_URL"),
32 ("aws_endpoint", "AWS_ENDPOINT"),
33 ("aws_endpoint_url_s3", "AWS_ENDPOINT_URL_S3"),
34 ("aws_profile", "AWS_PROFILE"),
35 ("aws_web_identity_token_file", "AWS_WEB_IDENTITY_TOKEN_FILE"),
36 ("aws_role_arn", "AWS_ROLE_ARN"),
37 ("aws_role_session_name", "AWS_ROLE_SESSION_NAME"),
38 (
39 "aws_container_credentials_relative_uri",
40 "AWS_CONTAINER_CREDENTIALS_RELATIVE_URI",
41 ),
42 (
43 "aws_container_credentials_full_uri",
44 "AWS_CONTAINER_CREDENTIALS_FULL_URI",
45 ),
46 (
47 "aws_container_authorization_token_file",
48 "AWS_CONTAINER_AUTHORIZATION_TOKEN_FILE",
49 ),
50 (
51 "aws_virtual_hosted_style_request",
52 "AWS_VIRTUAL_HOSTED_STYLE_REQUEST",
53 ),
54 ("aws_allow_http", "AWS_ALLOW_HTTP"),
55 ("aws_s3_allow_unsafe_rename", "AWS_S3_ALLOW_UNSAFE_RENAME"),
56];
57
58const AZURE_ENV_MAPPING: &[(&str, &str)] = &[
60 ("azure_storage_account_name", "AZURE_STORAGE_ACCOUNT_NAME"),
61 ("azure_storage_account_key", "AZURE_STORAGE_ACCOUNT_KEY"),
62 ("azure_storage_sas_token", "AZURE_STORAGE_SAS_TOKEN"),
63 ("azure_storage_client_id", "AZURE_CLIENT_ID"),
64 ("azure_storage_tenant_id", "AZURE_TENANT_ID"),
65 ("azure_storage_client_secret", "AZURE_CLIENT_SECRET"),
66 ("azure_federated_token_file", "AZURE_FEDERATED_TOKEN_FILE"),
67 ("azure_storage_authority_host", "AZURE_AUTHORITY_HOST"),
68 ("azure_storage_endpoint", "AZURE_STORAGE_ENDPOINT"),
69 ("azure_allow_http", "AZURE_ALLOW_HTTP"),
70 ("azure_msi_endpoint", "IDENTITY_ENDPOINT"),
71 ("azure_use_azure_cli", "AZURE_USE_AZURE_CLI"),
72];
73
74const GCS_ENV_MAPPING: &[(&str, &str)] = &[
76 ("google_service_account_path", "SERVICE_ACCOUNT"),
77 ("google_service_account_path", "GOOGLE_SERVICE_ACCOUNT"),
78 ("google_service_account_path", "GOOGLE_SERVICE_ACCOUNT_PATH"),
79 ("google_service_account_key", "GOOGLE_SERVICE_ACCOUNT_KEY"),
80 (
81 "google_application_credentials",
82 "GOOGLE_APPLICATION_CREDENTIALS",
83 ),
84 ("google_base_url", "GOOGLE_BASE_URL"),
85 ("google_base_url", "GOOGLE_ENDPOINT_URL"),
86 ("google_allow_http", "GOOGLE_ALLOW_HTTP"),
87];
88
89#[derive(Clone)]
91pub struct ResolvedStorageOptions {
92 pub provider: StorageProvider,
94 pub options: HashMap<String, String>,
97 pub env_resolved_keys: Vec<String>,
103 pub auth_source: AuthSource,
105 pub endpoint_class: StorageEndpointClass,
107 pub endpoint_override_configured: bool,
109}
110
111impl ResolvedStorageOptions {
112 #[must_use]
117 pub fn has_credentials(&self) -> bool {
118 matches!(
119 self.auth_source,
120 AuthSource::ExplicitStatic
121 | AuthSource::ExplicitToken
122 | AuthSource::EnvironmentStatic
123 | AuthSource::EnvironmentToken
124 | AuthSource::Profile
125 | AuthSource::WebIdentity
126 | AuthSource::WorkloadIdentity
127 | AuthSource::AzureCli
128 )
129 }
130
131 #[must_use]
133 pub fn endpoint_class(&self) -> StorageEndpointClass {
134 self.endpoint_class
135 }
136}
137
138impl fmt::Debug for ResolvedStorageOptions {
139 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
140 let mut option_keys = self.options.keys().collect::<Vec<_>>();
141 option_keys.sort_unstable();
142 let mut environment_keys = self.env_resolved_keys.iter().collect::<Vec<_>>();
143 environment_keys.sort_unstable();
144 formatter
145 .debug_struct("ResolvedStorageOptions")
146 .field("provider", &self.provider)
147 .field("auth_source", &self.auth_source)
148 .field("endpoint_class", &self.endpoint_class)
149 .field(
150 "endpoint_override_configured",
151 &self.endpoint_override_configured,
152 )
153 .field("option_keys", &option_keys)
154 .field("env_resolved_keys", &environment_keys)
155 .finish()
156 }
157}
158
159pub struct StorageCredentialResolver;
166
167impl StorageCredentialResolver {
168 #[must_use]
182 pub fn resolve(
183 table_path: &str,
184 explicit_options: &HashMap<String, String>,
185 ) -> ResolvedStorageOptions {
186 Self::resolve_from(table_path, explicit_options, &|name| {
187 std::env::var(name).ok()
188 })
189 }
190
191 #[cfg(test)]
196 #[must_use]
197 pub fn resolve_with_env<F>(
198 table_path: &str,
199 explicit_options: &HashMap<String, String>,
200 env_lookup: F,
201 ) -> ResolvedStorageOptions
202 where
203 F: Fn(&str) -> Option<String>,
204 {
205 Self::resolve_from(table_path, explicit_options, &env_lookup)
206 }
207
208 fn resolve_from<F>(
209 table_path: &str,
210 explicit_options: &HashMap<String, String>,
211 env_lookup: &F,
212 ) -> ResolvedStorageOptions
213 where
214 F: Fn(&str) -> Option<String>,
215 {
216 let parsed_location =
217 laminar_core::storage_location::StorageLocation::parse(table_path).ok();
218 let provider = parsed_location.as_ref().map_or_else(
219 || StorageProvider::detect(table_path),
220 |location| location.provider,
221 );
222
223 if provider == StorageProvider::Local {
224 return ResolvedStorageOptions {
225 provider,
226 options: explicit_options.clone(),
227 env_resolved_keys: Vec::new(),
228 auth_source: AuthSource::Anonymous,
229 endpoint_class: StorageEndpointClass::Local,
230 endpoint_override_configured: false,
231 };
232 }
233
234 let env_mapping = match provider {
235 StorageProvider::AwsS3 => AWS_ENV_MAPPING,
236 StorageProvider::AzureAdls => AZURE_ENV_MAPPING,
237 StorageProvider::Gcs => GCS_ENV_MAPPING,
238 StorageProvider::Local => &[],
239 };
240
241 let mut resolved = explicit_options.clone();
242 let mut env_resolved = Vec::new();
243 let explicit_endpoint = explicit_options
244 .iter()
245 .any(|(key, value)| is_endpoint_key(key) && !value.trim().is_empty());
246
247 for (option_key, env_var) in env_mapping {
248 if !resolved.contains_key(*option_key) {
249 if let Some(val) = env_lookup(env_var) {
250 if effective_environment_value(option_key, &val) {
251 if explicit_endpoint && is_endpoint_key(option_key) {
252 continue;
253 }
254 let canonical_key = (*option_key).to_string();
255 if !env_resolved.contains(&canonical_key) {
256 env_resolved.push(canonical_key.clone());
257 }
258 if is_endpoint_key(option_key)
259 && !explicit_endpoint
260 && !resolved.keys().any(|key| is_endpoint_key(key))
261 {
262 resolved.insert(canonical_key, val);
263 }
264 }
265 }
266 }
267 }
268
269 let endpoint_override_configured =
270 explicit_endpoint || env_resolved.iter().any(|key| is_endpoint_key(key));
271 let endpoint_class = resolved_endpoint_class(
272 provider,
273 parsed_location.as_ref(),
274 endpoint_override_configured,
275 );
276
277 if let Some(Ok(adapted)) = parsed_location.as_ref().map(|location| {
278 location.adapt(laminar_core::storage_location::StorageConsumer::ObjectStore)
279 }) {
280 for (key, value) in adapted.derived_options {
281 resolved.entry(key).or_insert(value);
282 }
283 }
284
285 let auth_source = classify_storage_auth_source(provider, explicit_options, env_lookup);
286
287 ResolvedStorageOptions {
288 provider,
289 options: resolved,
290 env_resolved_keys: env_resolved,
291 auth_source,
292 endpoint_class,
293 endpoint_override_configured,
294 }
295 }
296}
297
298fn resolved_endpoint_class(
299 provider: StorageProvider,
300 location: Option<&laminar_core::storage_location::StorageLocation>,
301 has_override: bool,
302) -> StorageEndpointClass {
303 if has_override {
304 return match provider {
305 StorageProvider::AwsS3 => StorageEndpointClass::S3Compatible,
306 StorageProvider::AzureAdls | StorageProvider::Gcs => {
307 StorageEndpointClass::CustomOrEmulator
308 }
309 StorageProvider::Local => StorageEndpointClass::Local,
310 };
311 }
312 location.map_or(StorageEndpointClass::Native, |location| {
313 location.endpoint_class()
314 })
315}
316
317fn effective_environment_value(option_key: &str, value: &str) -> bool {
318 if value.trim().is_empty() {
319 return false;
320 }
321 if option_key.ends_with("allow_http") {
322 return value.trim().eq_ignore_ascii_case("true");
323 }
324 true
325}
326
327fn is_endpoint_key(key: &str) -> bool {
328 matches!(
329 key.to_ascii_lowercase().as_str(),
330 "endpoint"
331 | "endpoint_url"
332 | "aws_endpoint"
333 | "aws_endpoint_url"
334 | "aws_endpoint_url_s3"
335 | "azure_endpoint"
336 | "azure_storage_endpoint"
337 | "base_url"
338 | "google_base_url"
339 | "google_service_path"
340 | "gcs.service.path"
341 )
342}
343
344#[cfg(test)]
345mod tests;