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