Skip to main content

object_store/
secure_fs.rs

1// Copyright 2023 Greptime Team
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! A capability-based filesystem backend for untrusted object paths.
16
17use 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/// An opened filesystem root that confines all descendant path resolution.
35#[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    /// Creates and opens `path` using ambient authority.
51    ///
52    /// Callers must only pass a server-controlled path.
53    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    /// Returns the canonical path used to open this root.
72    pub fn path(&self) -> &Path {
73        &self.path
74    }
75
76    /// Checks for a regular file without following the final symbolic link.
77    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    /// Opens a descendant directory without leaving this capability root.
91    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    /// Creates and opens a descendant directory without leaving this capability root.
105    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    /// Builds an OpenDAL operator confined to this root.
120    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    // On Windows, `std::fs::DirEntry` is a snapshot taken by
751    // `FindFirstFileW`: `file_type()` and `metadata()` keep returning the
752    // cached data even after the file is removed, so `read_list_entry`
753    // may return `Some` with the stale entry. On platforms where metadata
754    // is fetched from the live filesystem (e.g. Unix `lstat` returns
755    // `ENOENT` after removal), `read_list_entry` returns `None` instead.
756    // The strong assertion is Unix-specific; the portable part still
757    // verifies the call succeeds and yields the expected path.
758    #[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}