Skip to main content

cli/data/
snapshot_storage.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Storage abstraction for Export/Import V2.
16//!
17//! This module provides a unified interface for reading and writing snapshot data
18//! to various storage backends (S3, OSS, GCS, Azure Blob, local filesystem).
19
20use 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/// URI schemes supported for snapshot storage.
51#[derive(Debug, Clone, Copy, PartialEq, Eq)]
52pub enum StorageScheme {
53    /// Amazon S3.
54    S3,
55    /// Alibaba Cloud OSS.
56    Oss,
57    /// Google Cloud Storage.
58    Gcs,
59    /// Azure Blob Storage.
60    Azblob,
61    /// Local filesystem (file://).
62    File,
63}
64
65impl StorageScheme {
66    /// Parses storage scheme from URI.
67    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
81/// Extracts bucket/container and root path from a URI.
82fn 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
111/// Validates that a URI has a proper scheme.
112///
113/// Rejects bare paths (e.g., `/tmp/backup`, `./backup`) because:
114/// - Schema export (CLI) and data export (server) run in different processes
115/// - Using bare paths would split the snapshot across machines
116///
117/// Supported URI schemes:
118/// - `s3://bucket/path` - Amazon S3
119/// - `oss://bucket/path` - Alibaba Cloud OSS
120/// - `gs://bucket/path` - Google Cloud Storage
121/// - `azblob://container/path` - Azure Blob Storage
122/// - `file:///absolute/path` - Local filesystem
123pub fn validate_uri(uri: &str) -> Result<StorageScheme> {
124    // Must have a scheme
125    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
136/// Validates a URI for snapshot-scoped destructive operations.
137///
138/// Unlike read-only parent scans, destructive commands must target a concrete
139/// snapshot directory instead of a bucket/container root or filesystem root.
140/// Remote storage buckets/containers already provide namespace isolation, so a
141/// non-empty object prefix is enough; local filesystem paths require at least
142/// two non-root path segments to avoid deleting broad system directories.
143pub 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    // This is only a path-shape guard for destructive operations. It does not
181    // resolve symlinks. Drive prefixes and root separators also do not count
182    // toward depth; delete still relies on the manifest check and explicit
183    // confirmation before removing the rooted storage prefix.
184    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    // Defense in depth: catch dot segments at the raw URI level before
211    // `Url::to_file_path()` can normalize them away. The `Path::components()`
212    // check below still runs because URL decoding can reintroduce them.
213    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
226/// Extracts the absolute filesystem path from a file:// URI.
227fn 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/// Snapshot storage abstraction.
261///
262/// Provides operations for reading and writing snapshot data to various storage backends.
263#[async_trait]
264pub trait SnapshotStorage: Send + Sync {
265    /// Checks if a snapshot exists at this location (manifest.json exists).
266    async fn exists(&self) -> Result<bool>;
267
268    /// Reads the manifest file.
269    async fn read_manifest(&self) -> Result<Manifest>;
270
271    /// Returns file length, or None for a missing path or directory.
272    async fn file_size(&self, path: &str) -> Result<Option<u64>>;
273
274    /// Writes the manifest file.
275    async fn write_manifest(&self, manifest: &Manifest) -> Result<()>;
276
277    /// Writes the schema index to schema/schemas.json.
278    async fn write_schema(&self, schema: &SchemaSnapshot) -> Result<()>;
279
280    /// Writes a text file to a relative path under the snapshot root.
281    async fn write_text(&self, path: &str, content: &str) -> Result<()>;
282
283    /// Reads a text file from a relative path under the snapshot root.
284    async fn read_text(&self, path: &str) -> Result<String>;
285
286    /// Creates a directory-like prefix under the snapshot root when needed by the backend.
287    async fn create_dir_all(&self, path: &str) -> Result<()>;
288
289    /// Lists files recursively under a relative prefix.
290    async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>>;
291
292    /// Checks fresh output directories or removes a terminated, unfinished chunk's files.
293    async fn prepare_export_chunk(
294        &self,
295        schemas: &[String],
296        chunk_id: u32,
297        resume: bool,
298        packed: bool,
299    ) -> Result<()> {
300        let _ = (schemas, chunk_id, resume, packed);
301        InvalidUriSnafu {
302            uri: "snapshot",
303            reason: "storage does not support preparing export chunks",
304        }
305        .fail()
306    }
307
308    /// Deletes the entire snapshot (for --force).
309    async fn delete_snapshot(&self) -> Result<()>;
310}
311
312/// OpenDAL-based implementation of SnapshotStorage.
313pub struct OpenDalStorage {
314    object_store: ObjectStore,
315    target_uri: String,
316}
317
318impl OpenDalStorage {
319    fn new_operator_rooted(object_store: ObjectStore, target_uri: &str) -> Self {
320        Self {
321            object_store,
322            target_uri: target_uri.to_string(),
323        }
324    }
325
326    fn finish_local_store(object_store: ObjectStore) -> ObjectStore {
327        with_instrument_layers(object_store, false)
328    }
329
330    fn finish_remote_store(object_store: ObjectStore) -> ObjectStore {
331        with_instrument_layers(with_retry_layers(object_store), false)
332    }
333
334    fn ensure_backend_enabled(uri: &str, enabled: bool, reason: &'static str) -> Result<()> {
335        if enabled {
336            Ok(())
337        } else {
338            InvalidUriSnafu { uri, reason }.fail()
339        }
340    }
341
342    fn validate_remote_config<E: std::fmt::Display>(
343        uri: &str,
344        backend: &str,
345        result: std::result::Result<(), E>,
346    ) -> Result<()> {
347        result.map_err(|error| {
348            InvalidUriSnafu {
349                uri,
350                reason: format!("invalid {} config: {}", backend, error),
351            }
352            .build()
353        })
354    }
355
356    /// Creates a new storage from a file:// URI.
357    pub fn from_file_uri(uri: &str) -> Result<Self> {
358        let path = extract_file_path_from_uri(uri)?;
359
360        let builder = Fs::default().root(&path);
361        let object_store = ObjectStore::new(builder).context(BuildObjectStoreSnafu)?;
362        Ok(Self::new_operator_rooted(
363            Self::finish_local_store(object_store),
364            uri,
365        ))
366    }
367
368    fn from_file_uri_with_config(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
369        if storage.enable_s3 || storage.enable_oss || storage.enable_gcs || storage.enable_azblob {
370            return InvalidUriSnafu {
371                uri,
372                reason: "file:// cannot be used with remote storage flags",
373            }
374            .fail();
375        }
376
377        Self::from_file_uri(uri)
378    }
379
380    fn from_s3_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
381        Self::from_s3_uri_with_root_policy(uri, storage, false)
382    }
383
384    fn from_s3_uri_with_root_policy(
385        uri: &str,
386        storage: &ObjectStoreConfig,
387        allow_empty_root: bool,
388    ) -> Result<Self> {
389        Self::ensure_backend_enabled(
390            uri,
391            storage.enable_s3,
392            "s3:// requires --s3 and related options",
393        )?;
394
395        let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
396        let mut config = storage.s3.clone();
397        config.s3_bucket = location.bucket_or_container;
398        config.s3_root = location.root;
399        Self::validate_remote_config(uri, "s3", config.validate())?;
400
401        let conn: S3Connection = config.into();
402        let object_store = ObjectStore::new(S3::from(&conn)).context(BuildObjectStoreSnafu)?;
403        Ok(Self::new_operator_rooted(
404            Self::finish_remote_store(object_store),
405            uri,
406        ))
407    }
408
409    fn from_oss_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
410        Self::from_oss_uri_with_root_policy(uri, storage, false)
411    }
412
413    fn from_oss_uri_with_root_policy(
414        uri: &str,
415        storage: &ObjectStoreConfig,
416        allow_empty_root: bool,
417    ) -> Result<Self> {
418        Self::ensure_backend_enabled(
419            uri,
420            storage.enable_oss,
421            "oss:// requires --oss and related options",
422        )?;
423
424        let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
425        let mut config = storage.oss.clone();
426        config.oss_bucket = location.bucket_or_container;
427        config.oss_root = location.root;
428        Self::validate_remote_config(uri, "oss", config.validate())?;
429
430        let conn: OssConnection = config.into();
431        let object_store = ObjectStore::new(Oss::from(&conn)).context(BuildObjectStoreSnafu)?;
432        Ok(Self::new_operator_rooted(
433            Self::finish_remote_store(object_store),
434            uri,
435        ))
436    }
437
438    fn from_gcs_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
439        Self::from_gcs_uri_with_root_policy(uri, storage, false)
440    }
441
442    fn from_gcs_uri_with_root_policy(
443        uri: &str,
444        storage: &ObjectStoreConfig,
445        allow_empty_root: bool,
446    ) -> Result<Self> {
447        Self::ensure_backend_enabled(
448            uri,
449            storage.enable_gcs,
450            "gs:// or gcs:// requires --gcs and related options",
451        )?;
452
453        let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
454        let mut config = storage.gcs.clone();
455        config.gcs_bucket = location.bucket_or_container;
456        config.gcs_root = location.root;
457        // GCS validate() rejects empty root, unlike S3/OSS/Azblob.
458        if allow_empty_root && config.gcs_root.is_empty() {
459            Self::validate_gcs_parent_config(uri, &config)?;
460        } else {
461            Self::validate_remote_config(uri, "gcs", config.validate())?;
462        }
463
464        let conn: GcsConnection = config.into();
465        let object_store = ObjectStore::new(Gcs::from(&conn)).context(BuildObjectStoreSnafu)?;
466        Ok(Self::new_operator_rooted(
467            Self::finish_remote_store(object_store),
468            uri,
469        ))
470    }
471
472    fn validate_gcs_parent_config(
473        uri: &str,
474        config: &crate::common::PrefixedGcsConnection,
475    ) -> Result<()> {
476        if config.gcs_bucket.is_empty() {
477            return InvalidUriSnafu {
478                uri,
479                reason: "invalid gcs config: GCS bucket must be set when --gcs is enabled.",
480            }
481            .fail();
482        }
483        if config.gcs_scope.is_empty() {
484            return InvalidUriSnafu {
485                uri,
486                reason: "invalid gcs config: GCS scope must be set when --gcs is enabled.",
487            }
488            .fail();
489        }
490        Ok(())
491    }
492
493    fn from_azblob_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
494        Self::from_azblob_uri_with_root_policy(uri, storage, false)
495    }
496
497    fn from_azblob_uri_with_root_policy(
498        uri: &str,
499        storage: &ObjectStoreConfig,
500        allow_empty_root: bool,
501    ) -> Result<Self> {
502        Self::ensure_backend_enabled(
503            uri,
504            storage.enable_azblob,
505            "azblob:// requires --azblob and related options",
506        )?;
507
508        let location = extract_remote_location_with_root_policy(uri, allow_empty_root)?;
509        let mut config = storage.azblob.clone();
510        config.azblob_container = location.bucket_or_container;
511        config.azblob_root = location.root;
512        Self::validate_remote_config(uri, "azblob", config.validate())?;
513
514        let conn: AzblobConnection = config.into();
515        let object_store = ObjectStore::new(Azblob::from(&conn)).context(BuildObjectStoreSnafu)?;
516        Ok(Self::new_operator_rooted(
517            Self::finish_remote_store(object_store),
518            uri,
519        ))
520    }
521
522    /// Creates a new storage from a URI and object store config.
523    pub fn from_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
524        match StorageScheme::from_uri(uri)? {
525            StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
526            StorageScheme::S3 => Self::from_s3_uri(uri, storage),
527            StorageScheme::Oss => Self::from_oss_uri(uri, storage),
528            StorageScheme::Gcs => Self::from_gcs_uri(uri, storage),
529            StorageScheme::Azblob => Self::from_azblob_uri(uri, storage),
530        }
531    }
532
533    /// Creates storage rooted at a snapshot parent URI.
534    ///
535    /// Parent-oriented commands such as `export-v2 list` may scan bucket/container
536    /// roots. Snapshot-oriented commands must keep using `from_uri`, which rejects
537    /// empty remote roots to avoid unsafe snapshot operations at bucket scope.
538    pub fn from_parent_uri(uri: &str, storage: &ObjectStoreConfig) -> Result<Self> {
539        match StorageScheme::from_uri(uri)? {
540            StorageScheme::File => Self::from_file_uri_with_config(uri, storage),
541            StorageScheme::S3 => Self::from_s3_uri_with_root_policy(uri, storage, true),
542            StorageScheme::Oss => Self::from_oss_uri_with_root_policy(uri, storage, true),
543            StorageScheme::Gcs => Self::from_gcs_uri_with_root_policy(uri, storage, true),
544            StorageScheme::Azblob => Self::from_azblob_uri_with_root_policy(uri, storage, true),
545        }
546    }
547
548    /// Reads a file as bytes.
549    async fn read_file(&self, path: &str) -> Result<Vec<u8>> {
550        let data = self
551            .object_store
552            .read(path)
553            .await
554            .context(StorageOperationSnafu {
555                operation: format!("read {}", path),
556            })?;
557        Ok(data.to_vec())
558    }
559
560    /// Reads a file as bytes if it exists.
561    pub(crate) async fn read_file_if_exists(&self, path: &str) -> Result<Option<Vec<u8>>> {
562        match self.object_store.read(path).await {
563            Ok(data) => Ok(Some(data.to_vec())),
564            Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
565            Err(error) => Err(error).context(StorageOperationSnafu {
566                operation: format!("read {}", path),
567            }),
568        }
569    }
570
571    /// Writes bytes to a file.
572    async fn write_file(&self, path: &str, data: Vec<u8>) -> Result<()> {
573        self.object_store
574            .write(path, data)
575            .await
576            .map(|_| ())
577            .context(StorageOperationSnafu {
578                operation: format!("write {}", path),
579            })
580    }
581
582    /// Checks if a file exists using stat.
583    pub(crate) async fn file_exists(&self, path: &str) -> Result<bool> {
584        match self.object_store.stat(path).await {
585            Ok(metadata) => Ok(!metadata.is_dir()),
586            Err(e) if e.kind() == object_store::ErrorKind::NotFound => Ok(false),
587            Err(e) => Err(e).context(StorageOperationSnafu {
588                operation: format!("check exists {}", path),
589            }),
590        }
591    }
592
593    /// Iterates files recursively under a relative prefix without materializing
594    /// the full listing first.
595    pub(crate) async fn for_each_file_recursive<F>(&self, prefix: &str, mut f: F) -> Result<()>
596    where
597        F: FnMut(String) -> Result<()>,
598    {
599        let mut lister = match self.object_store.lister_with(prefix).recursive(true).await {
600            Ok(lister) => lister,
601            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(()),
602            Err(error) => {
603                return Err(error).context(StorageOperationSnafu {
604                    operation: format!("list {}", prefix),
605                });
606            }
607        };
608
609        while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
610            operation: format!("list {}", prefix),
611        })? {
612            if entry.metadata().is_dir() {
613                continue;
614            }
615            f(entry.path().to_string())?;
616        }
617
618        Ok(())
619    }
620
621    /// Lists direct child directory names under the storage root.
622    pub(crate) async fn list_direct_child_dirs(&self) -> Result<Vec<String>> {
623        let mut lister = match self.object_store.lister_with("/").recursive(false).await {
624            Ok(lister) => lister,
625            Err(error) if error.kind() == ErrorKind::NotFound => return Ok(Vec::new()),
626            Err(error) => {
627                return Err(error).context(StorageOperationSnafu {
628                    operation: "list /",
629                });
630            }
631        };
632
633        let mut dirs = BTreeSet::new();
634        while let Some(entry) = lister.try_next().await.context(StorageOperationSnafu {
635            operation: "list /",
636        })? {
637            let path = entry.path().trim_matches('/');
638            if path.is_empty() {
639                continue;
640            }
641
642            if entry.metadata().is_dir()
643                && let Some(name) = path.split('/').next()
644            {
645                dirs.insert(name.to_string());
646            }
647        }
648
649        Ok(dirs.into_iter().collect())
650    }
651
652    #[cfg(test)]
653    pub async fn read_schema(&self) -> Result<SchemaSnapshot> {
654        let schemas_path = schema_index_path();
655        let schemas: Vec<SchemaDefinition> = if self.file_exists(&schemas_path).await? {
656            let data = self.read_file(&schemas_path).await?;
657            serde_json::from_slice(&data).context(ManifestParseSnafu)?
658        } else {
659            vec![]
660        };
661
662        Ok(SchemaSnapshot { schemas })
663    }
664}
665
666#[async_trait]
667impl SnapshotStorage for OpenDalStorage {
668    async fn exists(&self) -> Result<bool> {
669        self.file_exists(MANIFEST_FILE).await
670    }
671
672    async fn read_manifest(&self) -> Result<Manifest> {
673        ensure_snapshot_exists(self).await?;
674
675        let data = self.read_file(MANIFEST_FILE).await?;
676        Manifest::from_json(&data).context(ManifestParseSnafu)
677    }
678
679    async fn file_size(&self, path: &str) -> Result<Option<u64>> {
680        match self.object_store.stat(path).await {
681            Ok(metadata) => Ok(metadata.is_file().then(|| metadata.content_length())),
682            Err(error) if error.kind() == ErrorKind::NotFound => Ok(None),
683            Err(error) => Err(error).with_context(|_| StorageOperationSnafu {
684                operation: format!("stat {path}"),
685            }),
686        }
687    }
688
689    async fn write_manifest(&self, manifest: &Manifest) -> Result<()> {
690        let data = serde_json::to_vec_pretty(manifest).context(ManifestSerializeSnafu)?;
691        self.write_file(MANIFEST_FILE, data).await
692    }
693
694    async fn write_schema(&self, schema: &SchemaSnapshot) -> Result<()> {
695        let schemas_path = schema_index_path();
696        let schemas_data =
697            serde_json::to_vec_pretty(&schema.schemas).context(ManifestSerializeSnafu)?;
698        self.write_file(&schemas_path, schemas_data).await
699    }
700
701    async fn write_text(&self, path: &str, content: &str) -> Result<()> {
702        self.write_file(path, content.as_bytes().to_vec()).await
703    }
704
705    async fn read_text(&self, path: &str) -> Result<String> {
706        let data = self.read_file(path).await?;
707        String::from_utf8(data).context(TextDecodeSnafu)
708    }
709
710    async fn create_dir_all(&self, path: &str) -> Result<()> {
711        self.object_store
712            .create_dir(path)
713            .await
714            .context(StorageOperationSnafu {
715                operation: format!("create dir {}", path),
716            })
717    }
718
719    async fn list_files_recursive(&self, prefix: &str) -> Result<Vec<String>> {
720        let mut files = Vec::new();
721        self.for_each_file_recursive(prefix, |path| {
722            files.push(path);
723            Ok(())
724        })
725        .await?;
726        Ok(files)
727    }
728
729    async fn prepare_export_chunk(
730        &self,
731        schemas: &[String],
732        chunk_id: u32,
733        resume: bool,
734        packed: bool,
735    ) -> Result<()> {
736        let mut files = Vec::new();
737        for schema in schemas {
738            let prefix = data_dir_for_schema_chunk(schema, chunk_id);
739            let mut entries = match self.object_store.lister_with(&prefix).recursive(true).await {
740                Ok(entries) => entries,
741                Err(error) if error.kind() == ErrorKind::NotFound => continue,
742                Err(error) => {
743                    return Err(error).context(StorageOperationSnafu {
744                        operation: format!("list {prefix}"),
745                    });
746                }
747            };
748            while let Some(entry) = entries.try_next().await.context(StorageOperationSnafu {
749                operation: format!("list {prefix}"),
750            })? {
751                let path = entry.path();
752                if path == prefix && entry.metadata().is_dir() {
753                    continue;
754                }
755                let name = path.strip_prefix(&prefix).unwrap_or("");
756                if !resume
757                    || entry.metadata().is_dir()
758                    || !(if packed {
759                        valid_packed_chunk_filename(name)
760                    } else {
761                        valid_chunk_filename(name)
762                    })
763                {
764                    return InvalidUriSnafu {
765                        uri: path,
766                        reason: "expected an empty new chunk or recognized files in an owned unfinished chunk",
767                    }.fail();
768                }
769                files.push(path.to_string());
770            }
771        }
772        // Validate every schema before deleting any files; COPY starts only after this returns.
773        for path in files {
774            match self.object_store.delete(&path).await {
775                Ok(()) => {}
776                Err(error) if error.kind() == ErrorKind::NotFound => {}
777                Err(error) => {
778                    return Err(error).context(StorageOperationSnafu {
779                        operation: format!("delete unfinished chunk file {path}"),
780                    });
781                }
782            }
783        }
784        Ok(())
785    }
786
787    async fn delete_snapshot(&self) -> Result<()> {
788        self.object_store
789            .delete_with("/")
790            .recursive(true)
791            .await
792            .context(StorageOperationSnafu {
793                operation: "delete snapshot",
794            })
795    }
796}
797
798fn valid_packed_chunk_filename(name: &str) -> bool {
799    name == common_datasource::packed_snapshot::PACK_INDEX_FILE
800        || [("pack-", ".bin"), ("table-", ".parquet")]
801            .iter()
802            .any(|(prefix, suffix)| {
803                name.strip_prefix(prefix)
804                    .and_then(|n| n.strip_suffix(suffix))
805                    .is_some_and(|id| !id.is_empty() && id.bytes().all(|b| b.is_ascii_digit()))
806            })
807}
808
809fn valid_chunk_filename(name: &str) -> bool {
810    name.strip_suffix(".parquet")
811        .is_some_and(|stem| !stem.is_empty())
812        && !name.contains(['/', '\\', '\0'])
813}
814
815#[cfg(test)]
816mod tests {
817    use std::collections::HashMap;
818    use std::path::Path;
819    use std::sync::Arc;
820
821    use object_store::ObjectStore;
822    use object_store::layers::mock::MockLayerBuilder;
823    use object_store::services::Fs;
824    use tempfile::tempdir;
825    use url::Url;
826
827    use super::*;
828    use crate::data::export_v2::error::Error;
829    use crate::data::export_v2::manifest::{DataFormat, TimeRange};
830    use crate::data::export_v2::schema::SchemaDefinition;
831
832    fn make_storage_with_rooted_fs(dir: &std::path::Path) -> OpenDalStorage {
833        let object_store = ObjectStore::new(Fs::default().root(dir.to_str().unwrap())).unwrap();
834        OpenDalStorage::new_operator_rooted(
835            OpenDalStorage::finish_local_store(object_store),
836            Url::from_directory_path(dir).unwrap().as_ref(),
837        )
838    }
839
840    #[test]
841    fn test_validate_uri_valid() {
842        assert_eq!(validate_uri("s3://bucket/path").unwrap(), StorageScheme::S3);
843        assert_eq!(
844            validate_uri("oss://bucket/path").unwrap(),
845            StorageScheme::Oss
846        );
847        assert_eq!(
848            validate_uri("gs://bucket/path").unwrap(),
849            StorageScheme::Gcs
850        );
851        assert_eq!(
852            validate_uri("gcs://bucket/path").unwrap(),
853            StorageScheme::Gcs
854        );
855        assert_eq!(
856            validate_uri("azblob://container/path").unwrap(),
857            StorageScheme::Azblob
858        );
859        assert_eq!(
860            validate_uri("file:///tmp/backup").unwrap(),
861            StorageScheme::File
862        );
863    }
864
865    #[test]
866    fn test_validate_uri_invalid() {
867        // Bare paths should be rejected
868        assert!(validate_uri("/tmp/backup").is_err());
869        assert!(validate_uri("./backup").is_err());
870        assert!(validate_uri("backup").is_err());
871
872        // Unknown schemes
873        assert!(validate_uri("ftp://server/path").is_err());
874    }
875
876    #[test]
877    fn test_extract_remote_location_requires_non_empty_root() {
878        assert!(extract_remote_location_with_root_policy("s3://bucket", false).is_err());
879        assert!(extract_remote_location_with_root_policy("s3://bucket/", false).is_err());
880        assert!(extract_remote_location_with_root_policy("oss://bucket", false).is_err());
881        assert!(extract_remote_location_with_root_policy("gs://bucket", false).is_err());
882        assert!(extract_remote_location_with_root_policy("azblob://container", false).is_err());
883    }
884
885    #[test]
886    fn test_extract_remote_location_allows_empty_root_when_permitted() {
887        let location = extract_remote_location_with_root_policy("s3://bucket", true).unwrap();
888        assert_eq!(location.bucket_or_container, "bucket");
889        assert_eq!(location.root, "");
890
891        let location =
892            extract_remote_location_with_root_policy("azblob://container/", true).unwrap();
893        assert_eq!(location.bucket_or_container, "container");
894        assert_eq!(location.root, "");
895    }
896
897    #[test]
898    fn test_parent_storage_allows_s3_bucket_root() {
899        let mut storage = ObjectStoreConfig {
900            enable_s3: true,
901            ..Default::default()
902        };
903        storage.s3.s3_region = Some("us-east-1".to_string());
904
905        assert!(OpenDalStorage::from_uri("s3://bucket", &storage).is_err());
906        assert!(OpenDalStorage::from_parent_uri("s3://bucket", &storage).is_ok());
907    }
908
909    #[test]
910    fn test_validate_snapshot_uri_rejects_dangerous_roots() {
911        assert!(validate_snapshot_uri("s3://bucket").is_err());
912        assert!(validate_snapshot_uri("s3://bucket/").is_err());
913        assert!(validate_snapshot_uri("oss://bucket").is_err());
914        assert!(validate_snapshot_uri("gs://bucket").is_err());
915        assert!(validate_snapshot_uri("azblob://container").is_err());
916        assert!(validate_snapshot_uri("s3://bucket/snapshot?version=1").is_err());
917        assert!(validate_snapshot_uri("file:///tmp/backup#fragment").is_err());
918        assert!(validate_snapshot_uri("file:///").is_err());
919        assert!(validate_snapshot_uri("file:///tmp").is_err());
920        assert!(validate_snapshot_uri("file:///tmp/backup/.").is_err());
921        assert!(validate_snapshot_uri("file:///tmp/backup/..").is_err());
922    }
923
924    #[test]
925    fn test_validate_snapshot_uri_accepts_snapshot_paths() {
926        assert_eq!(
927            validate_snapshot_uri("s3://bucket/snapshots/prod").unwrap(),
928            StorageScheme::S3
929        );
930
931        let dir = tempdir().unwrap();
932        let snapshot = dir.path().join("snapshot");
933        std::fs::create_dir_all(&snapshot).unwrap();
934        let uri = Url::from_directory_path(snapshot).unwrap().to_string();
935        assert_eq!(validate_snapshot_uri(&uri).unwrap(), StorageScheme::File);
936    }
937
938    #[cfg(windows)]
939    #[test]
940    fn test_validate_snapshot_uri_windows_drive_prefix_depth() {
941        assert!(validate_snapshot_uri("file:///C:/").is_err());
942        assert!(validate_snapshot_uri("file:///C:/Users").is_err());
943        assert!(validate_snapshot_uri("file:///C:/Users/snapshot").is_ok());
944    }
945
946    #[cfg(not(windows))]
947    #[test]
948    fn test_extract_path_from_uri_unix_examples() {
949        assert_eq!(
950            extract_file_path_from_uri("file:///tmp/backup").unwrap(),
951            "/tmp/backup"
952        );
953        assert_eq!(
954            extract_file_path_from_uri("file://localhost/tmp/backup").unwrap(),
955            "/tmp/backup"
956        );
957        assert_eq!(
958            extract_file_path_from_uri("file:///tmp/my%20backup").unwrap(),
959            "/tmp/my backup"
960        );
961        assert_eq!(
962            extract_file_path_from_uri("file://localhost/tmp/my%20backup").unwrap(),
963            "/tmp/my backup"
964        );
965    }
966
967    #[test]
968    fn test_extract_file_path_from_uri_rejects_file_host() {
969        assert!(extract_file_path_from_uri("file://tmp/backup").is_err());
970    }
971
972    #[test]
973    fn test_extract_file_path_from_uri_round_trips_directory_url() {
974        let dir = tempdir().unwrap();
975        let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
976        let path = extract_file_path_from_uri(&uri).unwrap();
977
978        assert_eq!(Path::new(&path), dir.path());
979    }
980
981    #[tokio::test]
982    async fn test_read_manifest_reports_requested_uri() {
983        let dir = tempdir().unwrap();
984        let uri = Url::from_directory_path(dir.path()).unwrap().to_string();
985        let storage = OpenDalStorage::from_file_uri(&uri).unwrap();
986
987        let error = storage.read_manifest().await.unwrap_err().to_string();
988
989        assert!(error.contains(uri.as_str()));
990    }
991
992    #[tokio::test]
993    async fn test_manifest_round_trip() {
994        let dir = tempdir().unwrap();
995        let storage = make_storage_with_rooted_fs(dir.path());
996
997        let manifest = Manifest::new_full(
998            "greptime".to_string(),
999            vec!["public".to_string()],
1000            TimeRange::unbounded(),
1001            DataFormat::Parquet,
1002        );
1003
1004        storage.write_manifest(&manifest).await.unwrap();
1005        let loaded = storage.read_manifest().await.unwrap();
1006
1007        assert_eq!(loaded.catalog, manifest.catalog);
1008        assert_eq!(loaded.schemas, manifest.schemas);
1009        assert_eq!(loaded.schema_only, manifest.schema_only);
1010        assert_eq!(loaded.format, manifest.format);
1011        assert_eq!(loaded.snapshot_id, manifest.snapshot_id);
1012    }
1013
1014    #[tokio::test]
1015    async fn test_schema_round_trip() {
1016        let dir = tempdir().unwrap();
1017        let storage = make_storage_with_rooted_fs(dir.path());
1018
1019        let mut snapshot = SchemaSnapshot::new();
1020        snapshot.add_schema(SchemaDefinition {
1021            catalog: "greptime".to_string(),
1022            name: "test_db".to_string(),
1023            options: HashMap::from([("ttl".to_string(), "7d".to_string())]),
1024        });
1025
1026        storage.write_schema(&snapshot).await.unwrap();
1027        let loaded = storage.read_schema().await.unwrap();
1028
1029        assert_eq!(loaded, snapshot);
1030    }
1031
1032    #[tokio::test]
1033    async fn test_text_round_trip() {
1034        let dir = tempdir().unwrap();
1035        let storage = make_storage_with_rooted_fs(dir.path());
1036        let content = "CREATE TABLE metrics (ts TIMESTAMP TIME INDEX);";
1037
1038        storage
1039            .write_text("schema/ddl/public.sql", content)
1040            .await
1041            .unwrap();
1042        let loaded = storage.read_text("schema/ddl/public.sql").await.unwrap();
1043
1044        assert_eq!(loaded, content);
1045    }
1046
1047    #[tokio::test]
1048    async fn test_read_text_rejects_invalid_utf8() {
1049        let dir = tempdir().unwrap();
1050        let storage = make_storage_with_rooted_fs(dir.path());
1051
1052        storage
1053            .write_file("schema/ddl/public.sql", vec![0xff, 0xfe, 0xfd])
1054            .await
1055            .unwrap();
1056
1057        let error = storage
1058            .read_text("schema/ddl/public.sql")
1059            .await
1060            .unwrap_err();
1061        assert!(error.to_string().contains("UTF-8"));
1062    }
1063
1064    #[tokio::test]
1065    async fn test_exists_follows_manifest_presence() {
1066        let dir = tempdir().unwrap();
1067        let storage = make_storage_with_rooted_fs(dir.path());
1068
1069        assert!(!storage.exists().await.unwrap());
1070
1071        storage
1072            .write_manifest(&Manifest::new_schema_only(
1073                "greptime".to_string(),
1074                vec!["public".to_string()],
1075            ))
1076            .await
1077            .unwrap();
1078
1079        assert!(storage.exists().await.unwrap());
1080    }
1081
1082    #[tokio::test]
1083    async fn test_prepare_export_chunk_preserves_other_chunks_and_schema_artifacts() {
1084        let dir = tempdir().unwrap();
1085        let storage = make_storage_with_rooted_fs(dir.path());
1086        for path in [
1087            "data/public/1/keep.parquet",
1088            "data/public/2/cpu.v1.parquet",
1089            "data/other/2/a.parquet",
1090            "schema/ddl/public.sql",
1091        ] {
1092            storage.write_text(path, "original").await.unwrap();
1093        }
1094        let schemas = vec!["public".to_string(), "other".to_string()];
1095        assert!(
1096            storage
1097                .prepare_export_chunk(&schemas, 2, false, false)
1098                .await
1099                .is_err()
1100        );
1101        assert!(
1102            storage
1103                .file_exists("data/public/2/cpu.v1.parquet")
1104                .await
1105                .unwrap()
1106        );
1107        storage
1108            .prepare_export_chunk(&schemas, 2, true, false)
1109            .await
1110            .unwrap();
1111        storage
1112            .prepare_export_chunk(&schemas, 2, true, false)
1113            .await
1114            .unwrap();
1115        assert!(
1116            !storage
1117                .file_exists("data/public/2/cpu.v1.parquet")
1118                .await
1119                .unwrap()
1120        );
1121        assert!(!storage.file_exists("data/other/2/a.parquet").await.unwrap());
1122        assert_eq!(
1123            storage
1124                .read_text("data/public/1/keep.parquet")
1125                .await
1126                .unwrap(),
1127            "original"
1128        );
1129        assert_eq!(
1130            storage.read_text("schema/ddl/public.sql").await.unwrap(),
1131            "original"
1132        );
1133    }
1134
1135    #[tokio::test]
1136    async fn test_prepare_export_chunk_rejects_foreign_or_nested_entries_before_deletion() {
1137        for foreign in ["notes.txt", "nested/a.parquet", "empty/"] {
1138            let dir = tempdir().unwrap();
1139            let storage = make_storage_with_rooted_fs(dir.path());
1140            storage
1141                .write_text("data/public/2/cpu.parquet", "keep")
1142                .await
1143                .unwrap();
1144            let path = format!("data/other/2/{foreign}");
1145            if foreign.ends_with('/') {
1146                storage.create_dir_all(&path).await.unwrap();
1147            } else {
1148                storage.write_text(&path, "foreign").await.unwrap();
1149            }
1150            assert!(
1151                storage
1152                    .prepare_export_chunk(&["public".into(), "other".into()], 2, true, false)
1153                    .await
1154                    .is_err()
1155            );
1156            assert_eq!(
1157                storage
1158                    .read_text("data/public/2/cpu.parquet")
1159                    .await
1160                    .unwrap(),
1161                "keep"
1162            );
1163        }
1164    }
1165
1166    #[tokio::test]
1167    async fn packed_retry_preserves_foreign_files_and_completed_chunks() {
1168        let dir = tempdir().unwrap();
1169        let storage = make_storage_with_rooted_fs(dir.path());
1170        let owned = ["pack-000000.bin", "table-42.parquet", "pack-index.json"];
1171        for name in owned {
1172            storage
1173                .write_text(&format!("data/public/2/{name}"), "partial")
1174                .await
1175                .unwrap();
1176        }
1177        for path in [
1178            "data/public/1/pack-000000.bin",
1179            "data/public/2/foreign.parquet",
1180        ] {
1181            storage.write_text(path, "keep").await.unwrap();
1182        }
1183        let schemas = ["public".into()];
1184        assert!(
1185            storage
1186                .prepare_export_chunk(&schemas, 2, true, true)
1187                .await
1188                .is_err()
1189        );
1190        for name in owned {
1191            assert!(
1192                storage
1193                    .file_exists(&format!("data/public/2/{name}"))
1194                    .await
1195                    .unwrap()
1196            );
1197        }
1198        assert_eq!(
1199            storage
1200                .read_text("data/public/2/foreign.parquet")
1201                .await
1202                .unwrap(),
1203            "keep"
1204        );
1205        storage
1206            .object_store
1207            .delete("data/public/2/foreign.parquet")
1208            .await
1209            .unwrap();
1210        storage
1211            .prepare_export_chunk(&schemas, 2, true, true)
1212            .await
1213            .unwrap();
1214        assert!(
1215            storage
1216                .list_files_recursive("data/public/2/")
1217                .await
1218                .unwrap()
1219                .is_empty()
1220        );
1221        assert_eq!(
1222            storage
1223                .read_text("data/public/1/pack-000000.bin")
1224                .await
1225                .unwrap(),
1226            "keep"
1227        );
1228    }
1229
1230    #[tokio::test]
1231    async fn test_prepare_export_chunk_reports_delete_failure() {
1232        let dir = tempdir().unwrap();
1233        let mut storage = make_storage_with_rooted_fs(dir.path());
1234        storage.object_store = storage.object_store.layer(
1235            MockLayerBuilder::default()
1236                // OpenDAL's unit deleter returns Unsupported for every deletion.
1237                .deleter_factory(Arc::new(|_| Box::new(())))
1238                .build()
1239                .unwrap(),
1240        );
1241        storage
1242            .write_text("data/public/2/a.parquet", "keep")
1243            .await
1244            .unwrap();
1245        let error = storage
1246            .prepare_export_chunk(&["public".into()], 2, true, false)
1247            .await
1248            .unwrap_err();
1249        let Error::StorageOperation {
1250            operation, error, ..
1251        } = error
1252        else {
1253            panic!("expected storage operation error, got {error:?}");
1254        };
1255        assert_eq!(
1256            operation,
1257            "delete unfinished chunk file data/public/2/a.parquet"
1258        );
1259        assert_eq!(error.kind(), ErrorKind::Unsupported);
1260        assert_eq!(
1261            storage.read_text("data/public/2/a.parquet").await.unwrap(),
1262            "keep"
1263        );
1264    }
1265
1266    #[tokio::test]
1267    async fn test_prepare_export_chunk_encodes_schema_boundary() {
1268        let dir = tempdir().unwrap();
1269        let storage = make_storage_with_rooted_fs(dir.path());
1270        let schema = "../other".to_string();
1271        let owned = format!("{}a.parquet", data_dir_for_schema_chunk(&schema, 2));
1272        storage.write_text(&owned, "remove").await.unwrap();
1273        storage
1274            .write_text("data/other/2/a.parquet", "keep")
1275            .await
1276            .unwrap();
1277        storage
1278            .prepare_export_chunk(&[schema], 2, true, false)
1279            .await
1280            .unwrap();
1281        assert!(!storage.file_exists(&owned).await.unwrap());
1282        assert_eq!(
1283            storage.read_text("data/other/2/a.parquet").await.unwrap(),
1284            "keep"
1285        );
1286    }
1287
1288    #[tokio::test]
1289    async fn test_delete_snapshot_only_removes_rooted_contents() {
1290        let parent = tempdir().unwrap();
1291        let snapshot_root = parent.path().join("snapshot");
1292        let sibling = parent.path().join("sibling");
1293        std::fs::create_dir_all(&snapshot_root).unwrap();
1294        std::fs::create_dir_all(&sibling).unwrap();
1295        std::fs::write(snapshot_root.join("manifest.json"), b"{}").unwrap();
1296        std::fs::write(sibling.join("keep.txt"), b"keep").unwrap();
1297
1298        let storage = make_storage_with_rooted_fs(&snapshot_root);
1299        storage.delete_snapshot().await.unwrap();
1300
1301        assert!(!snapshot_root.join("manifest.json").exists());
1302        assert!(sibling.join("keep.txt").exists());
1303    }
1304}