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