Skip to main content

timeseries_table_format/storage/
io.rs

1use parquet::arrow::async_reader::AsyncFileReader;
2use snafu::{Backtrace, prelude::*};
3#[cfg(test)]
4use std::{
5    collections::HashSet,
6    sync::{LazyLock, Mutex},
7};
8use std::{
9    io,
10    path::{Path, PathBuf},
11};
12use tokio::{
13    fs::{self, OpenOptions},
14    io::AsyncWriteExt,
15};
16
17use crate::storage::{
18    BackendError, NotFoundSnafu, OtherIoSnafu, StorageError, StorageLocation, StorageResult,
19    normalize_relative_storage_path,
20};
21
22/// Guard that removes a file on drop unless disarmed.
23pub(super) struct TempFileGuard {
24    path: PathBuf,
25    armed: bool,
26}
27
28impl TempFileGuard {
29    pub(super) fn new(path: PathBuf) -> Self {
30        Self { path, armed: true }
31    }
32
33    /// Disarm the guard so the file is NOT removed on drop.
34    /// Call this after a successful rename.
35    pub(super) fn disarm(&mut self) {
36        self.armed = false;
37    }
38
39    async fn cleanup(&mut self) -> io::Result<()> {
40        #[cfg(test)]
41        if take_cleanup_failure(&self.path) {
42            self.disarm();
43            return Err(io::Error::other("injected cleanup failure"));
44        }
45
46        let result = fs::remove_file(&self.path).await;
47        self.disarm();
48        result
49    }
50}
51
52impl Drop for TempFileGuard {
53    fn drop(&mut self) {
54        if self.armed {
55            // Best-effort cleanup; ignore errors since we're likely already handling another error.
56            let _ = std::fs::remove_file(&self.path);
57        }
58    }
59}
60
61fn cleanup_failure(path: &Path, operation: StorageError, cleanup: io::Error) -> StorageError {
62    let path = path.display().to_string();
63    let cleanup_error = StorageError::OtherIo {
64        path: path.clone(),
65        source: BackendError::Local(cleanup),
66        backtrace: Backtrace::capture(),
67    };
68    StorageError::CleanupFailed {
69        path,
70        operation_error: Box::new(operation),
71        cleanup_error: Box::new(cleanup_error),
72        backtrace: Backtrace::capture(),
73    }
74}
75
76#[cfg(test)]
77static WRITE_NEW_FAILURES: LazyLock<Mutex<HashSet<PathBuf>>> =
78    LazyLock::new(|| Mutex::new(HashSet::new()));
79
80#[cfg(test)]
81static CLEANUP_FAILURES: LazyLock<Mutex<HashSet<PathBuf>>> =
82    LazyLock::new(|| Mutex::new(HashSet::new()));
83
84#[cfg(test)]
85pub(crate) fn inject_write_new_failure(path: PathBuf, cleanup_fails: bool) {
86    if cleanup_fails {
87        inject_cleanup_failure(path.clone());
88    }
89    WRITE_NEW_FAILURES
90        .lock()
91        .unwrap_or_else(|poisoned| poisoned.into_inner())
92        .insert(path);
93}
94
95#[cfg(test)]
96pub(crate) fn inject_cleanup_failure(path: PathBuf) {
97    CLEANUP_FAILURES
98        .lock()
99        .unwrap_or_else(|poisoned| poisoned.into_inner())
100        .insert(path);
101}
102
103#[cfg(test)]
104fn take_write_new_failure(path: &Path) -> bool {
105    WRITE_NEW_FAILURES
106        .lock()
107        .unwrap_or_else(|poisoned| poisoned.into_inner())
108        .remove(path)
109}
110
111#[cfg(test)]
112fn take_cleanup_failure(path: &Path) -> bool {
113    CLEANUP_FAILURES
114        .lock()
115        .unwrap_or_else(|poisoned| poisoned.into_inner())
116        .remove(path)
117}
118
119#[cfg(test)]
120fn write_failure(path: &Path) -> StorageError {
121    StorageError::OtherIo {
122        path: path.display().to_string(),
123        source: BackendError::Local(io::Error::other("injected write failure")),
124        backtrace: Backtrace::capture(),
125    }
126}
127
128async fn cleanup_created_file(
129    guard: &mut TempFileGuard,
130    path: &Path,
131    operation: StorageError,
132) -> StorageError {
133    match guard.cleanup().await {
134        Ok(()) => operation,
135        Err(cleanup) => cleanup_failure(path, operation, cleanup),
136    }
137}
138
139async fn write_created_file(mut file: fs::File, path: &Path, contents: &[u8]) -> StorageResult<()> {
140    let mut guard = TempFileGuard::new(path.to_owned());
141    #[cfg(test)]
142    let injected_write_failure = take_write_new_failure(path);
143    let result = async {
144        #[cfg(test)]
145        if injected_write_failure {
146            return Err(write_failure(path));
147        }
148
149        file.write_all(contents)
150            .await
151            .map_err(BackendError::Local)
152            .context(OtherIoSnafu {
153                path: path.display().to_string(),
154            })?;
155
156        file.sync_all()
157            .await
158            .map_err(BackendError::Local)
159            .context(OtherIoSnafu {
160                path: path.display().to_string(),
161            })?;
162
163        Ok(())
164    }
165    .await;
166
167    match result {
168        Ok(()) => {
169            guard.disarm();
170            Ok(())
171        }
172        Err(operation) => {
173            drop(file);
174            Err(cleanup_created_file(&mut guard, path, operation).await)
175        }
176    }
177}
178
179pub(super) async fn create_new_file(path: &Path) -> StorageResult<fs::File> {
180    create_parent_dir(path).await?;
181
182    match OpenOptions::new()
183        .write(true)
184        .create_new(true)
185        .open(path)
186        .await
187    {
188        Ok(file) => Ok(file),
189        Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
190            Err(StorageError::AlreadyExists {
191                path: path.display().to_string(),
192                source: BackendError::Local(source),
193                backtrace: Backtrace::capture(),
194            })
195        }
196        Err(source) => Err(StorageError::OtherIo {
197            path: path.display().to_string(),
198            source: BackendError::Local(source),
199            backtrace: Backtrace::capture(),
200        }),
201    }
202}
203
204pub(crate) async fn copy_new_from_local(
205    location: &StorageLocation,
206    source: &Path,
207    rel_path: &Path,
208) -> StorageResult<()> {
209    match location {
210        StorageLocation::Local(_) => {
211            let mut source_file = fs::File::open(source).await.map_err(|error| {
212                if error.kind() == io::ErrorKind::NotFound {
213                    StorageError::NotFound {
214                        path: source.display().to_string(),
215                        source: BackendError::Local(error),
216                        backtrace: Backtrace::capture(),
217                    }
218                } else {
219                    StorageError::OtherIo {
220                        path: source.display().to_string(),
221                        source: BackendError::Local(error),
222                        backtrace: Backtrace::capture(),
223                    }
224                }
225            })?;
226            let destination = join_local(location, rel_path)?;
227            let mut destination_file = create_new_file(&destination).await?;
228            let mut guard = TempFileGuard::new(destination.clone());
229            #[cfg(test)]
230            let injected_copy_failure = take_write_new_failure(&destination);
231
232            let result = async {
233                #[cfg(test)]
234                if injected_copy_failure {
235                    return Err(write_failure(&destination));
236                }
237
238                tokio::io::copy(&mut source_file, &mut destination_file)
239                    .await
240                    .map_err(BackendError::Local)
241                    .context(OtherIoSnafu {
242                        path: destination.display().to_string(),
243                    })?;
244                destination_file
245                    .sync_all()
246                    .await
247                    .map_err(BackendError::Local)
248                    .context(OtherIoSnafu {
249                        path: destination.display().to_string(),
250                    })?;
251
252                Ok(())
253            }
254            .await;
255
256            match result {
257                Ok(()) => {
258                    guard.disarm();
259                    Ok(())
260                }
261                Err(operation) => {
262                    drop(destination_file);
263                    Err(cleanup_created_file(&mut guard, &destination, operation).await)
264                }
265            }
266        }
267    }
268}
269
270/// Validate a backend-relative storage key and resolve it under a local root.
271///
272/// v0.1: only Local is supported.
273pub(super) fn join_local(location: &StorageLocation, rel: &Path) -> StorageResult<PathBuf> {
274    let (_, native_path) = normalize_relative_storage_path(rel)?;
275    match location {
276        StorageLocation::Local(root) => Ok(root.join(native_path)),
277    }
278}
279
280/// Open a stored Parquet file for asynchronous range reads.
281pub(crate) async fn open_parquet_reader(
282    location: &StorageLocation,
283    rel_path: &Path,
284) -> StorageResult<Box<dyn AsyncFileReader>> {
285    let path = rel_path.display().to_string();
286    let absolute_path = join_local(location, rel_path)?;
287
288    match location {
289        StorageLocation::Local(_) => match fs::File::open(absolute_path).await {
290            Ok(file) => Ok(Box::new(file)),
291            Err(error) if error.kind() == io::ErrorKind::NotFound => {
292                Err(BackendError::Local(error)).context(NotFoundSnafu { path })
293            }
294            Err(error) => Err(BackendError::Local(error)).context(OtherIoSnafu { path }),
295        },
296    }
297}
298
299pub(super) async fn create_parent_dir(abs: &Path) -> StorageResult<()> {
300    if let Some(parent) = abs.parent() {
301        fs::create_dir_all(parent)
302            .await
303            .map_err(BackendError::Local)
304            .context(OtherIoSnafu {
305                path: parent.display().to_string(),
306            })?;
307    }
308    Ok(())
309}
310
311/// Write `contents` to `rel_path` inside `location` using an atomic write.
312///
313/// This performs a write-then-rename sequence on the local filesystem:
314/// it writes the payload to a temporary file next to the target path,
315/// syncs the file, and then renames it into place to provide an atomic
316/// replacement. Currently only `StorageLocation::Local` is supported.
317///
318/// # Parameters
319///
320/// - `location`: the table root location to resolve the relative path.
321/// - `rel_path`: the relative path (under `location`) to write the file to.
322/// - `contents`: the bytes to write.
323///
324/// # Errors
325///
326/// Returns `StorageError::LocalIo` when filesystem I/O fails; other internal
327/// helpers may add context to the returned error.
328pub async fn write_atomic(
329    location: &StorageLocation,
330    rel_path: &Path,
331    contents: &[u8],
332) -> StorageResult<()> {
333    match location {
334        StorageLocation::Local(_) => {
335            let abs = join_local(location, rel_path)?;
336
337            create_parent_dir(&abs).await?;
338
339            let tmp_path = abs.with_extension("tmp");
340            let mut guard = TempFileGuard::new(tmp_path.clone());
341
342            {
343                let mut file = fs::File::create(&tmp_path)
344                    .await
345                    .map_err(BackendError::Local)
346                    .context(OtherIoSnafu {
347                        path: tmp_path.display().to_string(),
348                    })?;
349
350                file.write_all(contents)
351                    .await
352                    .map_err(BackendError::Local)
353                    .context(OtherIoSnafu {
354                        path: tmp_path.display().to_string(),
355                    })?;
356
357                file.sync_all()
358                    .await
359                    .map_err(BackendError::Local)
360                    .context(OtherIoSnafu {
361                        path: tmp_path.display().to_string(),
362                    })?;
363            }
364
365            fs::rename(&tmp_path, &abs)
366                .await
367                .map_err(BackendError::Local)
368                .context(OtherIoSnafu {
369                    path: abs.display().to_string(),
370                })?;
371
372            // Success - don't remove the temp file (it's been renamed)
373            guard.disarm();
374
375            Ok(())
376        }
377    }
378}
379
380/// Read the file at `rel_path` within the given `location` and return its
381/// contents as a `String`.
382///
383/// Currently only `StorageLocation::Local` is supported. On success this returns
384/// the file contents; if the file cannot be found a `StorageError::NotFound` is
385/// returned, while other filesystem problems produce `StorageError::LocalIo`.
386pub async fn read_to_string(location: &StorageLocation, rel_path: &Path) -> StorageResult<String> {
387    match location {
388        StorageLocation::Local(_) => {
389            let abs = join_local(location, rel_path)?;
390
391            match fs::read_to_string(&abs).await {
392                Ok(s) => Ok(s),
393                Err(e) if e.kind() == io::ErrorKind::NotFound => Err(BackendError::Local(e))
394                    .context(NotFoundSnafu {
395                        path: abs.display().to_string(),
396                    }),
397                Err(e) => Err(BackendError::Local(e)).context(OtherIoSnafu {
398                    path: abs.display().to_string(),
399                }),
400            }
401        }
402    }
403}
404
405pub(crate) async fn remove_file(location: &StorageLocation, rel_path: &Path) -> StorageResult<()> {
406    match location {
407        StorageLocation::Local(_) => {
408            let abs = join_local(location, rel_path)?;
409            #[cfg(test)]
410            if take_cleanup_failure(&abs) {
411                return Err(StorageError::OtherIo {
412                    path: abs.display().to_string(),
413                    source: BackendError::Local(io::Error::other("injected cleanup failure")),
414                    backtrace: Backtrace::capture(),
415                });
416            }
417            match fs::remove_file(&abs).await {
418                Ok(()) => Ok(()),
419                Err(source) if source.kind() == io::ErrorKind::NotFound => {
420                    Err(StorageError::NotFound {
421                        path: abs.display().to_string(),
422                        source: BackendError::Local(source),
423                        backtrace: Backtrace::capture(),
424                    })
425                }
426                Err(source) => Err(StorageError::OtherIo {
427                    path: abs.display().to_string(),
428                    source: BackendError::Local(source),
429                    backtrace: Backtrace::capture(),
430                }),
431            }
432        }
433    }
434}
435
436pub(crate) async fn remove_file_if_exists(
437    location: &StorageLocation,
438    rel_path: &Path,
439) -> StorageResult<()> {
440    match remove_file(location, rel_path).await {
441        Ok(()) | Err(StorageError::NotFound { .. }) => Ok(()),
442        Err(error) => Err(error),
443    }
444}
445
446/// Create a *new* file at `rel_path` and write `contents`, failing if the file
447/// already exists.
448///
449/// This is used for objects that must not overwrite existing data, including
450/// commit files and writer-owned sidecars.
451pub async fn write_new(
452    location: &StorageLocation,
453    rel_path: &Path,
454    contents: &[u8],
455) -> StorageResult<()> {
456    match location {
457        StorageLocation::Local(_) => {
458            let abs = join_local(location, rel_path)?;
459            let file = create_new_file(&abs).await?;
460            write_created_file(file, &abs, contents).await
461        }
462    }
463}
464
465/// Read the full contents of a file at `rel_path` within `location` and return
466/// them as a `Vec<u8>`.
467///
468/// Only `StorageLocation::Local` is supported in this crate version.
469///
470/// Errors:
471/// - If the file does not exist this returns `StorageError::NotFound`.
472/// - On any other I/O error this returns `StorageError::OtherIo`.
473pub async fn read_all_bytes(location: &StorageLocation, rel_path: &Path) -> StorageResult<Vec<u8>> {
474    match location {
475        StorageLocation::Local(_) => {
476            let abs = join_local(location, rel_path)?;
477            let path_str = abs.display().to_string();
478
479            match fs::read(&abs).await {
480                Ok(bytes) => Ok(bytes),
481                Err(e) if e.kind() == io::ErrorKind::NotFound => {
482                    Err(BackendError::Local(e)).context(NotFoundSnafu { path: path_str })
483                }
484                Err(e) => Err(BackendError::Local(e)).context(OtherIoSnafu { path: path_str }),
485            }
486        }
487    }
488}
489
490/// Get the length (in bytes) of a file at `rel_path` within `location`.
491///
492/// v0.1: only StorageLocation::Local is supported.
493pub async fn file_size(location: &StorageLocation, rel_path: &Path) -> StorageResult<u64> {
494    match location {
495        StorageLocation::Local(_) => {
496            let abs = join_local(location, rel_path)?;
497            let path_str = rel_path.display().to_string();
498
499            let meta = fs::metadata(&abs).await;
500            match meta {
501                Ok(m) => Ok(m.len()),
502                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
503                    Err(BackendError::Local(e)).context(NotFoundSnafu { path: path_str })
504                }
505                Err(e) => Err(BackendError::Local(e)).context(OtherIoSnafu { path: path_str }),
506            }
507        }
508    }
509}
510
511#[cfg(test)]
512mod tests {
513
514    use super::*;
515    use tempfile::TempDir;
516
517    type TestResult = Result<(), Box<dyn std::error::Error>>;
518
519    #[tokio::test]
520    async fn write_atomic_creates_file_with_contents() -> TestResult {
521        let tmp = TempDir::new()?;
522        let location = StorageLocation::local(tmp.path());
523
524        let rel_path = Path::new("test.txt");
525        let contents = b"hello world";
526
527        write_atomic(&location, rel_path, contents).await?;
528
529        // Verify file exists and has correct contents.
530        let abs = tmp.path().join(rel_path);
531        let read_back = tokio::fs::read_to_string(&abs).await?;
532        assert_eq!(read_back, "hello world");
533        Ok(())
534    }
535
536    #[tokio::test]
537    async fn write_atomic_creates_parent_directories() -> TestResult {
538        let tmp = TempDir::new()?;
539        let location = StorageLocation::local(tmp.path());
540
541        let rel_path = Path::new("nested/deep/dir/file.txt");
542        let contents = b"nested content";
543
544        write_atomic(&location, rel_path, contents).await?;
545
546        let abs = tmp.path().join(rel_path);
547        assert!(abs.exists());
548        let read_back = tokio::fs::read_to_string(&abs).await?;
549        assert_eq!(read_back, "nested content");
550        Ok(())
551    }
552
553    #[tokio::test]
554    async fn write_atomic_overwrites_existing_file() -> TestResult {
555        let tmp = TempDir::new()?;
556        let location = StorageLocation::local(tmp.path());
557        let rel_path = Path::new("overwrite.txt");
558
559        // Write initial content.
560        write_atomic(&location, rel_path, b"original").await?;
561
562        // Overwrite with new content.
563        write_atomic(&location, rel_path, b"updated").await?;
564
565        let abs = tmp.path().join(rel_path);
566        let read_back = tokio::fs::read_to_string(&abs).await?;
567        assert_eq!(read_back, "updated");
568        Ok(())
569    }
570
571    #[tokio::test]
572    async fn write_atomic_no_leftover_tmp_file() -> TestResult {
573        let tmp = TempDir::new()?;
574        let location = StorageLocation::local(tmp.path());
575        let rel_path = Path::new("clean.txt");
576
577        write_atomic(&location, rel_path, b"data").await?;
578
579        // The .tmp file should not remain after successful write.
580        let tmp_path = tmp.path().join("clean.tmp");
581        assert!(!tmp_path.exists());
582        Ok(())
583    }
584
585    #[tokio::test]
586    async fn read_to_string_returns_file_contents() -> TestResult {
587        let tmp = TempDir::new()?;
588        let location = StorageLocation::local(tmp.path());
589        let rel_path = Path::new("readable.txt");
590
591        // Create a file directly.
592        let abs = tmp.path().join(rel_path);
593        tokio::fs::write(&abs, "file contents").await?;
594
595        let result = read_to_string(&location, rel_path).await?;
596        assert_eq!(result, "file contents");
597        Ok(())
598    }
599
600    #[tokio::test]
601    async fn read_to_string_returns_not_found_for_missing_file() -> TestResult {
602        let tmp = TempDir::new()?;
603        let location = StorageLocation::local(tmp.path());
604        let rel_path = Path::new("does_not_exist.txt");
605
606        let result = read_to_string(&location, rel_path).await;
607
608        assert!(result.is_err());
609        let err = result.expect_err("expected NotFound error");
610        assert!(matches!(err, StorageError::NotFound { .. }));
611        Ok(())
612    }
613
614    #[tokio::test]
615    async fn write_then_read_roundtrip() -> TestResult {
616        let tmp = TempDir::new()?;
617        let location = StorageLocation::local(tmp.path());
618        let rel_path = Path::new("roundtrip.txt");
619
620        let original = "roundtrip content 🎉";
621        write_atomic(&location, rel_path, original.as_bytes()).await?;
622
623        let read_back = read_to_string(&location, rel_path).await?;
624        assert_eq!(read_back, original);
625        Ok(())
626    }
627
628    #[tokio::test]
629    async fn write_new_creates_file_with_contents() -> TestResult {
630        let tmp = TempDir::new()?;
631        let location = StorageLocation::local(tmp.path());
632        let rel_path = Path::new("new_file.txt");
633
634        write_new(&location, rel_path, b"new content").await?;
635
636        let abs = tmp.path().join(rel_path);
637        let read_back = tokio::fs::read_to_string(&abs).await?;
638        assert_eq!(read_back, "new content");
639        Ok(())
640    }
641
642    #[tokio::test]
643    async fn write_new_fails_if_file_exists() -> TestResult {
644        let tmp = TempDir::new()?;
645        let location = StorageLocation::local(tmp.path());
646        let rel_path = Path::new("existing.txt");
647
648        // Create the file first.
649        write_new(&location, rel_path, b"first").await?;
650
651        // Second write should fail with AlreadyExists.
652        let result = write_new(&location, rel_path, b"second").await;
653
654        assert!(result.is_err());
655        let err = result.expect_err("expected AlreadyExists error");
656        assert!(matches!(err, StorageError::AlreadyExists { .. }));
657
658        // Original content should be unchanged.
659        let read_back = read_to_string(&location, rel_path).await?;
660        assert_eq!(read_back, "first");
661        Ok(())
662    }
663
664    #[tokio::test]
665    async fn write_new_removes_target_after_write_failure() -> TestResult {
666        let tmp = TempDir::new()?;
667        let location = StorageLocation::local(tmp.path());
668        let rel_path = Path::new("failed.txt");
669        let path = tmp.path().join(rel_path);
670        inject_write_new_failure(path.clone(), false);
671
672        let err = write_new(&location, rel_path, b"contents")
673            .await
674            .expect_err("write should fail");
675
676        assert!(matches!(err, StorageError::OtherIo { .. }));
677        assert!(!path.exists());
678        Ok(())
679    }
680
681    #[tokio::test]
682    async fn copy_new_from_local_removes_target_after_copy_failure() -> TestResult {
683        let tmp = TempDir::new()?;
684        let table_root = tmp.path().join("table");
685        tokio::fs::create_dir(&table_root).await?;
686        let location = StorageLocation::local(&table_root);
687        let source = tmp.path().join("source.parquet");
688        tokio::fs::write(&source, b"parquet").await?;
689        let rel_path = Path::new("data/copied.parquet");
690        let destination = table_root.join(rel_path);
691        inject_write_new_failure(destination.clone(), false);
692
693        let err = copy_new_from_local(&location, &source, rel_path)
694            .await
695            .expect_err("copy should fail");
696
697        assert!(matches!(err, StorageError::OtherIo { .. }));
698        assert!(!destination.exists());
699        assert_eq!(tokio::fs::read(source).await?, b"parquet");
700        Ok(())
701    }
702
703    #[tokio::test]
704    async fn write_new_reports_cleanup_failure() -> TestResult {
705        let tmp = TempDir::new()?;
706        let location = StorageLocation::local(tmp.path());
707        let rel_path = Path::new("orphaned.txt");
708        let path = tmp.path().join(rel_path);
709        inject_write_new_failure(path.clone(), true);
710
711        let err = write_new(&location, rel_path, b"contents")
712            .await
713            .expect_err("write and cleanup should fail");
714        let message = err.to_string();
715
716        assert!(matches!(err, StorageError::CleanupFailed { .. }));
717        assert!(message.contains("orphaned.txt"));
718        assert!(message.contains("injected write failure"));
719        assert!(message.contains("injected cleanup failure"));
720        assert!(path.exists());
721        tokio::fs::remove_file(path).await?;
722        Ok(())
723    }
724
725    #[tokio::test]
726    async fn write_new_creates_parent_directories() -> TestResult {
727        let tmp = TempDir::new()?;
728        let location = StorageLocation::local(tmp.path());
729        let rel_path = Path::new("nested/path/new_file.txt");
730
731        write_new(&location, rel_path, b"nested new").await?;
732
733        let abs = tmp.path().join(rel_path);
734        assert!(abs.exists());
735        let read_back = tokio::fs::read_to_string(&abs).await?;
736        assert_eq!(read_back, "nested new");
737        Ok(())
738    }
739
740    #[tokio::test]
741    async fn storage_operations_reject_paths_outside_root() -> TestResult {
742        let tmp = TempDir::new()?;
743        let table_root = tmp.path().join("table");
744        tokio::fs::create_dir(&table_root).await?;
745        let location = StorageLocation::local(&table_root);
746        let outside = tmp.path().join("outside.txt");
747
748        for path in [PathBuf::from("../outside.txt"), outside.clone()] {
749            let write_error = write_new(&location, &path, b"escaped")
750                .await
751                .expect_err("outside write path must be rejected");
752            assert!(matches!(write_error, StorageError::OtherIo { .. }));
753
754            let read_error = read_all_bytes(&location, &path)
755                .await
756                .expect_err("outside read path must be rejected");
757            assert!(matches!(read_error, StorageError::OtherIo { .. }));
758        }
759
760        assert!(!outside.exists());
761        Ok(())
762    }
763}