Skip to main content

vfsi_sync/
client.rs

1//! Owned, shareable synchronous client and file handles.
2
3use std::collections::{HashMap, HashSet};
4use std::fmt;
5use std::io::{self, Read, Seek, SeekFrom as IoSeekFrom, Write};
6use std::path::{Component, Path, PathBuf};
7use std::sync::{Arc, Mutex};
8use std::time::SystemTime;
9
10use crate::traits::{validate_read_into_results, validate_read_results, validate_write_results};
11use crate::{
12    AttrMask, Capabilities, CopyFileSystem, DEFAULT_READ_MAX_BYTES, DirEntry, DirectoryFileSystem,
13    FileSystem, LinkFileSystem, Metadata, MetadataFileSystem, MetadataQuery, MetadataUpdate,
14    NamespaceFileSystem, OpenFlags, OpenRequest, Permissions, ReadAllOptions, ReadDirOptions,
15    ReadOp, ReadResult, ReadStreamOptions, RemoveOptions, SetAttributes, VecFs, VectorFileSystem,
16    VfDir, VfError, VfFile, VfOffset, VfResult, WriteOpRef, WriteResult,
17};
18
19/// Default aggregate payload limit for owned vector reads.
20pub const DEFAULT_READV_MAX_TOTAL_BYTES: usize = DEFAULT_READ_MAX_BYTES;
21
22/// Application result for one positional vector read, in request order.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct FsReadResult {
25    pub offset: u64,
26    pub data: Vec<u8>,
27    pub eof: bool,
28}
29
30fn read_result(result: ReadResult) -> FsReadResult {
31    FsReadResult {
32        offset: result.offset,
33        data: result.data,
34        eof: result.eof,
35    }
36}
37
38/// Application result for one read into caller storage, in request order.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub struct FsReadIntoResult {
41    pub offset: u64,
42    pub read: usize,
43    pub eof: bool,
44}
45
46/// Application result for one positional vector write, in request order.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct FsWriteResult {
49    pub offset: u64,
50    pub written: usize,
51    pub stable: bool,
52}
53
54fn write_result(result: WriteResult) -> FsWriteResult {
55    FsWriteResult {
56        offset: result.offset,
57        written: result.written,
58        stable: result.stable,
59    }
60}
61
62/// Completion of a streaming read. Stopping does not mean EOF.
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub enum StreamCompletion {
65    Complete,
66    Stopped { next_offset: u64 },
67}
68
69/// Completion of a callback-based directory traversal.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum TraversalCompletion {
72    Complete,
73    Stopped,
74}
75
76/// Default resource policy for a client. Per-call options override it.
77/// Byte limits bound logical payload, not allocator capacity or RPC overhead.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub struct ResourceLimits {
80    /// Maximum collected scalar payload or aggregate vector read buffers.
81    /// Not a process-memory cap; standard `Read::read_to_end` and explicit
82    /// positional file reads remain caller-managed.
83    pub max_read_bytes: usize,
84    /// Default request size for client `read_stream`, not file `Read` or pools.
85    pub stream_chunk_bytes: usize,
86    /// Aggregate entries delivered/collected by a directory call or tree walk.
87    pub max_directory_entries: usize,
88    /// Aggregate logical path bytes, excluding allocator and metadata overhead.
89    pub max_directory_path_bytes: usize,
90    /// Maximum descent depth; the starting directory is depth zero.
91    pub max_walk_depth: usize,
92}
93
94impl Default for ResourceLimits {
95    fn default() -> Self {
96        Self {
97            max_read_bytes: DEFAULT_READ_MAX_BYTES,
98            stream_chunk_bytes: crate::DEFAULT_READ_STREAM_CHUNK_BYTES,
99            max_directory_entries: crate::DEFAULT_DIRECTORY_MAX_ENTRIES,
100            max_directory_path_bytes: crate::DEFAULT_DIRECTORY_MAX_PATH_BYTES,
101            max_walk_depth: crate::DEFAULT_WALK_MAX_DEPTH,
102        }
103    }
104}
105
106impl ResourceLimits {
107    pub fn directory_options(self) -> ReadDirOptions {
108        ReadDirOptions::new()
109            .max_entries(self.max_directory_entries)
110            .max_path_bytes(self.max_directory_path_bytes)
111    }
112
113    pub fn walk_options(self) -> crate::WalkOptions {
114        crate::WalkOptions::new()
115            .max_entries(self.max_directory_entries)
116            .max_path_bytes(self.max_directory_path_bytes)
117            .max_depth(self.max_walk_depth)
118    }
119}
120
121/// One directory and its entries, with attributes fetched during enumeration.
122#[derive(Clone, Debug, PartialEq, Eq)]
123pub struct DirectoryListing {
124    pub path: PathBuf,
125    pub entries: Vec<DirEntry>,
126}
127
128fn io_error(error: VfError) -> io::Error {
129    error.into()
130}
131
132fn poisoned() -> VfError {
133    VfError::client(0, crate::ERR_IO)
134}
135
136fn wrong_result_count(operation: &str, expected: usize, actual: usize) -> VfError {
137    VfError::transport_with_kind(
138        None,
139        crate::TransportKind::InvalidReply,
140        format!("{operation} backend returned {actual} results for {expected} requests"),
141    )
142}
143
144/// Cloneable owner of one synchronous backend connection.
145/// Clones share a mutex and serialize backend calls; use separate connections
146/// for parallel RPCs. Explicitly close handles to observe cleanup failures.
147pub struct FsClient<F> {
148    inner: Arc<Mutex<F>>,
149    limits: ResourceLimits,
150}
151
152impl<F> Clone for FsClient<F> {
153    fn clone(&self) -> Self {
154        Self {
155            inner: Arc::clone(&self.inner),
156            limits: self.limits,
157        }
158    }
159}
160
161impl<F> fmt::Debug for FsClient<F> {
162    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
163        formatter.debug_struct("FsClient").finish_non_exhaustive()
164    }
165}
166
167impl<F> FsClient<F> {
168    pub fn new(filesystem: F) -> Self {
169        Self {
170            inner: Arc::new(Mutex::new(filesystem)),
171            limits: ResourceLimits::default(),
172        }
173    }
174
175    /// Configure this client view and its future clones. Existing clones keep
176    /// their policy; all views still share the same connection and ownership.
177    pub fn with_limits(mut self, limits: ResourceLimits) -> Self {
178        self.limits = limits;
179        self
180    }
181
182    pub fn limits(&self) -> ResourceLimits {
183        self.limits
184    }
185
186    fn lock(&self) -> VfResult<std::sync::MutexGuard<'_, F>> {
187        self.inner.lock().map_err(|_| poisoned())
188    }
189
190    pub fn into_inner(self) -> Result<F, Self> {
191        match Arc::try_unwrap(self.inner) {
192            Ok(mutex) => match mutex.into_inner() {
193                Ok(filesystem) => Ok(filesystem),
194                Err(poisoned) => Ok(poisoned.into_inner()),
195            },
196            Err(inner) => Err(Self {
197                inner,
198                limits: self.limits,
199            }),
200        }
201    }
202}
203
204impl<F: FileSystem> FsClient<F> {
205    pub fn capabilities(&self) -> VfResult<Capabilities> {
206        Ok(self.lock()?.capabilities())
207    }
208    /// Open a path read-only.
209    pub fn open(&self, path: impl AsRef<Path>) -> VfResult<FsFile<F>> {
210        self.open_with(OpenRequest::new(path.as_ref(), OpenFlags::READ))
211    }
212
213    pub fn open_with(&self, request: OpenRequest) -> VfResult<FsFile<F>> {
214        let file = self.lock()?.open_one(&request)?;
215        Ok(FsFile {
216            inner: Arc::clone(&self.inner),
217            file: Some(file),
218            path: request.path,
219        })
220    }
221
222    pub fn open_options(&self) -> OpenOptions<'_, F> {
223        OpenOptions::new(self)
224    }
225
226    pub fn create(&self, path: impl AsRef<Path>) -> VfResult<FsFile<F>> {
227        self.open_with(OpenRequest::new(
228            path.as_ref(),
229            OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE,
230        ))
231    }
232
233    pub fn read(&self, path: impl AsRef<Path>) -> VfResult<Vec<u8>> {
234        self.read_with_limit(path, self.limits.max_read_bytes)
235    }
236
237    /// Read one complete file while limiting the returned allocation.
238    ///
239    /// Use [`FsFile::read_native`] or [`std::io::Read`] to stream files that
240    /// should not be held in one allocation.
241    pub fn read_with_limit(&self, path: impl AsRef<Path>, max_bytes: usize) -> VfResult<Vec<u8>> {
242        let path = path.as_ref();
243        let file = self.open(path)?;
244        let operation = self
245            .lock()?
246            .read_file(file.raw()?, max_bytes)
247            .map_err(|error| error.with_context("read", path))
248            .and_then(|data| {
249                if data.len() > max_bytes {
250                    Err(VfError::client(0, libc::EFBIG as u32).with_context("read", path))
251                } else {
252                    Ok(data)
253                }
254            });
255        // Owned cleanup retries once through Drop on close failure, and keeps
256        // the read error primary when both read and close fail.
257        let cleanup = file.close();
258        operation.and_then(|data| cleanup.map(|()| data))
259    }
260
261    pub fn read_to_string(&self, path: impl AsRef<Path>) -> VfResult<String> {
262        self.read_to_string_with_limit(path, self.limits.max_read_bytes)
263    }
264
265    /// Read one complete UTF-8 file with a caller-selected allocation limit.
266    pub fn read_to_string_with_limit(
267        &self,
268        path: impl AsRef<Path>,
269        max_bytes: usize,
270    ) -> VfResult<String> {
271        let path = path.as_ref();
272        String::from_utf8(self.read_with_limit(path, max_bytes)?)
273            .map_err(|_| VfError::client(0, crate::ERR_INVAL).with_context("read_to_string", path))
274    }
275
276    pub fn write(&self, path: impl AsRef<Path>, data: &[u8]) -> VfResult<()> {
277        let path = path.as_ref();
278        let mut file = self.create(path)?;
279        let mut written = 0;
280        while written < data.len() {
281            let count = file.write_native(&data[written..])?;
282            if count == 0 {
283                return Err(VfError::client(0, crate::ERR_IO).with_context("write", path));
284            }
285            written += count;
286        }
287        file.close()
288    }
289}
290
291impl<F: FileSystem> FsClient<F> {
292    /// Stream one file from offset zero in bounded chunks.
293    ///
294    /// The callback runs without holding the backend lock, so it may use this
295    /// client or drop other files owned by it. Return `Ok(false)` to stop
296    /// successfully. Callback errors
297    /// are propagated. The file is closed on success, cancellation, callback
298    /// error, or read error. At most one requested chunk is buffered at once.
299    pub fn read_stream(
300        &self,
301        path: impl AsRef<Path>,
302        callback: impl FnMut(u64, &[u8]) -> VfResult<bool>,
303    ) -> VfResult<StreamCompletion> {
304        self.read_stream_with_options(
305            path,
306            ReadStreamOptions::new().chunk_size(self.limits.stream_chunk_bytes),
307            callback,
308        )
309    }
310
311    /// Stream one file using an explicit maximum chunk size.
312    pub fn read_stream_with_options(
313        &self,
314        path: impl AsRef<Path>,
315        options: ReadStreamOptions,
316        mut callback: impl FnMut(u64, &[u8]) -> VfResult<bool>,
317    ) -> VfResult<StreamCompletion> {
318        let chunk_size = options.chunk_size_bytes();
319        if chunk_size == 0 {
320            return Err(VfError::failure(0, crate::ERR_INVAL));
321        }
322
323        let file = self.open(path)?;
324        let raw_file = file.raw()?.clone();
325        let operation = (|| -> VfResult<StreamCompletion> {
326            let mut offset = 0u64;
327            loop {
328                let request = ReadOp::at(raw_file.clone(), offset, chunk_size);
329                let result = {
330                    let mut backend = self.lock()?;
331                    let result = backend.read_one(&request)?;
332                    validate_read_results(
333                        "read_stream",
334                        std::slice::from_ref(&request),
335                        std::slice::from_ref(&result),
336                    )?;
337                    result
338                };
339                let length = result.data.len();
340                if !result.data.is_empty() && !callback(offset, &result.data)? {
341                    return Ok(StreamCompletion::Stopped {
342                        next_offset: offset
343                            .checked_add(length as u64)
344                            .ok_or_else(|| VfError::client(0, crate::ERR_INVAL))?,
345                    });
346                }
347                if result.eof {
348                    return Ok(StreamCompletion::Complete);
349                }
350                offset = offset
351                    .checked_add(length as u64)
352                    .ok_or_else(|| VfError::client(0, libc::EOVERFLOW as u32))?;
353            }
354        })();
355        let close = file.close();
356        match operation {
357            Err(error) => Err(error),
358            Ok(completion) => close.map(|()| completion),
359        }
360    }
361}
362
363impl<F: MetadataFileSystem> FsClient<F> {
364    pub fn metadata(&self, path: impl AsRef<Path>) -> VfResult<Metadata> {
365        self.lock()?.metadata_path(path.as_ref(), true)
366    }
367
368    pub fn symlink_metadata(&self, path: impl AsRef<Path>) -> VfResult<Metadata> {
369        self.lock()?.metadata_path(path.as_ref(), false)
370    }
371
372    pub fn set_metadata(&self, path: impl AsRef<Path>) -> SetMetadata<'_, F> {
373        SetMetadata {
374            client: self,
375            path: path.as_ref().to_path_buf(),
376            update: MetadataUpdate::new(),
377            follow: true,
378        }
379    }
380}
381
382impl<F: DirectoryFileSystem> FsClient<F> {
383    pub fn create_dir(&self, path: impl AsRef<Path>) -> VfResult<()> {
384        self.create_dir_with_mode(path, 0o777)
385    }
386
387    pub fn create_dir_with_mode(&self, path: impl AsRef<Path>, mode: u32) -> VfResult<()> {
388        self.lock()?.create_dir_one(path.as_ref(), mode)
389    }
390
391    pub fn read_dir(&self, path: impl AsRef<Path>) -> VfResult<Vec<DirEntry>> {
392        self.read_dir_with_options(path, self.limits.directory_options())
393    }
394
395    /// Read one directory with explicit entry and path-storage limits.
396    pub fn read_dir_with_options(
397        &self,
398        path: impl AsRef<Path>,
399        options: ReadDirOptions,
400    ) -> VfResult<Vec<DirEntry>> {
401        self.lock()?.read_dir_one(path.as_ref(), options)
402    }
403
404    /// Visit one directory one bounded page at a time. `Continue(())` requests
405    /// the next entry; `Break(())` stops the entire visit successfully.
406    /// The callback runs without the lock and may reenter or drop its files.
407    pub fn visit_dir(
408        &self,
409        path: impl AsRef<Path>,
410        callback: impl FnMut(DirEntry) -> VfResult<std::ops::ControlFlow<()>>,
411    ) -> VfResult<TraversalCompletion> {
412        self.visit_dir_with_options(path, self.limits.directory_options(), callback)
413    }
414
415    /// Visit entries with explicit entry and cumulative path-byte limits.
416    pub fn visit_dir_with_options(
417        &self,
418        path: impl AsRef<Path>,
419        options: ReadDirOptions,
420        mut callback: impl FnMut(DirEntry) -> VfResult<std::ops::ControlFlow<()>>,
421    ) -> VfResult<TraversalCompletion> {
422        const PAGE_SIZE: usize = 1024;
423        let path = path.as_ref();
424        let mut cursor = None;
425        let mut count = 0usize;
426        let mut path_bytes = 0usize;
427        let max_entries = if options.entry_limit() == usize::MAX {
428            0 // VecFs uses zero for an explicitly unlimited listing.
429        } else {
430            options.entry_limit().saturating_add(1)
431        };
432        loop {
433            // Start with one entry so early-stop callbacks and tight limits
434            // do not trigger a large local scan before application code runs.
435            let page_size = if cursor.is_none() {
436                1
437            } else {
438                PAGE_SIZE.min(
439                    options
440                        .entry_limit()
441                        .saturating_sub(count)
442                        .saturating_add(1),
443                )
444            };
445            let (entries, next) = {
446                self.lock()?
447                    .read_dir_page(path, cursor, page_size, max_entries)?
448            };
449            if entries.is_empty() && next.is_some() {
450                return Err(VfError::transport(None, "directory page made no progress"));
451            }
452            for entry in entries {
453                if count >= options.entry_limit() {
454                    return Err(
455                        VfError::failure(count, libc::EFBIG as u32).with_context("visit_dir", path)
456                    );
457                }
458                path_bytes = path_bytes
459                    .checked_add(entry.path().as_os_str().len())
460                    .ok_or_else(|| {
461                        VfError::failure(count, libc::EFBIG as u32).with_context("visit_dir", path)
462                    })?;
463                if path_bytes > options.path_byte_limit() {
464                    return Err(
465                        VfError::failure(count, libc::EFBIG as u32).with_context("visit_dir", path)
466                    );
467                }
468                count += 1;
469                if callback(entry)?.is_break() {
470                    return Ok(TraversalCompletion::Stopped);
471                }
472            }
473            match next {
474                Some(value) => cursor = Some(value),
475                None => return Ok(TraversalCompletion::Complete),
476            }
477        }
478    }
479}
480
481impl<F: DirectoryFileSystem + MetadataFileSystem> FsClient<F> {
482    pub fn create_dir_all(&self, path: impl AsRef<Path>) -> VfResult<()> {
483        let path = path.as_ref();
484        let mut current = PathBuf::new();
485        for component in path.components() {
486            match component {
487                Component::RootDir => {
488                    current.push(Path::new("/"));
489                    continue;
490                }
491                Component::Normal(part) => current.push(part),
492                Component::CurDir => continue,
493                Component::ParentDir => {
494                    // Preserve `..` so the backend resolves it relative to
495                    // its own rooted namespace. Lexically popping `/` would
496                    // accidentally turn a later absolute component into a
497                    // current-directory-relative path.
498                    current.push("..");
499                    continue;
500                }
501                Component::Prefix(_) => return Err(VfError::client(0, crate::ERR_INVAL)),
502            }
503            match self.create_dir(&current) {
504                Ok(()) => {}
505                Err(error) if error.err_no() == crate::ERR_EXIST => {
506                    if !self.metadata(&current)?.is_dir() {
507                        return Err(VfError::client(0, crate::ERR_NOTDIR)
508                            .with_context("create_dir_all", &current));
509                    }
510                }
511                Err(error) => return Err(error),
512            }
513        }
514        Ok(())
515    }
516}
517
518impl<F: NamespaceFileSystem + MetadataFileSystem> FsClient<F> {
519    fn removal_metadata(&self, path: &Path, operation: &'static str) -> VfResult<Metadata> {
520        let mut filesystem = self.lock()?;
521        let follow = !filesystem.capabilities().contains(Capabilities::LSTAT);
522        filesystem
523            .metadata_path(path, follow)
524            .map_err(|error| error.with_context(operation, path))
525    }
526
527    pub fn remove_file(&self, path: impl AsRef<Path>) -> VfResult<()> {
528        let path = path.as_ref();
529        if self.removal_metadata(path, "remove_file")?.is_dir() {
530            return Err(VfError::client(0, crate::ERR_ISDIR).with_context("remove_file", path));
531        }
532        self.lock()?.remove_one(path, false)
533    }
534
535    pub fn remove_dir(&self, path: impl AsRef<Path>) -> VfResult<()> {
536        let path = path.as_ref();
537        if !self.removal_metadata(path, "remove_dir")?.is_dir() {
538            return Err(VfError::client(0, crate::ERR_NOTDIR).with_context("remove_dir", path));
539        }
540        self.lock()?.remove_one(path, false)
541    }
542
543    pub fn remove_dir_all(&self, path: impl AsRef<Path>) -> VfResult<()> {
544        let path = path.as_ref();
545        if !self.removal_metadata(path, "remove_dir_all")?.is_dir() {
546            return Err(VfError::client(0, crate::ERR_NOTDIR).with_context("remove_dir_all", path));
547        }
548        self.lock()?.remove_one(path, true)
549    }
550
551    /// Remove the contents of a directory, keeping the directory itself.
552    pub fn remove_dir_contents(&self, path: impl AsRef<Path>) -> VfResult<()> {
553        let path = path.as_ref();
554        if !self.removal_metadata(path, "remove_dir_contents")?.is_dir() {
555            return Err(
556                VfError::client(0, crate::ERR_NOTDIR).with_context("remove_dir_contents", path)
557            );
558        }
559        self.lock()?.remove_dir_contents(path)
560    }
561
562    pub fn rename(&self, from: impl AsRef<Path>, to: impl AsRef<Path>) -> VfResult<()> {
563        self.lock()?.rename_one(from.as_ref(), to.as_ref())
564    }
565}
566
567impl<F: VecFs> FsClient<F> {
568    /// Lazy between directories, with selective no-follow metadata and pruning.
569    /// Enter runs before any listing; Leave follows even a pruned directory.
570    /// Sorting buffers one bounded directory, not the whole tree. The callback
571    /// runs outside the backend lock. Limits include the starting object.
572    pub fn walk_events_with_options(
573        &self,
574        root: impl AsRef<Path>,
575        fields: AttrMask,
576        options: crate::WalkOptions,
577        sort_by_name: bool,
578        callback: impl FnMut(&crate::WalkEvent) -> VfResult<crate::WalkControl>,
579    ) -> VfResult<TraversalCompletion> {
580        let root = root.as_ref();
581        let metadata = self.symlink_metadata_with_fields(root, fields | AttrMask::MODE)?;
582        crate::walk_events(
583            DirEntry::new(root.to_path_buf(), metadata),
584            options,
585            sort_by_name,
586            |path, limits| {
587                let mut listings =
588                    self.read_dirs_with_options(&[path], fields | AttrMask::MODE, limits)?;
589                if listings.len() != 1 {
590                    return Err(VfError::transport(None, "invalid directory result count"));
591                }
592                Ok(listings.remove(0).entries)
593            },
594            callback,
595        )
596    }
597    /// Visit a tree using bounded directory pages,
598    /// never follows symlinks, and invokes the callback outside the backend
599    /// lock. Unlike collecting `walk`, this trades multi-directory batching
600    /// for bounded incremental delivery. A backend without native paging
601    /// may retain one bounded listing; finish its pages before descending,
602    /// so snapshots never accumulate across ancestor directories.
603    /// `ControlFlow::Break(())` stops the entire walk successfully, not merely
604    /// the current subtree. Directory order is backend-defined.
605    pub fn visit_walk(
606        &self,
607        root: impl AsRef<Path>,
608        callback: impl FnMut(&DirEntry) -> VfResult<std::ops::ControlFlow<()>>,
609    ) -> VfResult<TraversalCompletion> {
610        self.visit_walk_with_options(root, self.limits.walk_options(), callback)
611    }
612
613    pub fn visit_walk_with_options(
614        &self,
615        root: impl AsRef<Path>,
616        options: crate::WalkOptions,
617        callback: impl FnMut(&DirEntry) -> VfResult<std::ops::ControlFlow<()>>,
618    ) -> VfResult<TraversalCompletion> {
619        let root = root.as_ref();
620        if !self.symlink_metadata(root)?.is_dir() {
621            return Err(VfError::client(0, crate::ERR_NOTDIR).with_context("visit_walk", root));
622        }
623        visit_walk_pages(
624            root,
625            options,
626            |path, cursor, page_size, max_entries| {
627                self.lock()?
628                    .read_dir_page(path, cursor, page_size, max_entries)
629            },
630            callback,
631        )
632    }
633    /// List several directories with common stat attributes and finite
634    /// allocation limits. Use `read_dirs_with_options` for richer fields.
635    pub fn read_dirs<P: AsRef<Path>>(&self, paths: &[P]) -> VfResult<Vec<DirectoryListing>> {
636        self.read_dirs_with_options(paths, AttrMask::stat(), self.limits.directory_options())
637    }
638}
639
640type DirectoryPage = (Vec<DirEntry>, Option<crate::DirPageCursor>);
641
642fn visit_walk_pages(
643    root: &Path,
644    options: crate::WalkOptions,
645    mut read_page: impl FnMut(
646        &Path,
647        Option<crate::DirPageCursor>,
648        usize,
649        usize,
650    ) -> VfResult<DirectoryPage>,
651    mut callback: impl FnMut(&DirEntry) -> VfResult<std::ops::ControlFlow<()>>,
652) -> VfResult<TraversalCompletion> {
653    let mut pending = vec![(root.to_path_buf(), 0usize)];
654    let mut count = 0usize;
655    let mut path_bytes = root.as_os_str().len();
656    if path_bytes > options.path_byte_limit() {
657        return Err(VfError::client(0, libc::EFBIG as u32));
658    }
659    while let Some((path, depth)) = pending.pop() {
660        let mut cursor = None;
661        let mut children = Vec::new();
662        loop {
663            let requested = options
664                .entry_limit()
665                .saturating_sub(count)
666                .saturating_add(1);
667            let (entries, next) = read_page(&path, cursor, requested.min(128), requested)?;
668            if entries.is_empty() && next.is_some() {
669                return Err(VfError::transport(None, "directory page made no progress")
670                    .with_context("visit_walk", &path));
671            }
672            for entry in entries {
673                count = count
674                    .checked_add(1)
675                    .ok_or_else(|| VfError::client(0, libc::EFBIG as u32))?;
676                path_bytes = path_bytes
677                    .checked_add(entry.path().as_os_str().len())
678                    .ok_or_else(|| VfError::client(0, libc::EFBIG as u32))?;
679                if count > options.entry_limit() || path_bytes > options.path_byte_limit() {
680                    return Err(
681                        VfError::client(0, libc::EFBIG as u32).with_context("visit_walk", &path)
682                    );
683                }
684                if callback(&entry)?.is_break() {
685                    return Ok(TraversalCompletion::Stopped);
686                }
687                if entry.metadata().is_dir() {
688                    if depth >= options.depth_limit() {
689                        if !options.truncates_at_depth_limit() {
690                            return Err(VfError::client(0, libc::EFBIG as u32)
691                                .with_context("visit_walk", entry.path()));
692                        }
693                    } else {
694                        children.push((entry.path().to_path_buf(), depth + 1));
695                    }
696                }
697            }
698            cursor = next;
699            if cursor.is_none() {
700                break;
701            }
702        }
703        pending.extend(children.into_iter().rev());
704    }
705    Ok(TraversalCompletion::Complete)
706}
707
708impl<F: VecFs> FsClient<F> {
709    /// Recursively enumerate a bounded tree with common stat attributes.
710    /// Use `walk_with_options` to select fields or change limits.
711    pub fn walk(&self, root: impl AsRef<Path>) -> VfResult<Vec<DirectoryListing>> {
712        self.walk_with_options(root, AttrMask::stat(), self.limits.walk_options())
713    }
714
715    /// Create `path` if missing, otherwise empty it. Errors if it exists and is
716    /// not a directory (a symlink to a directory is not a directory here).
717    pub fn ensure_empty_dir(&self, path: impl AsRef<Path>) -> VfResult<()> {
718        self.lock()?.ensure_empty_dir(path.as_ref())
719    }
720
721    /// Remove a directory tree with explicit error, batching, and retry policy.
722    pub fn remove_dir_all_with_options(
723        &self,
724        path: impl AsRef<Path>,
725        options: RemoveOptions,
726    ) -> VfResult<()> {
727        let path = path.as_ref();
728        if !self.removal_metadata(path, "remove_dir_all")?.is_dir() {
729            return Err(VfError::client(0, crate::ERR_NOTDIR).with_context("remove_dir_all", path));
730        }
731        self.lock()?
732            .rm_with_options(&[path], true, options)
733            .map_err(|error| error.with_context("remove_dir_all", path))
734    }
735
736    /// Empty a directory while keeping it, with explicit removal policy.
737    pub fn remove_dir_contents_with_options(
738        &self,
739        path: impl AsRef<Path>,
740        options: RemoveOptions,
741    ) -> VfResult<()> {
742        let path = path.as_ref();
743        if !self.removal_metadata(path, "remove_dir_contents")?.is_dir() {
744            return Err(
745                VfError::client(0, crate::ERR_NOTDIR).with_context("remove_dir_contents", path)
746            );
747        }
748        self.lock()?
749            .rm_contents_with_options(path, options)
750            .map_err(|error| error.with_context("remove_dir_contents", path))
751    }
752
753    /// Open a *genuine* directory handle for race-resistant, handle-rooted
754    /// removal. Backends that only return a path fail instead of silently
755    /// losing the handle safety guarantee.
756    pub fn open_dir_handle(&self, path: impl AsRef<Path>) -> VfResult<FsDir<F>> {
757        let path = path.as_ref();
758        let mut backend = self.lock()?;
759        let dir = backend.open_dir(path)?;
760        if !matches!(dir, VfDir::Descriptor { .. }) {
761            let _ = backend.close_dir(&dir);
762            return Err(VfError::unsupported(0).with_context("open_dir_handle", path));
763        }
764        Ok(FsDir {
765            inner: Arc::clone(&self.inner),
766            dir: Some(dir),
767            path: path.to_path_buf(),
768        })
769    }
770}
771
772/// Owned, handle-rooted directory. Dropping it releases backend state;
773/// [`close`](Self::close) reports cleanup errors explicitly.
774pub struct FsDir<F: VecFs> {
775    inner: Arc<Mutex<F>>,
776    dir: Option<VfDir>,
777    path: PathBuf,
778}
779
780impl<F: VecFs> fmt::Debug for FsDir<F> {
781    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
782        formatter
783            .debug_struct("FsDir")
784            .field("path", &self.path)
785            .finish_non_exhaustive()
786    }
787}
788
789impl<F: VecFs> FsDir<F> {
790    pub fn is_closed(&self) -> bool {
791        self.dir.is_none()
792    }
793    /// Name used at open, retained for diagnostics; not updated after rename.
794    pub fn path(&self) -> &Path {
795        &self.path
796    }
797
798    pub fn remove_contents(&self) -> VfResult<()> {
799        self.remove_contents_with_options(RemoveOptions::default())
800    }
801
802    pub fn remove_contents_with_options(&self, options: RemoveOptions) -> VfResult<()> {
803        let dir = self
804            .dir
805            .as_ref()
806            .ok_or_else(|| VfError::client(0, crate::ERR_EBADF))?;
807        self.inner
808            .lock()
809            .map_err(|_| poisoned())?
810            .rm_dir_contents_with_options(dir, options)
811            .map_err(|error| error.with_context("remove_dir_contents", &self.path))
812    }
813
814    pub fn close(mut self) -> VfResult<()> {
815        self.try_close()
816    }
817
818    /// Keep cleanup ownership on failure so the caller can retry explicitly.
819    pub fn try_close(&mut self) -> VfResult<()> {
820        let Some(dir) = self.dir.as_ref() else {
821            return Ok(());
822        };
823        self.inner.lock().map_err(|_| poisoned())?.close_dir(dir)?;
824        self.dir = None;
825        Ok(())
826    }
827}
828
829impl<F: VecFs> Drop for FsDir<F> {
830    fn drop(&mut self) {
831        if let Some(dir) = self.dir.take()
832            && let Ok(mut backend) = self.inner.lock()
833        {
834            let _ = backend.close_dir(&dir);
835        }
836    }
837}
838
839impl<F: LinkFileSystem> FsClient<F> {
840    pub fn symlink(&self, target: impl AsRef<Path>, link: impl AsRef<Path>) -> VfResult<()> {
841        self.lock()?.symlink_one(target.as_ref(), link.as_ref())
842    }
843
844    pub fn hard_link(&self, source: impl AsRef<Path>, link: impl AsRef<Path>) -> VfResult<()> {
845        self.lock()?.hard_link_one(source.as_ref(), link.as_ref())
846    }
847
848    pub fn read_link(&self, path: impl AsRef<Path>) -> VfResult<PathBuf> {
849        self.lock()?.read_link_one(path.as_ref())
850    }
851}
852
853impl<F: CopyFileSystem> FsClient<F> {
854    pub fn copy(&self, source: impl AsRef<Path>, destination: impl AsRef<Path>) -> VfResult<()> {
855        self.lock()?.copy_one(source.as_ref(), destination.as_ref())
856    }
857}
858
859impl<F: VecFs> FsClient<F> {
860    /// Fetch selected metadata for one path without following its final symlink.
861    /// Unavailable fields remain `None` on [`Metadata`].
862    pub fn symlink_metadata_with_fields(
863        &self,
864        path: impl AsRef<Path>,
865        fields: AttrMask,
866    ) -> VfResult<Metadata> {
867        let path = path.as_ref();
868        let mut attrs = crate::VfAttrs {
869            file: VfFile::from_os_path(path),
870            masks: fields | AttrMask::MODE | AttrMask::SIZE,
871            ..crate::VfAttrs::default()
872        };
873        self.lock()?
874            .lgetattrsv(std::slice::from_mut(&mut attrs))
875            .map_err(|error| error.with_context("symlink_metadata", path))?;
876        Ok(vfsi_core::metadata_from_attrs(attrs))
877    }
878
879    /// List multiple directories in a vector call. The limits apply to the
880    /// aggregate returned entries and stored path bytes. Streaming backends
881    /// apply these limits before collecting a full listing; a backend using
882    /// the compatibility `visit_dir` fallback may buffer one directory first.
883    /// An error discards the collected prefix; callers may retry individual
884    /// directories if desired.
885    pub fn read_dirs_with_options<P: AsRef<Path>>(
886        &self,
887        paths: &[P],
888        fields: AttrMask,
889        options: ReadDirOptions,
890    ) -> VfResult<Vec<DirectoryListing>> {
891        if paths.is_empty() {
892            return Ok(Vec::new());
893        }
894        let paths: Vec<&Path> = paths.iter().map(AsRef::as_ref).collect();
895        let mut positions: HashMap<PathBuf, Vec<usize>> = HashMap::with_capacity(paths.len());
896        let mut unique_paths = Vec::with_capacity(paths.len());
897        for (index, path) in paths.iter().enumerate() {
898            if let Some(indices) = positions.get_mut(*path) {
899                indices.push(index);
900            } else {
901                positions.insert(path.to_path_buf(), vec![index]);
902                unique_paths.push(*path);
903            }
904        }
905        let mut listings: Vec<DirectoryListing> = paths
906            .iter()
907            .map(|path| DirectoryListing {
908                path: path.to_path_buf(),
909                entries: Vec::new(),
910            })
911            .collect();
912        let mut entry_count = 0usize;
913        let mut path_bytes = 0usize;
914        // Bound the number of simultaneous first READDIR pages even when the
915        // caller supplies thousands of directory operands.
916        const DIRECTORY_COHORT: usize = 32;
917        for cohort in unique_paths.chunks(DIRECTORY_COHORT) {
918            let mut callback_error = None;
919            let requested = options
920                .entry_limit()
921                .saturating_sub(entry_count)
922                .saturating_add(1);
923            let result = self.lock()?.listdirv(
924                cohort,
925                fields | AttrMask::MODE | AttrMask::SIZE,
926                requested,
927                false,
928                &mut |attributes, directory| {
929                    let Some(indices) = positions.get(directory) else {
930                        callback_error = Some(VfError::transport(
931                            None,
932                            format!(
933                                "read_dirs backend returned an unexpected directory: {}",
934                                directory.display()
935                            ),
936                        ));
937                        return false;
938                    };
939                    if !cohort.contains(&directory) {
940                        callback_error = Some(VfError::transport(
941                            None,
942                            "read_dirs backend returned a directory outside the active cohort",
943                        ));
944                        return false;
945                    }
946                    let Some(path) = attributes.file.path() else {
947                        callback_error = Some(VfError::transport(
948                            None,
949                            "read_dirs backend returned an entry without a path",
950                        ));
951                        return false;
952                    };
953                    if path.parent() != Some(directory) {
954                        callback_error = Some(VfError::transport(
955                            None,
956                            format!(
957                                "read_dirs backend returned an entry outside {}",
958                                directory.display()
959                            ),
960                        ));
961                        return false;
962                    }
963                    for &index in indices {
964                        entry_count += 1;
965                        path_bytes = path_bytes.saturating_add(path.as_os_str().len());
966                        if entry_count > options.entry_limit()
967                            || path_bytes > options.path_byte_limit()
968                        {
969                            callback_error = Some(
970                                VfError::failure(index, libc::EFBIG as u32)
971                                    .with_context("read_dirs", directory),
972                            );
973                            return false;
974                        }
975                        listings[index].entries.push(DirEntry::new(
976                            path.to_path_buf(),
977                            vfsi_core::metadata_from_attrs(attributes.clone()),
978                        ));
979                    }
980                    true
981                },
982            );
983            if let Some(error) = callback_error {
984                return Err(error);
985            }
986            result.map_err(|error| {
987                error.map_index(|index| {
988                    cohort
989                        .get(index)
990                        .and_then(|path| positions.get(*path))
991                        .and_then(|indices| indices.first())
992                        .copied()
993                        .unwrap_or(index)
994                })
995            })?;
996        }
997        Ok(listings)
998    }
999
1000    /// Recursively enumerate directories with selected entry attributes.
1001    /// The walk is bounded by `options`; sorting and presentation remain the
1002    /// application's responsibility.
1003    pub fn walk_with_options(
1004        &self,
1005        root: impl AsRef<Path>,
1006        fields: AttrMask,
1007        options: crate::WalkOptions,
1008    ) -> VfResult<Vec<DirectoryListing>> {
1009        let root = root.as_ref();
1010        let tree = self.lock()?.walk_with_options(
1011            root,
1012            fields | AttrMask::MODE | AttrMask::SIZE,
1013            options,
1014            &mut |_, _| {},
1015        )?;
1016        tree.into_iter()
1017            .map(|directory| {
1018                let entries = directory
1019                    .entries
1020                    .into_iter()
1021                    .enumerate()
1022                    .map(|(index, attributes)| {
1023                        let path = attributes
1024                            .file
1025                            .path()
1026                            .ok_or_else(|| {
1027                                VfError::transport(
1028                                    None,
1029                                    format!("walk backend returned entry {index} without a path"),
1030                                )
1031                            })?
1032                            .to_path_buf();
1033                        Ok(DirEntry::new(
1034                            path,
1035                            vfsi_core::metadata_from_attrs(attributes),
1036                        ))
1037                    })
1038                    .collect::<VfResult<Vec<_>>>()?;
1039                Ok(DirectoryListing {
1040                    path: directory.path,
1041                    entries,
1042                })
1043            })
1044            .collect()
1045    }
1046
1047    /// Copy whole files in request order. A successful prefix may remain if
1048    /// a later request fails; this operation does not provide atomicity.
1049    pub fn copy_files<P: AsRef<Path>, Q: AsRef<Path>>(&self, pairs: &[(P, Q)]) -> VfResult<()> {
1050        let extents: Vec<_> = pairs
1051            .iter()
1052            .map(|(from, to)| {
1053                crate::ExtentPair::from_os_paths(from.as_ref(), 0, to.as_ref(), 0, None)
1054            })
1055            .collect();
1056        self.lock()?.copyv(&extents).map_err(|error| {
1057            error
1058                .index()
1059                .and_then(|index| pairs.get(index))
1060                .map_or(error.clone(), |(_, to)| {
1061                    error.with_context("copy_files", to.as_ref())
1062                })
1063        })
1064    }
1065
1066    /// Remove paths in request order, optionally recursing into directories.
1067    /// A successful prefix may remain if a later path fails.
1068    pub fn remove_paths<P: AsRef<Path>>(&self, paths: &[P], recursive: bool) -> VfResult<()> {
1069        self.remove_paths_with_options(paths, recursive, RemoveOptions::default())
1070    }
1071
1072    /// Remove paths with explicit error, batching, and retry policy.
1073    pub fn remove_paths_with_options<P: AsRef<Path>>(
1074        &self,
1075        paths: &[P],
1076        recursive: bool,
1077        options: RemoveOptions,
1078    ) -> VfResult<()> {
1079        let paths: Vec<&Path> = paths.iter().map(AsRef::as_ref).collect();
1080        self.lock()?
1081            .rm_with_options(&paths, recursive, options)
1082            .map_err(|error| {
1083                error
1084                    .index()
1085                    .and_then(|index| paths.get(index))
1086                    .map_or(error.clone(), |path| {
1087                        error.with_context("remove_paths", path)
1088                    })
1089            })
1090    }
1091
1092    /// Fetch no-follow metadata for many paths using the backend's vector
1093    /// operation. Useful for routing without one metadata RPC per path.
1094    pub fn symlink_metadatav(&self, paths: &[&Path]) -> VfResult<Vec<Metadata>> {
1095        let mut attrs: Vec<_> = paths
1096            .iter()
1097            .map(|path| crate::VfAttrs {
1098                file: VfFile::from_os_path(path),
1099                masks: AttrMask::MODE,
1100                ..crate::VfAttrs::default()
1101            })
1102            .collect();
1103        self.lock()?.lgetattrsv(&mut attrs).map_err(|error| {
1104            error
1105                .index()
1106                .and_then(|index| paths.get(index))
1107                .map_or(error.clone(), |path| {
1108                    error.with_context("symlink_metadatav", path)
1109                })
1110        })?;
1111        Ok(attrs
1112            .into_iter()
1113            .map(vfsi_core::metadata_from_attrs)
1114            .collect())
1115    }
1116}
1117
1118impl<F: VectorFileSystem> FsClient<F> {
1119    /// Open an ordered vector of files.
1120    ///
1121    /// Success returns one RAII handle per request. Failure returns no
1122    /// handles; VFSI does not promise transactional rollback of other
1123    /// filesystem effects such as file creation.
1124    pub fn openv(&self, requests: &[OpenRequest]) -> VfResult<Vec<FsFile<F>>> {
1125        let mut filesystem = self.lock()?;
1126        let files = filesystem.open_many(requests).map_err(|error| {
1127            error
1128                .index()
1129                .and_then(|index| requests.get(index))
1130                .map_or(error.clone(), |request| {
1131                    error.with_context("openv", &request.path)
1132                })
1133        })?;
1134        if files.len() != requests.len() {
1135            let error = wrong_result_count("openv", requests.len(), files.len());
1136            for file in &files {
1137                let _ = filesystem.close_one(file);
1138            }
1139            return Err(error);
1140        }
1141        drop(filesystem);
1142        Ok(files
1143            .into_iter()
1144            .zip(requests)
1145            .map(|(file, request)| FsFile {
1146                inner: Arc::clone(&self.inner),
1147                file: Some(file),
1148                path: request.path.clone(),
1149            })
1150            .collect())
1151    }
1152
1153    /// Try to close a group through one vector operation without consuming
1154    /// the handles. On failure, all handles remain armed: the backend may
1155    /// have closed a prefix, so callers must reconcile before retrying.
1156    pub fn try_closev<'a>(&self, files: impl IntoIterator<Item = &'a mut FsFile<F>>) -> VfResult<()>
1157    where
1158        F: 'a,
1159    {
1160        let mut files: Vec<_> = files.into_iter().collect();
1161        for (index, file) in files.iter().enumerate() {
1162            self.validate_owner(file, index)?;
1163        }
1164        let positions: Vec<_> = files
1165            .iter()
1166            .enumerate()
1167            .filter_map(|(index, file)| (!file.is_closed()).then_some(index))
1168            .collect();
1169        files.retain(|file| !file.is_closed());
1170        if files.is_empty() {
1171            return Ok(());
1172        }
1173        let descriptors: Vec<VfFile> = files
1174            .iter()
1175            .map(|file| file.raw().cloned())
1176            .collect::<VfResult<_>>()?;
1177        self.lock()?.close_many(&descriptors).map_err(|error| {
1178            error
1179                .index()
1180                .and_then(|index| files.get(index))
1181                .map_or(error.clone(), |file| {
1182                    error.with_context("closev", file.path())
1183                })
1184                .map_index(|index| positions.get(index).copied().unwrap_or(index))
1185        })?;
1186        for file in &mut files {
1187            file.file = None;
1188        }
1189        Ok(())
1190    }
1191
1192    /// Close a group of files through one vector operation.
1193    ///
1194    /// On failure, the handles are dropped and the backend receives
1195    /// best-effort scalar cleanup attempts. Use [`try_closev`](Self::try_closev)
1196    /// to retain the handles after an error.
1197    pub fn closev(&self, mut files: Vec<FsFile<F>>) -> VfResult<()> {
1198        self.try_closev(&mut files)
1199    }
1200
1201    /// Read an ordered vector with a 16 MiB aggregate request limit.
1202    /// Use [`readv_with_limit`](Self::readv_with_limit) to tune the limit or
1203    /// [`readv_into`](Self::readv_into) to provide bounded caller-owned buffers.
1204    pub fn readv(&self, requests: &[FsRead<'_, F>]) -> VfResult<Vec<FsReadResult>> {
1205        self.readv_with_limit(requests, self.limits.max_read_bytes)
1206    }
1207
1208    /// Read an ordered vector with an explicit aggregate request limit.
1209    pub fn readv_with_limit(
1210        &self,
1211        requests: &[FsRead<'_, F>],
1212        max_total_bytes: usize,
1213    ) -> VfResult<Vec<FsReadResult>> {
1214        self.readv_with_limit_projected(requests, max_total_bytes, |request| request)
1215    }
1216
1217    /// Backend adapter for opaque application requests; projection does not allocate.
1218    /// The projection must return the same embedded request on every invocation.
1219    pub fn readv_with_limit_projected<'b, T>(
1220        &self,
1221        requests: &[T],
1222        max_total_bytes: usize,
1223        project: impl for<'r> Fn(&'r T) -> &'r FsRead<'b, F>,
1224    ) -> VfResult<Vec<FsReadResult>>
1225    where
1226        F: 'b,
1227    {
1228        let mut requested = 0usize;
1229        for (index, request) in requests.iter().map(&project).enumerate() {
1230            requested = requested
1231                .checked_add(request.length)
1232                .ok_or_else(|| VfError::client(index, libc::EFBIG as u32))?;
1233            if requested > max_total_bytes {
1234                return Err(VfError::client(index, libc::EFBIG as u32)
1235                    .with_context("readv", request.file.path()));
1236            }
1237        }
1238        let reads = self.read_ops(requests, &project)?;
1239        let results = self.lock()?.read_many(&reads).map_err(|error| {
1240            error
1241                .index()
1242                .and_then(|index| requests.get(index).map(&project))
1243                .map_or(error.clone(), |request| {
1244                    error.with_context("readv", request.file.path())
1245                })
1246        })?;
1247        if results.len() != requests.len() {
1248            return Err(wrong_result_count("readv", requests.len(), results.len()));
1249        }
1250        validate_read_results("readv", &reads, &results).map_err(|error| {
1251            error
1252                .index()
1253                .and_then(|index| requests.get(index).map(&project))
1254                .map_or(error.clone(), |request| {
1255                    error.with_context("readv", request.file.path())
1256                })
1257        })?;
1258        Ok(results.into_iter().map(read_result).collect())
1259    }
1260
1261    fn read_ops<'b, T>(
1262        &self,
1263        requests: &[T],
1264        project: impl for<'r> Fn(&'r T) -> &'r FsRead<'b, F>,
1265    ) -> VfResult<Vec<ReadOp>>
1266    where
1267        F: 'b,
1268    {
1269        let mut reads = Vec::with_capacity(requests.len());
1270        for (index, request) in requests.iter().map(&project).enumerate() {
1271            self.validate_owner(request.file, index)?;
1272            reads.push(ReadOp::new(
1273                request.file.raw()?.clone(),
1274                request.offset,
1275                request.length,
1276            ));
1277        }
1278        Ok(reads)
1279    }
1280
1281    pub fn readv_into(
1282        &self,
1283        requests: &mut [FsReadInto<'_, F>],
1284    ) -> VfResult<Vec<FsReadIntoResult>> {
1285        self.readv_into_with_limit(requests, self.limits.max_read_bytes)
1286    }
1287
1288    /// Read into caller storage with an explicit aggregate buffer budget.
1289    /// This also bounds allocation in copying fallback implementations.
1290    pub fn readv_into_with_limit(
1291        &self,
1292        requests: &mut [FsReadInto<'_, F>],
1293        max_bytes: usize,
1294    ) -> VfResult<Vec<FsReadIntoResult>> {
1295        self.readv_into_with_limit_projected(
1296            requests,
1297            max_bytes,
1298            |request| request,
1299            |request| request,
1300        )
1301    }
1302
1303    /// Project borrowed buffers without an intermediate request allocation.
1304    /// Both projections must identify the same embedded request, and remain
1305    /// stable across preflight, dispatch, and result validation.
1306    pub fn readv_into_with_limit_projected<'b, T>(
1307        &self,
1308        requests: &mut [T],
1309        max_bytes: usize,
1310        project: impl for<'r> Fn(&'r T) -> &'r FsReadInto<'b, F>,
1311        mut project_mut: impl for<'r> FnMut(&'r mut T) -> &'r mut FsReadInto<'b, F>,
1312    ) -> VfResult<Vec<FsReadIntoResult>>
1313    where
1314        F: 'b,
1315    {
1316        let mut requested = 0usize;
1317        let mut reads = Vec::with_capacity(requests.len());
1318        for (index, request) in requests.iter().map(&project).enumerate() {
1319            self.validate_owner(request.file, index)?;
1320            requested = requested
1321                .checked_add(request.buffer.len())
1322                .ok_or_else(|| VfError::client(index, libc::EFBIG as u32))?;
1323            if requested > max_bytes {
1324                return Err(VfError::client(index, libc::EFBIG as u32));
1325            }
1326            reads.push(ReadOp::new(
1327                request.file.raw()?.clone(),
1328                request.offset,
1329                request.buffer.len(),
1330            ));
1331        }
1332        let results = {
1333            let mut buffers: Vec<&mut [u8]> = requests
1334                .iter_mut()
1335                .map(&mut project_mut)
1336                .map(|request| &mut *request.buffer)
1337                .collect();
1338            self.lock()?.read_many_into(&reads, &mut buffers)
1339        }
1340        .map_err(|error| {
1341            error
1342                .index()
1343                .and_then(|index| requests.get(index).map(&project))
1344                .map_or(error.clone(), |request| {
1345                    error.with_context("readv_into", request.file.path())
1346                })
1347        })?;
1348        validate_read_into_results("readv_into", &reads, &results).map_err(|error| {
1349            error
1350                .index()
1351                .and_then(|index| requests.get(index).map(&project))
1352                .map_or(error.clone(), |request| {
1353                    error.with_context("readv_into", request.file.path())
1354                })
1355        })?;
1356        Ok(results
1357            .into_iter()
1358            .map(|result| FsReadIntoResult {
1359                offset: result.offset,
1360                read: result.read,
1361                eof: result.eof,
1362            })
1363            .collect())
1364    }
1365
1366    pub fn writev(&self, requests: &[FsWrite<'_, F>]) -> VfResult<Vec<FsWriteResult>> {
1367        self.writev_projected(requests, |request| request)
1368    }
1369
1370    /// Backend adapter for opaque application requests, preserving borrowed payloads.
1371    /// The projection must return the same embedded request on every invocation.
1372    pub fn writev_projected<'b, T>(
1373        &self,
1374        requests: &[T],
1375        project: impl for<'r> Fn(&'r T) -> &'r FsWrite<'b, F>,
1376    ) -> VfResult<Vec<FsWriteResult>>
1377    where
1378        F: 'b,
1379    {
1380        let writes = self.write_ops(requests, &project)?;
1381        let results = self.lock()?.write_many(&writes).map_err(|error| {
1382            error
1383                .index()
1384                .and_then(|index| requests.get(index).map(&project))
1385                .map_or(error.clone(), |request| {
1386                    error.with_context("writev", request.file.path())
1387                })
1388        })?;
1389        if results.len() != requests.len() {
1390            return Err(wrong_result_count("writev", requests.len(), results.len()));
1391        }
1392        validate_write_results("writev", &writes, &results).map_err(|error| {
1393            error
1394                .index()
1395                .and_then(|index| requests.get(index).map(&project))
1396                .map_or(error.clone(), |request| {
1397                    error.with_context("writev", request.file.path())
1398                })
1399        })?;
1400        Ok(results.into_iter().map(write_result).collect())
1401    }
1402
1403    /// Write every byte in each positional request, retrying short writes in
1404    /// vector waves. Like `writev`, this is not transactional: an error may
1405    /// follow a successfully written prefix. Overlapping requests through the
1406    /// same path complete in input order; different paths are presumed
1407    /// independent (including hard-link aliases).
1408    pub fn write_allv(&self, requests: &[FsWrite<'_, F>]) -> VfResult<Vec<FsWriteResult>> {
1409        self.write_allv_projected(requests, |request| request)
1410    }
1411
1412    /// Complete projected requests with the same preflight and dependency waves.
1413    /// The projection must return the same embedded request on every invocation.
1414    pub fn write_allv_projected<'b, T>(
1415        &self,
1416        requests: &[T],
1417        project: impl for<'r> Fn(&'r T) -> &'r FsWrite<'b, F>,
1418    ) -> VfResult<Vec<FsWriteResult>>
1419    where
1420        F: 'b,
1421    {
1422        // Validate the entire batch before writing any prefix. In particular,
1423        // empty requests must not conceal a foreign or already-closed file.
1424        let mut prior_by_path: HashMap<&Path, Vec<(usize, u64, u64)>> = HashMap::new();
1425        let mut blocked_by = vec![Vec::new(); requests.len()];
1426        for (index, request) in requests.iter().map(&project).enumerate() {
1427            self.validate_owner(request.file, index)?;
1428            request.file.raw()?;
1429            let VfOffset::At(offset) = request.offset else {
1430                return Err(VfError::client(index, crate::ERR_INVAL)
1431                    .with_context("write_allv", request.file.path()));
1432            };
1433            let end = offset
1434                .checked_add(request.data.len() as u64)
1435                .ok_or_else(|| {
1436                    VfError::client(index, libc::EOVERFLOW as u32)
1437                        .with_context("write_allv", request.file.path())
1438                })?;
1439            // Non-overlapping writes commute. An overlapping later request
1440            // must wait until every earlier conflicting request is complete:
1441            // otherwise a short-write retry can overwrite the later bytes.
1442            let prior = prior_by_path.entry(request.file.path()).or_default();
1443            for &(earlier, start, earlier_end) in prior.iter() {
1444                if offset < earlier_end && start < end {
1445                    blocked_by[index].push(earlier);
1446                }
1447            }
1448            prior.push((index, offset, end));
1449        }
1450        let mut totals = vec![0usize; requests.len()];
1451        let mut stable = vec![true; requests.len()];
1452        let mut pending: Vec<usize> = (0..requests.len())
1453            .filter(|&index| !project(&requests[index]).data.is_empty())
1454            .collect();
1455        while !pending.is_empty() {
1456            let wave_indices: Vec<usize> = pending
1457                .iter()
1458                .copied()
1459                .filter(|&index| {
1460                    blocked_by[index]
1461                        .iter()
1462                        .all(|&earlier| totals[earlier] == project(&requests[earlier]).data.len())
1463                })
1464                .collect();
1465            let wave: Vec<_> = wave_indices
1466                .iter()
1467                .map(|&index| {
1468                    let request = project(&requests[index]);
1469                    let VfOffset::At(offset) = request.offset else {
1470                        return Err(VfError::client(index, crate::ERR_INVAL)
1471                            .with_context("write_allv", request.file.path()));
1472                    };
1473                    let offset = offset.checked_add(totals[index] as u64).ok_or_else(|| {
1474                        VfError::client(index, libc::EOVERFLOW as u32)
1475                            .with_context("write_allv", request.file.path())
1476                    })?;
1477                    Ok(FsWrite {
1478                        file: request.file,
1479                        offset: VfOffset::At(offset),
1480                        data: &request.data[totals[index]..],
1481                    })
1482                })
1483                .collect::<VfResult<_>>()?;
1484            let results = self.writev(&wave).map_err(|error| {
1485                error.map_index(|index| wave_indices.get(index).copied().unwrap_or(index))
1486            })?;
1487            for (&index, result) in wave_indices.iter().zip(results) {
1488                if result.written == 0 {
1489                    return Err(VfError::client(index, crate::ERR_IO)
1490                        .with_context("write_allv", project(&requests[index]).file.path()));
1491                }
1492                totals[index] += result.written;
1493                stable[index] &= result.stable;
1494            }
1495            pending.retain(|&index| totals[index] < project(&requests[index]).data.len());
1496        }
1497        requests
1498            .iter()
1499            .map(&project)
1500            .enumerate()
1501            .map(|(index, request)| {
1502                let VfOffset::At(offset) = request.offset else {
1503                    return Err(VfError::client(index, crate::ERR_INVAL)
1504                        .with_context("write_allv", request.file.path()));
1505                };
1506                Ok(FsWriteResult {
1507                    offset,
1508                    written: totals[index],
1509                    stable: stable[index],
1510                })
1511            })
1512            .collect()
1513    }
1514
1515    fn write_ops<'a, 'b, T>(
1516        &self,
1517        requests: &'a [T],
1518        project: impl for<'r> Fn(&'r T) -> &'r FsWrite<'b, F>,
1519    ) -> VfResult<Vec<WriteOpRef<'a>>>
1520    where
1521        F: 'b,
1522        'b: 'a,
1523    {
1524        let mut writes = Vec::with_capacity(requests.len());
1525        for (index, request) in requests.iter().map(&project).enumerate() {
1526            self.validate_owner(request.file, index)?;
1527            writes.push(WriteOpRef::new(
1528                request.file.raw()?,
1529                request.offset,
1530                request.data,
1531            ));
1532        }
1533        Ok(writes)
1534    }
1535
1536    fn validate_owner(&self, file: &FsFile<F>, index: usize) -> VfResult<()> {
1537        if Arc::ptr_eq(&self.inner, &file.inner) {
1538            Ok(())
1539        } else {
1540            Err(VfError::client(index, crate::ERR_INVAL))
1541        }
1542    }
1543}
1544
1545impl<F: VectorFileSystem + VecFs> FsClient<F> {
1546    /// Read several complete files by path using vector READ operations.
1547    ///
1548    /// The aggregate returned data is limited to 16 MiB by default. Use
1549    /// [`read_files_with_options`](Self::read_files_with_options) to choose a
1550    /// different limit, or stream large files instead. This is not a snapshot
1551    /// or an atomic operation across files.
1552    pub fn read_files<P: AsRef<Path>>(&self, paths: &[P]) -> VfResult<Vec<Vec<u8>>> {
1553        self.read_files_with_options(
1554            paths,
1555            ReadAllOptions::new().max_total_bytes(self.limits.max_read_bytes),
1556        )
1557    }
1558
1559    /// Read several complete files with an explicit aggregate allocation limit.
1560    pub fn read_files_with_options<P: AsRef<Path>>(
1561        &self,
1562        paths: &[P],
1563        options: ReadAllOptions,
1564    ) -> VfResult<Vec<Vec<u8>>> {
1565        let files: Vec<_> = paths
1566            .iter()
1567            .map(|path| VfFile::from_os_path(path.as_ref()))
1568            .collect();
1569        let result = self
1570            .lock()?
1571            .read_allv_with_options(&files, options)
1572            .map_err(|error| {
1573                error
1574                    .index()
1575                    .and_then(|index| paths.get(index))
1576                    .map_or(error.clone(), |path| {
1577                        error.with_context("read_files", path.as_ref())
1578                    })
1579            });
1580        result.and_then(|buffers| {
1581            if buffers.len() != paths.len() {
1582                Err(wrong_result_count("read_files", paths.len(), buffers.len()))
1583            } else {
1584                Ok(buffers)
1585            }
1586        })
1587    }
1588
1589    /// Replace several files from borrowed buffers using vector OPEN, WRITE,
1590    /// and CLOSE phases. Identical path spellings are rejected before opening
1591    /// anything; aliases such as hard links are still the caller's responsibility.
1592    /// The batch is not transactional: an error may follow files already
1593    /// created or written. Large inputs should be chunked by the caller rather
1594    /// than held in memory solely for this convenience method.
1595    pub fn write_files<P: AsRef<Path>, B: AsRef<[u8]>>(&self, entries: &[(P, B)]) -> VfResult<()> {
1596        let mut seen = HashSet::with_capacity(entries.len());
1597        for (index, (path, _)) in entries.iter().enumerate() {
1598            if !seen.insert(path.as_ref()) {
1599                return Err(VfError::client(index, crate::ERR_INVAL)
1600                    .with_context("write_files", path.as_ref()));
1601            }
1602        }
1603        let requests: Vec<_> = entries
1604            .iter()
1605            .map(|(path, _)| {
1606                OpenRequest::new(
1607                    path.as_ref(),
1608                    OpenFlags::WRITE | OpenFlags::CREATE | OpenFlags::TRUNCATE,
1609                )
1610            })
1611            .collect();
1612        let files = self.openv(&requests)?;
1613        let writes: Vec<_> = files
1614            .iter()
1615            .zip(entries)
1616            .map(|(file, (_, data))| file.write_request_at(0, data.as_ref()))
1617            .collect();
1618        let result = self.write_allv(&writes);
1619        drop(writes);
1620        let close_result = self.closev(files);
1621        result?;
1622        close_result
1623    }
1624}
1625
1626/// `std::fs::OpenOptions`-style builder tied to an [`FsClient`].
1627pub struct OpenOptions<'a, F: FileSystem> {
1628    client: &'a FsClient<F>,
1629    flags: OpenFlags,
1630    mode: u32,
1631}
1632
1633impl<F: FileSystem> Clone for OpenOptions<'_, F> {
1634    fn clone(&self) -> Self {
1635        Self {
1636            client: self.client,
1637            flags: self.flags,
1638            mode: self.mode,
1639        }
1640    }
1641}
1642
1643impl<F: FileSystem> fmt::Debug for OpenOptions<'_, F> {
1644    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1645        formatter
1646            .debug_struct("OpenOptions")
1647            .field("flags", &self.flags)
1648            .field("mode", &format_args!("{:#o}", self.mode))
1649            .finish()
1650    }
1651}
1652
1653impl<'a, F: FileSystem> OpenOptions<'a, F> {
1654    fn new(client: &'a FsClient<F>) -> Self {
1655        Self {
1656            client,
1657            flags: OpenFlags::empty(),
1658            mode: 0o666,
1659        }
1660    }
1661
1662    pub fn read(&mut self, enabled: bool) -> &mut Self {
1663        self.flags.set(OpenFlags::READ, enabled);
1664        self
1665    }
1666
1667    pub fn write(&mut self, enabled: bool) -> &mut Self {
1668        self.flags.set(OpenFlags::WRITE, enabled);
1669        self
1670    }
1671
1672    pub fn append(&mut self, enabled: bool) -> &mut Self {
1673        self.flags.set(OpenFlags::APPEND, enabled);
1674        self
1675    }
1676
1677    pub fn truncate(&mut self, enabled: bool) -> &mut Self {
1678        self.flags.set(OpenFlags::TRUNCATE, enabled);
1679        self
1680    }
1681
1682    pub fn create(&mut self, enabled: bool) -> &mut Self {
1683        self.flags.set(OpenFlags::CREATE, enabled);
1684        self
1685    }
1686
1687    pub fn create_new(&mut self, enabled: bool) -> &mut Self {
1688        self.flags.set(OpenFlags::CREATE_NEW, enabled);
1689        self
1690    }
1691
1692    pub fn mode(&mut self, mode: u32) -> &mut Self {
1693        self.mode = mode;
1694        self
1695    }
1696
1697    pub fn open(&self, path: impl AsRef<Path>) -> VfResult<FsFile<F>> {
1698        self.client
1699            .open_with(OpenRequest::new(path.as_ref(), self.flags).mode(self.mode))
1700    }
1701}
1702
1703impl<F: VectorFileSystem> OpenOptions<'_, F> {
1704    pub fn openv<P: AsRef<Path>>(&self, paths: &[P]) -> VfResult<Vec<FsFile<F>>> {
1705        let requests: Vec<OpenRequest> = paths
1706            .iter()
1707            .map(|path| OpenRequest::new(path.as_ref(), self.flags).mode(self.mode))
1708            .collect();
1709        self.client.openv(&requests)
1710    }
1711}
1712
1713/// Builder for an atomic metadata update request.
1714pub struct SetMetadata<'a, F: MetadataFileSystem> {
1715    client: &'a FsClient<F>,
1716    path: PathBuf,
1717    update: MetadataUpdate,
1718    follow: bool,
1719}
1720
1721impl<F: MetadataFileSystem> Clone for SetMetadata<'_, F> {
1722    fn clone(&self) -> Self {
1723        Self {
1724            client: self.client,
1725            path: self.path.clone(),
1726            update: self.update.clone(),
1727            follow: self.follow,
1728        }
1729    }
1730}
1731
1732impl<F: MetadataFileSystem> fmt::Debug for SetMetadata<'_, F> {
1733    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1734        formatter
1735            .debug_struct("SetMetadata")
1736            .field("path", &self.path)
1737            .field("update", &self.update)
1738            .field("follow", &self.follow)
1739            .finish_non_exhaustive()
1740    }
1741}
1742
1743impl<F: MetadataFileSystem> SetMetadata<'_, F> {
1744    pub fn permissions(&mut self, permissions: Permissions) -> &mut Self {
1745        self.update.permissions = Some(permissions);
1746        self
1747    }
1748
1749    pub fn len(&mut self, len: u64) -> &mut Self {
1750        self.update.len = Some(len);
1751        self
1752    }
1753
1754    pub fn accessed(&mut self, accessed: SystemTime) -> &mut Self {
1755        self.update.accessed = Some(accessed);
1756        self
1757    }
1758
1759    pub fn modified(&mut self, modified: SystemTime) -> &mut Self {
1760        self.update.modified = Some(modified);
1761        self
1762    }
1763
1764    pub fn follow_symlinks(&mut self, follow: bool) -> &mut Self {
1765        self.follow = follow;
1766        self
1767    }
1768
1769    pub fn apply(&self) -> VfResult<()> {
1770        self.client
1771            .lock()?
1772            .set_metadata_path(&self.path, self.update.clone(), self.follow)
1773    }
1774}
1775
1776/// Owned RAII file which does not borrow the client.
1777///
1778/// Dropping an open file attempts a best-effort CLOSE. This can block on the
1779/// backend mutex and on network I/O; use [`FsFile::try_close`] when the close
1780/// result matters or when its timing must be controlled.
1781pub struct FsFile<F: FileSystem> {
1782    inner: Arc<Mutex<F>>,
1783    file: Option<VfFile>,
1784    path: PathBuf,
1785}
1786
1787impl<F: FileSystem> fmt::Debug for FsFile<F> {
1788    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1789        formatter
1790            .debug_struct("FsFile")
1791            .field("path", &self.path)
1792            .field("is_open", &self.file.is_some())
1793            .finish()
1794    }
1795}
1796
1797impl<F: FileSystem> FsFile<F> {
1798    /// Whether explicit close has completed successfully on this handle.
1799    pub fn is_closed(&self) -> bool {
1800        self.file.is_none()
1801    }
1802    fn raw(&self) -> VfResult<&VfFile> {
1803        self.file
1804            .as_ref()
1805            .ok_or_else(|| VfError::client(0, crate::ERR_EBADF))
1806    }
1807
1808    /// Positional read which does not alter the file cursor.
1809    pub fn read_at(&self, buffer: &mut [u8], offset: u64) -> VfResult<usize> {
1810        self.read_into(buffer, VfOffset::At(offset))
1811    }
1812
1813    /// Positional write which does not alter the file cursor.
1814    pub fn write_at(&self, buffer: &[u8], offset: u64) -> VfResult<usize> {
1815        self.write_from(buffer, VfOffset::At(offset))
1816    }
1817
1818    pub fn read_request_at(&self, offset: u64, length: usize) -> FsRead<'_, F> {
1819        FsRead {
1820            file: self,
1821            offset: VfOffset::At(offset),
1822            length,
1823        }
1824    }
1825
1826    /// Name used at open, retained for diagnostics; not a current namespace
1827    /// lookup or proof of identity. Handle operations continue to use the
1828    /// opened object even if its pathname changes.
1829    pub fn path(&self) -> &Path {
1830        &self.path
1831    }
1832
1833    /// Query metadata for the open object without resolving its path again.
1834    pub fn metadata(&self) -> VfResult<Metadata> {
1835        let attributes = AttrMask::MODE
1836            | AttrMask::SIZE
1837            | AttrMask::NLINK
1838            | AttrMask::FILEID
1839            | AttrMask::UID
1840            | AttrMask::GID
1841            | AttrMask::ATIME
1842            | AttrMask::MTIME
1843            | AttrMask::CTIME
1844            | AttrMask::CHANGE;
1845        self.inner
1846            .lock()
1847            .map_err(|_| poisoned())?
1848            .metadata(MetadataQuery::new(self.raw()?.clone(), attributes))
1849            .map(vfsi_core::metadata_from_attrs)
1850            .map_err(|error| error.with_context("metadata", &self.path))
1851    }
1852
1853    /// Truncate or extend the open file.
1854    pub fn set_len(&self, len: u64) -> VfResult<()> {
1855        let mut update = SetAttributes::new(self.raw()?.clone());
1856        update.size = Some(len);
1857        self.inner
1858            .lock()
1859            .map_err(|_| poisoned())?
1860            .set_attributes(update)
1861            .map_err(|error| error.with_context("set_len", &self.path))
1862    }
1863
1864    /// Change permissions on the open file.
1865    pub fn set_permissions(&self, permissions: Permissions) -> VfResult<()> {
1866        let mut update = SetAttributes::new(self.raw()?.clone());
1867        update.mode = Some(permissions.mode());
1868        self.inner
1869            .lock()
1870            .map_err(|_| poisoned())?
1871            .set_attributes(update)
1872            .map_err(|error| error.with_context("set_permissions", &self.path))
1873    }
1874
1875    pub fn write_request_at<'a>(&'a self, offset: u64, data: &'a [u8]) -> FsWrite<'a, F> {
1876        FsWrite {
1877            file: self,
1878            offset: VfOffset::At(offset),
1879            data,
1880        }
1881    }
1882
1883    pub fn read_request_at_into<'a>(
1884        &'a self,
1885        offset: u64,
1886        buffer: &'a mut [u8],
1887    ) -> FsReadInto<'a, F> {
1888        FsReadInto {
1889            file: self,
1890            offset: VfOffset::At(offset),
1891            buffer,
1892        }
1893    }
1894
1895    pub fn read_native(&mut self, buffer: &mut [u8]) -> VfResult<usize> {
1896        self.read_into(buffer, VfOffset::Cur)
1897    }
1898
1899    /// Collect the remaining bytes from this opened object, starting at its
1900    /// cursor, with an explicit logical payload limit. This does not reopen
1901    /// its path. Unlike standard `Read::read_to_end`, allocation is bounded.
1902    ///
1903    /// An exact-limit read uses at most a one-byte EOF probe. On overflow or
1904    /// I/O failure no buffer is returned and the cursor may have advanced,
1905    /// including the probe byte; this operation does not restore the cursor.
1906    pub fn read_to_end_with_limit(&mut self, max_bytes: usize) -> VfResult<Vec<u8>> {
1907        let mut data = Vec::new();
1908        loop {
1909            let remaining = max_bytes - data.len();
1910            if remaining == 0 {
1911                let mut probe = [0_u8; 1];
1912                if self.read_native(&mut probe)? == 0 {
1913                    return Ok(data);
1914                }
1915                return Err(VfError::client(0, libc::EFBIG as u32)
1916                    .with_context("read_to_end_with_limit", &self.path));
1917            }
1918            let chunk = remaining.min(crate::DEFAULT_READ_STREAM_CHUNK_BYTES);
1919            data.try_reserve(chunk).map_err(|_| {
1920                VfError::client(0, libc::ENOMEM as u32)
1921                    .with_context("read_to_end_with_limit", &self.path)
1922            })?;
1923            let start = data.len();
1924            data.resize(start + chunk, 0);
1925            let count = self.read_native(&mut data[start..])?;
1926            data.truncate(start + count);
1927            if count == 0 {
1928                return Ok(data);
1929            }
1930        }
1931    }
1932
1933    pub fn write_native(&mut self, buffer: &[u8]) -> VfResult<usize> {
1934        self.write_from(buffer, VfOffset::Cur)
1935    }
1936
1937    fn read_into(&self, buffer: &mut [u8], offset: VfOffset) -> VfResult<usize> {
1938        if buffer.is_empty() {
1939            return Ok(0);
1940        }
1941        let request = ReadOp::new(self.raw()?.clone(), offset, buffer.len());
1942        let result = self
1943            .inner
1944            .lock()
1945            .map_err(|_| poisoned())?
1946            .read_one_into(&request, buffer)
1947            .map_err(|error| error.with_context("read", &self.path))?;
1948        validate_read_into_results(
1949            "read",
1950            std::slice::from_ref(&request),
1951            std::slice::from_ref(&result),
1952        )
1953        .map_err(|error| error.with_context("read", &self.path))?;
1954        Ok(result.read)
1955    }
1956
1957    fn write_from(&self, buffer: &[u8], offset: VfOffset) -> VfResult<usize> {
1958        if buffer.is_empty() {
1959            return Ok(0);
1960        }
1961        let file = self.raw()?.clone();
1962        let result = self
1963            .inner
1964            .lock()
1965            .map_err(|_| poisoned())?
1966            .write_one(WriteOpRef::new(&file, offset, buffer))
1967            .map_err(|error| error.with_context("write", &self.path))?;
1968        if result.written > buffer.len() {
1969            return Err(VfError::client(0, crate::ERR_IO).with_context("write", &self.path));
1970        }
1971        Ok(result.written)
1972    }
1973
1974    pub fn sync_data(&self) -> VfResult<()> {
1975        self.inner
1976            .lock()
1977            .map_err(|_| poisoned())?
1978            .sync_data(self.raw()?)
1979            .map_err(|error| error.with_context("sync_data", &self.path))
1980    }
1981
1982    pub fn sync_all(&self) -> VfResult<()> {
1983        self.inner
1984            .lock()
1985            .map_err(|_| poisoned())?
1986            .sync_all(self.raw()?)
1987            .map_err(|error| error.with_context("sync_all", &self.path))
1988    }
1989
1990    /// Attempt to close without consuming the handle. A failed close leaves
1991    /// the handle armed so the caller can reconcile or retry cleanup explicitly.
1992    /// After an ambiguous close failure, remote state is unknown: do not resume
1993    /// data I/O just because `is_closed()` is false. This is local ownership,
1994    /// not proof that the server still has the descriptor open.
1995    pub fn try_close(&mut self) -> VfResult<()> {
1996        let Some(file) = self.file.as_ref() else {
1997            return Ok(());
1998        };
1999        self.inner
2000            .lock()
2001            .map_err(|_| poisoned())?
2002            .close_one(file)
2003            .map_err(|error| error.with_context("close", &self.path))?;
2004        self.file = None;
2005        Ok(())
2006    }
2007
2008    /// Consume and close the handle. On failure, `Drop` makes one best-effort
2009    /// cleanup attempt; use [`try_close`](Self::try_close) to retain control.
2010    pub fn close(mut self) -> VfResult<()> {
2011        self.try_close()
2012    }
2013
2014    /// Seek while retaining [`VfError`] protocol and path information.
2015    pub fn seek_native(&mut self, position: IoSeekFrom) -> VfResult<u64> {
2016        self.inner
2017            .lock()
2018            .map_err(|_| poisoned())?
2019            .seek_one(self.raw()?, position)
2020            .map_err(|error| error.with_context("seek", &self.path))
2021    }
2022}
2023
2024impl<F: FileSystem> Read for FsFile<F> {
2025    fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
2026        self.read_native(buffer).map_err(io_error)
2027    }
2028}
2029
2030impl<F: FileSystem> Write for FsFile<F> {
2031    fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
2032        self.write_native(buffer).map_err(io_error)
2033    }
2034
2035    fn flush(&mut self) -> io::Result<()> {
2036        self.sync_data().map_err(io_error)
2037    }
2038}
2039
2040impl<F: FileSystem> Seek for FsFile<F> {
2041    fn seek(&mut self, position: IoSeekFrom) -> io::Result<u64> {
2042        self.seek_native(position).map_err(io_error)
2043    }
2044}
2045
2046/// Typed read request for [`FsClient::readv`].
2047pub struct FsRead<'a, F: FileSystem> {
2048    file: &'a FsFile<F>,
2049    offset: VfOffset,
2050    length: usize,
2051}
2052
2053/// Typed borrowed write request for [`FsClient::writev`].
2054pub struct FsWrite<'a, F: FileSystem> {
2055    file: &'a FsFile<F>,
2056    offset: VfOffset,
2057    data: &'a [u8],
2058}
2059
2060/// Typed vector read into caller-provided storage.
2061pub struct FsReadInto<'a, F: FileSystem> {
2062    file: &'a FsFile<F>,
2063    offset: VfOffset,
2064    buffer: &'a mut [u8],
2065}
2066
2067impl<F: FileSystem> Drop for FsFile<F> {
2068    fn drop(&mut self) {
2069        if let Some(file) = self.file.take()
2070            && let Ok(mut filesystem) = self.inner.lock()
2071        {
2072            let _ = filesystem.close_one(&file);
2073        }
2074    }
2075}
2076
2077#[cfg(test)]
2078mod traversal_tests {
2079    use super::*;
2080    use std::sync::atomic::{AtomicUsize, Ordering};
2081
2082    // The generic listdir_page fallback's cursor owns the full unconsumed
2083    // listing, not just its next page. Count these retained allocations.
2084    struct Snapshot {
2085        remaining: std::vec::IntoIter<DirEntry>,
2086        live: Arc<AtomicUsize>,
2087    }
2088    impl Drop for Snapshot {
2089        fn drop(&mut self) {
2090            self.live.fetch_sub(1, Ordering::SeqCst);
2091        }
2092    }
2093    fn snapshot_pages(
2094        live: Arc<AtomicUsize>,
2095        peak: Arc<AtomicUsize>,
2096    ) -> impl FnMut(&Path, Option<crate::DirPageCursor>, usize, usize) -> VfResult<DirectoryPage>
2097    {
2098        move |path, cursor, page_size, max_entries| {
2099            let mut snapshot = match cursor {
2100                Some(cursor) => cursor.into_state::<Snapshot>()?,
2101                None => {
2102                    let depth = path.components().count();
2103                    let entries = (0..129)
2104                        .take(max_entries)
2105                        .map(|index| {
2106                            let path = path.join(format!("entry-{index}"));
2107                            let attrs = crate::VfAttrs {
2108                                file: VfFile::from_os_path(&path),
2109                                ftype: if index == 0 && depth < 5 {
2110                                    crate::VfType::Directory
2111                                } else {
2112                                    crate::VfType::Regular
2113                                },
2114                                ..Default::default()
2115                            };
2116                            DirEntry::new(path, vfsi_core::metadata_from_attrs(attrs))
2117                        })
2118                        .collect::<Vec<_>>();
2119                    peak.fetch_max(live.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
2120                    Snapshot {
2121                        remaining: entries.into_iter(),
2122                        live: Arc::clone(&live),
2123                    }
2124                }
2125            };
2126            let entries = snapshot.remaining.by_ref().take(page_size).collect();
2127            let next = if snapshot.remaining.len() == 0 {
2128                None
2129            } else {
2130                Some(crate::DirPageCursor::new(snapshot))
2131            };
2132            Ok((entries, next))
2133        }
2134    }
2135    #[test]
2136    fn traversal_never_retains_multiple_fallback_snapshots() {
2137        let live = Arc::new(AtomicUsize::new(0));
2138        let peak = Arc::new(AtomicUsize::new(0));
2139        let mut seen = 0;
2140        let completion = visit_walk_pages(
2141            Path::new("/tree"),
2142            crate::WalkOptions::new().max_entries(1000),
2143            snapshot_pages(Arc::clone(&live), Arc::clone(&peak)),
2144            |_| {
2145                seen += 1;
2146                Ok(std::ops::ControlFlow::Continue(()))
2147            },
2148        )
2149        .unwrap();
2150        assert_eq!(completion, TraversalCompletion::Complete);
2151        assert_eq!(seen, 4 * 129);
2152        assert_eq!(live.load(Ordering::SeqCst), 0);
2153        assert_eq!(peak.load(Ordering::SeqCst), 1);
2154    }
2155    #[test]
2156    fn traversal_drops_fallback_snapshot_on_stop_error_and_limit() {
2157        for outcome in 0..3 {
2158            let live = Arc::new(AtomicUsize::new(0));
2159            let peak = Arc::new(AtomicUsize::new(0));
2160            let result = visit_walk_pages(
2161                Path::new("/tree"),
2162                crate::WalkOptions::new().max_entries(if outcome == 2 { 2 } else { 1000 }),
2163                snapshot_pages(Arc::clone(&live), peak),
2164                |_| match outcome {
2165                    0 => Ok(std::ops::ControlFlow::Break(())),
2166                    1 => Err(VfError::client(0, libc::EIO as u32)),
2167                    _ => Ok(std::ops::ControlFlow::Continue(())),
2168                },
2169            );
2170            match outcome {
2171                0 => assert_eq!(result.unwrap(), TraversalCompletion::Stopped),
2172                1 => assert_eq!(result.unwrap_err().err_no(), libc::EIO as u32),
2173                _ => assert_eq!(result.unwrap_err().err_no(), libc::EFBIG as u32),
2174            }
2175            assert_eq!(live.load(Ordering::SeqCst), 0);
2176        }
2177    }
2178}