1use std::collections::BTreeSet;
21use std::path::Component;
22
23use async_trait::async_trait;
24use futures::TryStreamExt;
25use object_store::services::{Azblob, Fs, Gcs, Oss, S3};
26use object_store::util::{with_instrument_layers, with_retry_layers};
27use object_store::{
28 AzblobConnection, ErrorKind, GcsConnection, ObjectStore, OssConnection, S3Connection,
29};
30use snafu::ResultExt;
31use url::Url;
32
33use crate::common::ObjectStoreConfig;
34use crate::data::export_v2::error::{
35 BuildObjectStoreSnafu, InvalidUriSnafu, ManifestParseSnafu, ManifestSerializeSnafu, Result,
36 SnapshotNotFoundSnafu, StorageOperationSnafu, TextDecodeSnafu, UnsupportedSchemeSnafu,
37 UrlParseSnafu,
38};
39use crate::data::export_v2::manifest::{MANIFEST_FILE, Manifest};
40#[cfg(test)]
41use crate::data::export_v2::schema::SchemaDefinition;
42use crate::data::export_v2::schema::{SCHEMA_DIR, SCHEMAS_FILE, SchemaSnapshot};
43use crate::data::path::data_dir_for_schema_chunk;
44
45struct RemoteLocation {
46 bucket_or_container: String,
47 root: String,
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum StorageScheme {
53 S3,
55 Oss,
57 Gcs,
59 Azblob,
61 File,
63}
64
65impl StorageScheme {
66 pub fn from_uri(uri: &str) -> Result<Self> {
68 let url = Url::parse(uri).context(UrlParseSnafu)?;
69
70 match url.scheme() {
71 "s3" => Ok(Self::S3),
72 "oss" => Ok(Self::Oss),
73 "gs" | "gcs" => Ok(Self::Gcs),
74 "azblob" => Ok(Self::Azblob),
75 "file" => Ok(Self::File),
76 scheme => UnsupportedSchemeSnafu { scheme }.fail(),
77 }
78 }
79}
80
81fn extract_remote_location_with_root_policy(
83 uri: &str,
84 allow_empty_root: bool,
85) -> Result<RemoteLocation> {
86 let url = Url::parse(uri).context(UrlParseSnafu)?;
87 let bucket_or_container = url.host_str().unwrap_or("").to_string();
88 if bucket_or_container.is_empty() {
89 return InvalidUriSnafu {
90 uri,
91 reason: "URI must include bucket/container in host",
92 }
93 .fail();
94 }
95
96 let root = url.path().trim_start_matches('/').to_string();
97 if root.is_empty() && !allow_empty_root {
98 return InvalidUriSnafu {
99 uri,
100 reason: "snapshot URI must include a non-empty path after the bucket/container",
101 }
102 .fail();
103 }
104
105 Ok(RemoteLocation {
106 bucket_or_container,
107 root,
108 })
109}
110
111pub fn validate_uri(uri: &str) -> Result<StorageScheme> {
124 if !uri.contains("://") {
126 return InvalidUriSnafu {
127 uri,
128 reason: "URI must have a scheme (e.g., s3://, file://). Bare paths are not supported.",
129 }
130 .fail();
131 }
132
133 StorageScheme::from_uri(uri)
134}
135
136pub fn validate_snapshot_uri(uri: &str) -> Result<StorageScheme> {
144 let scheme = validate_uri(uri)?;
145 reject_query_or_fragment(uri)?;
146 match scheme {
147 StorageScheme::File => validate_file_snapshot_uri(uri)?,
148 StorageScheme::S3 | StorageScheme::Oss | StorageScheme::Gcs | StorageScheme::Azblob => {
149 extract_remote_location_with_root_policy(uri, false)?;
150 }
151 }
152 Ok(scheme)
153}
154
155fn reject_query_or_fragment(uri: &str) -> Result<()> {
156 let url = Url::parse(uri).context(UrlParseSnafu)?;
157 if url.query().is_some() || url.fragment().is_some() {
158 return InvalidUriSnafu {
159 uri,
160 reason: "snapshot URI must not include query or fragment",
161 }
162 .fail();
163 }
164
165 Ok(())
166}
167
168fn validate_file_snapshot_uri(uri: &str) -> Result<()> {
169 if has_explicit_dot_segment(uri) {
170 return InvalidUriSnafu {
171 uri,
172 reason: "file snapshot URI must not contain '.' or '..' path segments",
173 }
174 .fail();
175 }
176
177 let path = extract_file_path_from_uri(uri)?;
178 let mut normal_component_count = 0;
179
180 for component in std::path::Path::new(&path).components() {
185 match component {
186 Component::Normal(_) => normal_component_count += 1,
187 Component::CurDir | Component::ParentDir => {
188 return InvalidUriSnafu {
189 uri,
190 reason: "file snapshot URI must not contain '.' or '..' path segments",
191 }
192 .fail();
193 }
194 Component::Prefix(_) | Component::RootDir => {}
195 }
196 }
197
198 if normal_component_count < 2 {
199 return InvalidUriSnafu {
200 uri,
201 reason: "file snapshot URI must point to a directory at least two levels deep",
202 }
203 .fail();
204 }
205
206 Ok(())
207}
208
209fn has_explicit_dot_segment(uri: &str) -> bool {
210 let without_fragment = uri.split_once('#').map_or(uri, |(path, _)| path);
214 let path = without_fragment
215 .split_once('?')
216 .map_or(without_fragment, |(path, _)| path);
217
218 path.split('/')
219 .any(|segment| segment == "." || segment == "..")
220}
221
222fn schema_index_path() -> String {
223 format!("{}/{}", SCHEMA_DIR, SCHEMAS_FILE)
224}
225
226fn extract_file_path_from_uri(uri: &str) -> Result<String> {
228 let url = Url::parse(uri).context(UrlParseSnafu)?;
229
230 match url.host_str() {
231 Some(host) if !host.is_empty() && host != "localhost" => InvalidUriSnafu {
232 uri,
233 reason: "file:// URI must use an absolute path like file:///tmp/backup",
234 }
235 .fail(),
236 _ => url
237 .to_file_path()
238 .map_err(|_| {
239 InvalidUriSnafu {
240 uri,
241 reason: "file:// URI must use an absolute path like file:///tmp/backup",
242 }
243 .build()
244 })
245 .map(|path| path.to_string_lossy().into_owned()),
246 }
247}
248
249async fn ensure_snapshot_exists(storage: &OpenDalStorage) -> Result<()> {
250 if storage.exists().await? {
251 Ok(())
252 } else {
253 SnapshotNotFoundSnafu {
254 uri: storage.target_uri.as_str(),
255 }
256 .fail()
257 }
258}
259
260#[async_trait]
264pub trait SnapshotStorage: Send + Sync {
265 async fn exists(&self) -> Result<bool>;
267
268 async fn read_manifest(&self) -> Result<Manifest>;
270
271 async fn file_size(&self, path: &str) -> Result<Option<u64>>;
273
274 async fn write_manifest(&self, manifest: &Manifest) -> Result<()>;
276
277 async fn write_schema(&self, schema: &SchemaSnapshot) -> Result<()>;
279
280 async fn write_text(&self, path: &str, content: &str) -> Result<()>;
282
283 async fn read_text(&self, path: &str) -> Result<String>;
285
286 async fn create_dir_all(&self, path: &str) -> Result<()>;
288
289 async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>>;
291
292 async fn prepare_export_chunk(
294 &self,
295 schemas: &[String],
296 chunk_id: u32,
297 resume: bool,
298 packed: bool,
299 ) -> Result<()> {
300 let _ = (schemas, chunk_id, resume, packed);
301 InvalidUriSnafu {
302 uri: "snapshot",
303 reason: "storage does not support preparing export chunks",
304 }
305 .fail()
306 }
307
308 async fn delete_snapshot(&self) -> Result<()>;
310}
311
312pub struct OpenDalStorage {
314 object_store: ObjectStore,
315 target_uri: String,
316}
317
318impl OpenDalStorage {
319 fn new_operator_rooted(object_store: ObjectStore, target_uri: &str) -> Self {
320 Self {
321 object_store,
322 target_uri: target_uri.to_string(),
323 }
324 }
325
326 fn finish_local_store(object_store: ObjectStore) -> ObjectStore {
327 with_instrument_layers(object_store, false)
328 }
329
330 fn finish_remote_store(object_store: ObjectStore) -> ObjectStore {
331 with_instrument_layers(with_retry_layers(object_store), false)
332 }
333
334 fn ensure_backend_enabled(uri: &str, enabled: bool, reason: &'static str) -> Result<()> {
335 if enabled {
336 Ok(())
337 } else {
338 InvalidUriSnafu { uri, reason }.fail()
339 }
340 }
341
342 fn validate_remote_config<E: std::fmt::Display>(
343 uri: &str,
344 backend: &str,
345 result: std::result::Result<(), E>,
346 ) -> Result<()> {
347 result.map_err(|error| {
348 InvalidUriSnafu {
349 uri,
350 reason: format!("invalid {} config: {}", backend, error),
351 }
352 .build()
353 })
354 }
355
356 pub fn from_file_uri(uri: &str) -> Result<Self> {
358 let path = extract_file_path_from_uri(uri)?;
359
360 let builder = Fs::default().root(&path);
361 let object_store = ObjectStore::new(builder).context(BuildObjectStoreSnafu)?;
362 Ok(Self::new_operator_rooted(
363 Self::finish_local_store(object_store),
364 uri,
365 ))
366 }
367
368 fn from_file_uri_with_config(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
369 if storage.enable_s3 || storage.enable_oss || storage.enable_gcs || storage.enable_azblob {
370 return InvalidUriSnafu {
371 uri,
372 reason: "file:// cannot be used with remote storage flags",
373 }
374 .fail();
375 }
376
377 Self::from_file_uri(uri)
378 }
379
380 fn from_s3_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
381 Self::from_s3_uri_with_root_policy(uri, storage, false)
382 }
383
384 fn from_s3_uri_with_root_policy(
385 uri: &str,
386 storage: &ObjectStoreConfig,
387 allow_empty_root: bool,
388 ) -> Result<Self> {
389 Self::ensure_backend_enabled(
390 uri,
391 storage.enable_s3,
392 "s3:// requires --s3 and related options",
393 )?;
394
395 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
396 let mut config = storage.s3.clone();
397 config.s3_bucket = location.bucket_or_container;
398 config.s3_root = location.root;
399 Self::validate_remote_config(uri, "s3", config.validate())?;
400
401 let conn: S3Connection = config.into();
402 let object_store = ObjectStore::new(S3::from(&conn)).context(BuildObjectStoreSnafu)?;
403 Ok(Self::new_operator_rooted(
404 Self::finish_remote_store(object_store),
405 uri,
406 ))
407 }
408
409 fn from_oss_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
410 Self::from_oss_uri_with_root_policy(uri, storage, false)
411 }
412
413 fn from_oss_uri_with_root_policy(
414 uri: &str,
415 storage: &ObjectStoreConfig,
416 allow_empty_root: bool,
417 ) -> Result<Self> {
418 Self::ensure_backend_enabled(
419 uri,
420 storage.enable_oss,
421 "oss:// requires --oss and related options",
422 )?;
423
424 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
425 let mut config = storage.oss.clone();
426 config.oss_bucket = location.bucket_or_container;
427 config.oss_root = location.root;
428 Self::validate_remote_config(uri, "oss", config.validate())?;
429
430 let conn: OssConnection = config.into();
431 let object_store = ObjectStore::new(Oss::from(&conn)).context(BuildObjectStoreSnafu)?;
432 Ok(Self::new_operator_rooted(
433 Self::finish_remote_store(object_store),
434 uri,
435 ))
436 }
437
438 fn from_gcs_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
439 Self::from_gcs_uri_with_root_policy(uri, storage, false)
440 }
441
442 fn from_gcs_uri_with_root_policy(
443 uri: &str,
444 storage: &ObjectStoreConfig,
445 allow_empty_root: bool,
446 ) -> Result<Self> {
447 Self::ensure_backend_enabled(
448 uri,
449 storage.enable_gcs,
450 "gs:// or gcs:// requires --gcs and related options",
451 )?;
452
453 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
454 let mut config = storage.gcs.clone();
455 config.gcs_bucket = location.bucket_or_container;
456 config.gcs_root = location.root;
457 if allow_empty_root && config.gcs_root.is_empty() {
459 Self::validate_gcs_parent_config(uri, &config)?;
460 } else {
461 Self::validate_remote_config(uri, "gcs", config.validate())?;
462 }
463
464 let conn: GcsConnection = config.into();
465 let object_store = ObjectStore::new(Gcs::from(&conn)).context(BuildObjectStoreSnafu)?;
466 Ok(Self::new_operator_rooted(
467 Self::finish_remote_store(object_store),
468 uri,
469 ))
470 }
471
472 fn validate_gcs_parent_config(
473 uri: &str,
474 config: &crate::common::PrefixedGcsConnection,
475 ) -> Result<()> {
476 if config.gcs_bucket.is_empty() {
477 return InvalidUriSnafu {
478 uri,
479 reason: "invalid gcs config: GCS bucket must be set when --gcs is enabled.",
480 }
481 .fail();
482 }
483 if config.gcs_scope.is_empty() {
484 return InvalidUriSnafu {
485 uri,
486 reason: "invalid gcs config: GCS scope must be set when --gcs is enabled.",
487 }
488 .fail();
489 }
490 Ok(())
491 }
492
493 fn from_azblob_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
494 Self::from_azblob_uri_with_root_policy(uri, storage, false)
495 }
496
497 fn from_azblob_uri_with_root_policy(
498 uri: &str,
499 storage: &ObjectStoreConfig,
500 allow_empty_root: bool,
501 ) -> Result<Self> {
502 Self::ensure_backend_enabled(
503 uri,
504 storage.enable_azblob,
505 "azblob:// requires --azblob and related options",
506 )?;
507
508 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
509 let mut config = storage.azblob.clone();
510 config.azblob_container = location.bucket_or_container;
511 config.azblob_root = location.root;
512 Self::validate_remote_config(uri, "azblob", config.validate())?;
513
514 let conn: AzblobConnection = config.into();
515 let object_store = ObjectStore::new(Azblob::from(&conn)).context(BuildObjectStoreSnafu)?;
516 Ok(Self::new_operator_rooted(
517 Self::finish_remote_store(object_store),
518 uri,
519 ))
520 }
521
522 pub fn from_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
524 match StorageScheme::from_uri(uri)? {
525 StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
526 StorageScheme::S3 => Self::from_s3_uri(uri, storage),
527 StorageScheme::Oss => Self::from_oss_uri(uri, storage),
528 StorageScheme::Gcs => Self::from_gcs_uri(uri, storage),
529 StorageScheme::Azblob => Self::from_azblob_uri(uri, storage),
530 }
531 }
532
533 pub fn from_parent_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
539 match StorageScheme::from_uri(uri)? {
540 StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
541 StorageScheme::S3 => Self::from_s3_uri_with_root_policy(uri, storage, true),
542 StorageScheme::Oss => Self::from_oss_uri_with_root_policy(uri, storage, true),
543 StorageScheme::Gcs => Self::from_gcs_uri_with_root_policy(uri, storage, true),
544 StorageScheme::Azblob => Self::from_azblob_uri_with_root_policy(uri, storage, true),
545 }
546 }
547
548 async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
550 let data = self
551 .object_store
552 .read(path)
553 .await
554 .context(StorageOperationSnafu {
555 operation: format!("read {}", path),
556 })?;
557 Ok(data.to_vec())
558 }
559
560 pub(crate) async fn read_file_if_exists(&self, path: &str) -> Result<Option<Vec<u8>>> {
562 match self.object_store.read(path).await {
563 Ok(data) => Ok(Some(data.to_vec())),
564 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
565 Err(error) => Err(error).context(StorageOperationSnafu {
566 operation: format!("read {}", path),
567 }),
568 }
569 }
570
571 async fn write_file(&self, path: &str, data: Vec<u8>) -> Result<()> {
573 self.object_store
574 .write(path, data)
575 .await
576 .map(|_| ())
577 .context(StorageOperationSnafu {
578 operation: format!("write {}", path),
579 })
580 }
581
582 pub(crate) async fn file_exists(&self, path: &str) -> Result<bool> {
584 match self.object_store.stat(path).await {
585 Ok(metadata) => Ok(!metadata.is_dir()),
586 Err(e) if e.kind() == object_store::ErrorKind::NotFound => Ok(false),
587 Err(e) => Err(e).context(StorageOperationSnafu {
588 operation: format!("check exists {}", path),
589 }),
590 }
591 }
592
593 pub(crate) async fn for_each_file_recursive<F>(&self, prefix: &str, mut f: F) -> Result<()>
596 where
597 F: FnMut(String) -> Result<()>,
598 {
599 let mut lister = match self.object_store.lister_with(prefix).recursive(true).await {
600 Ok(lister) => lister,
601 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
602 Err(error) => {
603 return Err(error).context(StorageOperationSnafu {
604 operation: format!("list {}", prefix),
605 });
606 }
607 };
608
609 while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
610 operation: format!("list {}", prefix),
611 })? {
612 if entry.metadata().is_dir() {
613 continue;
614 }
615 f(entry.path().to_string())?;
616 }
617
618 Ok(())
619 }
620
621 pub(crate) async fn list_direct_child_dirs(&self) -> Result<Vec<String>> {
623 let mut lister = match self.object_store.lister_with("/").recursive(false).await {
624 Ok(lister) => lister,
625 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
626 Err(error) => {
627 return Err(error).context(StorageOperationSnafu {
628 operation: "list /",
629 });
630 }
631 };
632
633 let mut dirs = BTreeSet::new();
634 while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
635 operation: "list /",
636 })? {
637 let path = entry.path().trim_matches('/');
638 if path.is_empty() {
639 continue;
640 }
641
642 if entry.metadata().is_dir()
643 && let Some(name) = path.split('/').next()
644 {
645 dirs.insert(name.to_string());
646 }
647 }
648
649 Ok(dirs.into_iter().collect())
650 }
651
652 #[cfg(test)]
653 pub async fn read_schema(&self) -> Result<SchemaSnapshot> {
654 let schemas_path = schema_index_path();
655 let schemas: Vec<SchemaDefinition> = if self.file_exists(&schemas_path).await? {
656 let data = self.read_file(&schemas_path).await?;
657 serde_json::from_slice(&data).context(ManifestParseSnafu)?
658 } else {
659 vec![]
660 };
661
662 Ok(SchemaSnapshot { schemas })
663 }
664}
665
666#[async_trait]
667impl SnapshotStorage for OpenDalStorage {
668 async fn exists(&self) -> Result<bool> {
669 self.file_exists(MANIFEST_FILE).await
670 }
671
672 async fn read_manifest(&self) -> Result<Manifest> {
673 ensure_snapshot_exists(self).await?;
674
675 let data = self.read_file(MANIFEST_FILE).await?;
676 Manifest::from_json(&data).context(ManifestParseSnafu)
677 }
678
679 async fn file_size(&self, path: &str) -> Result<Option<u64>> {
680 match self.object_store.stat(path).await {
681 Ok(metadata) => Ok(metadata.is_file().then(|| metadata.content_length())),
682 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
683 Err(error) => Err(error).with_context(|_| StorageOperationSnafu {
684 operation: format!("stat {path}"),
685 }),
686 }
687 }
688
689 async fn write_manifest(&self, manifest: &Manifest) -> Result<()> {
690 let data = serde_json::to_vec_pretty(manifest).context(ManifestSerializeSnafu)?;
691 self.write_file(MANIFEST_FILE, data).await
692 }
693
694 async fn write_schema(&self, schema: &SchemaSnapshot) -> Result<()> {
695 let schemas_path = schema_index_path();
696 let schemas_data =
697 serde_json::to_vec_pretty(&schema.schemas).context(ManifestSerializeSnafu)?;
698 self.write_file(&schemas_path, schemas_data).await
699 }
700
701 async fn write_text(&self, path: &str, content: &str) -> Result<()> {
702 self.write_file(path, content.as_bytes().to_vec()).await
703 }
704
705 async fn read_text(&self, path: &str) -> Result<String> {
706 let data = self.read_file(path).await?;
707 String::from_utf8(data).context(TextDecodeSnafu)
708 }
709
710 async fn create_dir_all(&self, path: &str) -> Result<()> {
711 self.object_store
712 .create_dir(path)
713 .await
714 .context(StorageOperationSnafu {
715 operation: format!("create dir {}", path),
716 })
717 }
718
719 async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>> {
720 let mut files = Vec::new();
721 self.for_each_file_recursive(prefix, |path| {
722 files.push(path);
723 Ok(())
724 })
725 .await?;
726 Ok(files)
727 }
728
729 async fn prepare_export_chunk(
730 &self,
731 schemas: &[String],
732 chunk_id: u32,
733 resume: bool,
734 packed: bool,
735 ) -> Result<()> {
736 let mut files = Vec::new();
737 for schema in schemas {
738 let prefix = data_dir_for_schema_chunk(schema, chunk_id);
739 let mut entries = match self.object_store.lister_with(&prefix).recursive(true).await {
740 Ok(entries) => entries,
741 Err(error) if error.kind() == ErrorKind::NotFound => continue,
742 Err(error) => {
743 return Err(error).context(StorageOperationSnafu {
744 operation: format!("list {prefix}"),
745 });
746 }
747 };
748 while let Some(entry) = entries.try_next().await.context(StorageOperationSnafu {
749 operation: format!("list {prefix}"),
750 })? {
751 let path = entry.path();
752 if path == prefix && entry.metadata().is_dir() {
753 continue;
754 }
755 let name = path.strip_prefix(&prefix).unwrap_or("");
756 if !resume
757 || entry.metadata().is_dir()
758 || !(if packed {
759 valid_packed_chunk_filename(name)
760 } else {
761 valid_chunk_filename(name)
762 })
763 {
764 return InvalidUriSnafu {
765 uri: path,
766 reason: "expected an empty new chunk or recognized files in an owned unfinished chunk",
767 }.fail();
768 }
769 files.push(path.to_string());
770 }
771 }
772 for path in files {
774 match self.object_store.delete(&path).await {
775 Ok(()) => {}
776 Err(error) if error.kind() == ErrorKind::NotFound => {}
777 Err(error) => {
778 return Err(error).context(StorageOperationSnafu {
779 operation: format!("delete unfinished chunk file {path}"),
780 });
781 }
782 }
783 }
784 Ok(())
785 }
786
787 async fn delete_snapshot(&self) -> Result<()> {
788 self.object_store
789 .delete_with("/")
790 .recursive(true)
791 .await
792 .context(StorageOperationSnafu {
793 operation: "delete snapshot",
794 })
795 }
796}
797
798fn valid_packed_chunk_filename(name: &str) -> bool {
799 name == common_datasource::packed_snapshot::PACK_INDEX_FILE
800 || [("pack-", ".bin"), ("table-", ".parquet")]
801 .iter()
802 .any(|(prefix, suffix)| {
803 name.strip_prefix(prefix)
804 .and_then(|n| n.strip_suffix(suffix))
805 .is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))
806 })
807}
808
809fn valid_chunk_filename(name: &str) -> bool {
810 name.strip_suffix(".parquet")
811 .is_some_and(|stem| !stem.is_empty())
812 && !name.contains(['/', '\\', '\0'])
813}
814
815#[cfg(test)]
816mod tests {
817 use std::collections::HashMap;
818 use std::path::Path;
819 use std::sync::Arc;
820
821 use object_store::ObjectStore;
822 use object_store::layers::mock::MockLayerBuilder;
823 use object_store::services::Fs;
824 use tempfile::tempdir;
825 use url::Url;
826
827 use super::*;
828 use crate::data::export_v2::error::Error;
829 use crate::data::export_v2::manifest::{DataFormat, TimeRange};
830 use crate::data::export_v2::schema::SchemaDefinition;
831
832 fn make_storage_with_rooted_fs(dir: &std::path::Path) -> OpenDalStorage {
833 let object_store = ObjectStore::new(Fs::default().root(dir.to_str().unwrap())).unwrap();
834 OpenDalStorage::new_operator_rooted(
835 OpenDalStorage::finish_local_store(object_store),
836 Url::from_directory_path(dir).unwrap().as_ref(),
837 )
838 }
839
840 #[test]
841 fn test_validate_uri_valid() {
842 assert_eq!(validate_uri("s3://bucket/path").unwrap(), StorageScheme::S3);
843 assert_eq!(
844 validate_uri("oss://bucket/path").unwrap(),
845 StorageScheme::Oss
846 );
847 assert_eq!(
848 validate_uri("gs://bucket/path").unwrap(),
849 StorageScheme::Gcs
850 );
851 assert_eq!(
852 validate_uri("gcs://bucket/path").unwrap(),
853 StorageScheme::Gcs
854 );
855 assert_eq!(
856 validate_uri("azblob://container/path").unwrap(),
857 StorageScheme::Azblob
858 );
859 assert_eq!(
860 validate_uri("file:///tmp/backup").unwrap(),
861 StorageScheme::File
862 );
863 }
864
865 #[test]
866 fn test_validate_uri_invalid() {
867 assert!(validate_uri("/tmp/backup").is_err());
869 assert!(validate_uri("./backup").is_err());
870 assert!(validate_uri("backup").is_err());
871
872 assert!(validate_uri("ftp://server/path").is_err());
874 }
875
876 #[test]
877 fn test_extract_remote_location_requires_non_empty_root() {
878 assert!(extract_remote_location_with_root_policy("s3://bucket", false).is_err());
879 assert!(extract_remote_location_with_root_policy("s3://bucket/", false).is_err());
880 assert!(extract_remote_location_with_root_policy("oss://bucket", false).is_err());
881 assert!(extract_remote_location_with_root_policy("gs://bucket", false).is_err());
882 assert!(extract_remote_location_with_root_policy("azblob://container", false).is_err());
883 }
884
885 #[test]
886 fn test_extract_remote_location_allows_empty_root_when_permitted() {
887 let location = extract_remote_location_with_root_policy("s3://bucket", true).unwrap();
888 assert_eq!(location.bucket_or_container, "bucket");
889 assert_eq!(location.root, "");
890
891 let location =
892 extract_remote_location_with_root_policy("azblob://container/", true).unwrap();
893 assert_eq!(location.bucket_or_container, "container");
894 assert_eq!(location.root, "");
895 }
896
897 #[test]
898 fn test_parent_storage_allows_s3_bucket_root() {
899 let mut storage = ObjectStoreConfig {
900 enable_s3: true,
901 ..Default::default()
902 };
903 storage.s3.s3_region = Some("us-east-1".to_string());
904
905 assert!(OpenDalStorage::from_uri("s3://bucket", &storage).is_err());
906 assert!(OpenDalStorage::from_parent_uri("s3://bucket", &storage).is_ok());
907 }
908
909 #[test]
910 fn test_validate_snapshot_uri_rejects_dangerous_roots() {
911 assert!(validate_snapshot_uri("s3://bucket").is_err());
912 assert!(validate_snapshot_uri("s3://bucket/").is_err());
913 assert!(validate_snapshot_uri("oss://bucket").is_err());
914 assert!(validate_snapshot_uri("gs://bucket").is_err());
915 assert!(validate_snapshot_uri("azblob://container").is_err());
916 assert!(validate_snapshot_uri("s3://bucket/snapshot?version=1").is_err());
917 assert!(validate_snapshot_uri("file:///tmp/backup#fragment").is_err());
918 assert!(validate_snapshot_uri("file:///").is_err());
919 assert!(validate_snapshot_uri("file:///tmp").is_err());
920 assert!(validate_snapshot_uri("file:///tmp/backup/.").is_err());
921 assert!(validate_snapshot_uri("file:///tmp/backup/..").is_err());
922 }
923
924 #[test]
925 fn test_validate_snapshot_uri_accepts_snapshot_paths() {
926 assert_eq!(
927 validate_snapshot_uri("s3://bucket/snapshots/prod").unwrap(),
928 StorageScheme::S3
929 );
930
931 let dir = tempdir().unwrap();
932 let snapshot = dir.path().join("snapshot");
933 std::fs::create_dir_all(&snapshot).unwrap();
934 let uri = Url::from_directory_path(snapshot).unwrap().to_string();
935 assert_eq!(validate_snapshot_uri(&uri).unwrap(), StorageScheme::File);
936 }
937
938 #[cfg(windows)]
939 #[test]
940 fn test_validate_snapshot_uri_windows_drive_prefix_depth() {
941 assert!(validate_snapshot_uri("file:///C:/").is_err());
942 assert!(validate_snapshot_uri("file:///C:/Users").is_err());
943 assert!(validate_snapshot_uri("file:///C:/Users/snapshot").is_ok());
944 }
945
946 #[cfg(not(windows))]
947 #[test]
948 fn test_extract_path_from_uri_unix_examples() {
949 assert_eq!(
950 extract_file_path_from_uri("file:///tmp/backup").unwrap(),
951 "/tmp/backup"
952 );
953 assert_eq!(
954 extract_file_path_from_uri("file://localhost/tmp/backup").unwrap(),
955 "/tmp/backup"
956 );
957 assert_eq!(
958 extract_file_path_from_uri("file:///tmp/my%20backup").unwrap(),
959 "/tmp/my backup"
960 );
961 assert_eq!(
962 extract_file_path_from_uri("file://localhost/tmp/my%20backup").unwrap(),
963 "/tmp/my backup"
964 );
965 }
966
967 #[test]
968 fn test_extract_file_path_from_uri_rejects_file_host() {
969 assert!(extract_file_path_from_uri("file://tmp/backup").is_err());
970 }
971
972 #[test]
973 fn test_extract_file_path_from_uri_round_trips_directory_url() {
974 let dir = tempdir().unwrap();
975 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
976 let path = extract_file_path_from_uri(&uri).unwrap();
977
978 assert_eq!(Path::new(&path), dir.path());
979 }
980
981 #[tokio::test]
982 async fn test_read_manifest_reports_requested_uri() {
983 let dir = tempdir().unwrap();
984 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
985 let storage = OpenDalStorage::from_file_uri(&uri).unwrap();
986
987 let error = storage.read_manifest().await.unwrap_err().to_string();
988
989 assert!(error.contains(uri.as_str()));
990 }
991
992 #[tokio::test]
993 async fn test_manifest_round_trip() {
994 let dir = tempdir().unwrap();
995 let storage = make_storage_with_rooted_fs(dir.path());
996
997 let manifest = Manifest::new_full(
998 "greptime".to_string(),
999 vec!["public".to_string()],
1000 TimeRange::unbounded(),
1001 DataFormat::Parquet,
1002 );
1003
1004 storage.write_manifest(&manifest).await.unwrap();
1005 let loaded = storage.read_manifest().await.unwrap();
1006
1007 assert_eq!(loaded.catalog, manifest.catalog);
1008 assert_eq!(loaded.schemas, manifest.schemas);
1009 assert_eq!(loaded.schema_only, manifest.schema_only);
1010 assert_eq!(loaded.format, manifest.format);
1011 assert_eq!(loaded.snapshot_id, manifest.snapshot_id);
1012 }
1013
1014 #[tokio::test]
1015 async fn test_schema_round_trip() {
1016 let dir = tempdir().unwrap();
1017 let storage = make_storage_with_rooted_fs(dir.path());
1018
1019 let mut snapshot = SchemaSnapshot::new();
1020 snapshot.add_schema(SchemaDefinition {
1021 catalog: "greptime".to_string(),
1022 name: "test_db".to_string(),
1023 options: HashMap::from([("ttl".to_string(), "7d".to_string())]),
1024 });
1025
1026 storage.write_schema(&snapshot).await.unwrap();
1027 let loaded = storage.read_schema().await.unwrap();
1028
1029 assert_eq!(loaded, snapshot);
1030 }
1031
1032 #[tokio::test]
1033 async fn test_text_round_trip() {
1034 let dir = tempdir().unwrap();
1035 let storage = make_storage_with_rooted_fs(dir.path());
1036 let content = "CREATE TABLE metrics (ts TIMESTAMP TIME INDEX);";
1037
1038 storage
1039 .write_text("schema/ddl/public.sql", content)
1040 .await
1041 .unwrap();
1042 let loaded = storage.read_text("schema/ddl/public.sql").await.unwrap();
1043
1044 assert_eq!(loaded, content);
1045 }
1046
1047 #[tokio::test]
1048 async fn test_read_text_rejects_invalid_utf8() {
1049 let dir = tempdir().unwrap();
1050 let storage = make_storage_with_rooted_fs(dir.path());
1051
1052 storage
1053 .write_file("schema/ddl/public.sql", vec![0xff, 0xfe, 0xfd])
1054 .await
1055 .unwrap();
1056
1057 let error = storage
1058 .read_text("schema/ddl/public.sql")
1059 .await
1060 .unwrap_err();
1061 assert!(error.to_string().contains("UTF-8"));
1062 }
1063
1064 #[tokio::test]
1065 async fn test_exists_follows_manifest_presence() {
1066 let dir = tempdir().unwrap();
1067 let storage = make_storage_with_rooted_fs(dir.path());
1068
1069 assert!(!storage.exists().await.unwrap());
1070
1071 storage
1072 .write_manifest(&Manifest::new_schema_only(
1073 "greptime".to_string(),
1074 vec!["public".to_string()],
1075 ))
1076 .await
1077 .unwrap();
1078
1079 assert!(storage.exists().await.unwrap());
1080 }
1081
1082 #[tokio::test]
1083 async fn test_prepare_export_chunk_preserves_other_chunks_and_schema_artifacts() {
1084 let dir = tempdir().unwrap();
1085 let storage = make_storage_with_rooted_fs(dir.path());
1086 for path in [
1087 "data/public/1/keep.parquet",
1088 "data/public/2/cpu.v1.parquet",
1089 "data/other/2/a.parquet",
1090 "schema/ddl/public.sql",
1091 ] {
1092 storage.write_text(path, "original").await.unwrap();
1093 }
1094 let schemas = vec!["public".to_string(), "other".to_string()];
1095 assert!(
1096 storage
1097 .prepare_export_chunk(&schemas, 2, false, false)
1098 .await
1099 .is_err()
1100 );
1101 assert!(
1102 storage
1103 .file_exists("data/public/2/cpu.v1.parquet")
1104 .await
1105 .unwrap()
1106 );
1107 storage
1108 .prepare_export_chunk(&schemas, 2, true, false)
1109 .await
1110 .unwrap();
1111 storage
1112 .prepare_export_chunk(&schemas, 2, true, false)
1113 .await
1114 .unwrap();
1115 assert!(
1116 !storage
1117 .file_exists("data/public/2/cpu.v1.parquet")
1118 .await
1119 .unwrap()
1120 );
1121 assert!(!storage.file_exists("data/other/2/a.parquet").await.unwrap());
1122 assert_eq!(
1123 storage
1124 .read_text("data/public/1/keep.parquet")
1125 .await
1126 .unwrap(),
1127 "original"
1128 );
1129 assert_eq!(
1130 storage.read_text("schema/ddl/public.sql").await.unwrap(),
1131 "original"
1132 );
1133 }
1134
1135 #[tokio::test]
1136 async fn test_prepare_export_chunk_rejects_foreign_or_nested_entries_before_deletion() {
1137 for foreign in ["notes.txt", "nested/a.parquet", "empty/"] {
1138 let dir = tempdir().unwrap();
1139 let storage = make_storage_with_rooted_fs(dir.path());
1140 storage
1141 .write_text("data/public/2/cpu.parquet", "keep")
1142 .await
1143 .unwrap();
1144 let path = format!("data/other/2/{foreign}");
1145 if foreign.ends_with('/') {
1146 storage.create_dir_all(&path).await.unwrap();
1147 } else {
1148 storage.write_text(&path, "foreign").await.unwrap();
1149 }
1150 assert!(
1151 storage
1152 .prepare_export_chunk(&["public".into(), "other".into()], 2, true, false)
1153 .await
1154 .is_err()
1155 );
1156 assert_eq!(
1157 storage
1158 .read_text("data/public/2/cpu.parquet")
1159 .await
1160 .unwrap(),
1161 "keep"
1162 );
1163 }
1164 }
1165
1166 #[tokio::test]
1167 async fn packed_retry_preserves_foreign_files_and_completed_chunks() {
1168 let dir = tempdir().unwrap();
1169 let storage = make_storage_with_rooted_fs(dir.path());
1170 let owned = ["pack-000000.bin", "table-42.parquet", "pack-index.json"];
1171 for name in owned {
1172 storage
1173 .write_text(&format!("data/public/2/{name}"), "partial")
1174 .await
1175 .unwrap();
1176 }
1177 for path in [
1178 "data/public/1/pack-000000.bin",
1179 "data/public/2/foreign.parquet",
1180 ] {
1181 storage.write_text(path, "keep").await.unwrap();
1182 }
1183 let schemas = ["public".into()];
1184 assert!(
1185 storage
1186 .prepare_export_chunk(&schemas, 2, true, true)
1187 .await
1188 .is_err()
1189 );
1190 for name in owned {
1191 assert!(
1192 storage
1193 .file_exists(&format!("data/public/2/{name}"))
1194 .await
1195 .unwrap()
1196 );
1197 }
1198 assert_eq!(
1199 storage
1200 .read_text("data/public/2/foreign.parquet")
1201 .await
1202 .unwrap(),
1203 "keep"
1204 );
1205 storage
1206 .object_store
1207 .delete("data/public/2/foreign.parquet")
1208 .await
1209 .unwrap();
1210 storage
1211 .prepare_export_chunk(&schemas, 2, true, true)
1212 .await
1213 .unwrap();
1214 assert!(
1215 storage
1216 .list_files_recursive("data/public/2/")
1217 .await
1218 .unwrap()
1219 .is_empty()
1220 );
1221 assert_eq!(
1222 storage
1223 .read_text("data/public/1/pack-000000.bin")
1224 .await
1225 .unwrap(),
1226 "keep"
1227 );
1228 }
1229
1230 #[tokio::test]
1231 async fn test_prepare_export_chunk_reports_delete_failure() {
1232 let dir = tempdir().unwrap();
1233 let mut storage = make_storage_with_rooted_fs(dir.path());
1234 storage.object_store = storage.object_store.layer(
1235 MockLayerBuilder::default()
1236 .deleter_factory(Arc::new(|_| Box::new(())))
1238 .build()
1239 .unwrap(),
1240 );
1241 storage
1242 .write_text("data/public/2/a.parquet", "keep")
1243 .await
1244 .unwrap();
1245 let error = storage
1246 .prepare_export_chunk(&["public".into()], 2, true, false)
1247 .await
1248 .unwrap_err();
1249 let Error::StorageOperation {
1250 operation, error, ..
1251 } = error
1252 else {
1253 panic!("expected storage operation error, got {error:?}");
1254 };
1255 assert_eq!(
1256 operation,
1257 "delete unfinished chunk file data/public/2/a.parquet"
1258 );
1259 assert_eq!(error.kind(), ErrorKind::Unsupported);
1260 assert_eq!(
1261 storage.read_text("data/public/2/a.parquet").await.unwrap(),
1262 "keep"
1263 );
1264 }
1265
1266 #[tokio::test]
1267 async fn test_prepare_export_chunk_encodes_schema_boundary() {
1268 let dir = tempdir().unwrap();
1269 let storage = make_storage_with_rooted_fs(dir.path());
1270 let schema = "../other".to_string();
1271 let owned = format!("{}a.parquet", data_dir_for_schema_chunk(&schema, 2));
1272 storage.write_text(&owned, "remove").await.unwrap();
1273 storage
1274 .write_text("data/other/2/a.parquet", "keep")
1275 .await
1276 .unwrap();
1277 storage
1278 .prepare_export_chunk(&[schema], 2, true, false)
1279 .await
1280 .unwrap();
1281 assert!(!storage.file_exists(&owned).await.unwrap());
1282 assert_eq!(
1283 storage.read_text("data/other/2/a.parquet").await.unwrap(),
1284 "keep"
1285 );
1286 }
1287
1288 #[tokio::test]
1289 async fn test_delete_snapshot_only_removes_rooted_contents() {
1290 let parent = tempdir().unwrap();
1291 let snapshot_root = parent.path().join("snapshot");
1292 let sibling = parent.path().join("sibling");
1293 std::fs::create_dir_all(&snapshot_root).unwrap();
1294 std::fs::create_dir_all(&sibling).unwrap();
1295 std::fs::write(snapshot_root.join("manifest.json"), b"{}").unwrap();
1296 std::fs::write(sibling.join("keep.txt"), b"keep").unwrap();
1297
1298 let storage = make_storage_with_rooted_fs(&snapshot_root);
1299 storage.delete_snapshot().await.unwrap();
1300
1301 assert!(!snapshot_root.join("manifest.json").exists());
1302 assert!(sibling.join("keep.txt").exists());
1303 }
1304}