1use std::{
4 collections,
5 collections::HashMap,
6 fmt,
7 sync::{Arc, Mutex, OnceLock},
8 time,
9 time::{Duration, Instant},
10};
11
12use bytes::Bytes;
13use object_store::aws::{AmazonS3, AmazonS3Builder, AwsCredential, AwsCredentialProvider};
14use query_planning::error::{QueryError, QueryErrorCode, Result};
15use tokio::runtime::{Builder, Runtime as TokioRuntime};
16
17#[derive(Clone, PartialEq, Eq)]
23pub struct SourceStorageProfile {
24 id: String,
25 s3: S3SourceStorageProfile,
26}
27
28impl SourceStorageProfile {
29 pub fn s3(id: impl Into<String>, s3: S3SourceStorageProfile) -> Self {
31 Self { id: id.into(), s3 }
32 }
33
34 pub fn id(&self) -> &str {
36 &self.id
37 }
38
39 pub fn s3_profile(&self) -> &S3SourceStorageProfile {
41 &self.s3
42 }
43
44 pub fn build_s3_object_store(
50 &self,
51 bucket: impl Into<String>,
52 fallback_region: impl Into<String>,
53 ) -> Result<Arc<dyn object_store::ObjectStore>> {
54 let key = S3ObjectStoreKey {
55 bucket: bucket.into(),
56 region: fallback_region.into(),
57 storage_profile_id: Some(self.id.clone()),
58 };
59 let store: Arc<dyn object_store::ObjectStore> = Arc::new(build_s3_store(&key, Some(self))?);
60 Ok(store)
61 }
62
63 pub fn requires_scoped_remote_direct_profile(&self) -> bool {
66 self.s3.requires_scoped_remote_direct_profile()
67 }
68}
69
70impl fmt::Debug for SourceStorageProfile {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 f.debug_struct("SourceStorageProfile").field("id", &self.id).field("s3", &self.s3).finish()
73 }
74}
75
76#[derive(Clone, PartialEq, Eq)]
78pub struct S3SourceStorageProfile {
79 pub region: Option<String>,
82 pub endpoint: Option<String>,
84 pub virtual_hosted_style_request: Option<bool>,
87 pub(crate) allow_http: Option<bool>,
89 pub credential: S3SourceStorageCredential,
91}
92
93impl S3SourceStorageProfile {
94 pub fn default_chain() -> Self {
97 Self {
98 region: None,
99 endpoint: None,
100 virtual_hosted_style_request: None,
101 allow_http: None,
102 credential: S3SourceStorageCredential::DefaultChain,
103 }
104 }
105
106 pub fn with_region(mut self, region: impl Into<String>) -> Self {
108 self.region = Some(region.into());
109 self
110 }
111
112 pub fn with_endpoint(mut self, endpoint: impl Into<String>) -> Self {
114 self.endpoint = Some(endpoint.into());
115 self
116 }
117
118 pub fn with_path_style_access(mut self, path_style: bool) -> Self {
120 self.virtual_hosted_style_request = Some(!path_style);
121 self
122 }
123
124 pub fn with_unsigned_requests(mut self) -> Self {
126 self.credential = S3SourceStorageCredential::Unsigned;
127 self
128 }
129
130 pub fn with_static_credentials(
132 mut self,
133 access_key_id: impl Into<String>,
134 secret_access_key: impl Into<String>,
135 session_token: Option<String>,
136 ) -> Self {
137 self.credential = S3SourceStorageCredential::Static {
138 access_key_id: access_key_id.into(),
139 secret_access_key: secret_access_key.into(),
140 session_token,
141 };
142 self
143 }
144
145 pub fn requires_scoped_remote_direct_profile(&self) -> bool {
148 self.region.as_ref().is_some_and(|region| !region.trim().is_empty())
149 || !matches!(self.credential, S3SourceStorageCredential::DefaultChain)
150 || self.endpoint.as_ref().is_some_and(|endpoint| !endpoint.trim().is_empty())
151 || self.virtual_hosted_style_request.is_some()
152 || self.allow_http.is_some()
153 }
154}
155
156impl fmt::Debug for S3SourceStorageProfile {
157 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
158 f.debug_struct("S3SourceStorageProfile")
159 .field("region", &self.region)
160 .field("endpoint", &self.endpoint.as_ref().map(|_| "<configured>"))
161 .field("virtual_hosted_style_request", &self.virtual_hosted_style_request)
162 .field("allow_http", &self.allow_http)
163 .field("credential", &self.credential)
164 .finish()
165 }
166}
167
168#[derive(Clone, PartialEq, Eq)]
170pub enum S3SourceStorageCredential {
171 DefaultChain,
173 Unsigned,
175 Static {
177 access_key_id: String,
179 secret_access_key: String,
181 session_token: Option<String>,
183 },
184}
185
186impl fmt::Debug for S3SourceStorageCredential {
187 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188 match self {
189 Self::DefaultChain => f.write_str("DefaultChain"),
190 Self::Unsigned => f.write_str("Unsigned"),
191 Self::Static { session_token, .. } => f
192 .debug_struct("Static")
193 .field("access_key_id", &"<redacted>")
194 .field("secret_access_key", &"<redacted>")
195 .field("session_token", &session_token.as_ref().map(|_| "<redacted>"))
196 .finish(),
197 }
198 }
199}
200
201#[derive(Clone, Debug, PartialEq, Eq, Hash)]
202pub struct S3ObjectStoreKey {
203 #[doc(hidden)]
204 pub bucket: String,
205 #[doc(hidden)]
206 pub region: String,
207 #[doc(hidden)]
208 pub storage_profile_id: Option<String>,
209}
210
211pub struct ObjectStoreFetchResult {
212 #[doc(hidden)]
213 pub bytes: Result<Bytes>,
214}
215
216pub struct ObjectStoreCustomFetchResult<T> {
217 #[doc(hidden)]
218 pub value: Result<T>,
219 #[doc(hidden)]
220 pub elapsed: Duration,
221}
222
223pub(super) const RESOLVED_SOURCE_CREDENTIAL_REUSE_WINDOW: Duration = Duration::from_secs(60);
228
229struct ResolvedSourceCredential {
230 resolved_at: Instant,
231 credential: Arc<AwsCredential>,
232}
233
234pub(super) struct SourceObjectStoreRegistry {
236 profiles: HashMap<String, SourceStorageProfile>,
237 stores: Mutex<HashMap<S3ObjectStoreKey, Arc<AmazonS3>>>,
238 resolved_credentials: Mutex<HashMap<S3ObjectStoreKey, ResolvedSourceCredential>>,
239}
240
241impl SourceObjectStoreRegistry {
242 #[cfg(test)]
243 pub(super) fn new() -> Self {
244 Self {
245 profiles: HashMap::new(),
246 stores: Mutex::new(HashMap::new()),
247 resolved_credentials: Mutex::new(HashMap::new()),
248 }
249 }
250
251 pub(super) fn with_profiles(profiles: impl IntoIterator<Item = SourceStorageProfile>) -> Result<Self> {
252 let profiles = profiles.into_iter().collect::<Vec<_>>();
253 Self::validate_profiles(&profiles)?;
254 let by_id = profiles.into_iter().map(|profile| (profile.id().to_string(), profile)).collect();
255 Ok(Self {
256 profiles: by_id,
257 stores: Mutex::new(HashMap::new()),
258 resolved_credentials: Mutex::new(HashMap::new()),
259 })
260 }
261
262 pub(super) fn validate_profiles(profiles: &[SourceStorageProfile]) -> Result<()> {
263 let mut profile_ids = collections::HashSet::with_capacity(profiles.len());
264 for profile in profiles {
265 if profile.id().trim().is_empty() {
266 return Err(QueryError::new(
267 QueryErrorCode::InvalidConfiguration,
268 "query_engine.source_object_store.profile",
269 "source storage profile id must not be empty",
270 )
271 .with_fact("reason", "empty_profile_id"));
272 }
273 if !profile_ids.insert(profile.id()) {
274 return Err(QueryError::new(
275 QueryErrorCode::InvalidConfiguration,
276 "query_engine.source_object_store.profile",
277 "source storage profile ids must be unique",
278 )
279 .with_fact("storage_profile_id", profile.id())
280 .with_fact("reason", "duplicate_profile_id"));
281 }
282 }
283 Ok(())
284 }
285
286 pub(super) fn s3_store(&self, key: S3ObjectStoreKey) -> Result<Arc<dyn object_store::ObjectStore>> {
287 let store: Arc<dyn object_store::ObjectStore> = self.s3_typed_store(key)?;
288 Ok(store)
289 }
290
291 pub(super) fn s3_credential_provider(&self, key: S3ObjectStoreKey) -> Result<AwsCredentialProvider> {
293 Ok(Arc::clone(self.s3_typed_store(key)?.credentials()))
294 }
295
296 pub(super) fn recently_resolved_s3_credential(&self, key: &S3ObjectStoreKey) -> Option<Arc<AwsCredential>> {
298 let resolved_credentials = self.resolved_credentials.lock().ok()?;
299 let resolved = resolved_credentials.get(key)?;
300 if resolved.resolved_at.elapsed() < RESOLVED_SOURCE_CREDENTIAL_REUSE_WINDOW {
301 Some(Arc::clone(&resolved.credential))
302 } else {
303 None
304 }
305 }
306
307 pub(super) fn record_resolved_s3_credential(&self, key: S3ObjectStoreKey, credential: Arc<AwsCredential>) {
309 if let Ok(mut resolved_credentials) = self.resolved_credentials.lock() {
310 resolved_credentials
311 .insert(key, ResolvedSourceCredential { resolved_at: time::Instant::now(), credential });
312 }
313 }
314
315 fn s3_typed_store(&self, key: S3ObjectStoreKey) -> Result<Arc<AmazonS3>> {
316 let mut stores = self.stores.lock().map_err(|_error| {
317 QueryError::new(
318 QueryErrorCode::Internal,
319 "query_engine.source_object_store.registry",
320 "source object-store registry mutex was poisoned",
321 )
322 })?;
323 if let Some(store) = stores.get(&key) {
324 return Ok(Arc::clone(store));
325 }
326 let profile = key
327 .storage_profile_id
328 .as_ref()
329 .map(|id| {
330 self.profiles.get(id).ok_or_else(|| {
331 QueryError::new(
332 QueryErrorCode::InvalidPlan,
333 "query_engine.source_object_store.profile",
334 "source storage profile id was not registered",
335 )
336 .with_fact("storage_profile_id", id.as_str())
337 })
338 })
339 .transpose()?;
340 let store = Arc::new(build_s3_store(&key, profile)?);
341 stores.insert(key, Arc::clone(&store));
342 Ok(store)
343 }
344
345 pub(super) fn profile(&self, id: &str) -> Result<&SourceStorageProfile> {
346 self.profiles.get(id).ok_or_else(|| {
347 QueryError::new(
348 QueryErrorCode::InvalidPlan,
349 "query_engine.source_object_store.profile",
350 "source storage profile id was not registered",
351 )
352 .with_fact("storage_profile_id", id)
353 })
354 }
355}
356
357fn build_s3_store(key: &S3ObjectStoreKey, profile: Option<&SourceStorageProfile>) -> Result<AmazonS3> {
358 let s3_profile = profile.map(SourceStorageProfile::s3_profile);
359 let region = s3_profile
360 .and_then(|profile| profile.region.as_deref())
361 .filter(|region| !region.is_empty())
362 .unwrap_or(key.region.as_str());
363 let mut builder = match s3_profile.map(|profile| &profile.credential) {
364 Some(S3SourceStorageCredential::Static { access_key_id, secret_access_key, session_token }) => {
365 let mut builder =
366 AmazonS3Builder::new().with_access_key_id(access_key_id).with_secret_access_key(secret_access_key);
367 if let Some(token) = session_token.as_ref().filter(|token| !token.is_empty()) {
368 builder = builder.with_token(token);
369 }
370 builder
371 },
372 Some(S3SourceStorageCredential::Unsigned) => AmazonS3Builder::new().with_skip_signature(true),
373 _ => AmazonS3Builder::from_env(),
374 }
375 .with_bucket_name(key.bucket.as_str())
376 .with_region(region);
377 if let Some(profile) = s3_profile {
378 if let Some(endpoint) = profile.endpoint.as_ref().filter(|endpoint| !endpoint.trim().is_empty()) {
379 if endpoint_authority_contains_userinfo(endpoint) {
380 return Err(
381 s3_store_build_error(key, region, s3_profile).with_fact("reason", "endpoint_userinfo_unsupported")
382 );
383 }
384 builder = builder.with_endpoint(endpoint);
385 if profile.allow_http.unwrap_or_else(|| endpoint.trim_start().starts_with("http://")) {
386 builder = builder.with_allow_http(true);
387 }
388 } else if let Some(allow_http) = profile.allow_http {
389 builder = builder.with_allow_http(allow_http);
390 }
391 if let Some(virtual_hosted_style_request) = profile.virtual_hosted_style_request {
392 builder = builder.with_virtual_hosted_style_request(virtual_hosted_style_request);
393 }
394 }
395 builder.build().map_err(|_error| s3_store_build_error(key, region, s3_profile))
396}
397
398fn s3_store_build_error(
399 key: &S3ObjectStoreKey,
400 region: &str,
401 s3_profile: Option<&S3SourceStorageProfile>,
402) -> QueryError {
403 let mut query_error = QueryError::new(
404 QueryErrorCode::CudfDependencyFailure,
405 "query_engine.source_object_store.s3",
406 "failed to build S3 object-store client for native control-plane work",
407 )
408 .with_fact("bucket", key.bucket.as_str())
409 .with_fact("region", region)
410 .with_fact("endpoint_configured", s3_profile.and_then(|profile| profile.endpoint.as_ref()).is_some());
411 if let Some(profile_id) = key.storage_profile_id.as_deref() {
412 query_error = query_error.with_fact("storage_profile_id", profile_id);
413 }
414 query_error
415}
416
417fn endpoint_authority_contains_userinfo(endpoint: &str) -> bool {
418 let trimmed = endpoint.trim();
419 let after_scheme = trimmed.split_once("://").map_or(trimmed, |(_, after_scheme)| after_scheme);
420 let authority = after_scheme.split(['/', '?', '#']).next().unwrap_or(after_scheme);
421 authority.contains('@')
422}
423
424impl fmt::Debug for SourceObjectStoreRegistry {
425 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
426 let cached_store_count = self.stores.lock().map_or(0, |stores| stores.len());
427 f.debug_struct("SourceObjectStoreRegistry")
428 .field("profile_count", &self.profiles.len())
429 .field("cached_store_count", &cached_store_count)
430 .finish()
431 }
432}
433
434pub(super) fn default_object_store_control_runtime() -> Result<Arc<TokioRuntime>> {
435 static RUNTIME: OnceLock<Arc<TokioRuntime>> = OnceLock::new();
436 static INIT_LOCK: Mutex<()> = Mutex::new(());
437 if let Some(runtime) = RUNTIME.get() {
438 return Ok(Arc::clone(runtime));
439 }
440 let _guard = INIT_LOCK.lock().map_err(|_| {
441 QueryError::new(
442 QueryErrorCode::CudfDependencyFailure,
443 "query_engine.runtime.object_store_control_runtime",
444 "object-store control runtime initialization lock was poisoned",
445 )
446 })?;
447 if let Some(runtime) = RUNTIME.get() {
448 return Ok(Arc::clone(runtime));
449 }
450 let runtime =
451 Arc::new(Builder::new_multi_thread().thread_name("algeon-object-store-control").enable_all().build().map_err(
452 |error| {
453 QueryError::new(
454 QueryErrorCode::CudfDependencyFailure,
455 "query_engine.runtime.object_store_control_runtime",
456 "failed to initialize object-store control runtime",
457 )
458 .with_source(error)
459 },
460 )?);
461 assert!(
462 RUNTIME.set(Arc::clone(&runtime)).is_ok(),
463 "object-store control runtime initialized while holding init lock"
464 );
465 Ok(runtime)
466}
467
468#[cfg(test)]
469mod tests {
470 use super::*;
471
472 #[test]
473 fn source_storage_profile_debug_redacts_endpoint_and_credentials() {
474 let profile = SourceStorageProfile::s3(
475 "rest-s3",
476 S3SourceStorageProfile::default_chain()
477 .with_region("us-east-1")
478 .with_endpoint("http://user:password@localhost:9000")
479 .with_path_style_access(true)
480 .with_static_credentials(
481 "access-key-value",
482 "secret-key-value",
483 Some("session-token-value".to_string()),
484 ),
485 );
486
487 let rendered = format!("{profile:?}");
488
489 assert!(rendered.contains("rest-s3"));
490 assert!(rendered.contains("<configured>"));
491 assert!(rendered.contains("<redacted>"));
492 assert!(!rendered.contains("user:password"));
493 assert!(!rendered.contains("access-key-value"));
494 assert!(!rendered.contains("secret-key-value"));
495 assert!(!rendered.contains("session-token-value"));
496 }
497
498 #[test]
499 fn source_object_store_registry_rejects_duplicate_profile_ids() {
500 let profile = SourceStorageProfile::s3("same", S3SourceStorageProfile::default_chain());
501 let error = SourceObjectStoreRegistry::with_profiles([profile.clone(), profile]).unwrap_err();
502
503 assert_eq!(error.code(), QueryErrorCode::InvalidConfiguration);
504 assert_eq!(error.fact_value("reason"), Some("duplicate_profile_id"));
505 assert_eq!(error.fact_value("storage_profile_id"), Some("same"));
506 }
507
508 #[test]
509 fn source_object_store_registry_rejects_empty_profile_ids() {
510 let profile = SourceStorageProfile::s3(" ", S3SourceStorageProfile::default_chain());
511 let error = SourceObjectStoreRegistry::with_profiles([profile]).unwrap_err();
512
513 assert_eq!(error.code(), QueryErrorCode::InvalidConfiguration);
514 assert_eq!(error.fact_value("reason"), Some("empty_profile_id"));
515 }
516
517 #[test]
518 fn source_object_store_registry_requires_registered_profile_id() {
519 let error = SourceObjectStoreRegistry::new()
520 .s3_store(S3ObjectStoreKey {
521 bucket: "bucket".to_string(),
522 region: "us-east-1".to_string(),
523 storage_profile_id: Some("missing".to_string()),
524 })
525 .unwrap_err();
526
527 assert_eq!(error.code(), QueryErrorCode::InvalidPlan);
528 assert_eq!(error.fact_value("storage_profile_id"), Some("missing"));
529 }
530
531 #[test]
532 fn source_storage_profile_reports_scoped_remote_direct_requirement_without_secrets() {
533 let default_chain = SourceStorageProfile::s3("aws-default", S3SourceStorageProfile::default_chain());
534 assert!(!default_chain.requires_scoped_remote_direct_profile());
535
536 let region_override =
537 SourceStorageProfile::s3("region", S3SourceStorageProfile::default_chain().with_region("us-west-2"));
538 assert!(region_override.requires_scoped_remote_direct_profile());
539
540 let static_credentials = SourceStorageProfile::s3(
541 "static",
542 S3SourceStorageProfile::default_chain().with_static_credentials("access", "secret", None),
543 );
544 assert!(static_credentials.requires_scoped_remote_direct_profile());
545
546 let custom_endpoint = SourceStorageProfile::s3(
547 "rustfs",
548 S3SourceStorageProfile::default_chain().with_endpoint("http://localhost:9000").with_path_style_access(true),
549 );
550 assert!(custom_endpoint.requires_scoped_remote_direct_profile());
551 }
552
553 #[test]
554 fn source_storage_profile_builds_object_store_for_adapter_metadata_reads() {
555 let profile =
556 SourceStorageProfile::s3("s3-profile", S3SourceStorageProfile::default_chain().with_unsigned_requests());
557
558 let store = profile.build_s3_object_store("lakehouse", "us-east-1");
559
560 if let Err(error) = store {
561 panic!("native storage profile should build an S3 object store: {error}");
562 }
563 }
564
565 #[test]
566 fn s3_store_build_errors_do_not_expose_endpoint_userinfo() {
567 let profile = SourceStorageProfile::s3(
568 "bad-endpoint",
569 S3SourceStorageProfile::default_chain()
570 .with_endpoint("http://user:password@[::1")
571 .with_path_style_access(true),
572 );
573 let key = S3ObjectStoreKey {
574 bucket: "bucket".to_string(),
575 region: "us-east-1".to_string(),
576 storage_profile_id: Some("bad-endpoint".to_string()),
577 };
578
579 let error = build_s3_store(&key, Some(&profile)).unwrap_err();
580 let rendered = format!("{error:?}\n{error}");
581
582 assert_eq!(error.code(), QueryErrorCode::CudfDependencyFailure);
583 assert_eq!(error.fact_value("reason"), Some("endpoint_userinfo_unsupported"));
584 assert_eq!(error.fact_value("endpoint_configured"), Some("true"));
585 assert!(error.source_error().is_none());
586 assert!(!rendered.contains("user:password"));
587 assert!(!rendered.contains("password"));
588 }
589
590 #[test]
591 fn s3_credential_provider_is_shared_with_control_plane_store_and_resolves_static_credentials() {
592 let registry = SourceObjectStoreRegistry::with_profiles([SourceStorageProfile::s3(
593 "static",
594 S3SourceStorageProfile::default_chain().with_region("us-east-1").with_static_credentials(
595 "access-key-value",
596 "secret-key-value",
597 Some("session-token-value".to_string()),
598 ),
599 )])
600 .expect("registry builds");
601 let key = S3ObjectStoreKey {
602 bucket: "bucket".to_string(),
603 region: "us-east-1".to_string(),
604 storage_profile_id: Some("static".to_string()),
605 };
606
607 let _control_plane_store = registry.s3_store(key.clone()).expect("control-plane store builds");
608 let provider = registry.s3_credential_provider(key).expect("credential provider resolves");
609 let credential = default_object_store_control_runtime()
610 .unwrap()
611 .block_on(provider.get_credential())
612 .expect("credential resolves");
613
614 assert_eq!(credential.key_id, "access-key-value");
615 assert_eq!(credential.secret_key, "secret-key-value");
616 assert_eq!(credential.token.as_deref(), Some("session-token-value"));
617 }
618
619 #[test]
620 fn resolved_s3_credentials_are_reused_per_key_within_the_reuse_window() {
621 let registry = SourceObjectStoreRegistry::new();
622 let key = S3ObjectStoreKey {
623 bucket: "bucket".to_string(),
624 region: "us-east-1".to_string(),
625 storage_profile_id: Some("glue".to_string()),
626 };
627 let other_key = S3ObjectStoreKey { bucket: "other-bucket".to_string(), ..key.clone() };
628 assert!(registry.recently_resolved_s3_credential(&key).is_none());
629
630 let credential = Arc::new(AwsCredential {
631 key_id: "resolved-access-key".to_string(),
632 secret_key: "resolved-secret-key".to_string(),
633 token: None,
634 });
635 registry.record_resolved_s3_credential(key.clone(), Arc::clone(&credential));
636
637 let reused = registry.recently_resolved_s3_credential(&key).expect("fresh credential is reused");
638 assert!(Arc::ptr_eq(&reused, &credential));
639 assert!(registry.recently_resolved_s3_credential(&other_key).is_none());
640 }
641
642 #[test]
643 fn s3_store_rejects_endpoint_userinfo_without_scheme() {
644 let profile = SourceStorageProfile::s3(
645 "bad-endpoint",
646 S3SourceStorageProfile::default_chain()
647 .with_endpoint("user:password@localhost:9000")
648 .with_path_style_access(true),
649 );
650 let key = S3ObjectStoreKey {
651 bucket: "bucket".to_string(),
652 region: "us-east-1".to_string(),
653 storage_profile_id: Some("bad-endpoint".to_string()),
654 };
655
656 let error = build_s3_store(&key, Some(&profile)).unwrap_err();
657
658 assert_eq!(error.code(), QueryErrorCode::CudfDependencyFailure);
659 assert_eq!(error.fact_value("reason"), Some("endpoint_userinfo_unsupported"));
660 assert!(error.source_error().is_none());
661 }
662}