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
22pub(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 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 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
270pub(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
280pub(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
311pub 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 guard.disarm();
374
375 Ok(())
376 }
377 }
378}
379
380pub 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
446pub 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
465pub 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
490pub 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 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_atomic(&location, rel_path, b"original").await?;
561
562 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 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 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 write_new(&location, rel_path, b"first").await?;
650
651 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 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}