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