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