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}
54
55#[derive(Clone, Debug, Default)]
57pub enum LocalFileAccess {
58 #[default]
60 Disabled,
61 Sandboxed { root: LocalFileRoot },
63}
64
65#[derive(Clone, Debug)]
67pub struct LocalFileRoot {
68 root: Arc<SecureFsRoot>,
69 configured_path: Arc<PathBuf>,
70}
71
72impl LocalFileAccess {
73 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 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
185pub 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
275pub fn parse_url(url: &str) -> Result<(String, Option<String>, String)> {
277 #[cfg(windows)]
278 {
279 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
310pub 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
319pub 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
332pub 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(©_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(©_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(©_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(©_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(©_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(©_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}