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 ) -> Result<()> {
299 let _ = (schemas, chunk_id, resume);
300 InvalidUriSnafu {
301 uri: "snapshot",
302 reason: "storage does not support preparing export chunks",
303 }
304 .fail()
305 }
306
307 async fn delete_snapshot(&self) -> Result<()>;
309}
310
311pub struct OpenDalStorage {
313 object_store: ObjectStore,
314 target_uri: String,
315}
316
317impl OpenDalStorage {
318 fn new_operator_rooted(object_store: ObjectStore, target_uri: &str) -> Self {
319 Self {
320 object_store,
321 target_uri: target_uri.to_string(),
322 }
323 }
324
325 fn finish_local_store(object_store: ObjectStore) -> ObjectStore {
326 with_instrument_layers(object_store, false)
327 }
328
329 fn finish_remote_store(object_store: ObjectStore) -> ObjectStore {
330 with_instrument_layers(with_retry_layers(object_store), false)
331 }
332
333 fn ensure_backend_enabled(uri: &str, enabled: bool, reason: &'static str) -> Result<()> {
334 if enabled {
335 Ok(())
336 } else {
337 InvalidUriSnafu { uri, reason }.fail()
338 }
339 }
340
341 fn validate_remote_config<E: std::fmt::Display>(
342 uri: &str,
343 backend: &str,
344 result: std::result::Result<(), E>,
345 ) -> Result<()> {
346 result.map_err(|error| {
347 InvalidUriSnafu {
348 uri,
349 reason: format!("invalid {} config: {}", backend, error),
350 }
351 .build()
352 })
353 }
354
355 pub fn from_file_uri(uri: &str) -> Result<Self> {
357 let path = extract_file_path_from_uri(uri)?;
358
359 let builder = Fs::default().root(&path);
360 let object_store = ObjectStore::new(builder).context(BuildObjectStoreSnafu)?;
361 Ok(Self::new_operator_rooted(
362 Self::finish_local_store(object_store),
363 uri,
364 ))
365 }
366
367 fn from_file_uri_with_config(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
368 if storage.enable_s3 || storage.enable_oss || storage.enable_gcs || storage.enable_azblob {
369 return InvalidUriSnafu {
370 uri,
371 reason: "file:// cannot be used with remote storage flags",
372 }
373 .fail();
374 }
375
376 Self::from_file_uri(uri)
377 }
378
379 fn from_s3_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
380 Self::from_s3_uri_with_root_policy(uri, storage, false)
381 }
382
383 fn from_s3_uri_with_root_policy(
384 uri: &str,
385 storage: &ObjectStoreConfig,
386 allow_empty_root: bool,
387 ) -> Result<Self> {
388 Self::ensure_backend_enabled(
389 uri,
390 storage.enable_s3,
391 "s3:// requires --s3 and related options",
392 )?;
393
394 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
395 let mut config = storage.s3.clone();
396 config.s3_bucket = location.bucket_or_container;
397 config.s3_root = location.root;
398 Self::validate_remote_config(uri, "s3", config.validate())?;
399
400 let conn: S3Connection = config.into();
401 let object_store = ObjectStore::new(S3::from(&conn)).context(BuildObjectStoreSnafu)?;
402 Ok(Self::new_operator_rooted(
403 Self::finish_remote_store(object_store),
404 uri,
405 ))
406 }
407
408 fn from_oss_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
409 Self::from_oss_uri_with_root_policy(uri, storage, false)
410 }
411
412 fn from_oss_uri_with_root_policy(
413 uri: &str,
414 storage: &ObjectStoreConfig,
415 allow_empty_root: bool,
416 ) -> Result<Self> {
417 Self::ensure_backend_enabled(
418 uri,
419 storage.enable_oss,
420 "oss:// requires --oss and related options",
421 )?;
422
423 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
424 let mut config = storage.oss.clone();
425 config.oss_bucket = location.bucket_or_container;
426 config.oss_root = location.root;
427 Self::validate_remote_config(uri, "oss", config.validate())?;
428
429 let conn: OssConnection = config.into();
430 let object_store = ObjectStore::new(Oss::from(&conn)).context(BuildObjectStoreSnafu)?;
431 Ok(Self::new_operator_rooted(
432 Self::finish_remote_store(object_store),
433 uri,
434 ))
435 }
436
437 fn from_gcs_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
438 Self::from_gcs_uri_with_root_policy(uri, storage, false)
439 }
440
441 fn from_gcs_uri_with_root_policy(
442 uri: &str,
443 storage: &ObjectStoreConfig,
444 allow_empty_root: bool,
445 ) -> Result<Self> {
446 Self::ensure_backend_enabled(
447 uri,
448 storage.enable_gcs,
449 "gs:// or gcs:// requires --gcs and related options",
450 )?;
451
452 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
453 let mut config = storage.gcs.clone();
454 config.gcs_bucket = location.bucket_or_container;
455 config.gcs_root = location.root;
456 if allow_empty_root && config.gcs_root.is_empty() {
458 Self::validate_gcs_parent_config(uri, &config)?;
459 } else {
460 Self::validate_remote_config(uri, "gcs", config.validate())?;
461 }
462
463 let conn: GcsConnection = config.into();
464 let object_store = ObjectStore::new(Gcs::from(&conn)).context(BuildObjectStoreSnafu)?;
465 Ok(Self::new_operator_rooted(
466 Self::finish_remote_store(object_store),
467 uri,
468 ))
469 }
470
471 fn validate_gcs_parent_config(
472 uri: &str,
473 config: &crate::common::PrefixedGcsConnection,
474 ) -> Result<()> {
475 if config.gcs_bucket.is_empty() {
476 return InvalidUriSnafu {
477 uri,
478 reason: "invalid gcs config: GCS bucket must be set when --gcs is enabled.",
479 }
480 .fail();
481 }
482 if config.gcs_scope.is_empty() {
483 return InvalidUriSnafu {
484 uri,
485 reason: "invalid gcs config: GCS scope must be set when --gcs is enabled.",
486 }
487 .fail();
488 }
489 Ok(())
490 }
491
492 fn from_azblob_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
493 Self::from_azblob_uri_with_root_policy(uri, storage, false)
494 }
495
496 fn from_azblob_uri_with_root_policy(
497 uri: &str,
498 storage: &ObjectStoreConfig,
499 allow_empty_root: bool,
500 ) -> Result<Self> {
501 Self::ensure_backend_enabled(
502 uri,
503 storage.enable_azblob,
504 "azblob:// requires --azblob and related options",
505 )?;
506
507 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
508 let mut config = storage.azblob.clone();
509 config.azblob_container = location.bucket_or_container;
510 config.azblob_root = location.root;
511 Self::validate_remote_config(uri, "azblob", config.validate())?;
512
513 let conn: AzblobConnection = config.into();
514 let object_store = ObjectStore::new(Azblob::from(&conn)).context(BuildObjectStoreSnafu)?;
515 Ok(Self::new_operator_rooted(
516 Self::finish_remote_store(object_store),
517 uri,
518 ))
519 }
520
521 pub fn from_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
523 match StorageScheme::from_uri(uri)? {
524 StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
525 StorageScheme::S3 => Self::from_s3_uri(uri, storage),
526 StorageScheme::Oss => Self::from_oss_uri(uri, storage),
527 StorageScheme::Gcs => Self::from_gcs_uri(uri, storage),
528 StorageScheme::Azblob => Self::from_azblob_uri(uri, storage),
529 }
530 }
531
532 pub fn from_parent_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
538 match StorageScheme::from_uri(uri)? {
539 StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
540 StorageScheme::S3 => Self::from_s3_uri_with_root_policy(uri, storage, true),
541 StorageScheme::Oss => Self::from_oss_uri_with_root_policy(uri, storage, true),
542 StorageScheme::Gcs => Self::from_gcs_uri_with_root_policy(uri, storage, true),
543 StorageScheme::Azblob => Self::from_azblob_uri_with_root_policy(uri, storage, true),
544 }
545 }
546
547 async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
549 let data = self
550 .object_store
551 .read(path)
552 .await
553 .context(StorageOperationSnafu {
554 operation: format!("read {}", path),
555 })?;
556 Ok(data.to_vec())
557 }
558
559 pub(crate) async fn read_file_if_exists(&self, path: &str) -> Result<Option<Vec<u8>>> {
561 match self.object_store.read(path).await {
562 Ok(data) => Ok(Some(data.to_vec())),
563 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
564 Err(error) => Err(error).context(StorageOperationSnafu {
565 operation: format!("read {}", path),
566 }),
567 }
568 }
569
570 async fn write_file(&self, path: &str, data: Vec<u8>) -> Result<()> {
572 self.object_store
573 .write(path, data)
574 .await
575 .map(|_| ())
576 .context(StorageOperationSnafu {
577 operation: format!("write {}", path),
578 })
579 }
580
581 pub(crate) async fn file_exists(&self, path: &str) -> Result<bool> {
583 match self.object_store.stat(path).await {
584 Ok(metadata) => Ok(!metadata.is_dir()),
585 Err(e) if e.kind() == object_store::ErrorKind::NotFound => Ok(false),
586 Err(e) => Err(e).context(StorageOperationSnafu {
587 operation: format!("check exists {}", path),
588 }),
589 }
590 }
591
592 pub(crate) async fn for_each_file_recursive<F>(&self, prefix: &str, mut f: F) -> Result<()>
595 where
596 F: FnMut(String) -> Result<()>,
597 {
598 let mut lister = match self.object_store.lister_with(prefix).recursive(true).await {
599 Ok(lister) => lister,
600 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
601 Err(error) => {
602 return Err(error).context(StorageOperationSnafu {
603 operation: format!("list {}", prefix),
604 });
605 }
606 };
607
608 while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
609 operation: format!("list {}", prefix),
610 })? {
611 if entry.metadata().is_dir() {
612 continue;
613 }
614 f(entry.path().to_string())?;
615 }
616
617 Ok(())
618 }
619
620 pub(crate) async fn list_direct_child_dirs(&self) -> Result<Vec<String>> {
622 let mut lister = match self.object_store.lister_with("/").recursive(false).await {
623 Ok(lister) => lister,
624 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
625 Err(error) => {
626 return Err(error).context(StorageOperationSnafu {
627 operation: "list /",
628 });
629 }
630 };
631
632 let mut dirs = BTreeSet::new();
633 while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
634 operation: "list /",
635 })? {
636 let path = entry.path().trim_matches('/');
637 if path.is_empty() {
638 continue;
639 }
640
641 if entry.metadata().is_dir()
642 && let Some(name) = path.split('/').next()
643 {
644 dirs.insert(name.to_string());
645 }
646 }
647
648 Ok(dirs.into_iter().collect())
649 }
650
651 #[cfg(test)]
652 pub async fn read_schema(&self) -> Result<SchemaSnapshot> {
653 let schemas_path = schema_index_path();
654 let schemas: Vec<SchemaDefinition> = if self.file_exists(&schemas_path).await? {
655 let data = self.read_file(&schemas_path).await?;
656 serde_json::from_slice(&data).context(ManifestParseSnafu)?
657 } else {
658 vec![]
659 };
660
661 Ok(SchemaSnapshot { schemas })
662 }
663}
664
665#[async_trait]
666impl SnapshotStorage for OpenDalStorage {
667 async fn exists(&self) -> Result<bool> {
668 self.file_exists(MANIFEST_FILE).await
669 }
670
671 async fn read_manifest(&self) -> Result<Manifest> {
672 ensure_snapshot_exists(self).await?;
673
674 let data = self.read_file(MANIFEST_FILE).await?;
675 Manifest::from_json(&data).context(ManifestParseSnafu)
676 }
677
678 async fn file_size(&self, path: &str) -> Result<Option<u64>> {
679 match self.object_store.stat(path).await {
680 Ok(metadata) => Ok(metadata.is_file().then(|| metadata.content_length())),
681 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
682 Err(error) => Err(error).with_context(|_| StorageOperationSnafu {
683 operation: format!("stat {path}"),
684 }),
685 }
686 }
687
688 async fn write_manifest(&self, manifest: &Manifest) -> Result<()> {
689 let data = serde_json::to_vec_pretty(manifest).context(ManifestSerializeSnafu)?;
690 self.write_file(MANIFEST_FILE, data).await
691 }
692
693 async fn write_schema(&self, schema: &SchemaSnapshot) -> Result<()> {
694 let schemas_path = schema_index_path();
695 let schemas_data =
696 serde_json::to_vec_pretty(&schema.schemas).context(ManifestSerializeSnafu)?;
697 self.write_file(&schemas_path, schemas_data).await
698 }
699
700 async fn write_text(&self, path: &str, content: &str) -> Result<()> {
701 self.write_file(path, content.as_bytes().to_vec()).await
702 }
703
704 async fn read_text(&self, path: &str) -> Result<String> {
705 let data = self.read_file(path).await?;
706 String::from_utf8(data).context(TextDecodeSnafu)
707 }
708
709 async fn create_dir_all(&self, path: &str) -> Result<()> {
710 self.object_store
711 .create_dir(path)
712 .await
713 .context(StorageOperationSnafu {
714 operation: format!("create dir {}", path),
715 })
716 }
717
718 async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>> {
719 let mut files = Vec::new();
720 self.for_each_file_recursive(prefix, |path| {
721 files.push(path);
722 Ok(())
723 })
724 .await?;
725 Ok(files)
726 }
727
728 async fn prepare_export_chunk(
729 &self,
730 schemas: &[String],
731 chunk_id: u32,
732 resume: bool,
733 ) -> Result<()> {
734 let mut files = Vec::new();
735 for schema in schemas {
736 let prefix = data_dir_for_schema_chunk(schema, chunk_id);
737 let mut entries = match self.object_store.lister_with(&prefix).recursive(true).await {
738 Ok(entries) => entries,
739 Err(error) if error.kind() == ErrorKind::NotFound => continue,
740 Err(error) => {
741 return Err(error).context(StorageOperationSnafu {
742 operation: format!("list {prefix}"),
743 });
744 }
745 };
746 while let Some(entry) = entries.try_next().await.context(StorageOperationSnafu {
747 operation: format!("list {prefix}"),
748 })? {
749 let path = entry.path();
750 if path == prefix && entry.metadata().is_dir() {
751 continue;
752 }
753 let name = path.strip_prefix(&prefix).unwrap_or("");
754 if !resume || entry.metadata().is_dir() || !valid_chunk_filename(name) {
755 return InvalidUriSnafu {
756 uri: path,
757 reason: "expected an empty new chunk or direct Parquet files in an owned unfinished chunk",
758 }.fail();
759 }
760 files.push(path.to_string());
761 }
762 }
763 for path in files {
765 match self.object_store.delete(&path).await {
766 Ok(()) => {}
767 Err(error) if error.kind() == ErrorKind::NotFound => {}
768 Err(error) => {
769 return Err(error).context(StorageOperationSnafu {
770 operation: format!("delete unfinished chunk file {path}"),
771 });
772 }
773 }
774 }
775 Ok(())
776 }
777
778 async fn delete_snapshot(&self) -> Result<()> {
779 self.object_store
780 .delete_with("/")
781 .recursive(true)
782 .await
783 .context(StorageOperationSnafu {
784 operation: "delete snapshot",
785 })
786 }
787}
788
789fn valid_chunk_filename(name: &str) -> bool {
790 name.strip_suffix(".parquet")
791 .is_some_and(|stem| !stem.is_empty())
792 && !name.contains(['/', '\\', '\0'])
793}
794
795#[cfg(test)]
796mod tests {
797 use std::collections::HashMap;
798 use std::path::Path;
799 use std::sync::Arc;
800
801 use object_store::ObjectStore;
802 use object_store::layers::mock::MockLayerBuilder;
803 use object_store::services::Fs;
804 use tempfile::tempdir;
805 use url::Url;
806
807 use super::*;
808 use crate::data::export_v2::error::Error;
809 use crate::data::export_v2::manifest::{DataFormat, TimeRange};
810 use crate::data::export_v2::schema::SchemaDefinition;
811
812 fn make_storage_with_rooted_fs(dir: &std::path::Path) -> OpenDalStorage {
813 let object_store = ObjectStore::new(Fs::default().root(dir.to_str().unwrap())).unwrap();
814 OpenDalStorage::new_operator_rooted(
815 OpenDalStorage::finish_local_store(object_store),
816 Url::from_directory_path(dir).unwrap().as_ref(),
817 )
818 }
819
820 #[test]
821 fn test_validate_uri_valid() {
822 assert_eq!(validate_uri("s3://bucket/path").unwrap(), StorageScheme::S3);
823 assert_eq!(
824 validate_uri("oss://bucket/path").unwrap(),
825 StorageScheme::Oss
826 );
827 assert_eq!(
828 validate_uri("gs://bucket/path").unwrap(),
829 StorageScheme::Gcs
830 );
831 assert_eq!(
832 validate_uri("gcs://bucket/path").unwrap(),
833 StorageScheme::Gcs
834 );
835 assert_eq!(
836 validate_uri("azblob://container/path").unwrap(),
837 StorageScheme::Azblob
838 );
839 assert_eq!(
840 validate_uri("file:///tmp/backup").unwrap(),
841 StorageScheme::File
842 );
843 }
844
845 #[test]
846 fn test_validate_uri_invalid() {
847 assert!(validate_uri("/tmp/backup").is_err());
849 assert!(validate_uri("./backup").is_err());
850 assert!(validate_uri("backup").is_err());
851
852 assert!(validate_uri("ftp://server/path").is_err());
854 }
855
856 #[test]
857 fn test_extract_remote_location_requires_non_empty_root() {
858 assert!(extract_remote_location_with_root_policy("s3://bucket", false).is_err());
859 assert!(extract_remote_location_with_root_policy("s3://bucket/", false).is_err());
860 assert!(extract_remote_location_with_root_policy("oss://bucket", false).is_err());
861 assert!(extract_remote_location_with_root_policy("gs://bucket", false).is_err());
862 assert!(extract_remote_location_with_root_policy("azblob://container", false).is_err());
863 }
864
865 #[test]
866 fn test_extract_remote_location_allows_empty_root_when_permitted() {
867 let location = extract_remote_location_with_root_policy("s3://bucket", true).unwrap();
868 assert_eq!(location.bucket_or_container, "bucket");
869 assert_eq!(location.root, "");
870
871 let location =
872 extract_remote_location_with_root_policy("azblob://container/", true).unwrap();
873 assert_eq!(location.bucket_or_container, "container");
874 assert_eq!(location.root, "");
875 }
876
877 #[test]
878 fn test_parent_storage_allows_s3_bucket_root() {
879 let mut storage = ObjectStoreConfig {
880 enable_s3: true,
881 ..Default::default()
882 };
883 storage.s3.s3_region = Some("us-east-1".to_string());
884
885 assert!(OpenDalStorage::from_uri("s3://bucket", &storage).is_err());
886 assert!(OpenDalStorage::from_parent_uri("s3://bucket", &storage).is_ok());
887 }
888
889 #[test]
890 fn test_validate_snapshot_uri_rejects_dangerous_roots() {
891 assert!(validate_snapshot_uri("s3://bucket").is_err());
892 assert!(validate_snapshot_uri("s3://bucket/").is_err());
893 assert!(validate_snapshot_uri("oss://bucket").is_err());
894 assert!(validate_snapshot_uri("gs://bucket").is_err());
895 assert!(validate_snapshot_uri("azblob://container").is_err());
896 assert!(validate_snapshot_uri("s3://bucket/snapshot?version=1").is_err());
897 assert!(validate_snapshot_uri("file:///tmp/backup#fragment").is_err());
898 assert!(validate_snapshot_uri("file:///").is_err());
899 assert!(validate_snapshot_uri("file:///tmp").is_err());
900 assert!(validate_snapshot_uri("file:///tmp/backup/.").is_err());
901 assert!(validate_snapshot_uri("file:///tmp/backup/..").is_err());
902 }
903
904 #[test]
905 fn test_validate_snapshot_uri_accepts_snapshot_paths() {
906 assert_eq!(
907 validate_snapshot_uri("s3://bucket/snapshots/prod").unwrap(),
908 StorageScheme::S3
909 );
910
911 let dir = tempdir().unwrap();
912 let snapshot = dir.path().join("snapshot");
913 std::fs::create_dir_all(&snapshot).unwrap();
914 let uri = Url::from_directory_path(snapshot).unwrap().to_string();
915 assert_eq!(validate_snapshot_uri(&uri).unwrap(), StorageScheme::File);
916 }
917
918 #[cfg(windows)]
919 #[test]
920 fn test_validate_snapshot_uri_windows_drive_prefix_depth() {
921 assert!(validate_snapshot_uri("file:///C:/").is_err());
922 assert!(validate_snapshot_uri("file:///C:/Users").is_err());
923 assert!(validate_snapshot_uri("file:///C:/Users/snapshot").is_ok());
924 }
925
926 #[cfg(not(windows))]
927 #[test]
928 fn test_extract_path_from_uri_unix_examples() {
929 assert_eq!(
930 extract_file_path_from_uri("file:///tmp/backup").unwrap(),
931 "/tmp/backup"
932 );
933 assert_eq!(
934 extract_file_path_from_uri("file://localhost/tmp/backup").unwrap(),
935 "/tmp/backup"
936 );
937 assert_eq!(
938 extract_file_path_from_uri("file:///tmp/my%20backup").unwrap(),
939 "/tmp/my backup"
940 );
941 assert_eq!(
942 extract_file_path_from_uri("file://localhost/tmp/my%20backup").unwrap(),
943 "/tmp/my backup"
944 );
945 }
946
947 #[test]
948 fn test_extract_file_path_from_uri_rejects_file_host() {
949 assert!(extract_file_path_from_uri("file://tmp/backup").is_err());
950 }
951
952 #[test]
953 fn test_extract_file_path_from_uri_round_trips_directory_url() {
954 let dir = tempdir().unwrap();
955 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
956 let path = extract_file_path_from_uri(&uri).unwrap();
957
958 assert_eq!(Path::new(&path), dir.path());
959 }
960
961 #[tokio::test]
962 async fn test_read_manifest_reports_requested_uri() {
963 let dir = tempdir().unwrap();
964 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
965 let storage = OpenDalStorage::from_file_uri(&uri).unwrap();
966
967 let error = storage.read_manifest().await.unwrap_err().to_string();
968
969 assert!(error.contains(uri.as_str()));
970 }
971
972 #[tokio::test]
973 async fn test_manifest_round_trip() {
974 let dir = tempdir().unwrap();
975 let storage = make_storage_with_rooted_fs(dir.path());
976
977 let manifest = Manifest::new_full(
978 "greptime".to_string(),
979 vec!["public".to_string()],
980 TimeRange::unbounded(),
981 DataFormat::Parquet,
982 );
983
984 storage.write_manifest(&manifest).await.unwrap();
985 let loaded = storage.read_manifest().await.unwrap();
986
987 assert_eq!(loaded.catalog, manifest.catalog);
988 assert_eq!(loaded.schemas, manifest.schemas);
989 assert_eq!(loaded.schema_only, manifest.schema_only);
990 assert_eq!(loaded.format, manifest.format);
991 assert_eq!(loaded.snapshot_id, manifest.snapshot_id);
992 }
993
994 #[tokio::test]
995 async fn test_schema_round_trip() {
996 let dir = tempdir().unwrap();
997 let storage = make_storage_with_rooted_fs(dir.path());
998
999 let mut snapshot = SchemaSnapshot::new();
1000 snapshot.add_schema(SchemaDefinition {
1001 catalog: "greptime".to_string(),
1002 name: "test_db".to_string(),
1003 options: HashMap::from([("ttl".to_string(), "7d".to_string())]),
1004 });
1005
1006 storage.write_schema(&snapshot).await.unwrap();
1007 let loaded = storage.read_schema().await.unwrap();
1008
1009 assert_eq!(loaded, snapshot);
1010 }
1011
1012 #[tokio::test]
1013 async fn test_text_round_trip() {
1014 let dir = tempdir().unwrap();
1015 let storage = make_storage_with_rooted_fs(dir.path());
1016 let content = "CREATE TABLE metrics (ts TIMESTAMP TIME INDEX);";
1017
1018 storage
1019 .write_text("schema/ddl/public.sql", content)
1020 .await
1021 .unwrap();
1022 let loaded = storage.read_text("schema/ddl/public.sql").await.unwrap();
1023
1024 assert_eq!(loaded, content);
1025 }
1026
1027 #[tokio::test]
1028 async fn test_read_text_rejects_invalid_utf8() {
1029 let dir = tempdir().unwrap();
1030 let storage = make_storage_with_rooted_fs(dir.path());
1031
1032 storage
1033 .write_file("schema/ddl/public.sql", vec![0xff, 0xfe, 0xfd])
1034 .await
1035 .unwrap();
1036
1037 let error = storage
1038 .read_text("schema/ddl/public.sql")
1039 .await
1040 .unwrap_err();
1041 assert!(error.to_string().contains("UTF-8"));
1042 }
1043
1044 #[tokio::test]
1045 async fn test_exists_follows_manifest_presence() {
1046 let dir = tempdir().unwrap();
1047 let storage = make_storage_with_rooted_fs(dir.path());
1048
1049 assert!(!storage.exists().await.unwrap());
1050
1051 storage
1052 .write_manifest(&Manifest::new_schema_only(
1053 "greptime".to_string(),
1054 vec!["public".to_string()],
1055 ))
1056 .await
1057 .unwrap();
1058
1059 assert!(storage.exists().await.unwrap());
1060 }
1061
1062 #[tokio::test]
1063 async fn test_prepare_export_chunk_preserves_other_chunks_and_schema_artifacts() {
1064 let dir = tempdir().unwrap();
1065 let storage = make_storage_with_rooted_fs(dir.path());
1066 for path in [
1067 "data/public/1/keep.parquet",
1068 "data/public/2/cpu.v1.parquet",
1069 "data/other/2/a.parquet",
1070 "schema/ddl/public.sql",
1071 ] {
1072 storage.write_text(path, "original").await.unwrap();
1073 }
1074 let schemas = vec!["public".to_string(), "other".to_string()];
1075 assert!(
1076 storage
1077 .prepare_export_chunk(&schemas, 2, false)
1078 .await
1079 .is_err()
1080 );
1081 assert!(
1082 storage
1083 .file_exists("data/public/2/cpu.v1.parquet")
1084 .await
1085 .unwrap()
1086 );
1087 storage
1088 .prepare_export_chunk(&schemas, 2, true)
1089 .await
1090 .unwrap();
1091 storage
1092 .prepare_export_chunk(&schemas, 2, true)
1093 .await
1094 .unwrap();
1095 assert!(
1096 !storage
1097 .file_exists("data/public/2/cpu.v1.parquet")
1098 .await
1099 .unwrap()
1100 );
1101 assert!(!storage.file_exists("data/other/2/a.parquet").await.unwrap());
1102 assert_eq!(
1103 storage
1104 .read_text("data/public/1/keep.parquet")
1105 .await
1106 .unwrap(),
1107 "original"
1108 );
1109 assert_eq!(
1110 storage.read_text("schema/ddl/public.sql").await.unwrap(),
1111 "original"
1112 );
1113 }
1114
1115 #[tokio::test]
1116 async fn test_prepare_export_chunk_rejects_foreign_or_nested_entries_before_deletion() {
1117 for foreign in ["notes.txt", "nested/a.parquet", "empty/"] {
1118 let dir = tempdir().unwrap();
1119 let storage = make_storage_with_rooted_fs(dir.path());
1120 storage
1121 .write_text("data/public/2/cpu.parquet", "keep")
1122 .await
1123 .unwrap();
1124 let path = format!("data/other/2/{foreign}");
1125 if foreign.ends_with('/') {
1126 storage.create_dir_all(&path).await.unwrap();
1127 } else {
1128 storage.write_text(&path, "foreign").await.unwrap();
1129 }
1130 assert!(
1131 storage
1132 .prepare_export_chunk(&["public".into(), "other".into()], 2, true)
1133 .await
1134 .is_err()
1135 );
1136 assert_eq!(
1137 storage
1138 .read_text("data/public/2/cpu.parquet")
1139 .await
1140 .unwrap(),
1141 "keep"
1142 );
1143 }
1144 }
1145
1146 #[tokio::test]
1147 async fn test_prepare_export_chunk_reports_delete_failure() {
1148 let dir = tempdir().unwrap();
1149 let mut storage = make_storage_with_rooted_fs(dir.path());
1150 storage.object_store = storage.object_store.layer(
1151 MockLayerBuilder::default()
1152 .deleter_factory(Arc::new(|_| Box::new(())))
1154 .build()
1155 .unwrap(),
1156 );
1157 storage
1158 .write_text("data/public/2/a.parquet", "keep")
1159 .await
1160 .unwrap();
1161 let error = storage
1162 .prepare_export_chunk(&["public".into()], 2, true)
1163 .await
1164 .unwrap_err();
1165 let Error::StorageOperation {
1166 operation, error, ..
1167 } = error
1168 else {
1169 panic!("expected storage operation error, got {error:?}");
1170 };
1171 assert_eq!(
1172 operation,
1173 "delete unfinished chunk file data/public/2/a.parquet"
1174 );
1175 assert_eq!(error.kind(), ErrorKind::Unsupported);
1176 assert_eq!(
1177 storage.read_text("data/public/2/a.parquet").await.unwrap(),
1178 "keep"
1179 );
1180 }
1181
1182 #[tokio::test]
1183 async fn test_prepare_export_chunk_encodes_schema_boundary() {
1184 let dir = tempdir().unwrap();
1185 let storage = make_storage_with_rooted_fs(dir.path());
1186 let schema = "../other".to_string();
1187 let owned = format!("{}a.parquet", data_dir_for_schema_chunk(&schema, 2));
1188 storage.write_text(&owned, "remove").await.unwrap();
1189 storage
1190 .write_text("data/other/2/a.parquet", "keep")
1191 .await
1192 .unwrap();
1193 storage
1194 .prepare_export_chunk(&[schema], 2, true)
1195 .await
1196 .unwrap();
1197 assert!(!storage.file_exists(&owned).await.unwrap());
1198 assert_eq!(
1199 storage.read_text("data/other/2/a.parquet").await.unwrap(),
1200 "keep"
1201 );
1202 }
1203
1204 #[tokio::test]
1205 async fn test_delete_snapshot_only_removes_rooted_contents() {
1206 let parent = tempdir().unwrap();
1207 let snapshot_root = parent.path().join("snapshot");
1208 let sibling = parent.path().join("sibling");
1209 std::fs::create_dir_all(&snapshot_root).unwrap();
1210 std::fs::create_dir_all(&sibling).unwrap();
1211 std::fs::write(snapshot_root.join("manifest.json"), b"{}").unwrap();
1212 std::fs::write(sibling.join("keep.txt"), b"keep").unwrap();
1213
1214 let storage = make_storage_with_rooted_fs(&snapshot_root);
1215 storage.delete_snapshot().await.unwrap();
1216
1217 assert!(!snapshot_root.join("manifest.json").exists());
1218 assert!(sibling.join("keep.txt").exists());
1219 }
1220}