1use std::path::{Component, Path, PathBuf};
18use std::sync::{Arc, Mutex};
19use std::vec::IntoIter;
20use std::{fmt, io};
21
22use cap_std::ambient_authority;
23use cap_std::fs::{Dir, DirEntry, OpenOptions, ReadDir};
24use opendal::layers::SimulateLayer;
25use opendal::raw::*;
26use opendal::{
27 Buffer, BytesRange, Capability, EntryMode, Error, ErrorKind, Metadata, MetadataBuilder,
28 OperationContext, Operator, Result,
29};
30use tokio::io::{AsyncReadExt, AsyncSeekExt, AsyncWriteExt};
31
32const LIST_BATCH_SIZE: usize = 128;
33
34#[derive(Clone)]
36pub struct SecureFsRoot {
37 dir: Arc<Dir>,
38 path: Arc<PathBuf>,
39}
40
41impl fmt::Debug for SecureFsRoot {
42 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43 f.debug_struct("SecureFsRoot")
44 .field("path", &self.path)
45 .finish_non_exhaustive()
46 }
47}
48
49impl SecureFsRoot {
50 pub fn open(path: impl AsRef<Path>) -> io::Result<Self> {
54 let path = path.as_ref();
55 std::fs::create_dir_all(path)?;
56 if std::fs::symlink_metadata(path)?.file_type().is_symlink() {
57 return Err(io::Error::new(
58 io::ErrorKind::InvalidInput,
59 "filesystem sandbox root must not be a symbolic link",
60 ));
61 }
62
63 let path = path.canonicalize()?;
64 let dir = Dir::open_ambient_dir(&path, ambient_authority())?;
65 Ok(Self {
66 dir: Arc::new(dir),
67 path: Arc::new(path),
68 })
69 }
70
71 pub fn path(&self) -> &Path {
73 &self.path
74 }
75
76 pub async fn is_file(&self, path: &str) -> Result<bool> {
78 let path = backend_path(path).map_err(new_std_io_error)?;
79 let root = self.clone();
80 common_runtime::spawn_blocking_global(move || {
81 root.dir
82 .symlink_metadata(path)
83 .map(|metadata| metadata.is_file())
84 })
85 .await
86 .map_err(new_task_join_error)?
87 .map_err(new_std_io_error)
88 }
89
90 pub fn open_subdir(&self, path: impl AsRef<Path>) -> io::Result<Self> {
92 let path = normalize_relative_path(path.as_ref())?;
93 if path.as_os_str().is_empty() {
94 return Ok(self.clone());
95 }
96
97 let dir = self.dir.open_dir(&path)?;
98 Ok(Self {
99 dir: Arc::new(dir),
100 path: Arc::new(self.path.join(path)),
101 })
102 }
103
104 pub fn create_subdir(&self, path: impl AsRef<Path>) -> io::Result<Self> {
106 let path = normalize_relative_path(path.as_ref())?;
107 if path.as_os_str().is_empty() {
108 return Ok(self.clone());
109 }
110
111 self.dir.create_dir_all(&path)?;
112 let dir = self.dir.open_dir(&path)?;
113 Ok(Self {
114 dir: Arc::new(dir),
115 path: Arc::new(self.path.join(path)),
116 })
117 }
118
119 pub fn build_operator(&self) -> Operator {
121 Operator::from_parts(
122 OperationContext::default(),
123 Arc::new(SecureFsBackend::new(self.clone())) as Servicer,
124 )
125 .layer(SimulateLayer::default())
126 }
127}
128
129fn normalize_relative_path(path: &Path) -> io::Result<PathBuf> {
130 let mut normalized = PathBuf::new();
131 for component in path.components() {
132 match component {
133 Component::CurDir => {}
134 Component::Normal(value) => normalized.push(value),
135 Component::ParentDir | Component::RootDir | Component::Prefix(_) => {
136 return Err(io::Error::new(
137 io::ErrorKind::PermissionDenied,
138 "path escapes the filesystem sandbox",
139 ));
140 }
141 }
142 }
143 Ok(normalized)
144}
145
146fn backend_path(path: &str) -> io::Result<PathBuf> {
147 let path = path.trim_matches('/');
148 if path.is_empty() {
149 Ok(PathBuf::new())
150 } else {
151 normalize_relative_path(Path::new(path))
152 }
153}
154
155fn parse_write_error(error: io::Error, if_not_exists: bool) -> Error {
156 if if_not_exists && error.kind() == io::ErrorKind::AlreadyExists {
157 Error::new(
158 ErrorKind::ConditionNotMatch,
159 "the file already exists in the filesystem",
160 )
161 .set_source(error)
162 } else {
163 new_std_io_error(error)
164 }
165}
166
167fn metadata_from_fs(metadata: cap_std::fs::Metadata) -> Result<Metadata> {
168 let mut builder = if metadata.is_dir() {
169 MetadataBuilder::dir()
170 } else if metadata.is_file() {
171 MetadataBuilder::file(metadata.len())
172 } else {
173 MetadataBuilder::unknown()
174 };
175
176 builder.last_modified(Timestamp::try_from(
177 metadata.modified().map_err(new_std_io_error)?.into_std(),
178 )?);
179 Ok(builder.build())
180}
181
182#[derive(Clone, Debug)]
183struct SecureFsBackend {
184 root: SecureFsRoot,
185 info: ServiceInfo,
186 capability: Capability,
187}
188
189impl SecureFsBackend {
190 fn new(root: SecureFsRoot) -> Self {
191 let info = ServiceInfo::new("fs", root.path().to_string_lossy(), "");
192 let capability = Capability {
193 stat: true,
194 read: true,
195 write: true,
196 write_can_empty: true,
197 write_can_append: true,
198 write_can_multi: true,
199 write_with_if_not_exists: true,
200 create_dir: true,
201 delete: true,
202 delete_with_recursive: true,
203 list: true,
204 shared: true,
205 ..Default::default()
206 };
207 Self {
208 root,
209 info,
210 capability,
211 }
212 }
213}
214
215impl Service for SecureFsBackend {
216 type Reader = oio::StreamReader<SecureFsReader>;
217 type Writer = SecureFsWriter;
218 type Lister = SecureFsLister;
219 type Deleter = oio::OneShotDeleter<SecureFsDeleter>;
220 type Copier = ();
221 type Composer = ();
222
223 fn info(&self) -> ServiceInfo {
224 self.info.clone()
225 }
226
227 fn capability(&self) -> Capability {
228 self.capability
229 }
230
231 async fn create_dir(
232 &self,
233 _: &OperationContext,
234 path: &str,
235 _: OpCreateDir,
236 ) -> Result<RpCreateDir> {
237 let path = backend_path(path).map_err(new_std_io_error)?;
238 let root = self.root.clone();
239 common_runtime::spawn_blocking_global(move || root.dir.create_dir_all(path))
240 .await
241 .map_err(new_task_join_error)?
242 .map_err(new_std_io_error)?;
243 Ok(RpCreateDir::default())
244 }
245
246 async fn stat(&self, _: &OperationContext, path: &str, _: OpStat) -> Result<RpStat> {
247 let path = backend_path(path).map_err(new_std_io_error)?;
248 let root = self.root.clone();
249 let metadata = common_runtime::spawn_blocking_global(move || {
250 if path.as_os_str().is_empty() {
251 root.dir.dir_metadata()
252 } else {
253 root.dir.metadata(path)
254 }
255 })
256 .await
257 .map_err(new_task_join_error)?
258 .map_err(new_std_io_error)?;
259 Ok(RpStat::new(metadata_from_fs(metadata)?))
260 }
261
262 fn read(&self, _: &OperationContext, path: &str, _: OpRead) -> Result<Self::Reader> {
263 let path = backend_path(path).map_err(new_std_io_error)?;
264 Ok(oio::StreamReader::new(SecureFsReader {
265 root: self.root.clone(),
266 path,
267 }))
268 }
269
270 fn write(&self, _: &OperationContext, path: &str, args: OpWrite) -> Result<Self::Writer> {
271 let path = backend_path(path).map_err(new_std_io_error)?;
272 Ok(SecureFsWriter {
273 root: self.root.clone(),
274 path,
275 args,
276 file: None,
277 })
278 }
279
280 fn delete(&self, _: &OperationContext) -> Result<Self::Deleter> {
281 Ok(oio::OneShotDeleter::new(SecureFsDeleter {
282 root: self.root.clone(),
283 }))
284 }
285
286 fn list(&self, _: &OperationContext, path: &str, _: OpList) -> Result<Self::Lister> {
287 let path = backend_path(path).map_err(new_std_io_error)?;
288 let display_prefix = if path.as_os_str().is_empty() {
289 String::new()
290 } else {
291 format!("{}/", path.to_string_lossy().replace('\\', "/"))
292 };
293 Ok(SecureFsLister {
294 root: self.root.clone(),
295 path,
296 display_prefix,
297 read_dir: None,
298 entries: vec![].into_iter(),
299 seeded: false,
300 done: false,
301 })
302 }
303
304 fn copy(&self, _: &OperationContext, _: &str, _: &str, _: OpCopy) -> Result<Self::Copier> {
305 Err(Error::new(
306 ErrorKind::Unsupported,
307 "operation is not supported",
308 ))
309 }
310
311 async fn rename(
312 &self,
313 _: &OperationContext,
314 _: &str,
315 _: &str,
316 _: OpRename,
317 ) -> Result<RpRename> {
318 Err(Error::new(
319 ErrorKind::Unsupported,
320 "operation is not supported",
321 ))
322 }
323
324 async fn presign(&self, _: &OperationContext, _: &str, _: OpPresign) -> Result<RpPresign> {
325 Err(Error::new(
326 ErrorKind::Unsupported,
327 "operation is not supported",
328 ))
329 }
330}
331
332struct SecureFsReader {
333 root: SecureFsRoot,
334 path: PathBuf,
335}
336
337impl oio::StreamRead for SecureFsReader {
338 async fn open(&self, range: BytesRange) -> Result<(RpRead, Box<dyn oio::ReadStreamDyn>)> {
339 let path = self.path.clone();
340 let root = self.root.clone();
341 let file = common_runtime::spawn_blocking_global(move || root.dir.open(path))
342 .await
343 .map_err(new_task_join_error)?
344 .map_err(new_std_io_error)?;
345 let mut file = tokio::fs::File::from_std(file.into_std());
346 if range.offset() != 0 {
347 file.seek(io::SeekFrom::Start(range.offset()))
348 .await
349 .map_err(new_std_io_error)?;
350 }
351 Ok((
352 RpRead::default(),
353 Box::new(SecureFsReadStream {
354 file,
355 remaining: range.size().unwrap_or(u64::MAX),
356 }),
357 ))
358 }
359}
360
361struct SecureFsReadStream {
362 file: tokio::fs::File,
363 remaining: u64,
364}
365
366impl oio::ReadStream for SecureFsReadStream {
367 async fn read(&mut self) -> Result<Buffer> {
368 if self.remaining == 0 {
369 return Ok(Buffer::new());
370 }
371
372 let size = self.remaining.min(2 * 1024 * 1024) as usize;
373 let mut buffer = vec![0; size];
374 let read = self
375 .file
376 .read(&mut buffer)
377 .await
378 .map_err(new_std_io_error)?;
379 self.remaining = self.remaining.saturating_sub(read as u64);
380 buffer.truncate(read);
381 Ok(Buffer::from(buffer))
382 }
383}
384
385struct SecureFsWriter {
386 root: SecureFsRoot,
387 path: PathBuf,
388 args: OpWrite,
389 file: Option<tokio::fs::File>,
390}
391
392impl SecureFsWriter {
393 async fn ensure_file(&mut self) -> Result<&mut tokio::fs::File> {
394 if self.file.is_none() {
395 let path = self.path.clone();
396 let root = self.root.clone();
397 let if_not_exists = self.args.if_not_exists();
398 let append = self.args.append();
399 let file = common_runtime::spawn_blocking_global(move || {
400 if let Some(parent) = path.parent()
401 && !parent.as_os_str().is_empty()
402 {
403 root.dir.create_dir_all(parent).map_err(new_std_io_error)?;
404 }
405
406 let mut options = OpenOptions::new();
407 options.write(true);
408 if if_not_exists {
409 options.create_new(true);
410 } else {
411 options.create(true);
412 }
413 if append {
414 options.append(true);
415 } else {
416 options.truncate(true);
417 }
418 root.dir
419 .open_with(path, &options)
420 .map_err(|error| parse_write_error(error, if_not_exists))
421 })
422 .await
423 .map_err(new_task_join_error)??;
424
425 self.file = Some(tokio::fs::File::from_std(file.into_std()));
426 }
427 Ok(self.file.as_mut().expect("file must be initialized"))
428 }
429}
430
431impl oio::Write for SecureFsWriter {
432 async fn write(&mut self, buffer: Buffer) -> Result<()> {
433 self.ensure_file()
434 .await?
435 .write_all(&buffer.to_bytes())
436 .await
437 .map_err(new_std_io_error)
438 }
439
440 async fn close(&mut self) -> Result<Metadata> {
441 let file = self.ensure_file().await?;
442 file.flush().await.map_err(new_std_io_error)?;
443 file.sync_all().await.map_err(new_std_io_error)?;
444 let metadata = file.metadata().await.map_err(new_std_io_error)?;
445 let mut builder = MetadataBuilder::file(metadata.len());
446 builder.last_modified(Timestamp::try_from(
447 metadata.modified().map_err(new_std_io_error)?,
448 )?);
449 Ok(builder.build())
450 }
451
452 async fn abort(&mut self) -> Result<()> {
453 Err(Error::new(
454 ErrorKind::Unsupported,
455 "filesystem writes cannot be aborted without atomic writes",
456 ))
457 }
458}
459
460struct SecureFsLister {
461 root: SecureFsRoot,
462 path: PathBuf,
463 display_prefix: String,
464 read_dir: Option<Arc<Mutex<ReadDir>>>,
465 entries: IntoIter<oio::Entry>,
466 seeded: bool,
467 done: bool,
468}
469
470impl oio::List for SecureFsLister {
471 async fn next(&mut self) -> Result<Option<oio::Entry>> {
472 if !self.seeded {
473 self.seeded = true;
474 let path = self.path.clone();
475 let root = self.root.clone();
476 let display_prefix = self.display_prefix.clone();
477 let read_dir = common_runtime::spawn_blocking_global(move || {
478 let result = (|| {
479 let dir = if path.as_os_str().is_empty() {
480 root.dir.open_dir(".")?
481 } else {
482 root.dir.open_dir(&path)?
483 };
484 dir.entries()
485 })();
486
487 match result {
488 Ok(read_dir) => Ok(Some(read_dir)),
489 Err(error)
490 if matches!(
491 error.kind(),
492 io::ErrorKind::NotFound | io::ErrorKind::NotADirectory
493 ) =>
494 {
495 Ok(None)
496 }
497 Err(error) => Err(error),
498 }
499 })
500 .await
501 .map_err(new_task_join_error)?
502 .map_err(new_std_io_error)?;
503
504 let Some(read_dir) = read_dir else {
505 self.done = true;
506 return Ok(None);
507 };
508 self.read_dir = Some(Arc::new(Mutex::new(read_dir)));
509 let current_path = oio::Entry::new(
510 if display_prefix.is_empty() {
511 "/"
512 } else {
513 &display_prefix
514 },
515 MetadataBuilder::dir().build(),
516 );
517 self.entries = vec![current_path].into_iter();
518 }
519
520 if let Some(entry) = self.entries.next() {
521 return Ok(Some(entry));
522 }
523 if self.done {
524 return Ok(None);
525 }
526
527 let Some(read_dir) = self.read_dir.clone() else {
528 self.done = true;
529 return Ok(None);
530 };
531 let display_prefix = self.display_prefix.clone();
532 let (entries, done) = common_runtime::spawn_blocking_global(move || {
533 let mut read_dir = read_dir
534 .lock()
535 .map_err(|_| io::Error::other("filesystem directory iterator lock is poisoned"))?;
536 read_list_batch(&mut read_dir, &display_prefix)
537 })
538 .await
539 .map_err(new_task_join_error)?
540 .map_err(new_std_io_error)?;
541
542 self.entries = entries.into_iter();
543 self.done = done;
544 Ok(self.entries.next())
545 }
546}
547
548fn read_list_batch(
549 read_dir: &mut ReadDir,
550 display_prefix: &str,
551) -> io::Result<(Vec<oio::Entry>, bool)> {
552 let mut entries = Vec::with_capacity(LIST_BATCH_SIZE);
553 while entries.len() < LIST_BATCH_SIZE {
554 let entry = match read_dir.next() {
555 Some(Ok(entry)) => entry,
556 Some(Err(error)) if error.kind() == io::ErrorKind::NotFound => {
557 return Ok((entries, true));
558 }
559 Some(Err(error)) => return Err(error),
560 None => return Ok((entries, true)),
561 };
562
563 if let Some(entry) = read_list_entry(entry, display_prefix)? {
564 entries.push(entry);
565 }
566 }
567 Ok((entries, false))
568}
569
570fn read_list_entry(entry: DirEntry, display_prefix: &str) -> io::Result<Option<oio::Entry>> {
571 let file_type = match entry.file_type() {
572 Ok(file_type) => file_type,
573 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
574 Err(error) => return Err(error),
575 };
576 let name = entry.file_name().to_string_lossy().to_string();
577 let (path, mode) = if file_type.is_dir() {
578 (format!("{display_prefix}{name}/"), EntryMode::DIR)
579 } else if file_type.is_file() {
580 (format!("{display_prefix}{name}"), EntryMode::FILE)
581 } else {
582 (format!("{display_prefix}{name}"), EntryMode::Unknown)
583 };
584 let metadata = if mode == EntryMode::Unknown {
585 MetadataBuilder::unknown().build()
586 } else {
587 match entry.metadata() {
588 Ok(metadata) => match metadata_from_fs(metadata) {
589 Ok(metadata) => metadata,
590 Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None),
591 Err(error) => return Err(io::Error::other(error.to_string())),
592 },
593 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
594 Err(error) => return Err(error),
595 }
596 };
597 Ok(Some(oio::Entry::new(&path, metadata)))
598}
599
600struct SecureFsDeleter {
601 root: SecureFsRoot,
602}
603
604impl oio::OneShotDelete for SecureFsDeleter {
605 async fn delete_once(&self, path: String, args: OpDelete) -> Result<()> {
606 let path = backend_path(&path).map_err(new_std_io_error)?;
607 if path.as_os_str().is_empty() {
608 return Err(Error::new(
609 ErrorKind::Unsupported,
610 "deleting the filesystem sandbox root is not supported",
611 ));
612 }
613 let root = self.root.clone();
614 common_runtime::spawn_blocking_global(move || {
615 let metadata = match root.dir.symlink_metadata(&path) {
616 Ok(metadata) => metadata,
617 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(()),
618 Err(error) => return Err(error),
619 };
620
621 if metadata.is_dir() {
622 if args.recursive() {
623 root.dir.remove_dir_all(path)
624 } else {
625 root.dir.remove_dir(path)
626 }
627 } else {
628 root.dir.remove_file(path)
629 }
630 })
631 .await
632 .map_err(new_task_join_error)?
633 .map_err(new_std_io_error)
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use bytes::Bytes;
640 use common_test_util::temp_dir::create_temp_dir;
641 use opendal::raw::oio::List;
642 use opendal::raw::{OpList, Service};
643 use opendal::{BytesRange, ErrorKind, OperationContext};
644
645 use super::{LIST_BATCH_SIZE, SecureFsBackend, SecureFsRoot, read_list_entry};
646
647 #[tokio::test]
648 async fn test_operator_suffix_reads_final_bytes() {
649 let temp_dir = create_temp_dir("secure_fs_operator_suffix");
650 std::fs::write(temp_dir.path().join("file"), b"0123456789").unwrap();
651 let operator = SecureFsRoot::open(temp_dir.path())
652 .unwrap()
653 .build_operator();
654
655 assert_eq!(
656 Bytes::from_static(b"789"),
657 operator
658 .read_with("file")
659 .range(7..10)
660 .await
661 .unwrap()
662 .to_bytes()
663 );
664 assert_eq!(
665 Bytes::from_static(b"789"),
666 operator
667 .read_with("file")
668 .range(BytesRange::Suffix { size: 3 })
669 .await
670 .unwrap()
671 .to_bytes()
672 );
673 }
674
675 #[tokio::test]
676 async fn test_lister_streams_entries() {
677 let temp_dir = create_temp_dir("secure_fs_lister_streams_entries");
678 for index in 0..129 {
679 std::fs::write(temp_dir.path().join(format!("{index}.parquet")), []).unwrap();
680 }
681
682 let root = SecureFsRoot::open(temp_dir.path()).unwrap();
683 let backend = SecureFsBackend::new(root);
684 let ctx = OperationContext::default();
685 let mut lister = backend.list(&ctx, "/", OpList::new()).unwrap();
686
687 let mut paths = Vec::new();
688 while let Some(entry) = lister.next().await.unwrap() {
689 paths.push(entry.path().to_string());
690 assert!(lister.entries.len() <= LIST_BATCH_SIZE);
691 }
692 assert_eq!(130, paths.len());
693 assert!(paths.iter().any(|path| path == "/"));
694 assert!(paths.iter().any(|path| path == "128.parquet"));
695 }
696
697 #[tokio::test]
698 async fn test_if_not_exists_returns_condition_not_match() {
699 let temp_dir = create_temp_dir("secure_fs_if_not_exists");
700 let operator = SecureFsRoot::open(temp_dir.path())
701 .unwrap()
702 .build_operator();
703 operator
704 .write("existing", Bytes::from_static(b"original"))
705 .await
706 .unwrap();
707
708 let error = operator
709 .write_with("existing", Bytes::from_static(b"replacement"))
710 .if_not_exists(true)
711 .await
712 .unwrap_err();
713
714 assert_eq!(ErrorKind::ConditionNotMatch, error.kind());
715 assert_eq!(
716 Bytes::from_static(b"original"),
717 operator.read("existing").await.unwrap().to_bytes()
718 );
719 }
720
721 #[tokio::test]
722 async fn test_if_not_exists_does_not_remap_parent_directory_error() {
723 let temp_dir = create_temp_dir("secure_fs_if_not_exists_parent_error");
724 std::fs::write(temp_dir.path().join("parent"), []).unwrap();
725 let operator = SecureFsRoot::open(temp_dir.path())
726 .unwrap()
727 .build_operator();
728
729 let error = operator
730 .write_with("parent/file", Bytes::new())
731 .if_not_exists(true)
732 .await
733 .unwrap_err();
734
735 assert_eq!(ErrorKind::AlreadyExists, error.kind());
736 }
737
738 #[tokio::test]
739 async fn test_list_missing_or_non_directory_is_empty() {
740 let temp_dir = create_temp_dir("secure_fs_list_missing_or_non_directory");
741 std::fs::write(temp_dir.path().join("file"), []).unwrap();
742 let operator = SecureFsRoot::open(temp_dir.path())
743 .unwrap()
744 .build_operator();
745
746 assert!(operator.list("missing/").await.unwrap().is_empty());
747 assert!(operator.list("file/").await.unwrap().is_empty());
748 }
749
750 #[test]
759 fn test_lister_skips_entry_removed_during_iteration() {
760 let temp_dir = create_temp_dir("secure_fs_lister_removed_entry");
761 let path = temp_dir.path().join("removed");
762 std::fs::write(&path, []).unwrap();
763 let root = SecureFsRoot::open(temp_dir.path()).unwrap();
764 let mut read_dir = root.dir.entries().unwrap();
765 let entry = read_dir.next().unwrap().unwrap();
766 std::fs::remove_file(path).unwrap();
767
768 let result = read_list_entry(entry, "").unwrap();
769
770 if let Some(entry) = &result {
771 assert_eq!("removed", entry.path());
772 }
773
774 #[cfg(not(windows))]
775 assert!(result.is_none());
776 }
777
778 #[tokio::test]
779 async fn test_delete_root_is_unsupported() {
780 let temp_dir = create_temp_dir("secure_fs_delete_root");
781 let operator = SecureFsRoot::open(temp_dir.path())
782 .unwrap()
783 .build_operator();
784 operator
785 .write("nested/file", Bytes::from_static(b"data"))
786 .await
787 .unwrap();
788
789 let error = operator.delete_with("/").recursive(true).await.unwrap_err();
790
791 assert_eq!(ErrorKind::Unsupported, error.kind());
792 assert!(temp_dir.path().join("nested/file").exists());
793
794 operator
795 .delete_with("nested/")
796 .recursive(true)
797 .await
798 .unwrap();
799 assert!(!temp_dir.path().join("nested").exists());
800 }
801
802 #[tokio::test]
803 async fn test_writer_abort_is_unsupported_without_atomic_write() {
804 let temp_dir = create_temp_dir("secure_fs_writer_abort");
805 let operator = SecureFsRoot::open(temp_dir.path())
806 .unwrap()
807 .build_operator();
808 let mut writer = operator.writer("partial").await.unwrap();
809 writer.write(Bytes::from_static(b"partial")).await.unwrap();
810
811 let error = writer.abort().await.unwrap_err();
812
813 assert_eq!(ErrorKind::Unsupported, error.kind());
814 }
815}