Skip to main content

vfsi_sync/
application.rs

1//! Native execution of the portable VFSI contract.
2use std::path::Path;
3use vfsi_core::api::*;
4#[doc(hidden)]
5pub fn vector_index(error: vfsi_core::api::Error, index: usize) -> vfsi_core::api::Error {
6    if error.index().is_some() {
7        error.with_index(index)
8    } else {
9        error
10    }
11}
12use vfsi_core::internal::TraversalBudget;
13pub fn visit_directory_pages<P: AsRef<Path>>(
14    roots: &[P],
15    policy: vfsi_core::api::ListDirOptions,
16    limits: ResourceLimits,
17    mut capacity: impl FnMut(&[&Path]) -> Result<usize>,
18    mut validate_root: impl FnMut(&Path) -> Result<()>,
19    mut fetch: impl FnMut(
20        &[&Path],
21        Vec<Option<crate::DirPageCursor>>,
22        usize,
23        usize,
24        bool,
25    ) -> Result<Vec<crate::DirectoryPage>>,
26    mut callback: impl FnMut(usize, DirectoryListing) -> Result<std::ops::ControlFlow<()>>,
27) -> Result<Vec<crate::TraversalCompletion>> {
28    let directory = policy.directory_options(limits);
29    let walk = policy.walk_options(limits);
30    let mut budget = TraversalBudget::new(
31        directory.max_entries_or(vfsi_core::api::DEFAULT_DIRECTORY_MAX_ENTRIES),
32        directory.max_path_bytes_or(vfsi_core::api::DEFAULT_DIRECTORY_MAX_PATH_BYTES),
33    );
34    let mut completed = Vec::new();
35    let mut pending = std::collections::VecDeque::new();
36    for (index, root) in roots.iter().enumerate() {
37        pending.push_back((index, root.as_ref().to_path_buf(), 0usize, None));
38    }
39    while !pending.is_empty() {
40        let owner = pending.front().unwrap().0;
41        if policy.recursive() && pending.front().unwrap().2 == 0 {
42            validate_root(&pending.front().unwrap().1)
43                .map_err(|error| vector_index(error, owner))?;
44        }
45        // Recursive traversal completes one input root before starting another.
46        let candidate_paths: Vec<_> = pending
47            .iter()
48            .take_while(|state| !policy.recursive() || state.0 == owner)
49            .take(32)
50            .map(|state| state.1.as_path())
51            .collect();
52        let capacity = capacity(&candidate_paths)?.clamp(1, 32);
53        let cohort_size = if policy.recursive() {
54            pending
55                .iter()
56                .take(capacity)
57                .take_while(|state| state.0 == owner)
58                .count()
59        } else {
60            pending.len().min(capacity)
61        };
62        let mut cohort: Vec<_> = (0..cohort_size)
63            .map(|_| pending.pop_front().unwrap())
64            .collect();
65        let cursors = cohort.iter_mut().map(|state| state.3.take()).collect();
66        let paths: Vec<_> = cohort
67            .iter()
68            .map(|(_, path, _, _)| path.as_path())
69            .collect();
70        let requested = budget.remaining_entries().saturating_add(1);
71        let mut deferred_error = None;
72        // Only input roots may follow caller-permitted path components.
73        // Descendant paths were admitted from earlier no-follow metadata.
74        let follow_symlinks = policy.allow_symlink_components() && cohort[0].2 == 0;
75        let pages = match fetch(&paths, cursors, 1, requested, follow_symlinks) {
76            Ok(pages) => pages,
77            Err(error)
78                if !policy.recursive()
79                    && !error.is_transport()
80                    && error.index().is_some_and(|i| i > 0 && i < cohort.len()) =>
81            {
82                // A speculative later root must not hide an earlier callback's
83                // cancellation. Re-fetch only the known successful read-only
84                // prefix; this exceptional path never replays a mutation.
85                let failed = error.index().unwrap();
86                deferred_error = Some(vector_index(error, cohort[failed].0));
87                let prefix = fetch(
88                    &paths[..failed],
89                    (0..failed).map(|_| None).collect(),
90                    1,
91                    requested,
92                    follow_symlinks,
93                )
94                .map_err(|error| error.map_index(|i| cohort.get(i).map_or(i, |state| state.0)))?;
95                cohort.truncate(failed);
96                prefix
97            }
98            Err(error) => return Err(error.map_index(|i| cohort.get(i).map_or(i, |state| state.0))),
99        };
100        if pages.len() != cohort.len() {
101            return Err(vfsi_core::api::Error::transport(
102                None,
103                "invalid directory page result count",
104            ));
105        }
106        let mut children = Vec::new();
107        let mut wave: Vec<_> = cohort
108            .into_iter()
109            .map(|(index, path, depth, _)| (index, path, depth, true))
110            .zip(pages)
111            .collect();
112        while !wave.is_empty() {
113            let mut continuations = Vec::new();
114            for ((index, path, depth, first), (mut page, next, seeds)) in wave {
115                if first && policy.recursive() {
116                    budget.charge_path(&path).map_err(|error| {
117                        vector_index(error.with_context("vector traversal", &path), index)
118                    })?;
119                }
120                if page.path != path || (page.entries.is_empty() && next.is_some()) {
121                    return Err(vfsi_core::api::Error::transport(
122                        Some(index),
123                        "invalid directory page parent or progress",
124                    ));
125                }
126                let mut seeds: std::collections::HashMap<_, _> = seeds.into_iter().collect();
127                let mut page_error = None;
128                let mut accepted = 0;
129                for entry in &page.entries {
130                    if let Err(error) = budget.charge(entry.path()) {
131                        page_error = Some(vector_index(
132                            error.with_context("vector traversal", entry.path()),
133                            index,
134                        ));
135                        break;
136                    }
137                    accepted += 1;
138                    if policy.recursive() && entry.attrs().is_dir() {
139                        if depth >= walk.max_depth_or(Default::default()).get() {
140                            if !walk.truncate_at_max_depth() {
141                                page_error = Some(
142                                    vfsi_core::api::Error::client(index, libc::EFBIG as u32)
143                                        .with_context("visit_dirs", entry.path()),
144                                );
145                                break;
146                            }
147                        } else {
148                            children.push((
149                                index,
150                                entry.path().to_path_buf(),
151                                depth + 1,
152                                seeds.remove(entry.path()),
153                            ));
154                        }
155                    }
156                }
157                if let Some(error) = &page_error {
158                    if accepted == 0 {
159                        return Err(error.clone());
160                    }
161                    page.entries.truncate(accepted);
162                }
163                if !policy.recursive() && completed.len() <= index {
164                    completed.resize(index + 1, crate::TraversalCompletion::Stopped);
165                }
166                if callback(index, page)
167                    .map_err(|error| vector_index(error, index))?
168                    .is_break()
169                {
170                    if policy.recursive() {
171                        completed.truncate(index);
172                        completed.push(crate::TraversalCompletion::Stopped);
173                    } else {
174                        completed[index] = crate::TraversalCompletion::Stopped;
175                    }
176                    return Ok(completed);
177                }
178                if let Some(error) = page_error {
179                    return Err(error);
180                }
181                match next {
182                    Some(cursor) => continuations.push(((index, path, depth, false), Some(cursor))),
183                    None if !policy.recursive() => {
184                        completed[index] = crate::TraversalCompletion::Complete
185                    }
186                    None => {}
187                }
188            }
189            if continuations.is_empty() {
190                break;
191            }
192            let cursors = continuations
193                .iter_mut()
194                .map(|state| state.1.take())
195                .collect();
196            let paths: Vec<_> = continuations
197                .iter()
198                .map(|state| state.0.1.as_path())
199                .collect();
200            let requested = budget.remaining_entries().saturating_add(1);
201            let follow_symlinks = policy.allow_symlink_components() && continuations[0].0.2 == 0;
202            let pages = fetch(
203                &paths,
204                cursors,
205                requested.min(128),
206                requested,
207                follow_symlinks,
208            )
209            .map_err(|error| {
210                error.map_index(|i| continuations.get(i).map_or(i, |state| state.0.0))
211            })?;
212            if pages.len() != continuations.len() {
213                return Err(vfsi_core::api::Error::transport(
214                    None,
215                    "invalid directory continuation count",
216                ));
217            }
218            wave = continuations
219                .into_iter()
220                .map(|state| state.0)
221                .zip(pages)
222                .collect();
223        }
224        if let Some(error) = deferred_error {
225            return Err(error);
226        }
227        // Do not hold fallback snapshots while descending; all pages above
228        // were consumed and their cursors released before adding the frontier.
229        for child in children.into_iter().rev() {
230            pending.push_front(child);
231        }
232        if policy.recursive() && pending.front().is_none_or(|state| state.0 != owner) {
233            completed.push(crate::TraversalCompletion::Complete);
234        }
235    }
236    Ok(completed)
237}
238
239impl<F: crate::backend::HandleBackend> FileHandle for crate::OpenFile<F> {
240    crate::__vfsi_file_methods!(crate::OpenFile<F>);
241}
242
243impl<F: crate::backend::VectorBackend> vfsi_core::api::DirHandle for crate::FsDir<F> {
244    crate::__vfsi_file_methods!(crate::FsDir<F>);
245}
246
247impl<F: crate::backend::VectorBackend + 'static> Vfsi for crate::FsClient<F> {
248    type File = crate::OpenFile<F>;
249    type Dir = crate::FsDir<F>;
250    crate::__vfsi_client_methods!(
251        crate::FsClient<F>,
252        std::convert::identity,
253        read_backend::<F>,
254        std::convert::identity,
255        write_backend::<F>,
256        write_backend_all::<F>,
257        metadata_backend::<F, _>,
258        std::convert::identity,
259        |client: &Self, _: &[&Path]| client.directory_page_batch_size(),
260        Self::open_with_native,
261        Self::read_stream_with_options
262    );
263}
264
265/// Shared owned-read dispatcher for native clients and their opaque facades.
266#[doc(hidden)]
267pub fn read_backend_owned<F: crate::backend::VectorBackend + 'static>(
268    client: &crate::FsClient<F>,
269    requests: &[ReadRequest<'_, crate::FsRead<'_, F>>],
270    budget: usize,
271) -> Result<Vec<OwnedReadResult>> {
272    if requests.iter().all(|request| request.range_ref().is_some()) {
273        return client.vread_with_limit_projected_native(requests, budget, |request| {
274            request.range_ref().expect("checked ranges")
275        });
276    }
277    read_batch(
278        requests,
279        budget,
280        |ranges, bytes| client.vread_with_limit_projected_native(ranges, bytes, |request| request),
281        |paths, bytes| {
282            client.read_files_native(
283                paths,
284                crate::ReadAllOptions::new().with_max_total_bytes(bytes),
285            )
286        },
287    )
288}
289pub(crate) fn read_backend<'a, F: crate::backend::VectorBackend + 'static>(
290    client: &crate::FsClient<F>,
291    ops: impl IntoIterator<Item = ReadOp<'a, crate::OpenFile<F>>>,
292    options: ReadOptions,
293) -> Result<Vec<ReadResult>> {
294    consume_ops(
295        ops,
296        options.max_total_bytes_or(client.limits().max_read_bytes()),
297        crate::OpenFile::read_request_at,
298        crate::OpenFile::read_request_at_into,
299        |requests, options| read_backend_owned(client, requests, options),
300        |requests, bytes| client.vread_into_with_limit_native(requests, bytes),
301    )
302}
303
304// Keep low-level completion machinery private without leaking its historical
305// operation name through application errors. Preserve status, index, and path.
306#[doc(hidden)]
307pub fn public_write_error(error: vfsi_core::api::Error) -> vfsi_core::api::Error {
308    if error.operation() == Some("vwrite_all_native")
309        && let Some(path) = error.path().map(std::path::Path::to_path_buf)
310    {
311        error.with_context("vwrite_native", path)
312    } else {
313        error
314    }
315}
316
317pub(crate) fn write_backend<'a, F>(
318    client: &crate::FsClient<F>,
319    ops: &[WriteOp<'a, crate::OpenFile<F>>],
320) -> vfsi_core::api::Result<Vec<vfsi_core::api::WriteResult>>
321where
322    F: crate::backend::VectorBackend + 'static,
323{
324    client.vwrite_mapped_native(ops, |op| op.file().write_request_at(op.offset(), op.data()))
325}
326
327pub(crate) fn write_backend_all<'a, F>(
328    client: &crate::FsClient<F>,
329    ops: &[WriteOp<'a, crate::OpenFile<F>>],
330) -> vfsi_core::api::Result<Vec<vfsi_core::api::WriteResult>>
331where
332    F: crate::backend::VectorBackend + 'static,
333{
334    client.vwrite_all_mapped_native(ops, |op| op.file().write_request_at(op.offset(), op.data()))
335}
336
337pub(crate) fn metadata_backend<F, P: vfsi_core::AsTarget<crate::OpenFile<F>>>(
338    client: &crate::FsClient<F>,
339    paths: &[P],
340    options: AttrsOptions,
341) -> vfsi_core::api::Result<Vec<crate::Attrs>>
342where
343    F: crate::backend::VectorBackend + 'static,
344{
345    client.vgetattrs_native(paths, options.fields(), options.follow_final_symlink())
346}
347
348use vfsi_core::api::internal::{OwnedReadResult, ReadRequest, consume_ops, read_batch};
349#[cfg(test)]
350mod traversal_tests {
351    use super::*;
352    use crate::{AttrMask, DirEntry, DirPageCursor, FileOperand, VfAttrs, VfType};
353    use std::sync::{
354        Arc,
355        atomic::{AtomicUsize, Ordering},
356    };
357
358    // The fallback cursor owns the unconsumed listing. Track retained snapshots
359    // while exercising the same visitor the Vfsi adapter uses in production.
360    struct Snapshot {
361        remaining: std::vec::IntoIter<DirEntry>,
362        live: Arc<AtomicUsize>,
363    }
364
365    impl Drop for Snapshot {
366        fn drop(&mut self) {
367            self.live.fetch_sub(1, Ordering::SeqCst);
368        }
369    }
370
371    #[allow(clippy::type_complexity)] // Mirrors the page-fetch callback contract under test.
372    fn snapshot_pages(
373        live: Arc<AtomicUsize>,
374        peak: Arc<AtomicUsize>,
375    ) -> impl FnMut(
376        &[&Path],
377        Vec<Option<DirPageCursor>>,
378        usize,
379        usize,
380        bool,
381    ) -> Result<Vec<crate::DirectoryPage>> {
382        move |paths, cursors, page_size, max_entries, _follow_symlinks| {
383            let mut pages = Vec::with_capacity(paths.len());
384            for (path, cursor) in paths.iter().zip(cursors) {
385                let mut snapshot = match cursor {
386                    Some(cursor) => cursor.into_state::<Snapshot>()?,
387                    None => {
388                        let depth = path.components().count();
389                        let entries = (0..129)
390                            .take(max_entries)
391                            .map(|index| {
392                                let path = path.join(format!("entry-{index}"));
393                                let attrs = VfAttrs {
394                                    file: FileOperand::from_os_path(&path),
395                                    ftype: if index == 0 && depth < 5 {
396                                        VfType::Directory
397                                    } else {
398                                        VfType::Regular
399                                    },
400                                    masks: AttrMask::MODE,
401                                    ..Default::default()
402                                };
403                                DirEntry::new(path, vfsi_core::metadata_from_attrs(attrs))
404                            })
405                            .collect::<Vec<_>>();
406                        peak.fetch_max(live.fetch_add(1, Ordering::SeqCst) + 1, Ordering::SeqCst);
407                        Snapshot {
408                            remaining: entries.into_iter(),
409                            live: Arc::clone(&live),
410                        }
411                    }
412                };
413                let entries = snapshot.remaining.by_ref().take(page_size).collect();
414                let next = if snapshot.remaining.len() == 0 {
415                    None
416                } else {
417                    Some(DirPageCursor::new(snapshot))
418                };
419                pages.push((
420                    crate::DirectoryListing {
421                        path: path.to_path_buf(),
422                        entries,
423                    },
424                    next,
425                    Vec::new(),
426                ));
427            }
428            Ok(pages)
429        }
430    }
431
432    fn visit(
433        live: Arc<AtomicUsize>,
434        peak: Arc<AtomicUsize>,
435        options: ListDirOptions,
436        callback: impl FnMut(usize, crate::DirectoryListing) -> Result<std::ops::ControlFlow<()>>,
437    ) -> Result<Vec<crate::TraversalCompletion>> {
438        visit_directory_pages(
439            &["/tree"],
440            options,
441            ResourceLimits::default(),
442            |_| Ok(1),
443            |_| Ok(()),
444            snapshot_pages(live, peak),
445            callback,
446        )
447    }
448
449    #[test]
450    fn recursive_visit_releases_fallback_snapshots_before_descending() {
451        let live = Arc::new(AtomicUsize::new(0));
452        let peak = Arc::new(AtomicUsize::new(0));
453        let mut seen = 0;
454        let completions = visit(
455            Arc::clone(&live),
456            Arc::clone(&peak),
457            ListDirOptions::new()
458                .with_recursive(true)
459                .with_max_entries(1000)
460                .with_max_path_bytes(1_000_000),
461            |_, page| {
462                seen += page.entries.len();
463                Ok(std::ops::ControlFlow::Continue(()))
464            },
465        )
466        .unwrap();
467
468        assert_eq!(completions, [crate::TraversalCompletion::Complete]);
469        assert_eq!(seen, 4 * 129);
470        assert_eq!(live.load(Ordering::SeqCst), 0);
471        assert_eq!(peak.load(Ordering::SeqCst), 1);
472    }
473
474    #[test]
475    fn recursive_visit_drops_fallback_snapshot_on_stop_error_and_limit() {
476        for outcome in 0..4 {
477            let live = Arc::new(AtomicUsize::new(0));
478            let peak = Arc::new(AtomicUsize::new(0));
479            let options = ListDirOptions::new()
480                .with_recursive(true)
481                .with_max_entries(if outcome == 2 { 2 } else { 1000 })
482                // Root (5 bytes) and two entry paths (13 bytes each) fit.
483                .with_max_path_bytes(if outcome == 3 { 31 } else { 1_000_000 });
484            let mut fetch = snapshot_pages(Arc::clone(&live), peak);
485            let mut callbacks = 0;
486            let mut live_budget_page = false;
487            let result = visit_directory_pages(
488                &["/tree"],
489                options,
490                ResourceLimits::default(),
491                |_| Ok(1),
492                |_| Ok(()),
493                |paths, cursors, page_size, max_entries, follow_symlinks| {
494                    // A legal short page leaves a live snapshot when the byte
495                    // limit rejects its second entry, after accepting the first.
496                    let pages = fetch(
497                        paths,
498                        cursors,
499                        page_size.min(2),
500                        max_entries,
501                        follow_symlinks,
502                    )?;
503                    if outcome == 3 && pages[0].0.entries.len() == 2 {
504                        assert!(pages[0].1.is_some());
505                        live_budget_page = true;
506                    }
507                    Ok(pages)
508                },
509                |_, page| {
510                    if outcome != 2 {
511                        assert_eq!(live.load(Ordering::SeqCst), 1);
512                    }
513                    assert_eq!(page.entries.len(), 1);
514                    assert_eq!(
515                        page.entries[0].path(),
516                        Path::new(&format!("/tree/entry-{callbacks}"))
517                    );
518                    callbacks += 1;
519                    match outcome {
520                        0 => Ok(std::ops::ControlFlow::Break(())),
521                        1 => Err(vfsi_core::api::Error::client(0, libc::EIO as u32)),
522                        _ => Ok(std::ops::ControlFlow::Continue(())),
523                    }
524                },
525            );
526            match outcome {
527                0 => assert_eq!(result.unwrap(), [crate::TraversalCompletion::Stopped]),
528                1 => assert_eq!(result.unwrap_err().raw_code(), libc::EIO as u32),
529                _ => {
530                    let error = result.unwrap_err();
531                    assert_eq!(error.raw_code(), libc::EFBIG as u32);
532                    assert_eq!(error.path(), Some(Path::new("/tree/entry-2")));
533                    if outcome == 3 {
534                        assert!(live_budget_page);
535                    }
536                }
537            }
538            assert_eq!(callbacks, if outcome >= 2 { 2 } else { 1 });
539            assert_eq!(live.load(Ordering::SeqCst), 0);
540        }
541    }
542    #[test]
543    fn recursive_pages_keep_root_policy_and_reject_descendant_symlinks() {
544        for recursive in [false, true] {
545            for allow in [false, true] {
546                let live = Arc::new(AtomicUsize::new(0));
547                let peak = Arc::new(AtomicUsize::new(0));
548                let mut fetch = snapshot_pages(live, peak);
549                let mut observed = Vec::new();
550                visit_directory_pages(
551                    &["/tree"],
552                    ListDirOptions::new()
553                        .with_recursive(recursive)
554                        .with_allow_symlink_components(allow),
555                    ResourceLimits::default(),
556                    |_| Ok(1),
557                    |_| Ok(()),
558                    |paths, cursors, page_size, max_entries, follow| {
559                        for path in paths {
560                            observed.push((path.to_path_buf(), follow));
561                        }
562                        fetch(paths, cursors, page_size, max_entries, follow)
563                    },
564                    |_, _| Ok(std::ops::ControlFlow::Continue(())),
565                )
566                .unwrap();
567                assert!(observed.len() > 1, "exercise continuation fetches");
568                assert_eq!(
569                    observed.iter().any(|(path, _)| path != Path::new("/tree")),
570                    recursive
571                );
572                for (path, follow) in observed {
573                    assert_eq!(follow, allow && path == Path::new("/tree"));
574                }
575            }
576        }
577    }
578}