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};
43
44struct RemoteLocation {
45 bucket_or_container: String,
46 root: String,
47}
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum StorageScheme {
52 S3,
54 Oss,
56 Gcs,
58 Azblob,
60 File,
62}
63
64impl StorageScheme {
65 pub fn from_uri(uri: &str) -> Result<Self> {
67 let url = Url::parse(uri).context(UrlParseSnafu)?;
68
69 match url.scheme() {
70 "s3" => Ok(Self::S3),
71 "oss" => Ok(Self::Oss),
72 "gs" | "gcs" => Ok(Self::Gcs),
73 "azblob" => Ok(Self::Azblob),
74 "file" => Ok(Self::File),
75 scheme => UnsupportedSchemeSnafu { scheme }.fail(),
76 }
77 }
78}
79
80fn extract_remote_location_with_root_policy(
82 uri: &str,
83 allow_empty_root: bool,
84) -> Result<RemoteLocation> {
85 let url = Url::parse(uri).context(UrlParseSnafu)?;
86 let bucket_or_container = url.host_str().unwrap_or("").to_string();
87 if bucket_or_container.is_empty() {
88 return InvalidUriSnafu {
89 uri,
90 reason: "URI must include bucket/container in host",
91 }
92 .fail();
93 }
94
95 let root = url.path().trim_start_matches('/').to_string();
96 if root.is_empty() && !allow_empty_root {
97 return InvalidUriSnafu {
98 uri,
99 reason: "snapshot URI must include a non-empty path after the bucket/container",
100 }
101 .fail();
102 }
103
104 Ok(RemoteLocation {
105 bucket_or_container,
106 root,
107 })
108}
109
110pub fn validate_uri(uri: &str) -> Result<StorageScheme> {
123 if !uri.contains("://") {
125 return InvalidUriSnafu {
126 uri,
127 reason: "URI must have a scheme (e.g., s3://, file://). Bare paths are not supported.",
128 }
129 .fail();
130 }
131
132 StorageScheme::from_uri(uri)
133}
134
135pub fn validate_snapshot_uri(uri: &str) -> Result<StorageScheme> {
143 let scheme = validate_uri(uri)?;
144 reject_query_or_fragment(uri)?;
145 match scheme {
146 StorageScheme::File => validate_file_snapshot_uri(uri)?,
147 StorageScheme::S3 | StorageScheme::Oss | StorageScheme::Gcs | StorageScheme::Azblob => {
148 extract_remote_location_with_root_policy(uri, false)?;
149 }
150 }
151 Ok(scheme)
152}
153
154fn reject_query_or_fragment(uri: &str) -> Result<()> {
155 let url = Url::parse(uri).context(UrlParseSnafu)?;
156 if url.query().is_some() || url.fragment().is_some() {
157 return InvalidUriSnafu {
158 uri,
159 reason: "snapshot URI must not include query or fragment",
160 }
161 .fail();
162 }
163
164 Ok(())
165}
166
167fn validate_file_snapshot_uri(uri: &str) -> Result<()> {
168 if has_explicit_dot_segment(uri) {
169 return InvalidUriSnafu {
170 uri,
171 reason: "file snapshot URI must not contain '.' or '..' path segments",
172 }
173 .fail();
174 }
175
176 let path = extract_file_path_from_uri(uri)?;
177 let mut normal_component_count = 0;
178
179 for component in std::path::Path::new(&path).components() {
184 match component {
185 Component::Normal(_) => normal_component_count += 1,
186 Component::CurDir | Component::ParentDir => {
187 return InvalidUriSnafu {
188 uri,
189 reason: "file snapshot URI must not contain '.' or '..' path segments",
190 }
191 .fail();
192 }
193 Component::Prefix(_) | Component::RootDir => {}
194 }
195 }
196
197 if normal_component_count < 2 {
198 return InvalidUriSnafu {
199 uri,
200 reason: "file snapshot URI must point to a directory at least two levels deep",
201 }
202 .fail();
203 }
204
205 Ok(())
206}
207
208fn has_explicit_dot_segment(uri: &str) -> bool {
209 let without_fragment = uri.split_once('#').map_or(uri, |(path, _)| path);
213 let path = without_fragment
214 .split_once('?')
215 .map_or(without_fragment, |(path, _)| path);
216
217 path.split('/')
218 .any(|segment| segment == "." || segment == "..")
219}
220
221fn schema_index_path() -> String {
222 format!("{}/{}", SCHEMA_DIR, SCHEMAS_FILE)
223}
224
225fn extract_file_path_from_uri(uri: &str) -> Result<String> {
227 let url = Url::parse(uri).context(UrlParseSnafu)?;
228
229 match url.host_str() {
230 Some(host) if !host.is_empty() && host != "localhost" => InvalidUriSnafu {
231 uri,
232 reason: "file:// URI must use an absolute path like file:///tmp/backup",
233 }
234 .fail(),
235 _ => url
236 .to_file_path()
237 .map_err(|_| {
238 InvalidUriSnafu {
239 uri,
240 reason: "file:// URI must use an absolute path like file:///tmp/backup",
241 }
242 .build()
243 })
244 .map(|path| path.to_string_lossy().into_owned()),
245 }
246}
247
248async fn ensure_snapshot_exists(storage: &OpenDalStorage) -> Result<()> {
249 if storage.exists().await? {
250 Ok(())
251 } else {
252 SnapshotNotFoundSnafu {
253 uri: storage.target_uri.as_str(),
254 }
255 .fail()
256 }
257}
258
259#[async_trait]
263pub trait SnapshotStorage: Send + Sync {
264 async fn exists(&self) -> Result<bool>;
266
267 async fn read_manifest(&self) -> Result<Manifest>;
269
270 async fn write_manifest(&self, manifest: &Manifest) -> Result<()>;
272
273 async fn write_schema(&self, schema: &SchemaSnapshot) -> Result<()>;
275
276 async fn write_text(&self, path: &str, content: &str) -> Result<()>;
278
279 async fn read_text(&self, path: &str) -> Result<String>;
281
282 async fn create_dir_all(&self, path: &str) -> Result<()>;
284
285 async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>>;
287
288 async fn delete_snapshot(&self) -> Result<()>;
290}
291
292pub struct OpenDalStorage {
294 object_store: ObjectStore,
295 target_uri: String,
296}
297
298impl OpenDalStorage {
299 fn new_operator_rooted(object_store: ObjectStore, target_uri: &str) -> Self {
300 Self {
301 object_store,
302 target_uri: target_uri.to_string(),
303 }
304 }
305
306 fn finish_local_store(object_store: ObjectStore) -> ObjectStore {
307 with_instrument_layers(object_store, false)
308 }
309
310 fn finish_remote_store(object_store: ObjectStore) -> ObjectStore {
311 with_instrument_layers(with_retry_layers(object_store), false)
312 }
313
314 fn ensure_backend_enabled(uri: &str, enabled: bool, reason: &'static str) -> Result<()> {
315 if enabled {
316 Ok(())
317 } else {
318 InvalidUriSnafu { uri, reason }.fail()
319 }
320 }
321
322 fn validate_remote_config<E: std::fmt::Display>(
323 uri: &str,
324 backend: &str,
325 result: std::result::Result<(), E>,
326 ) -> Result<()> {
327 result.map_err(|error| {
328 InvalidUriSnafu {
329 uri,
330 reason: format!("invalid {} config: {}", backend, error),
331 }
332 .build()
333 })
334 }
335
336 pub fn from_file_uri(uri: &str) -> Result<Self> {
338 let path = extract_file_path_from_uri(uri)?;
339
340 let builder = Fs::default().root(&path);
341 let object_store = ObjectStore::new(builder).context(BuildObjectStoreSnafu)?;
342 Ok(Self::new_operator_rooted(
343 Self::finish_local_store(object_store),
344 uri,
345 ))
346 }
347
348 fn from_file_uri_with_config(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
349 if storage.enable_s3 || storage.enable_oss || storage.enable_gcs || storage.enable_azblob {
350 return InvalidUriSnafu {
351 uri,
352 reason: "file:// cannot be used with remote storage flags",
353 }
354 .fail();
355 }
356
357 Self::from_file_uri(uri)
358 }
359
360 fn from_s3_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
361 Self::from_s3_uri_with_root_policy(uri, storage, false)
362 }
363
364 fn from_s3_uri_with_root_policy(
365 uri: &str,
366 storage: &ObjectStoreConfig,
367 allow_empty_root: bool,
368 ) -> Result<Self> {
369 Self::ensure_backend_enabled(
370 uri,
371 storage.enable_s3,
372 "s3:// requires --s3 and related options",
373 )?;
374
375 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
376 let mut config = storage.s3.clone();
377 config.s3_bucket = location.bucket_or_container;
378 config.s3_root = location.root;
379 Self::validate_remote_config(uri, "s3", config.validate())?;
380
381 let conn: S3Connection = config.into();
382 let object_store = ObjectStore::new(S3::from(&conn)).context(BuildObjectStoreSnafu)?;
383 Ok(Self::new_operator_rooted(
384 Self::finish_remote_store(object_store),
385 uri,
386 ))
387 }
388
389 fn from_oss_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
390 Self::from_oss_uri_with_root_policy(uri, storage, false)
391 }
392
393 fn from_oss_uri_with_root_policy(
394 uri: &str,
395 storage: &ObjectStoreConfig,
396 allow_empty_root: bool,
397 ) -> Result<Self> {
398 Self::ensure_backend_enabled(
399 uri,
400 storage.enable_oss,
401 "oss:// requires --oss and related options",
402 )?;
403
404 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
405 let mut config = storage.oss.clone();
406 config.oss_bucket = location.bucket_or_container;
407 config.oss_root = location.root;
408 Self::validate_remote_config(uri, "oss", config.validate())?;
409
410 let conn: OssConnection = config.into();
411 let object_store = ObjectStore::new(Oss::from(&conn)).context(BuildObjectStoreSnafu)?;
412 Ok(Self::new_operator_rooted(
413 Self::finish_remote_store(object_store),
414 uri,
415 ))
416 }
417
418 fn from_gcs_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
419 Self::from_gcs_uri_with_root_policy(uri, storage, false)
420 }
421
422 fn from_gcs_uri_with_root_policy(
423 uri: &str,
424 storage: &ObjectStoreConfig,
425 allow_empty_root: bool,
426 ) -> Result<Self> {
427 Self::ensure_backend_enabled(
428 uri,
429 storage.enable_gcs,
430 "gs:// or gcs:// requires --gcs and related options",
431 )?;
432
433 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
434 let mut config = storage.gcs.clone();
435 config.gcs_bucket = location.bucket_or_container;
436 config.gcs_root = location.root;
437 if allow_empty_root && config.gcs_root.is_empty() {
439 Self::validate_gcs_parent_config(uri, &config)?;
440 } else {
441 Self::validate_remote_config(uri, "gcs", config.validate())?;
442 }
443
444 let conn: GcsConnection = config.into();
445 let object_store = ObjectStore::new(Gcs::from(&conn)).context(BuildObjectStoreSnafu)?;
446 Ok(Self::new_operator_rooted(
447 Self::finish_remote_store(object_store),
448 uri,
449 ))
450 }
451
452 fn validate_gcs_parent_config(
453 uri: &str,
454 config: &crate::common::PrefixedGcsConnection,
455 ) -> Result<()> {
456 if config.gcs_bucket.is_empty() {
457 return InvalidUriSnafu {
458 uri,
459 reason: "invalid gcs config: GCS bucket must be set when --gcs is enabled.",
460 }
461 .fail();
462 }
463 if config.gcs_scope.is_empty() {
464 return InvalidUriSnafu {
465 uri,
466 reason: "invalid gcs config: GCS scope must be set when --gcs is enabled.",
467 }
468 .fail();
469 }
470 Ok(())
471 }
472
473 fn from_azblob_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
474 Self::from_azblob_uri_with_root_policy(uri, storage, false)
475 }
476
477 fn from_azblob_uri_with_root_policy(
478 uri: &str,
479 storage: &ObjectStoreConfig,
480 allow_empty_root: bool,
481 ) -> Result<Self> {
482 Self::ensure_backend_enabled(
483 uri,
484 storage.enable_azblob,
485 "azblob:// requires --azblob and related options",
486 )?;
487
488 let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
489 let mut config = storage.azblob.clone();
490 config.azblob_container = location.bucket_or_container;
491 config.azblob_root = location.root;
492 Self::validate_remote_config(uri, "azblob", config.validate())?;
493
494 let conn: AzblobConnection = config.into();
495 let object_store = ObjectStore::new(Azblob::from(&conn)).context(BuildObjectStoreSnafu)?;
496 Ok(Self::new_operator_rooted(
497 Self::finish_remote_store(object_store),
498 uri,
499 ))
500 }
501
502 pub fn from_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
504 match StorageScheme::from_uri(uri)? {
505 StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
506 StorageScheme::S3 => Self::from_s3_uri(uri, storage),
507 StorageScheme::Oss => Self::from_oss_uri(uri, storage),
508 StorageScheme::Gcs => Self::from_gcs_uri(uri, storage),
509 StorageScheme::Azblob => Self::from_azblob_uri(uri, storage),
510 }
511 }
512
513 pub fn from_parent_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
519 match StorageScheme::from_uri(uri)? {
520 StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
521 StorageScheme::S3 => Self::from_s3_uri_with_root_policy(uri, storage, true),
522 StorageScheme::Oss => Self::from_oss_uri_with_root_policy(uri, storage, true),
523 StorageScheme::Gcs => Self::from_gcs_uri_with_root_policy(uri, storage, true),
524 StorageScheme::Azblob => Self::from_azblob_uri_with_root_policy(uri, storage, true),
525 }
526 }
527
528 async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
530 let data = self
531 .object_store
532 .read(path)
533 .await
534 .context(StorageOperationSnafu {
535 operation: format!("read {}", path),
536 })?;
537 Ok(data.to_vec())
538 }
539
540 pub(crate) async fn read_file_if_exists(&self, path: &str) -> Result<Option<Vec<u8>>> {
542 match self.object_store.read(path).await {
543 Ok(data) => Ok(Some(data.to_vec())),
544 Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
545 Err(error) => Err(error).context(StorageOperationSnafu {
546 operation: format!("read {}", path),
547 }),
548 }
549 }
550
551 async fn write_file(&self, path: &str, data: Vec<u8>) -> Result<()> {
553 self.object_store
554 .write(path, data)
555 .await
556 .map(|_| ())
557 .context(StorageOperationSnafu {
558 operation: format!("write {}", path),
559 })
560 }
561
562 pub(crate) async fn file_exists(&self, path: &str) -> Result<bool> {
564 match self.object_store.stat(path).await {
565 Ok(metadata) => Ok(!metadata.is_dir()),
566 Err(e) if e.kind() == object_store::ErrorKind::NotFound => Ok(false),
567 Err(e) => Err(e).context(StorageOperationSnafu {
568 operation: format!("check exists {}", path),
569 }),
570 }
571 }
572
573 pub(crate) async fn for_each_file_recursive<F>(&self, prefix: &str, mut f: F) -> Result<()>
576 where
577 F: FnMut(String) -> Result<()>,
578 {
579 let mut lister = match self.object_store.lister_with(prefix).recursive(true).await {
580 Ok(lister) => lister,
581 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
582 Err(error) => {
583 return Err(error).context(StorageOperationSnafu {
584 operation: format!("list {}", prefix),
585 });
586 }
587 };
588
589 while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
590 operation: format!("list {}", prefix),
591 })? {
592 if entry.metadata().is_dir() {
593 continue;
594 }
595 f(entry.path().to_string())?;
596 }
597
598 Ok(())
599 }
600
601 pub(crate) async fn list_direct_child_dirs(&self) -> Result<Vec<String>> {
603 let mut lister = match self.object_store.lister_with("/").recursive(false).await {
604 Ok(lister) => lister,
605 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
606 Err(error) => {
607 return Err(error).context(StorageOperationSnafu {
608 operation: "list /",
609 });
610 }
611 };
612
613 let mut dirs = BTreeSet::new();
614 while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
615 operation: "list /",
616 })? {
617 let path = entry.path().trim_matches('/');
618 if path.is_empty() {
619 continue;
620 }
621
622 if entry.metadata().is_dir()
623 && let Some(name) = path.split('/').next()
624 {
625 dirs.insert(name.to_string());
626 }
627 }
628
629 Ok(dirs.into_iter().collect())
630 }
631
632 #[cfg(test)]
633 pub async fn read_schema(&self) -> Result<SchemaSnapshot> {
634 let schemas_path = schema_index_path();
635 let schemas: Vec<SchemaDefinition> = if self.file_exists(&schemas_path).await? {
636 let data = self.read_file(&schemas_path).await?;
637 serde_json::from_slice(&data).context(ManifestParseSnafu)?
638 } else {
639 vec![]
640 };
641
642 Ok(SchemaSnapshot { schemas })
643 }
644}
645
646#[async_trait]
647impl SnapshotStorage for OpenDalStorage {
648 async fn exists(&self) -> Result<bool> {
649 self.file_exists(MANIFEST_FILE).await
650 }
651
652 async fn read_manifest(&self) -> Result<Manifest> {
653 ensure_snapshot_exists(self).await?;
654
655 let data = self.read_file(MANIFEST_FILE).await?;
656 serde_json::from_slice(&data).context(ManifestParseSnafu)
657 }
658
659 async fn write_manifest(&self, manifest: &Manifest) -> Result<()> {
660 let data = serde_json::to_vec_pretty(manifest).context(ManifestSerializeSnafu)?;
661 self.write_file(MANIFEST_FILE, data).await
662 }
663
664 async fn write_schema(&self, schema: &SchemaSnapshot) -> Result<()> {
665 let schemas_path = schema_index_path();
666 let schemas_data =
667 serde_json::to_vec_pretty(&schema.schemas).context(ManifestSerializeSnafu)?;
668 self.write_file(&schemas_path, schemas_data).await
669 }
670
671 async fn write_text(&self, path: &str, content: &str) -> Result<()> {
672 self.write_file(path, content.as_bytes().to_vec()).await
673 }
674
675 async fn read_text(&self, path: &str) -> Result<String> {
676 let data = self.read_file(path).await?;
677 String::from_utf8(data).context(TextDecodeSnafu)
678 }
679
680 async fn create_dir_all(&self, path: &str) -> Result<()> {
681 self.object_store
682 .create_dir(path)
683 .await
684 .context(StorageOperationSnafu {
685 operation: format!("create dir {}", path),
686 })
687 }
688
689 async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>> {
690 let mut files = Vec::new();
691 self.for_each_file_recursive(prefix, |path| {
692 files.push(path);
693 Ok(())
694 })
695 .await?;
696 Ok(files)
697 }
698
699 async fn delete_snapshot(&self) -> Result<()> {
700 self.object_store
701 .delete_with("/")
702 .recursive(true)
703 .await
704 .context(StorageOperationSnafu {
705 operation: "delete snapshot",
706 })
707 }
708}
709
710#[cfg(test)]
711mod tests {
712 use std::collections::HashMap;
713 use std::path::Path;
714
715 use object_store::ObjectStore;
716 use object_store::services::Fs;
717 use tempfile::tempdir;
718 use url::Url;
719
720 use super::*;
721 use crate::data::export_v2::manifest::{DataFormat, TimeRange};
722 use crate::data::export_v2::schema::SchemaDefinition;
723
724 fn make_storage_with_rooted_fs(dir: &std::path::Path) -> OpenDalStorage {
725 let object_store = ObjectStore::new(Fs::default().root(dir.to_str().unwrap())).unwrap();
726 OpenDalStorage::new_operator_rooted(
727 OpenDalStorage::finish_local_store(object_store),
728 Url::from_directory_path(dir).unwrap().as_ref(),
729 )
730 }
731
732 #[test]
733 fn test_validate_uri_valid() {
734 assert_eq!(validate_uri("s3://bucket/path").unwrap(), StorageScheme::S3);
735 assert_eq!(
736 validate_uri("oss://bucket/path").unwrap(),
737 StorageScheme::Oss
738 );
739 assert_eq!(
740 validate_uri("gs://bucket/path").unwrap(),
741 StorageScheme::Gcs
742 );
743 assert_eq!(
744 validate_uri("gcs://bucket/path").unwrap(),
745 StorageScheme::Gcs
746 );
747 assert_eq!(
748 validate_uri("azblob://container/path").unwrap(),
749 StorageScheme::Azblob
750 );
751 assert_eq!(
752 validate_uri("file:///tmp/backup").unwrap(),
753 StorageScheme::File
754 );
755 }
756
757 #[test]
758 fn test_validate_uri_invalid() {
759 assert!(validate_uri("/tmp/backup").is_err());
761 assert!(validate_uri("./backup").is_err());
762 assert!(validate_uri("backup").is_err());
763
764 assert!(validate_uri("ftp://server/path").is_err());
766 }
767
768 #[test]
769 fn test_extract_remote_location_requires_non_empty_root() {
770 assert!(extract_remote_location_with_root_policy("s3://bucket", false).is_err());
771 assert!(extract_remote_location_with_root_policy("s3://bucket/", false).is_err());
772 assert!(extract_remote_location_with_root_policy("oss://bucket", false).is_err());
773 assert!(extract_remote_location_with_root_policy("gs://bucket", false).is_err());
774 assert!(extract_remote_location_with_root_policy("azblob://container", false).is_err());
775 }
776
777 #[test]
778 fn test_extract_remote_location_allows_empty_root_when_permitted() {
779 let location = extract_remote_location_with_root_policy("s3://bucket", true).unwrap();
780 assert_eq!(location.bucket_or_container, "bucket");
781 assert_eq!(location.root, "");
782
783 let location =
784 extract_remote_location_with_root_policy("azblob://container/", true).unwrap();
785 assert_eq!(location.bucket_or_container, "container");
786 assert_eq!(location.root, "");
787 }
788
789 #[test]
790 fn test_parent_storage_allows_s3_bucket_root() {
791 let mut storage = ObjectStoreConfig {
792 enable_s3: true,
793 ..Default::default()
794 };
795 storage.s3.s3_region = Some("us-east-1".to_string());
796
797 assert!(OpenDalStorage::from_uri("s3://bucket", &storage).is_err());
798 assert!(OpenDalStorage::from_parent_uri("s3://bucket", &storage).is_ok());
799 }
800
801 #[test]
802 fn test_validate_snapshot_uri_rejects_dangerous_roots() {
803 assert!(validate_snapshot_uri("s3://bucket").is_err());
804 assert!(validate_snapshot_uri("s3://bucket/").is_err());
805 assert!(validate_snapshot_uri("oss://bucket").is_err());
806 assert!(validate_snapshot_uri("gs://bucket").is_err());
807 assert!(validate_snapshot_uri("azblob://container").is_err());
808 assert!(validate_snapshot_uri("s3://bucket/snapshot?version=1").is_err());
809 assert!(validate_snapshot_uri("file:///tmp/backup#fragment").is_err());
810 assert!(validate_snapshot_uri("file:///").is_err());
811 assert!(validate_snapshot_uri("file:///tmp").is_err());
812 assert!(validate_snapshot_uri("file:///tmp/backup/.").is_err());
813 assert!(validate_snapshot_uri("file:///tmp/backup/..").is_err());
814 }
815
816 #[test]
817 fn test_validate_snapshot_uri_accepts_snapshot_paths() {
818 assert_eq!(
819 validate_snapshot_uri("s3://bucket/snapshots/prod").unwrap(),
820 StorageScheme::S3
821 );
822
823 let dir = tempdir().unwrap();
824 let snapshot = dir.path().join("snapshot");
825 std::fs::create_dir_all(&snapshot).unwrap();
826 let uri = Url::from_directory_path(snapshot).unwrap().to_string();
827 assert_eq!(validate_snapshot_uri(&uri).unwrap(), StorageScheme::File);
828 }
829
830 #[cfg(windows)]
831 #[test]
832 fn test_validate_snapshot_uri_windows_drive_prefix_depth() {
833 assert!(validate_snapshot_uri("file:///C:/").is_err());
834 assert!(validate_snapshot_uri("file:///C:/Users").is_err());
835 assert!(validate_snapshot_uri("file:///C:/Users/snapshot").is_ok());
836 }
837
838 #[cfg(not(windows))]
839 #[test]
840 fn test_extract_path_from_uri_unix_examples() {
841 assert_eq!(
842 extract_file_path_from_uri("file:///tmp/backup").unwrap(),
843 "/tmp/backup"
844 );
845 assert_eq!(
846 extract_file_path_from_uri("file://localhost/tmp/backup").unwrap(),
847 "/tmp/backup"
848 );
849 assert_eq!(
850 extract_file_path_from_uri("file:///tmp/my%20backup").unwrap(),
851 "/tmp/my backup"
852 );
853 assert_eq!(
854 extract_file_path_from_uri("file://localhost/tmp/my%20backup").unwrap(),
855 "/tmp/my backup"
856 );
857 }
858
859 #[test]
860 fn test_extract_file_path_from_uri_rejects_file_host() {
861 assert!(extract_file_path_from_uri("file://tmp/backup").is_err());
862 }
863
864 #[test]
865 fn test_extract_file_path_from_uri_round_trips_directory_url() {
866 let dir = tempdir().unwrap();
867 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
868 let path = extract_file_path_from_uri(&uri).unwrap();
869
870 assert_eq!(Path::new(&path), dir.path());
871 }
872
873 #[tokio::test]
874 async fn test_read_manifest_reports_requested_uri() {
875 let dir = tempdir().unwrap();
876 let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
877 let storage = OpenDalStorage::from_file_uri(&uri).unwrap();
878
879 let error = storage.read_manifest().await.unwrap_err().to_string();
880
881 assert!(error.contains(uri.as_str()));
882 }
883
884 #[tokio::test]
885 async fn test_manifest_round_trip() {
886 let dir = tempdir().unwrap();
887 let storage = make_storage_with_rooted_fs(dir.path());
888
889 let manifest = Manifest::new_full(
890 "greptime".to_string(),
891 vec!["public".to_string()],
892 TimeRange::unbounded(),
893 DataFormat::Parquet,
894 );
895
896 storage.write_manifest(&manifest).await.unwrap();
897 let loaded = storage.read_manifest().await.unwrap();
898
899 assert_eq!(loaded.catalog, manifest.catalog);
900 assert_eq!(loaded.schemas, manifest.schemas);
901 assert_eq!(loaded.schema_only, manifest.schema_only);
902 assert_eq!(loaded.format, manifest.format);
903 assert_eq!(loaded.snapshot_id, manifest.snapshot_id);
904 }
905
906 #[tokio::test]
907 async fn test_schema_round_trip() {
908 let dir = tempdir().unwrap();
909 let storage = make_storage_with_rooted_fs(dir.path());
910
911 let mut snapshot = SchemaSnapshot::new();
912 snapshot.add_schema(SchemaDefinition {
913 catalog: "greptime".to_string(),
914 name: "test_db".to_string(),
915 options: HashMap::from([("ttl".to_string(), "7d".to_string())]),
916 });
917
918 storage.write_schema(&snapshot).await.unwrap();
919 let loaded = storage.read_schema().await.unwrap();
920
921 assert_eq!(loaded, snapshot);
922 }
923
924 #[tokio::test]
925 async fn test_text_round_trip() {
926 let dir = tempdir().unwrap();
927 let storage = make_storage_with_rooted_fs(dir.path());
928 let content = "CREATE TABLE metrics (ts TIMESTAMP TIME INDEX);";
929
930 storage
931 .write_text("schema/ddl/public.sql", content)
932 .await
933 .unwrap();
934 let loaded = storage.read_text("schema/ddl/public.sql").await.unwrap();
935
936 assert_eq!(loaded, content);
937 }
938
939 #[tokio::test]
940 async fn test_read_text_rejects_invalid_utf8() {
941 let dir = tempdir().unwrap();
942 let storage = make_storage_with_rooted_fs(dir.path());
943
944 storage
945 .write_file("schema/ddl/public.sql", vec![0xff, 0xfe, 0xfd])
946 .await
947 .unwrap();
948
949 let error = storage
950 .read_text("schema/ddl/public.sql")
951 .await
952 .unwrap_err();
953 assert!(error.to_string().contains("UTF-8"));
954 }
955
956 #[tokio::test]
957 async fn test_exists_follows_manifest_presence() {
958 let dir = tempdir().unwrap();
959 let storage = make_storage_with_rooted_fs(dir.path());
960
961 assert!(!storage.exists().await.unwrap());
962
963 storage
964 .write_manifest(&Manifest::new_schema_only(
965 "greptime".to_string(),
966 vec!["public".to_string()],
967 ))
968 .await
969 .unwrap();
970
971 assert!(storage.exists().await.unwrap());
972 }
973
974 #[tokio::test]
975 async fn test_delete_snapshot_only_removes_rooted_contents() {
976 let parent = tempdir().unwrap();
977 let snapshot_root = parent.path().join("snapshot");
978 let sibling = parent.path().join("sibling");
979 std::fs::create_dir_all(&snapshot_root).unwrap();
980 std::fs::create_dir_all(&sibling).unwrap();
981 std::fs::write(snapshot_root.join("manifest.json"), b"{}").unwrap();
982 std::fs::write(sibling.join("keep.txt"), b"keep").unwrap();
983
984 let storage = make_storage_with_rooted_fs(&snapshot_root);
985 storage.delete_snapshot().await.unwrap();
986
987 assert!(!snapshot_root.join("manifest.json").exists());
988 assert!(sibling.join("keep.txt").exists());
989 }
990}