Skip to main content

common_datasource/
object_store.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
15pub mod azblob;
16pub mod fs;
17pub mod gcs;
18pub mod oss;
19pub mod s3;
20
21use std::collections::HashMap;
22use std::path::{Component, Path, PathBuf};
23use std::sync::Arc;
24
25use common_telemetry::debug;
26use lazy_static::lazy_static;
27use object_store::ObjectStore;
28use object_store::secure_fs::SecureFsRoot;
29use regex::Regex;
30use snafu::{OptionExt, ResultExt};
31use url::{ParseError, Url};
32
33use self::azblob::build_azblob_backend;
34use self::fs::build_fs_backend;
35use self::gcs::build_gcs_backend;
36use self::s3::build_s3_backend;
37use crate::error::{self, Result};
38use crate::object_store::oss::build_oss_backend;
39use crate::util::find_dir_and_filename;
40
41pub const FS_SCHEMA: &str = "FS";
42pub const FILE_SCHEMA: &str = "FILE";
43pub const S3_SCHEMA: &str = "S3";
44pub const OSS_SCHEMA: &str = "OSS";
45pub const GCS_SCHEMA: &str = "GCS";
46pub const AZBLOB_SCHEMA: &str = "AZBLOB";
47
48/// An object store rooted at the target's parent, together with the optional
49/// target path relative to that root.
50pub struct BuiltBackend {
51    pub object_store: ObjectStore,
52    pub object_path: Option<String>,
53}
54
55/// Controls whether SQL paths may access the local filesystem.
56#[derive(Clone, Debug, Default)]
57pub enum LocalFileAccess {
58    /// Local filesystem paths are rejected.
59    #[default]
60    Disabled,
61    /// Local filesystem paths are confined to a server-configured root.
62    Sandboxed { root: LocalFileRoot },
63}
64
65/// An opened server-controlled root for sandboxed SQL file access.
66#[derive(Clone, Debug)]
67pub struct LocalFileRoot {
68    root: Arc<SecureFsRoot>,
69    configured_path: Arc<PathBuf>,
70}
71
72impl LocalFileAccess {
73    /// Creates a sandbox rooted at a server-controlled local directory.
74    pub fn sandboxed(root: impl AsRef<Path>) -> Result<Self> {
75        let root_path = root.as_ref();
76        let configured_path =
77            std::path::absolute(root_path).with_context(|_| error::InvalidLocalFileRootSnafu {
78                root: root_path.display().to_string(),
79            })?;
80        let root =
81            SecureFsRoot::open(root_path).with_context(|_| error::InvalidLocalFileRootSnafu {
82                root: root_path.display().to_string(),
83            })?;
84        Ok(Self::Sandboxed {
85            root: LocalFileRoot {
86                root: Arc::new(root),
87                configured_path: Arc::new(configured_path),
88            },
89        })
90    }
91
92    /// Returns the canonical path of the configured sandbox root.
93    pub fn sandbox_root(&self) -> Option<&Path> {
94        match self {
95            Self::Disabled => None,
96            Self::Sandboxed { root } => Some(root.root.path()),
97        }
98    }
99
100    fn authorize(&self, location: &str, path: &Path, trailing_slash: bool) -> Result<String> {
101        let LocalFileAccess::Sandboxed { root } = self else {
102            return error::LocalFileAccessDisabledSnafu {
103                path: location.to_string(),
104            }
105            .fail();
106        };
107
108        let path = normalize_untrusted_path(path).map_err(|reason| {
109            error::LocalFileAccessDeniedSnafu {
110                path: location.to_string(),
111                reason,
112            }
113            .build()
114        })?;
115        let relative = if path.is_absolute() {
116            strip_local_prefix(&path, root.configured_path.as_path())
117                .or_else(|| strip_local_prefix(&path, root.root.path()))
118                .ok_or_else(|| {
119                    error::LocalFileAccessDeniedSnafu {
120                        path: location.to_string(),
121                        reason: "absolute path is outside the configured copy root".to_string(),
122                    }
123                    .build()
124                })?
125        } else {
126            path.as_path()
127        };
128
129        let mut authorized = relative
130            .components()
131            .filter_map(|component| match component {
132                Component::CurDir => None,
133                Component::Normal(value) => Some(value.to_string_lossy().into_owned()),
134                _ => None,
135            })
136            .collect::<Vec<_>>()
137            .join("/");
138        if trailing_slash && !authorized.is_empty() {
139            authorized.push('/');
140        }
141        Ok(authorized)
142    }
143
144    async fn open_backend_root(
145        &self,
146        location: &str,
147        relative_root: &str,
148        create: bool,
149    ) -> Result<SecureFsRoot> {
150        let LocalFileAccess::Sandboxed { root } = self else {
151            return error::LocalFileAccessDisabledSnafu {
152                path: location.to_string(),
153            }
154            .fail();
155        };
156
157        let root = root.root.clone();
158        let relative_root = relative_root.trim_matches('/').to_string();
159        common_runtime::spawn_blocking_global(move || {
160            if create {
161                root.create_subdir(relative_root)
162            } else {
163                root.open_subdir(relative_root)
164            }
165        })
166        .await
167        .context(error::JoinHandleSnafu)?
168        .map_err(|error| {
169            debug!(
170                "Failed to open an authorized local SQL path inside the copy root, path: {location}, error: {error:?}"
171            );
172            if error.kind() == std::io::ErrorKind::NotFound {
173                return error::LocalFilePathNotFoundSnafu { path: location }.build();
174            }
175            error::LocalFileAccessDeniedSnafu {
176                path: location.to_string(),
177                reason: "path could not be safely resolved within the configured copy root"
178                    .to_string(),
179            }
180            .build()
181        })
182    }
183}
184
185/// Converts a configured location into a local path.
186///
187/// Bare paths and `file://` URLs are local. Other URL schemes return `None`.
188pub fn configured_local_path(location: &str) -> Result<Option<PathBuf>> {
189    #[cfg(windows)]
190    if Path::new(location).is_absolute() {
191        return Ok(Some(PathBuf::from(location)));
192    }
193
194    let (schema, _, path) = parse_url(location)?;
195    match schema.to_uppercase().as_str() {
196        FS_SCHEMA => Ok(Some(PathBuf::from(path))),
197        FILE_SCHEMA => {
198            let url = Url::parse(location).context(error::InvalidUrlSnafu { url: location })?;
199            url.to_file_path().map(Some).map_err(|_| {
200                error::InvalidLocalFileRootConfigSnafu {
201                    root: location.to_string(),
202                    reason: "file URL must contain a local absolute path".to_string(),
203                }
204                .build()
205            })
206        }
207        _ => Ok(None),
208    }
209}
210
211fn strip_local_prefix<'a>(path: &'a Path, prefix: &Path) -> Option<&'a Path> {
212    #[cfg(not(windows))]
213    {
214        path.strip_prefix(prefix).ok()
215    }
216
217    #[cfg(windows)]
218    {
219        let mut path_components = path.components();
220        for prefix_component in prefix.components() {
221            let path_component = path_components.next()?;
222            if !windows_component_eq(path_component, prefix_component) {
223                return None;
224            }
225        }
226        Some(path_components.as_path())
227    }
228}
229
230#[cfg(windows)]
231fn windows_component_eq(left: Component<'_>, right: Component<'_>) -> bool {
232    match (left, right) {
233        (Component::Prefix(left), Component::Prefix(right)) => {
234            windows_os_str_eq(left.as_os_str(), right.as_os_str())
235        }
236        (Component::Normal(left), Component::Normal(right)) => windows_os_str_eq(left, right),
237        (Component::RootDir, Component::RootDir)
238        | (Component::CurDir, Component::CurDir)
239        | (Component::ParentDir, Component::ParentDir) => true,
240        _ => false,
241    }
242}
243
244#[cfg(windows)]
245fn windows_os_str_eq(left: &std::ffi::OsStr, right: &std::ffi::OsStr) -> bool {
246    use std::os::windows::ffi::OsStrExt;
247
248    fn ascii_lowercase(value: u16) -> u16 {
249        if (u16::from(b'A')..=u16::from(b'Z')).contains(&value) {
250            value + u16::from(b'a' - b'A')
251        } else {
252            value
253        }
254    }
255
256    left.encode_wide()
257        .map(ascii_lowercase)
258        .eq(right.encode_wide().map(ascii_lowercase))
259}
260
261fn normalize_untrusted_path(path: &Path) -> std::result::Result<PathBuf, String> {
262    let mut normalized = PathBuf::new();
263    for component in path.components() {
264        match component {
265            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
266            Component::RootDir => normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR)),
267            Component::CurDir => {}
268            Component::Normal(value) => normalized.push(value),
269            Component::ParentDir => return Err("'..' path components are not allowed".to_string()),
270        }
271    }
272    Ok(normalized)
273}
274
275/// Returns `(schema, Option<host>, path)`
276pub fn parse_url(url: &str) -> Result<(String, Option<String>, String)> {
277    #[cfg(windows)]
278    {
279        // On Windows, the URL may start with `C:/` or `C:\`.
280        if handle_windows_path(url).is_some() {
281            return Ok((FS_SCHEMA.to_string(), None, url.to_string()));
282        }
283    }
284    let parsed_url = Url::parse(url);
285    match parsed_url {
286        Ok(url) => Ok((
287            url.scheme().to_string(),
288            url.host_str().map(|s| s.to_string()),
289            url.path().to_string(),
290        )),
291        Err(ParseError::RelativeUrlWithoutBase) => {
292            Ok((FS_SCHEMA.to_string(), None, url.to_string()))
293        }
294        Err(err) => Err(err).context(error::InvalidUrlSnafu { url }),
295    }
296}
297
298pub async fn build_backend(
299    url: &str,
300    connection: &HashMap<String, String>,
301    local_file_access: &LocalFileAccess,
302) -> Result<ObjectStore> {
303    Ok(
304        build_backend_inner(url, connection, local_file_access, false, false)
305            .await?
306            .object_store,
307    )
308}
309
310/// Builds a backend and returns the target path relative to the backend root.
311pub async fn build_backend_with_path(
312    url: &str,
313    connection: &HashMap<String, String>,
314    local_file_access: &LocalFileAccess,
315) -> Result<BuiltBackend> {
316    build_backend_inner(url, connection, local_file_access, false, false).await
317}
318
319/// Builds a backend for an operation that may create the target directory.
320pub async fn build_backend_for_write(
321    url: &str,
322    connection: &HashMap<String, String>,
323    local_file_access: &LocalFileAccess,
324) -> Result<ObjectStore> {
325    Ok(
326        build_backend_inner(url, connection, local_file_access, true, false)
327            .await?
328            .object_store,
329    )
330}
331
332/// Builds a writable backend and returns the target path relative to the backend root.
333pub async fn build_backend_for_write_with_path(
334    url: &str,
335    connection: &HashMap<String, String>,
336    local_file_access: &LocalFileAccess,
337) -> Result<BuiltBackend> {
338    build_backend_inner(url, connection, local_file_access, true, true).await
339}
340
341async fn build_backend_inner(
342    url: &str,
343    connection: &HashMap<String, String>,
344    local_file_access: &LocalFileAccess,
345    create_local_root: bool,
346    require_object_path: bool,
347) -> Result<BuiltBackend> {
348    let (schema, host, path) = parse_url(url)?;
349    let normalized_schema = schema.to_uppercase();
350
351    if normalized_schema == FS_SCHEMA || normalized_schema == FILE_SCHEMA {
352        let (local_path, trailing_slash) = if normalized_schema == FILE_SCHEMA {
353            let url = Url::parse(url).context(error::InvalidUrlSnafu { url })?;
354            let path = url.to_file_path().map_err(|_| {
355                error::LocalFileAccessDeniedSnafu {
356                    path: url.to_string(),
357                    reason: "file URL must contain a local absolute path".to_string(),
358                }
359                .build()
360            })?;
361            (path, url.path().ends_with('/'))
362        } else {
363            (
364                PathBuf::from(&path),
365                path.ends_with('/') || cfg!(windows) && path.ends_with(std::path::MAIN_SEPARATOR),
366            )
367        };
368        let authorized = local_file_access.authorize(url, &local_path, trailing_slash)?;
369        let (root, object_path) = find_dir_and_filename(&authorized);
370        if require_object_path && object_path.is_none() {
371            return error::MissingObjectNameSnafu {
372                path: url.to_string(),
373            }
374            .fail();
375        }
376        let root = local_file_access
377            .open_backend_root(url, &root, create_local_root)
378            .await?;
379        return Ok(BuiltBackend {
380            object_store: build_fs_backend(&root)?,
381            object_path,
382        });
383    }
384
385    let (root, object_path) = find_dir_and_filename(&path);
386
387    let object_store = match normalized_schema.as_str() {
388        S3_SCHEMA => {
389            let host = host.context(error::EmptyHostPathSnafu {
390                url: url.to_string(),
391            })?;
392            build_s3_backend(&host, &root, connection)?
393        }
394        OSS_SCHEMA => {
395            let host = host.context(error::EmptyHostPathSnafu {
396                url: url.to_string(),
397            })?;
398            build_oss_backend(&host, &root, connection)?
399        }
400        GCS_SCHEMA => {
401            let host = host.context(error::EmptyHostPathSnafu {
402                url: url.to_string(),
403            })?;
404            build_gcs_backend(&host, &root, connection)?
405        }
406        AZBLOB_SCHEMA => {
407            let host = host.context(error::EmptyHostPathSnafu {
408                url: url.to_string(),
409            })?;
410            build_azblob_backend(&host, &root, connection)?
411        }
412        _ => error::UnsupportedBackendProtocolSnafu {
413            protocol: schema,
414            url,
415        }
416        .fail()?,
417    };
418    Ok(BuiltBackend {
419        object_store,
420        object_path,
421    })
422}
423
424lazy_static! {
425    static ref DISK_SYMBOL_PATTERN: Regex = Regex::new(r"^([A-Za-z]:[/\\])").unwrap();
426}
427
428pub fn handle_windows_path(url: &str) -> Option<String> {
429    DISK_SYMBOL_PATTERN
430        .captures(url)
431        .map(|captures| captures[0].to_string())
432}
433
434#[cfg(test)]
435mod tests {
436    use std::collections::HashMap;
437    use std::fs;
438
439    use common_error::ext::{ErrorExt, RetryHint};
440    use common_error::status_code::StatusCode;
441    use common_test_util::temp_dir::create_temp_dir;
442    use url::Url;
443
444    use super::{
445        LocalFileAccess, build_backend, build_backend_for_write, build_backend_for_write_with_path,
446        build_backend_with_path, handle_windows_path,
447    };
448    use crate::error::Error;
449
450    #[test]
451    fn test_handle_windows_path() {
452        assert_eq!(
453            handle_windows_path("C:/to/path/file"),
454            Some("C:/".to_string())
455        );
456        assert_eq!(
457            handle_windows_path(r"C:\to\path\file"),
458            Some(r"C:\".to_string())
459        );
460        assert_eq!(handle_windows_path("https://google.com"), None);
461        assert_eq!(handle_windows_path("s3://bucket/path/to"), None);
462    }
463
464    #[cfg(windows)]
465    #[test]
466    fn test_windows_local_path_detection_and_prefix() {
467        use std::path::{Path, PathBuf};
468
469        let location = r"C:\gtdata";
470        assert_eq!(
471            super::configured_local_path(location).unwrap(),
472            Some(PathBuf::from(location))
473        );
474        assert_eq!(
475            super::parse_url(location).unwrap(),
476            ("FS".to_string(), None, location.to_string())
477        );
478        assert_eq!(
479            super::strip_local_prefix(
480                Path::new(r"c:\Data\Copy\nested\data.parquet"),
481                Path::new(r"C:\data\copy"),
482            ),
483            Some(Path::new(r"nested\data.parquet"))
484        );
485    }
486
487    #[tokio::test]
488    async fn test_local_file_access_policy() {
489        let data_home = create_temp_dir("local_file_access_policy");
490        let copy_root = data_home.path().join("copy");
491        let internal_dir = data_home.path().join("data");
492        fs::create_dir_all(&internal_dir).unwrap();
493        fs::write(internal_dir.join("secret"), "secret").unwrap();
494
495        let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
496        let connection = HashMap::new();
497
498        let store = build_backend_for_write("nested/data.txt", &connection, &access)
499            .await
500            .unwrap();
501        store.write("data.txt", "first").await.unwrap();
502        store.write("data.txt", "second").await.unwrap();
503        assert_eq!(
504            fs::read_to_string(copy_root.join("nested/data.txt")).unwrap(),
505            "second"
506        );
507
508        let missing = copy_root.join("missing/directory");
509        let error = build_backend("missing/directory/data.txt", &connection, &access)
510            .await
511            .unwrap_err();
512        assert!(matches!(&error, Error::LocalFilePathNotFound { .. }));
513        assert_eq!(error.status_code(), StatusCode::InvalidArguments);
514        assert_eq!(error.retry_hint(), RetryHint::NonRetryable);
515        assert!(
516            error.to_string().contains("does not exist"),
517            "unexpected error: {error}"
518        );
519        assert!(!missing.exists());
520
521        let absolute = copy_root.join("nested/data.txt");
522        let store = build_backend(absolute.to_str().unwrap(), &connection, &access)
523            .await
524            .unwrap();
525        assert_eq!(store.read("data.txt").await.unwrap().to_vec(), b"second");
526
527        let file_url = Url::from_file_path(&absolute).unwrap().to_string();
528        let store = build_backend(&file_url, &connection, &access)
529            .await
530            .unwrap();
531        assert_eq!(store.read("data.txt").await.unwrap().to_vec(), b"second");
532
533        let internal_file = internal_dir.join("secret");
534        assert!(matches!(
535            build_backend(internal_file.to_str().unwrap(), &connection, &access).await,
536            Err(Error::LocalFileAccessDenied { .. })
537        ));
538        assert!(
539            build_backend("../escape/data.txt", &connection, &access)
540                .await
541                .is_err()
542        );
543
544        let outside = data_home.path().join("outside/new");
545        assert!(
546            build_backend(outside.to_str().unwrap(), &connection, &access)
547                .await
548                .is_err()
549        );
550        assert!(!outside.parent().unwrap().exists());
551
552        let prefix_escape = data_home.path().join("copy-not-the-root/new");
553        assert!(matches!(
554            build_backend(prefix_escape.to_str().unwrap(), &connection, &access).await,
555            Err(Error::LocalFileAccessDenied { .. })
556        ));
557        assert!(!prefix_escape.parent().unwrap().exists());
558
559        let disabled = LocalFileAccess::Disabled;
560        let error = build_backend(internal_file.to_str().unwrap(), &connection, &disabled)
561            .await
562            .unwrap_err();
563        assert!(matches!(&error, Error::LocalFileAccessDisabled { .. }));
564        assert_eq!(error.status_code(), StatusCode::InvalidArguments);
565        assert_eq!(error.retry_hint(), RetryHint::NonRetryable);
566        assert!(matches!(
567            build_backend(&file_url, &connection, &disabled).await,
568            Err(Error::LocalFileAccessDisabled { .. })
569        ));
570        assert!(matches!(
571            build_backend(outside.to_str().unwrap(), &connection, &disabled).await,
572            Err(Error::LocalFileAccessDisabled { .. })
573        ));
574        assert!(!outside.parent().unwrap().exists());
575    }
576
577    #[tokio::test]
578    async fn test_file_url_returns_decoded_backend_relative_path() {
579        let temp_dir = create_temp_dir("file_url_backend_relative_path");
580        let copy_root = temp_dir.path().join("copy root");
581        let file = copy_root.join("nested dir/data file.txt");
582        fs::create_dir_all(file.parent().unwrap()).unwrap();
583        fs::write(&file, "data").unwrap();
584
585        let location = Url::from_file_path(&file).unwrap().to_string();
586        assert!(location.contains("%20"));
587        let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
588        let backend = build_backend_with_path(&location, &HashMap::new(), &access)
589            .await
590            .unwrap();
591
592        assert_eq!(backend.object_path.as_deref(), Some("data file.txt"));
593        assert_eq!(
594            backend
595                .object_store
596                .read(backend.object_path.as_deref().unwrap())
597                .await
598                .unwrap()
599                .to_vec(),
600            b"data"
601        );
602    }
603
604    #[tokio::test]
605    async fn test_write_with_path_rejects_directory_before_creation() {
606        let temp_dir = create_temp_dir("write_with_path_rejects_directory");
607        let copy_root = temp_dir.path().join("copy");
608        let target = copy_root.join("new directory");
609        let location = Url::from_directory_path(&target).unwrap().to_string();
610        let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
611
612        let result = build_backend_for_write_with_path(&location, &HashMap::new(), &access).await;
613
614        assert!(matches!(result, Err(Error::MissingObjectName { .. })));
615        assert!(!target.exists());
616
617        let result = build_backend_for_write_with_path(
618            &location,
619            &HashMap::new(),
620            &LocalFileAccess::Disabled,
621        )
622        .await;
623        assert!(matches!(result, Err(Error::LocalFileAccessDisabled { .. })));
624        assert!(!target.exists());
625
626        let relative_target = copy_root.join("relative directory");
627        let result =
628            build_backend_for_write_with_path("relative directory/", &HashMap::new(), &access)
629                .await;
630        assert!(matches!(result, Err(Error::MissingObjectName { .. })));
631        assert!(!relative_target.exists());
632
633        let allowed_directory = copy_root.join("allowed directory");
634        build_backend_for_write("allowed directory/", &HashMap::new(), &access)
635            .await
636            .unwrap();
637        assert!(allowed_directory.is_dir());
638    }
639
640    #[cfg(windows)]
641    #[tokio::test]
642    async fn test_windows_backslash_path_returns_backend_relative_path() {
643        let temp_dir = create_temp_dir("windows_backend_relative_path");
644        let copy_root = temp_dir.path().join("copy");
645        let directory = copy_root.join("nested");
646        let file = directory.join("data.txt");
647        fs::create_dir_all(&directory).unwrap();
648        fs::write(&file, "data").unwrap();
649
650        let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
651        let connection = HashMap::new();
652        let file_location = file.to_str().unwrap();
653        assert!(file_location.contains('\\'));
654        let backend = build_backend_with_path(file_location, &connection, &access)
655            .await
656            .unwrap();
657        assert_eq!(backend.object_path.as_deref(), Some("data.txt"));
658        assert_eq!(
659            backend
660                .object_store
661                .read(backend.object_path.as_deref().unwrap())
662                .await
663                .unwrap()
664                .to_vec(),
665            b"data"
666        );
667
668        let new_directory = copy_root.join("new directory");
669        let directory_location = format!("{}\\", new_directory.display());
670        assert!(matches!(
671            build_backend_for_write_with_path(&directory_location, &connection, &access).await,
672            Err(Error::MissingObjectName { .. })
673        ));
674        assert!(!new_directory.exists());
675    }
676
677    #[tokio::test]
678    async fn test_object_storage_ignores_local_file_policy() {
679        let cases = [
680            (
681                "s3://bucket/path/data%20file.parquet",
682                HashMap::from([
683                    ("region".to_string(), "us-east-1".to_string()),
684                    ("disable_ec2_metadata".to_string(), "true".to_string()),
685                ]),
686                "data%20file.parquet",
687            ),
688            (
689                "oss://bucket/path/file.parquet",
690                HashMap::from([
691                    ("endpoint".to_string(), "http://oss.example.com".to_string()),
692                    ("allow_anonymous".to_string(), "true".to_string()),
693                ]),
694                "file.parquet",
695            ),
696            (
697                "gcs://bucket/path/file.parquet",
698                HashMap::from([(
699                    "endpoint".to_string(),
700                    "http://storage.example.com".to_string(),
701                )]),
702                "file.parquet",
703            ),
704            (
705                "azblob://container/path/file.parquet",
706                HashMap::from([
707                    (
708                        "endpoint".to_string(),
709                        "http://storage.example.com".to_string(),
710                    ),
711                    ("account_name".to_string(), "test".to_string()),
712                ]),
713                "file.parquet",
714            ),
715        ];
716        for (location, connection, expected_path) in cases {
717            let backend = build_backend_for_write_with_path(
718                location,
719                &connection,
720                &LocalFileAccess::Disabled,
721            )
722            .await
723            .unwrap();
724            assert_eq!(backend.object_path.as_deref(), Some(expected_path));
725        }
726    }
727
728    #[cfg(unix)]
729    #[tokio::test]
730    async fn test_local_file_access_rejects_symlink_escape() {
731        use std::os::unix::fs::symlink;
732
733        let temp_dir = create_temp_dir("local_file_access_symlink");
734        let copy_root = temp_dir.path().join("copy");
735        let outside = temp_dir.path().join("outside");
736        fs::create_dir_all(&copy_root).unwrap();
737        fs::create_dir_all(&outside).unwrap();
738        fs::write(outside.join("secret"), "secret").unwrap();
739        symlink(&outside, copy_root.join("escape")).unwrap();
740        symlink(outside.join("secret"), copy_root.join("secret-link")).unwrap();
741
742        let access = LocalFileAccess::sandboxed(&copy_root).unwrap();
743        let connection = HashMap::new();
744
745        assert!(
746            build_backend("escape/secret", &connection, &access)
747                .await
748                .is_err()
749        );
750        assert!(
751            build_backend_for_write("escape/new", &connection, &access)
752                .await
753                .is_err()
754        );
755        assert!(!outside.join("new").exists());
756
757        let store = build_backend("secret-link", &connection, &access)
758            .await
759            .unwrap();
760        assert!(store.read("secret-link").await.is_err());
761        assert!(store.write("secret-link", "overwritten").await.is_err());
762        assert_eq!(
763            fs::read_to_string(outside.join("secret")).unwrap(),
764            "secret"
765        );
766    }
767}