Skip to main content

acyclic_fs/
native_capture.rs

1//! Bounded host-state capture into one atomic sparse checkout transaction.
2
3use crate::kernel::{
4    FileKind, FileMetadata, FileRecord, LogicalName, MetadataField, NameEncoding, NamespacePath,
5};
6use crate::model::FilesystemProfile;
7use crate::native_host::{HostDataRange, HostRoot, allocated_data_ranges};
8use crate::{
9    AsyncAuthorityStore, AsyncObjectStore, AuthoredMutation, CancellationToken, Checkout,
10    ContentStager, NativeRootIdentity, OperationFailure, OperationReceipt, StagedContent,
11    WatchBatch, WatchChange, WatchEpoch, WatchInvalidationReason, WatchSequence, WorkBudget,
12    WorkCounters, WorkError,
13};
14use futures::{StreamExt as _, stream};
15
16/// Returns the stable identity of a no-follow, capability-held capture root.
17///
18/// # Errors
19///
20/// Rejects symlink/reparse roots, non-directories, and platforms unable to
21/// report a stable root identity.
22pub fn capture_root_identity(path: &Path) -> Result<NativeRootIdentity, CaptureError> {
23    HostRoot::open(path)
24        .map(|root| root.identity())
25        .map_err(|_| CaptureError::InvalidOptions)
26}
27use std::collections::{BTreeMap, BTreeSet};
28use std::path::{Path, PathBuf};
29use thiserror::Error;
30
31#[cfg(feature = "native-mount")]
32pub(crate) const MAX_NATIVE_EXACT_CAPTURE_PATHS: usize = 65_536;
33
34/// A native view may have host-generated timestamps that are not SDK metadata.
35/// The held root identity binds this policy to the prepared view without a
36/// second full-tree scan after materialization.
37#[cfg(unix)]
38pub(crate) struct NativeViewBaseline {
39    root_identity: NativeRootIdentity,
40}
41
42#[cfg(windows)]
43pub(crate) struct NativeViewBaseline;
44
45#[cfg(unix)]
46impl NativeViewBaseline {
47    pub(crate) fn new(root_identity: NativeRootIdentity) -> Self {
48        Self { root_identity }
49    }
50
51    fn restore_canonical_stamps(
52        &self,
53        root_identity: NativeRootIdentity,
54        _path: &Path,
55        _snapshot: &HostSnapshot,
56        prior: FileMetadata,
57        observed: &mut FileMetadata,
58    ) {
59        if self.root_identity != root_identity {
60            return;
61        }
62        observed.changed_ns = prior.changed_ns;
63        #[cfg(target_os = "linux")]
64        {
65            observed.created_ns = prior.created_ns;
66        }
67    }
68}
69
70#[cfg(all(test, unix))]
71mod native_view_baseline_tests {
72    use super::*;
73
74    #[test]
75    fn native_view_keeps_canonical_stamps_for_the_same_root()
76    -> Result<(), Box<dyn std::error::Error>> {
77        let directory = tempfile::tempdir()?;
78        std::fs::write(directory.path().join("file"), b"body")?;
79        let root = HostRoot::open(directory.path())?;
80        let identity = root.identity();
81        let baseline = NativeViewBaseline::new(identity);
82        let snapshot = HostSnapshot::from_metadata(&root.symlink_metadata(Path::new("file"))?)?;
83        let prior = FileMetadata {
84            changed_ns: MetadataField::Value(-123),
85            created_ns: MetadataField::Value(-456),
86            ..FileMetadata::default()
87        };
88        let mut observed = snapshot.metadata;
89        baseline.restore_canonical_stamps(
90            identity,
91            Path::new("file"),
92            &snapshot,
93            prior,
94            &mut observed,
95        );
96        assert_eq!(observed.changed_ns, prior.changed_ns);
97        #[cfg(target_os = "linux")]
98        assert_eq!(observed.created_ns, prior.created_ns);
99
100        let mut changed = snapshot;
101        changed.metadata.changed_ns = MetadataField::Value(-789);
102        let mut observed = changed.metadata;
103        baseline.restore_canonical_stamps(
104            identity,
105            Path::new("file"),
106            &changed,
107            prior,
108            &mut observed,
109        );
110        assert_eq!(observed.changed_ns, prior.changed_ns);
111
112        let wrong_identity = NativeRootIdentity {
113            device: identity.device,
114            object: identity.object.wrapping_add(1),
115        };
116        let mut observed = snapshot.metadata;
117        baseline.restore_canonical_stamps(
118            wrong_identity,
119            Path::new("file"),
120            &snapshot,
121            prior,
122            &mut observed,
123        );
124        assert_eq!(observed.changed_ns, snapshot.metadata.changed_ns);
125        Ok(())
126    }
127}
128
129/// Exact native capture options.
130#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct CaptureOptions {
132    /// Materialized checkout root owned by the caller.
133    pub source_root: PathBuf,
134    /// Stable identity that the capability actually used for capture must own.
135    pub expected_root_identity: NativeRootIdentity,
136    /// Maximum changed paths admitted in one atomic transaction.
137    pub maximum_paths: u32,
138    /// Maximum physically allocated host ranges admitted per regular file.
139    pub maximum_extent_spans: u32,
140}
141
142/// Canonical path eligibility for native capture.
143///
144/// Excluded prefixes are omitted symmetrically from host discovery, checkout
145/// discovery, explicit capture, and watcher reconciliation. An excluded
146/// checkout path is therefore never mistaken for a host-side deletion.
147#[derive(Clone, Debug, Default, Eq, PartialEq)]
148pub struct CapturePolicy {
149    excluded_prefixes: Vec<NamespacePath>,
150}
151
152impl CapturePolicy {
153    /// Admits every path.
154    #[must_use]
155    pub const fn allow_all() -> Self {
156        Self {
157            excluded_prefixes: Vec::new(),
158        }
159    }
160
161    /// Creates a policy from canonical namespace prefixes.
162    ///
163    /// Redundant descendants are removed so equivalent policies have one
164    /// stable fingerprint. The volume root cannot be excluded.
165    ///
166    /// # Errors
167    ///
168    /// Rejects an attempt to exclude the complete capture root.
169    pub fn excluding(mut prefixes: Vec<NamespacePath>) -> Result<Self, CaptureError> {
170        if prefixes.iter().any(NamespacePath::is_root) {
171            return Err(CaptureError::InvalidOptions);
172        }
173        prefixes.sort();
174        prefixes.dedup();
175        let mut canonical = Vec::<NamespacePath>::new();
176        for prefix in prefixes {
177            if canonical
178                .last()
179                .is_some_and(|ancestor| prefix.is_within(ancestor))
180            {
181                continue;
182            }
183            canonical.push(prefix);
184        }
185        Ok(Self {
186            excluded_prefixes: canonical,
187        })
188    }
189
190    /// Returns whether a path is excluded by an exact prefix.
191    #[must_use]
192    pub fn excludes(&self, path: &NamespacePath) -> bool {
193        let candidate = self
194            .excluded_prefixes
195            .partition_point(|prefix| prefix <= path)
196            .checked_sub(1)
197            .and_then(|index| self.excluded_prefixes.get(index));
198        candidate.is_some_and(|prefix| path.is_within(prefix))
199    }
200
201    /// Stable semantic identity used by durable source binding.
202    #[must_use]
203    pub fn fingerprint(&self) -> crate::foundation::Digest {
204        let mut hasher = blake3::Hasher::new();
205        hasher.update(b"acyclic-fs-capture-policy-v1\0");
206        for prefix in &self.excluded_prefixes {
207            hasher.update(&(prefix.depth() as u64).to_le_bytes());
208            for component in prefix.components() {
209                hasher.update(&[match component.encoding() {
210                    NameEncoding::Utf8 => 1,
211                    NameEncoding::PosixBytes => 2,
212                    NameEncoding::WindowsUtf16Le => 3,
213                }]);
214                hasher.update(&(component.as_bytes().len() as u64).to_le_bytes());
215                hasher.update(component.as_bytes());
216            }
217        }
218        crate::foundation::Digest::from_bytes(*hasher.finalize().as_bytes())
219    }
220}
221
222#[cfg(test)]
223mod capture_policy_tests {
224    use super::*;
225
226    fn path(value: &str) -> Result<NamespacePath, Box<dyn std::error::Error>> {
227        let limits = crate::model::VolumeLimits::default();
228        Ok(NamespacePath::from_portable(
229            &crate::path::PortablePath::parse(value, limits)?,
230            limits,
231        )?)
232    }
233
234    #[test]
235    fn canonical_policy_uses_the_nearest_sorted_prefix() -> Result<(), Box<dyn std::error::Error>> {
236        let policy = CapturePolicy::excluding(vec![
237            path("/z/private/nested")?,
238            path("/a/cache")?,
239            path("/z/private")?,
240            path("/a/cache/deeper")?,
241            path("/m")?,
242        ])?;
243
244        assert_eq!(
245            policy.excluded_prefixes,
246            vec![path("/a/cache")?, path("/m")?, path("/z/private")?]
247        );
248        assert!(policy.excludes(&path("/a/cache/object")?));
249        assert!(policy.excludes(&path("/m")?));
250        assert!(policy.excludes(&path("/z/private/nested/object")?));
251        assert!(!policy.excludes(&path("/a/cached")?));
252        assert!(!policy.excludes(&path("/n")?));
253        assert!(!policy.excludes(&path("/z/public")?));
254        Ok(())
255    }
256
257    #[test]
258    fn subtree_roots_collapse_duplicates_and_descendants() -> Result<(), Box<dyn std::error::Error>>
259    {
260        let policy = CapturePolicy::excluding(vec![path("/excluded")?])?;
261        assert_eq!(
262            canonical_subtree_roots(
263                &[
264                    path("/z/child")?,
265                    path("/a")?,
266                    path("/z")?,
267                    path("/a")?,
268                    path("/excluded/child")?,
269                ],
270                &policy,
271            ),
272            vec![path("/a")?, path("/z")?]
273        );
274        Ok(())
275    }
276
277    #[test]
278    fn combined_host_and_checkout_paths_share_one_limit() {
279        assert!(!combined_path_count_exceeds(2, 1, 3));
280        assert!(combined_path_count_exceeds(2, 2, 3));
281        assert!(combined_path_count_exceeds(usize::MAX, 1, usize::MAX));
282    }
283
284    #[test]
285    fn checkout_union_deduplicates_before_enforcing_the_path_limit()
286    -> Result<(), Box<dyn std::error::Error>> {
287        let duplicate = path("/same")?;
288        let ordered = order_host_checkout_union(
289            BTreeMap::new(),
290            vec![duplicate.clone(), duplicate.clone(), duplicate.clone()],
291            1,
292        )?;
293        assert_eq!(ordered.len(), 1);
294        assert!(ordered[0].0.is_none());
295        assert_eq!(ordered[0].1, duplicate);
296        Ok(())
297    }
298
299    #[test]
300    fn scanned_set_charges_only_unique_paths_at_the_limit() -> Result<(), Box<dyn std::error::Error>>
301    {
302        let duplicate = path("/same")?;
303        let mut paths = BTreeSet::new();
304        let mut work = WorkCounters::default();
305        append_scanned_path(
306            &mut paths,
307            duplicate.clone(),
308            1,
309            &mut work,
310            WorkBudget::UNBOUNDED,
311        )?;
312        let charged = work;
313        append_scanned_path(&mut paths, duplicate, 1, &mut work, WorkBudget::UNBOUNDED)?;
314        assert_eq!(work, charged);
315        let failure = append_scanned_path(
316            &mut paths,
317            path("/other")?,
318            1,
319            &mut work,
320            WorkBudget::UNBOUNDED,
321        )
322        .err()
323        .ok_or("distinct path exceeded the limit")?;
324        assert!(matches!(failure.error, CaptureError::InvalidOptions));
325        assert_eq!(*failure.work, charged);
326        assert_eq!(paths.len(), 1);
327        Ok(())
328    }
329
330    #[tokio::test]
331    async fn watch_hint_observes_only_missing_ancestors_of_a_nested_file()
332    -> Result<(), Box<dyn std::error::Error>> {
333        let temporary = tempfile::tempdir()?;
334        std::fs::create_dir(temporary.path().join("sub"))?;
335        std::fs::write(temporary.path().join("sub/changed.txt"), b"changed")?;
336        std::fs::write(temporary.path().join("sub/unrelated.txt"), b"unrelated")?;
337        let workspace = crate::Fs::memory()
338            .create_workspace("watch-ancestor")
339            .await?;
340        let mut checkout = workspace
341            .checkout(
342                crate::model::GenerationSelector::Head,
343                crate::model::CheckoutMode::tracking_transaction(),
344            )
345            .await?;
346        let changed = path("/sub/changed.txt")?;
347        capture_watch_batch(
348            &mut checkout,
349            WatchBatch::Changes {
350                epoch: WatchEpoch::from_u64(1),
351                first_sequence: WatchSequence::from_u64(1),
352                next_sequence: WatchSequence::from_u64(2),
353                changes: vec![WatchChange::Modified(changed.clone())],
354            },
355            &CaptureOptions {
356                source_root: temporary.path().to_path_buf(),
357                expected_root_identity: capture_root_identity(temporary.path())?,
358                maximum_paths: 2,
359                maximum_extent_spans: 8,
360            },
361            WorkBudget::UNBOUNDED,
362            &CancellationToken::new(),
363        )
364        .await?;
365        let token = CancellationToken::new();
366        let parent = checkout
367            .lookup_no_follow(&path("/sub")?, WorkBudget::UNBOUNDED, &token)
368            .await?;
369        assert_eq!(
370            parent.value.record.map(|record| record.kind),
371            Some(FileKind::Directory)
372        );
373        let file = checkout
374            .lookup_no_follow(&changed, WorkBudget::UNBOUNDED, &token)
375            .await?;
376        assert_eq!(
377            file.value.record.map(|record| record.kind),
378            Some(FileKind::Regular)
379        );
380        let unrelated = checkout
381            .lookup_no_follow(&path("/sub/unrelated.txt")?, WorkBudget::UNBOUNDED, &token)
382            .await?;
383        assert!(unrelated.value.record.is_none());
384        Ok(())
385    }
386
387    #[tokio::test]
388    async fn watch_rename_creates_an_unobserved_destination_parent_first()
389    -> Result<(), Box<dyn std::error::Error>> {
390        let temporary = tempfile::tempdir()?;
391        std::fs::create_dir(temporary.path().join("sub"))?;
392        std::fs::write(temporary.path().join("old.txt"), b"old")?;
393        let workspace = crate::Fs::memory()
394            .create_workspace("watch-rename-parent")
395            .await?;
396        workspace.write_text("/old.txt", "old").await?;
397        let mut checkout = workspace
398            .checkout(
399                crate::model::GenerationSelector::Head,
400                crate::model::CheckoutMode::tracking_transaction(),
401            )
402            .await?;
403        std::fs::rename(
404            temporary.path().join("old.txt"),
405            temporary.path().join("sub/new.txt"),
406        )?;
407        let old = path("/old.txt")?;
408        let new = path("/sub/new.txt")?;
409        capture_watch_batch(
410            &mut checkout,
411            WatchBatch::Changes {
412                epoch: WatchEpoch::from_u64(1),
413                first_sequence: WatchSequence::from_u64(1),
414                next_sequence: WatchSequence::from_u64(2),
415                changes: vec![WatchChange::Renamed {
416                    from: old.clone(),
417                    to: new.clone(),
418                }],
419            },
420            &CaptureOptions {
421                source_root: temporary.path().to_path_buf(),
422                expected_root_identity: capture_root_identity(temporary.path())?,
423                maximum_paths: 2,
424                maximum_extent_spans: 8,
425            },
426            WorkBudget::UNBOUNDED,
427            &CancellationToken::new(),
428        )
429        .await?;
430        let token = CancellationToken::new();
431        assert!(
432            checkout
433                .lookup_no_follow(&old, WorkBudget::UNBOUNDED, &token)
434                .await?
435                .value
436                .record
437                .is_none()
438        );
439        assert!(
440            checkout
441                .lookup_no_follow(&new, WorkBudget::UNBOUNDED, &token)
442                .await?
443                .value
444                .record
445                .is_some()
446        );
447        Ok(())
448    }
449
450    #[tokio::test]
451    async fn watch_directory_rename_then_child_change_keeps_the_renamed_directory()
452    -> Result<(), Box<dyn std::error::Error>> {
453        let temporary = tempfile::tempdir()?;
454        std::fs::create_dir(temporary.path().join("old"))?;
455        std::fs::write(temporary.path().join("old/file.txt"), b"old")?;
456        let workspace = crate::Fs::memory()
457            .create_workspace("watch-renamed-directory")
458            .await?;
459        let mut transaction = workspace
460            .begin_transaction(crate::IdempotencyKey::new())
461            .await?;
462        transaction.create_dir_all("/old").await?;
463        transaction.write_text("/old/file.txt", "old").await?;
464        transaction.commit().await?;
465        let mut checkout = workspace
466            .checkout(
467                crate::model::GenerationSelector::Head,
468                crate::model::CheckoutMode::tracking_transaction(),
469            )
470            .await?;
471        assert!(
472            checkout
473                .lookup_no_follow(
474                    &path("/old")?,
475                    WorkBudget::UNBOUNDED,
476                    &CancellationToken::new()
477                )
478                .await?
479                .value
480                .record
481                .is_some()
482        );
483        std::fs::rename(temporary.path().join("old"), temporary.path().join("new"))?;
484        std::fs::write(temporary.path().join("new/file.txt"), b"new")?;
485        let old = path("/old")?;
486        let new = path("/new")?;
487        let child = path("/new/file.txt")?;
488        capture_watch_batch(
489            &mut checkout,
490            WatchBatch::Changes {
491                epoch: WatchEpoch::from_u64(1),
492                first_sequence: WatchSequence::from_u64(1),
493                next_sequence: WatchSequence::from_u64(3),
494                changes: vec![
495                    WatchChange::Renamed {
496                        from: old.clone(),
497                        to: new.clone(),
498                    },
499                    WatchChange::Modified(child.clone()),
500                ],
501            },
502            &CaptureOptions {
503                source_root: temporary.path().to_path_buf(),
504                expected_root_identity: capture_root_identity(temporary.path())?,
505                maximum_paths: 3,
506                maximum_extent_spans: 8,
507            },
508            WorkBudget::UNBOUNDED,
509            &CancellationToken::new(),
510        )
511        .await?;
512        let token = CancellationToken::new();
513        assert!(
514            checkout
515                .lookup_no_follow(&old, WorkBudget::UNBOUNDED, &token)
516                .await?
517                .value
518                .record
519                .is_none()
520        );
521        assert_eq!(
522            checkout
523                .lookup_no_follow(&new, WorkBudget::UNBOUNDED, &token)
524                .await?
525                .value
526                .record
527                .map(|record| record.kind),
528            Some(FileKind::Directory)
529        );
530        assert_eq!(
531            checkout
532                .lookup_no_follow(&child, WorkBudget::UNBOUNDED, &token)
533                .await?
534                .value
535                .record
536                .map(|record| record.kind),
537            Some(FileKind::Regular)
538        );
539        Ok(())
540    }
541
542    #[tokio::test]
543    async fn watch_nested_file_replaces_a_stale_non_directory_ancestor()
544    -> Result<(), Box<dyn std::error::Error>> {
545        let temporary = tempfile::tempdir()?;
546        std::fs::create_dir(temporary.path().join("sub"))?;
547        std::fs::write(temporary.path().join("sub/new.txt"), b"new")?;
548        let workspace = crate::Fs::memory()
549            .create_workspace("watch-stale-ancestor")
550            .await?;
551        workspace.write_text("/sub", "old").await?;
552        let mut checkout = workspace
553            .checkout(
554                crate::model::GenerationSelector::Head,
555                crate::model::CheckoutMode::tracking_transaction(),
556            )
557            .await?;
558        let child = path("/sub/new.txt")?;
559        capture_watch_batch_with_policy(
560            &mut checkout,
561            WatchBatch::Changes {
562                epoch: WatchEpoch::from_u64(1),
563                first_sequence: WatchSequence::from_u64(1),
564                next_sequence: WatchSequence::from_u64(2),
565                changes: vec![WatchChange::Created(child.clone())],
566            },
567            &CaptureOptions {
568                source_root: temporary.path().to_path_buf(),
569                expected_root_identity: capture_root_identity(temporary.path())?,
570                maximum_paths: 2,
571                maximum_extent_spans: 8,
572            },
573            &CapturePolicy::allow_all(),
574            WorkBudget::UNBOUNDED,
575            &CancellationToken::new(),
576        )
577        .await?;
578        let token = CancellationToken::new();
579        assert_eq!(
580            checkout
581                .lookup_no_follow(&path("/sub")?, WorkBudget::UNBOUNDED, &token)
582                .await?
583                .value
584                .record
585                .map(|record| record.kind),
586            Some(FileKind::Directory)
587        );
588        assert!(
589            checkout
590                .lookup_no_follow(&child, WorkBudget::UNBOUNDED, &token)
591                .await?
592                .value
593                .record
594                .is_some()
595        );
596        Ok(())
597    }
598
599    #[tokio::test]
600    async fn watch_rename_chain_observes_intermediate_parent()
601    -> Result<(), Box<dyn std::error::Error>> {
602        let temporary = tempfile::tempdir()?;
603        std::fs::create_dir(temporary.path().join("sub"))?;
604        std::fs::write(temporary.path().join("final.txt"), b"old")?;
605        let workspace = crate::Fs::memory()
606            .create_workspace("watch-rename-chain")
607            .await?;
608        workspace.write_text("/old.txt", "old").await?;
609        let mut checkout = workspace
610            .checkout(
611                crate::model::GenerationSelector::Head,
612                crate::model::CheckoutMode::tracking_transaction(),
613            )
614            .await?;
615        capture_watch_batch(
616            &mut checkout,
617            WatchBatch::Changes {
618                epoch: WatchEpoch::from_u64(1),
619                first_sequence: WatchSequence::from_u64(1),
620                next_sequence: WatchSequence::from_u64(3),
621                changes: vec![
622                    WatchChange::Renamed {
623                        from: path("/old.txt")?,
624                        to: path("/sub/intermediate.txt")?,
625                    },
626                    WatchChange::Renamed {
627                        from: path("/sub/intermediate.txt")?,
628                        to: path("/final.txt")?,
629                    },
630                ],
631            },
632            &CaptureOptions {
633                source_root: temporary.path().to_path_buf(),
634                expected_root_identity: capture_root_identity(temporary.path())?,
635                maximum_paths: 3,
636                maximum_extent_spans: 8,
637            },
638            WorkBudget::UNBOUNDED,
639            &CancellationToken::new(),
640        )
641        .await?;
642        let token = CancellationToken::new();
643        assert!(
644            checkout
645                .lookup_no_follow(&path("/old.txt")?, WorkBudget::UNBOUNDED, &token)
646                .await?
647                .value
648                .record
649                .is_none()
650        );
651        assert!(
652            checkout
653                .lookup_no_follow(&path("/final.txt")?, WorkBudget::UNBOUNDED, &token)
654                .await?
655                .value
656                .record
657                .is_some()
658        );
659        Ok(())
660    }
661}
662
663/// Successful authored host-state capture.
664#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
665pub struct CaptureReceipt {
666    /// Exact paths examined from the caller's bounded set.
667    pub examined_paths: u64,
668    /// Paths whose final host state required a checkout mutation.
669    pub changed_paths: u64,
670    /// Logical regular-file bytes streamed into immutable objects.
671    pub staged_file_bytes: u64,
672    /// Complete canonical-engine and source movement work.
673    pub work: WorkCounters,
674}
675
676/// A watcher interval acknowledged only after its complete capture transaction
677/// changes the checkout candidate.
678#[derive(Clone, Copy, Debug, Eq, PartialEq)]
679pub struct WatchCaptureReceipt {
680    /// Watcher epoch against which every path was interpreted.
681    pub epoch: WatchEpoch,
682    /// First process-local sequence represented by the batch.
683    pub first_sequence: WatchSequence,
684    /// Sequence safe to request after this transaction succeeds.
685    pub next_sequence: WatchSequence,
686    /// Exact capture and movement facts.
687    pub capture: CaptureReceipt,
688}
689
690/// Fail-closed native capture errors.
691#[derive(Debug, Error)]
692pub enum CaptureError {
693    /// Capture bounds or source root are invalid.
694    #[error("native capture options are invalid")]
695    InvalidOptions,
696    /// The exact capability opened for capture is not the admitted root.
697    #[error("native capture root identity changed")]
698    RootChanged,
699    /// Watcher hints are invalid until the caller completes a fresh baseline.
700    #[error("watcher epoch {epoch} requires a new baseline: {reason}")]
701    RescanRequired {
702        /// Invalidated watcher epoch.
703        epoch: u64,
704        /// Exact invalidation reason.
705        reason: WatchInvalidationReason,
706    },
707    /// A complete baseline cannot replace an existing private mutation set.
708    #[error("baseline capture requires a clean checkout")]
709    DirtyCheckout,
710    /// One requested path cannot be represented exactly on this host.
711    #[error("native capture path is not exactly representable")]
712    UnrepresentablePath,
713    /// The host kind cannot be represented exactly by this volume adapter.
714    #[error("host file kind is unsupported for exact capture")]
715    UnsupportedKind,
716    /// Canonical filesystem operation failed.
717    #[error("filesystem engine failed: {0}")]
718    Engine(String),
719    /// Source filesystem operation failed.
720    #[error("source filesystem operation failed: {0}")]
721    Io(#[from] std::io::Error),
722    /// Exact work overflowed or exceeded the admitted budget.
723    #[error(transparent)]
724    Work(#[from] WorkError),
725}
726
727/// Reads a bounded set of final host states and applies one atomic checkout
728/// transaction.
729///
730/// Regular files stream directly into immutable chunks; their complete bodies
731/// are never retained in memory. Before applying the transaction, capture
732/// rechecks the path and open handle against the observed host metadata.
733/// This is not an atomic host snapshot: callers that need one must quiesce
734/// the source or reconcile changes through a native watcher. Missing host
735/// paths become removals only when
736/// the checkout currently contains the path. Existing paths are replaced only
737/// when kind changes; same-kind regular files preserve stable file identity.
738/// Watch notifications are suitable inputs because they are treated only as
739/// hints selecting which host paths to authenticate and capture.
740///
741/// # Errors
742///
743/// Rejects zero/excessive bounds, source-root escape, unsupported host kinds,
744/// source races or I/O, canonical engine failures, cancellation, and work
745/// exhaustion. No checkout candidate changes unless the complete transaction
746/// succeeds.
747pub async fn capture_paths<A: AsyncAuthorityStore, O: AsyncObjectStore>(
748    checkout: &mut Checkout<A, O>,
749    paths: &[NamespacePath],
750    options: &CaptureOptions,
751    budget: WorkBudget,
752    cancellation: &CancellationToken,
753) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
754    capture_paths_with_policy(
755        checkout,
756        paths,
757        options,
758        &CapturePolicy::allow_all(),
759        budget,
760        cancellation,
761    )
762    .await
763}
764
765/// Captures eligible paths under an exact, symmetric path policy.
766pub async fn capture_paths_with_policy<A: AsyncAuthorityStore, O: AsyncObjectStore>(
767    checkout: &mut Checkout<A, O>,
768    paths: &[NamespacePath],
769    options: &CaptureOptions,
770    policy: &CapturePolicy,
771    budget: WorkBudget,
772    cancellation: &CancellationToken,
773) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
774    validate_path_count(paths, options).map_err(OperationFailure::before_work)?;
775    let source_root = open_source_root(options).map_err(OperationFailure::before_work)?;
776    capture_paths_from_root(
777        checkout,
778        paths,
779        policy,
780        options.maximum_extent_spans,
781        &source_root,
782        budget,
783        cancellation,
784    )
785    .await
786}
787
788/// Captures a large explicit path set as bounded transactions on one private
789/// checkout candidate. The caller sees either every path or none of them;
790/// hard-link identities are shared across batches. `budget` applies to the
791/// complete path set, not independently to each batch.
792#[cfg(feature = "native-mount")]
793pub(crate) async fn capture_paths_batched_with_baseline<
794    A: AsyncAuthorityStore,
795    O: AsyncObjectStore,
796>(
797    checkout: &mut Checkout<A, O>,
798    paths: &[NamespacePath],
799    options: &CaptureOptions,
800    batch_size: usize,
801    budget: WorkBudget,
802    cancellation: &CancellationToken,
803    baseline: Option<&NativeViewBaseline>,
804) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
805    validate_path_count(paths, options).map_err(OperationFailure::before_work)?;
806    if batch_size == 0 || paths.len() > MAX_NATIVE_EXACT_CAPTURE_PATHS {
807        return Err(OperationFailure::before_work(CaptureError::InvalidOptions));
808    }
809    let source_root = open_source_root(options).map_err(OperationFailure::before_work)?;
810    let mut unique = paths.to_vec();
811    unique.sort();
812    if unique
813        .windows(2)
814        .any(|pair| matches!(pair, [left, right] if left == right))
815    {
816        return Err(OperationFailure::before_work(CaptureError::InvalidOptions));
817    }
818    let mut candidate = checkout.private_candidate();
819    let mut receipt = CaptureReceipt::default();
820    let mut states = Vec::with_capacity(unique.len());
821    let observed = observe_unique_paths(unique, &source_root, budget, cancellation)?;
822    receipt.work = observed.work;
823    let mut observations = observed.value.into_iter();
824    loop {
825        let page = observations.by_ref().take(batch_size).collect::<Vec<_>>();
826        if page.is_empty() {
827            break;
828        }
829        let paths = page
830            .iter()
831            .map(|(_, path)| path.clone())
832            .collect::<Vec<_>>();
833        let remaining = receipt
834            .work
835            .remaining(budget)
836            .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
837        let lookup = candidate
838            .lookup_batch_no_follow(&paths, remaining, cancellation)
839            .await
840            .map_err(|failure| map_engine_failure(failure, receipt.work))?;
841        receipt.work = add_work(receipt.work, lookup.work)?;
842        states.extend(
843            page.into_iter()
844                .zip(lookup.value.entries)
845                .map(|((observation, path), entry)| (observation, path, entry.record)),
846        );
847    }
848    sort_capture_states(&mut states);
849    let mut host_links = BTreeMap::new();
850    let mut states = states.into_iter();
851    loop {
852        let batch = states.by_ref().take(batch_size).collect::<Vec<_>>();
853        if batch.is_empty() {
854            break;
855        }
856        let remaining = receipt
857            .work
858            .remaining(budget)
859            .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
860        let captured = capture_ranked_paths_from_root(
861            &mut candidate,
862            batch,
863            &mut host_links,
864            options.maximum_extent_spans,
865            &source_root,
866            remaining,
867            cancellation,
868            baseline,
869        )
870        .await
871        .map_err(|failure| map_capture_failure(failure, receipt.work))?;
872        for source in host_links.values_mut() {
873            if let HostLinkSource::Pending(path) = source {
874                *source = HostLinkSource::Ready(path.clone());
875            }
876        }
877        receipt.examined_paths = receipt
878            .examined_paths
879            .checked_add(captured.value.examined_paths)
880            .ok_or_else(|| {
881                OperationFailure::new(CaptureError::Work(WorkError::Overflow), receipt.work)
882            })?;
883        receipt.changed_paths = receipt
884            .changed_paths
885            .checked_add(captured.value.changed_paths)
886            .ok_or_else(|| {
887                OperationFailure::new(CaptureError::Work(WorkError::Overflow), receipt.work)
888            })?;
889        receipt.staged_file_bytes = receipt
890            .staged_file_bytes
891            .checked_add(captured.value.staged_file_bytes)
892            .ok_or_else(|| {
893                OperationFailure::new(CaptureError::Work(WorkError::Overflow), receipt.work)
894            })?;
895        receipt.work = add_work(receipt.work, captured.work)?;
896    }
897    *checkout = candidate;
898    Ok(OperationReceipt {
899        value: receipt,
900        work: receipt.work,
901    })
902}
903
904/// Reconciles one host directory and every descendant as one authored
905/// transaction. Both host and checkout descendants are included so removals
906/// inside a replaced imported tree are exact.
907pub async fn capture_subtree<A: AsyncAuthorityStore, O: AsyncObjectStore>(
908    checkout: &mut Checkout<A, O>,
909    root: NamespacePath,
910    options: &CaptureOptions,
911    budget: WorkBudget,
912    cancellation: &CancellationToken,
913) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
914    capture_subtrees_with_policy(
915        checkout,
916        &[root],
917        options,
918        &CapturePolicy::allow_all(),
919        budget,
920        cancellation,
921    )
922    .await
923}
924
925/// Reconciles one eligible host subtree under an exact path policy.
926pub async fn capture_subtree_with_policy<A: AsyncAuthorityStore, O: AsyncObjectStore>(
927    checkout: &mut Checkout<A, O>,
928    root: NamespacePath,
929    options: &CaptureOptions,
930    policy: &CapturePolicy,
931    budget: WorkBudget,
932    cancellation: &CancellationToken,
933) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
934    capture_subtrees_with_policy(checkout, &[root], options, policy, budget, cancellation).await
935}
936
937/// Reconciles eligible disjoint host subtrees as one authored transaction.
938///
939/// Redundant descendants and duplicate roots are removed before any host or
940/// checkout work. The remaining roots share one capability root, one batched
941/// checkout lookup, and one atomic mutation transaction.
942pub async fn capture_subtrees_with_policy<A: AsyncAuthorityStore, O: AsyncObjectStore>(
943    checkout: &mut Checkout<A, O>,
944    roots: &[NamespacePath],
945    options: &CaptureOptions,
946    policy: &CapturePolicy,
947    budget: WorkBudget,
948    cancellation: &CancellationToken,
949) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
950    capture_subtrees_with_policy_and_baseline(
951        checkout,
952        roots,
953        options,
954        policy,
955        budget,
956        cancellation,
957        None,
958    )
959    .await
960}
961
962pub(crate) async fn capture_subtrees_with_policy_and_baseline<
963    A: AsyncAuthorityStore,
964    O: AsyncObjectStore,
965>(
966    checkout: &mut Checkout<A, O>,
967    roots: &[NamespacePath],
968    options: &CaptureOptions,
969    policy: &CapturePolicy,
970    budget: WorkBudget,
971    cancellation: &CancellationToken,
972    baseline: Option<&NativeViewBaseline>,
973) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
974    if options.maximum_paths == 0 || options.maximum_extent_spans == 0 {
975        return Err(OperationFailure::before_work(CaptureError::InvalidOptions));
976    }
977    cancellation
978        .check()
979        .map_err(|error| OperationFailure::before_work(CaptureError::Engine(error.to_string())))?;
980    let canonical = canonical_subtree_roots(roots, policy);
981    if canonical.is_empty() {
982        return Ok(OperationReceipt {
983            value: CaptureReceipt::default(),
984            work: WorkCounters::default(),
985        });
986    }
987    let limits = checkout.volume_config().limits;
988    let maximum = usize::try_from(options.maximum_paths)
989        .map_err(|_| OperationFailure::before_work(CaptureError::InvalidOptions))?;
990    let profile = checkout.volume_config().profile;
991    let mut paths = canonical.clone();
992    let source_path = options.source_root.clone();
993    let expected_identity = options.expected_root_identity;
994    let host_roots = canonical.clone();
995    let host_policy = policy.clone();
996    let host_cancellation = cancellation.clone();
997    let (source_root, observed, mut work) = acyclic_native_runtime::run_blocking_io(move || {
998        let source_root = HostRoot::open(&source_path)
999            .map_err(|_| OperationFailure::before_work(CaptureError::InvalidOptions))?;
1000        if source_root.identity() != expected_identity {
1001            return Err(OperationFailure::before_work(CaptureError::RootChanged));
1002        }
1003        let mut observed = BTreeMap::new();
1004        let mut work = WorkCounters::default();
1005        collect_host_subtree_roots(
1006            &source_root,
1007            profile,
1008            limits,
1009            maximum,
1010            &host_roots,
1011            &host_policy,
1012            &mut observed,
1013            &mut work,
1014            budget,
1015            &host_cancellation,
1016        )?;
1017        Ok((source_root, observed, work))
1018    })
1019    .await
1020    .map_err(|error| OperationFailure::before_work(CaptureError::Engine(error.to_string())))??;
1021    let remaining = work
1022        .remaining(budget)
1023        .map_err(|error| OperationFailure::new(CaptureError::Work(error), work))?;
1024    let current = checkout
1025        .lookup_batch_no_follow(&canonical, remaining, cancellation)
1026        .await
1027        .map_err(|failure| map_engine_failure(failure, work))?;
1028    work = add_work(work, current.work)?;
1029    for (root, entry) in canonical.into_iter().zip(current.value.entries) {
1030        if entry
1031            .record
1032            .is_some_and(|record| record.kind == FileKind::Directory)
1033        {
1034            collect_checkout_subtree_paths(
1035                checkout,
1036                limits,
1037                root,
1038                policy,
1039                maximum,
1040                &mut paths,
1041                &mut work,
1042                budget,
1043                cancellation,
1044            )
1045            .await?;
1046        }
1047    }
1048    let ordered = order_host_checkout_union(observed, paths, maximum)
1049        .map_err(|error| OperationFailure::new(error, work))?;
1050    let remaining = work
1051        .remaining(budget)
1052        .map_err(|error| OperationFailure::new(CaptureError::Work(error), work))?;
1053    let mut captured = capture_observed_paths_from_root(
1054        checkout,
1055        ordered,
1056        options.maximum_extent_spans,
1057        &source_root,
1058        remaining,
1059        cancellation,
1060        baseline,
1061    )
1062    .await
1063    .map_err(|failure| map_capture_failure(failure, work))?;
1064    let combined = add_work(work, captured.work)?;
1065    captured.value.work = combined;
1066    Ok(OperationReceipt {
1067        value: captured.value,
1068        work: combined,
1069    })
1070}
1071
1072fn canonical_subtree_roots(roots: &[NamespacePath], policy: &CapturePolicy) -> Vec<NamespacePath> {
1073    let mut sorted = roots
1074        .iter()
1075        .filter(|root| !policy.excludes(root))
1076        .cloned()
1077        .collect::<Vec<_>>();
1078    sorted.sort();
1079    sorted.dedup();
1080    let mut canonical = Vec::<NamespacePath>::new();
1081    for root in sorted {
1082        if canonical
1083            .last()
1084            .is_none_or(|ancestor| !root.is_within(ancestor))
1085        {
1086            canonical.push(root);
1087        }
1088    }
1089    canonical
1090}
1091
1092async fn capture_paths_from_root<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1093    checkout: &mut Checkout<A, O>,
1094    paths: &[NamespacePath],
1095    policy: &CapturePolicy,
1096    maximum_extent_spans: u32,
1097    source_root: &HostRoot,
1098    budget: WorkBudget,
1099    cancellation: &CancellationToken,
1100) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
1101    let mut unique = paths
1102        .iter()
1103        .filter(|path| !policy.excludes(path))
1104        .cloned()
1105        .collect::<Vec<_>>();
1106    unique.sort();
1107    if unique
1108        .windows(2)
1109        .any(|pair| matches!(pair, [left, right] if left == right))
1110    {
1111        return Err(OperationFailure::before_work(CaptureError::InvalidOptions));
1112    }
1113    capture_unique_paths_from_root(
1114        checkout,
1115        unique,
1116        maximum_extent_spans,
1117        source_root,
1118        budget,
1119        cancellation,
1120    )
1121    .await
1122}
1123
1124async fn capture_unique_paths_from_root<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1125    checkout: &mut Checkout<A, O>,
1126    unique: Vec<NamespacePath>,
1127    maximum_extent_spans: u32,
1128    source_root: &HostRoot,
1129    budget: WorkBudget,
1130    cancellation: &CancellationToken,
1131) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
1132    // A comparator must not inspect a changing host tree: one path could
1133    // otherwise alternate between present and absent during the sort. This
1134    // also reduces host metadata probes from O(paths * log paths) to O(paths).
1135    let observed = observe_unique_paths(unique, source_root, budget, cancellation)?;
1136    let remaining = observed
1137        .work
1138        .remaining(budget)
1139        .map_err(|error| OperationFailure::new(CaptureError::Work(error), observed.work))?;
1140    let mut captured = capture_observed_paths_from_root(
1141        checkout,
1142        observed.value,
1143        maximum_extent_spans,
1144        source_root,
1145        remaining,
1146        cancellation,
1147        None,
1148    )
1149    .await
1150    .map_err(|failure| map_capture_failure(failure, observed.work))?;
1151    captured.work = add_work(observed.work, captured.work)?;
1152    captured.value.work = captured.work;
1153    Ok(captured)
1154}
1155
1156type ObservedPath = (Option<HostObservation>, NamespacePath);
1157
1158fn observe_unique_paths(
1159    unique: Vec<NamespacePath>,
1160    source_root: &HostRoot,
1161    budget: WorkBudget,
1162    cancellation: &CancellationToken,
1163) -> Result<OperationReceipt<Vec<ObservedPath>>, OperationFailure<CaptureError>> {
1164    let mut ordered = Vec::new();
1165    let mut work = WorkCounters::default();
1166    for path in unique {
1167        cancellation.check().map_err(|error| {
1168            OperationFailure::new(CaptureError::Engine(error.to_string()), work)
1169        })?;
1170        let probe = WorkCounters {
1171            source_entries_visited: 1,
1172            source_path_components: u64::try_from(path.depth()).map_err(|_| {
1173                OperationFailure::new(CaptureError::Work(WorkError::Overflow), work)
1174            })?,
1175            ..WorkCounters::default()
1176        };
1177        let next = add_work(work, probe)?;
1178        next.verify(budget)
1179            .map_err(|error| OperationFailure::new(CaptureError::Work(error), work))?;
1180        ordered
1181            .try_reserve(1)
1182            .map_err(|_| OperationFailure::new(CaptureError::InvalidOptions, work))?;
1183        let host_path =
1184            namespace_to_host_path(&path).map_err(|error| OperationFailure::new(error, work))?;
1185        let observation = observe_host_path(source_root, &host_path)
1186            .map_err(|error| OperationFailure::new(error, work))?;
1187        ordered.push((observation, path));
1188        work = next;
1189    }
1190    cancellation
1191        .check()
1192        .map_err(|error| OperationFailure::new(CaptureError::Engine(error.to_string()), work))?;
1193    ordered.sort_by(|(left_observation, left), (right_observation, right)| {
1194        match (left_observation.is_some(), right_observation.is_some()) {
1195            (true, true) => left
1196                .depth()
1197                .cmp(&right.depth())
1198                .then_with(|| left.cmp(right)),
1199            (false, false) => right
1200                .depth()
1201                .cmp(&left.depth())
1202                .then_with(|| left.cmp(right)),
1203            (true, false) => std::cmp::Ordering::Less,
1204            (false, true) => std::cmp::Ordering::Greater,
1205        }
1206    });
1207    cancellation
1208        .check()
1209        .map_err(|error| OperationFailure::new(CaptureError::Engine(error.to_string()), work))?;
1210    Ok(OperationReceipt {
1211        value: ordered,
1212        work,
1213    })
1214}
1215
1216fn observe_host_path(
1217    source_root: &HostRoot,
1218    host_path: &Path,
1219) -> Result<Option<HostObservation>, CaptureError> {
1220    classify_host_observation(source_root.symlink_metadata_held(host_path))
1221}
1222
1223fn classify_host_observation(
1224    metadata: std::io::Result<cap_std::fs::Metadata>,
1225) -> Result<Option<HostObservation>, CaptureError> {
1226    match metadata {
1227        Ok(metadata) => Ok(Some(HostObservation::from_metadata(metadata))),
1228        Err(error) if host_path_is_absent(&error) => Ok(None),
1229        Err(error) => Err(CaptureError::Io(error)),
1230    }
1231}
1232
1233#[cfg(test)]
1234mod host_observation_tests {
1235    use super::*;
1236
1237    #[test]
1238    fn only_absent_paths_may_become_deletions() -> Result<(), Box<dyn std::error::Error>> {
1239        for kind in [
1240            std::io::ErrorKind::NotFound,
1241            std::io::ErrorKind::NotADirectory,
1242        ] {
1243            assert!(classify_host_observation(Err(kind.into()))?.is_none());
1244        }
1245        for kind in [
1246            std::io::ErrorKind::PermissionDenied,
1247            std::io::ErrorKind::InvalidInput,
1248        ] {
1249            assert!(matches!(
1250                classify_host_observation(Err(kind.into())),
1251                Err(CaptureError::Io(_))
1252            ));
1253        }
1254        let directory = tempfile::tempdir()?;
1255        std::fs::write(directory.path().join("file"), b"body")?;
1256        let source_root = HostRoot::open(directory.path())?;
1257        assert!(observe_host_path(&source_root, Path::new("file/child"))?.is_none());
1258        Ok(())
1259    }
1260
1261    #[cfg(unix)]
1262    #[test]
1263    fn subtree_walk_rejects_intermediate_symlinks() -> Result<(), Box<dyn std::error::Error>> {
1264        use std::os::unix::fs::symlink;
1265
1266        let directory = tempfile::tempdir()?;
1267        let outside = tempfile::tempdir()?;
1268        std::fs::write(outside.path().join("private"), b"outside")?;
1269        symlink(outside.path(), directory.path().join("alias"))?;
1270        let source_root = HostRoot::open(directory.path())?;
1271        assert!(source_root.open_dir_held(Path::new("alias")).is_err());
1272        assert!(
1273            source_root
1274                .symlink_metadata_held(Path::new("alias/private"))
1275                .is_err()
1276        );
1277        Ok(())
1278    }
1279}
1280
1281async fn capture_observed_paths_from_root<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1282    checkout: &mut Checkout<A, O>,
1283    ordered: Vec<(Option<HostObservation>, NamespacePath)>,
1284    maximum_extent_spans: u32,
1285    source_root: &HostRoot,
1286    budget: WorkBudget,
1287    cancellation: &CancellationToken,
1288    baseline: Option<&NativeViewBaseline>,
1289) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
1290    let mut receipt = CaptureReceipt::default();
1291
1292    let mut observations = Vec::new();
1293    let mut paths = Vec::new();
1294    observations
1295        .try_reserve(ordered.len())
1296        .map_err(|_| OperationFailure::before_work(CaptureError::InvalidOptions))?;
1297    paths
1298        .try_reserve(ordered.len())
1299        .map_err(|_| OperationFailure::before_work(CaptureError::InvalidOptions))?;
1300    for (observation, path) in ordered {
1301        observations.push(observation);
1302        paths.push(path);
1303    }
1304    let current = if paths.is_empty() {
1305        Vec::new()
1306    } else {
1307        let remaining = receipt
1308            .work
1309            .remaining(budget)
1310            .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
1311        let lookup = checkout
1312            .lookup_batch_no_follow(&paths, remaining, cancellation)
1313            .await
1314            .map_err(|failure| map_engine_failure(failure, receipt.work))?;
1315        receipt.work = add_work(receipt.work, lookup.work)?;
1316        lookup
1317            .value
1318            .entries
1319            .into_iter()
1320            .map(|entry| entry.record)
1321            .collect()
1322    };
1323
1324    let states = order_capture_states(observations, paths, current);
1325    let mut host_links = BTreeMap::new();
1326    let remaining = receipt
1327        .work
1328        .remaining(budget)
1329        .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
1330    let captured = capture_ranked_paths_from_root(
1331        checkout,
1332        states,
1333        &mut host_links,
1334        maximum_extent_spans,
1335        source_root,
1336        remaining,
1337        cancellation,
1338        baseline,
1339    )
1340    .await
1341    .map_err(|failure| map_capture_failure(failure, receipt.work))?;
1342    receipt.examined_paths = captured.value.examined_paths;
1343    receipt.changed_paths = captured.value.changed_paths;
1344    receipt.staged_file_bytes = captured.value.staged_file_bytes;
1345    receipt.work = add_work(receipt.work, captured.work)?;
1346    Ok(OperationReceipt {
1347        value: receipt,
1348        work: receipt.work,
1349    })
1350}
1351
1352#[allow(clippy::too_many_arguments)]
1353async fn capture_ranked_paths_from_root<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1354    checkout: &mut Checkout<A, O>,
1355    states: Vec<CapturePathState>,
1356    host_links: &mut BTreeMap<[u8; 16], HostLinkSource>,
1357    maximum_extent_spans: u32,
1358    source_root: &HostRoot,
1359    budget: WorkBudget,
1360    cancellation: &CancellationToken,
1361    baseline: Option<&NativeViewBaseline>,
1362) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
1363    let mut receipt = CaptureReceipt::default();
1364    let mut mutations = Vec::new();
1365    mutations
1366        .try_reserve(states.len().saturating_mul(3))
1367        .map_err(|_| OperationFailure::before_work(CaptureError::InvalidOptions))?;
1368    let mut prepared = Vec::new();
1369    prepared
1370        .try_reserve(states.len())
1371        .map_err(|_| OperationFailure::new(CaptureError::InvalidOptions, receipt.work))?;
1372    for (observation, path, current) in states {
1373        let plan = prepare_final_path(
1374            checkout,
1375            path,
1376            CurrentRecord::Known(current),
1377            CaptureIntent::Complete,
1378            source_root,
1379            observation,
1380            host_links,
1381            None,
1382            baseline,
1383            &mut mutations,
1384            &mut receipt,
1385            budget,
1386            cancellation,
1387        )
1388        .await?;
1389        if let Some(plan) = plan {
1390            prepared.push(plan);
1391        }
1392    }
1393    finish_prepared_paths(
1394        &checkout.content_stager(),
1395        source_root,
1396        prepared,
1397        maximum_extent_spans,
1398        &mut mutations,
1399        &mut receipt,
1400        budget,
1401        cancellation,
1402    )
1403    .await?;
1404
1405    apply_capture_transaction(checkout, mutations, &mut receipt, budget, cancellation).await?;
1406    receipt
1407        .work
1408        .verify(budget)
1409        .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
1410    Ok(OperationReceipt {
1411        value: receipt,
1412        work: receipt.work,
1413    })
1414}
1415
1416type CapturePathState = (Option<HostObservation>, NamespacePath, Option<FileRecord>);
1417
1418fn order_capture_states(
1419    observations: Vec<Option<HostObservation>>,
1420    paths: Vec<NamespacePath>,
1421    current: Vec<Option<FileRecord>>,
1422) -> Vec<CapturePathState> {
1423    let mut states = observations
1424        .into_iter()
1425        .zip(paths)
1426        .zip(current)
1427        .map(|((observation, path), current)| (observation, path, current))
1428        .collect::<Vec<_>>();
1429    // Remove checkout-only descendants first, before an observed ancestor
1430    // changes kind. Existing bindings then lead newly observed hard-link
1431    // aliases so an earlier-sorting alias cannot replace a stable SDK FileId.
1432    sort_capture_states(&mut states);
1433    states
1434}
1435
1436fn sort_capture_states(states: &mut [CapturePathState]) {
1437    states.sort_by(
1438        |(left, left_path, left_current), (right, right_path, right_current)| {
1439            let rank = |observation: &Option<HostObservation>, current: &Option<FileRecord>| {
1440                match observation {
1441                    None => 0,
1442                    Some(observation) if observation.metadata.is_dir() => 1,
1443                    Some(_) if current.is_some() => 2,
1444                    Some(_) => 3,
1445                }
1446            };
1447            let left_rank = rank(left, left_current);
1448            let right_rank = rank(right, right_current);
1449            left_rank.cmp(&right_rank).then_with(|| {
1450                if left_rank == 0 {
1451                    right_path
1452                        .depth()
1453                        .cmp(&left_path.depth())
1454                        .then_with(|| left_path.cmp(right_path))
1455                } else {
1456                    left_path
1457                        .depth()
1458                        .cmp(&right_path.depth())
1459                        .then_with(|| left_path.cmp(right_path))
1460                }
1461            })
1462        },
1463    );
1464}
1465
1466/// Authenticates one complete bounded host baseline into a checkout candidate.
1467///
1468/// The scan takes the exact union of host and checkout paths, so host additions,
1469/// changes, and deletions become one authored transaction. Directories are
1470/// admitted before descendants and removals are applied deepest-first. Native
1471/// links are never followed. Callers bracket this operation with
1472/// [`crate::NativeWatch::begin_rescan`] and `finish_rescan`; events arriving
1473/// during the scan remain change hints for the next observation interval.
1474/// Native notifications alone do not prove that a later tool boundary has
1475/// captured every write, so callers without complete change evidence must
1476/// reconcile the subtree before publication.
1477///
1478/// # Errors
1479///
1480/// Rejects an unrepresentable host name, path/count/work bound, source race or
1481/// I/O error, unsupported exact file kind, cancellation, or canonical-engine
1482/// failure. The checkout remains unchanged unless the complete baseline
1483/// transaction succeeds.
1484pub async fn capture_baseline<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1485    checkout: &mut Checkout<A, O>,
1486    options: &CaptureOptions,
1487    budget: WorkBudget,
1488    cancellation: &CancellationToken,
1489) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
1490    Box::pin(capture_baseline_with_policy(
1491        checkout,
1492        options,
1493        &CapturePolicy::allow_all(),
1494        budget,
1495        cancellation,
1496    ))
1497    .await
1498}
1499
1500/// Captures a complete eligible baseline under an exact path policy.
1501pub async fn capture_baseline_with_policy<A: AsyncAuthorityStore, O: AsyncObjectStore>(
1502    checkout: &mut Checkout<A, O>,
1503    options: &CaptureOptions,
1504    policy: &CapturePolicy,
1505    budget: WorkBudget,
1506    cancellation: &CancellationToken,
1507) -> Result<OperationReceipt<CaptureReceipt>, OperationFailure<CaptureError>> {
1508    if checkout.has_pending_mutations() {
1509        return Err(OperationFailure::before_work(CaptureError::DirtyCheckout));
1510    }
1511    if options.maximum_paths == 0 || options.maximum_extent_spans == 0 {
1512        return Err(OperationFailure::before_work(CaptureError::InvalidOptions));
1513    }
1514    let source_root = open_source_root(options).map_err(OperationFailure::before_work)?;
1515    let limits = checkout.volume_config().limits;
1516    let profile = checkout.volume_config().profile;
1517    let maximum = usize::try_from(options.maximum_paths)
1518        .map_err(|_| OperationFailure::before_work(CaptureError::InvalidOptions))?;
1519    let mut paths = Vec::new();
1520    let mut work = WorkCounters::default();
1521    let observed = collect_host_observations(
1522        &source_root,
1523        profile,
1524        limits,
1525        maximum,
1526        policy,
1527        &mut work,
1528        budget,
1529        cancellation,
1530    )?;
1531    Box::pin(collect_checkout_paths(
1532        checkout,
1533        limits,
1534        policy,
1535        maximum,
1536        &mut paths,
1537        &mut work,
1538        budget,
1539        cancellation,
1540    ))
1541    .await?;
1542    let ordered = order_host_checkout_union(observed, paths, maximum)
1543        .map_err(|error| OperationFailure::new(error, work))?;
1544    let remaining = work
1545        .remaining(budget)
1546        .map_err(|error| OperationFailure::new(CaptureError::Work(error), work))?;
1547    let mut captured = Box::pin(capture_observed_paths_from_root(
1548        checkout,
1549        ordered,
1550        options.maximum_extent_spans,
1551        &source_root,
1552        remaining,
1553        cancellation,
1554        None,
1555    ))
1556    .await
1557    .map_err(|failure| map_capture_failure(failure, work))?;
1558    let combined = add_work(work, captured.work)?;
1559    captured.value.work = combined;
1560    Ok(OperationReceipt {
1561        value: captured.value,
1562        work: combined,
1563    })
1564}
1565
1566#[allow(clippy::too_many_arguments)]
1567fn collect_host_observations(
1568    root: &HostRoot,
1569    profile: FilesystemProfile,
1570    limits: crate::model::VolumeLimits,
1571    maximum: usize,
1572    policy: &CapturePolicy,
1573    work: &mut WorkCounters,
1574    budget: WorkBudget,
1575    cancellation: &CancellationToken,
1576) -> Result<BTreeMap<NamespacePath, HostObservation>, OperationFailure<CaptureError>> {
1577    let mut observed = BTreeMap::new();
1578    let volume_root = NamespacePath::new(Vec::new(), limits)
1579        .map_err(|error| OperationFailure::before_work(CaptureError::Engine(error.to_string())))?;
1580    collect_host_subtree_observations(
1581        root,
1582        profile,
1583        limits,
1584        maximum,
1585        PathBuf::new(),
1586        volume_root,
1587        policy,
1588        &mut observed,
1589        work,
1590        budget,
1591        cancellation,
1592    )?;
1593    Ok(observed)
1594}
1595
1596fn order_host_checkout_union(
1597    observed: BTreeMap<NamespacePath, HostObservation>,
1598    mut checkout_paths: Vec<NamespacePath>,
1599    maximum: usize,
1600) -> Result<Vec<(Option<HostObservation>, NamespacePath)>, CaptureError> {
1601    checkout_paths.sort();
1602    checkout_paths.dedup();
1603    checkout_paths.retain(|path| !observed.contains_key(path));
1604    if combined_path_count_exceeds(observed.len(), checkout_paths.len(), maximum) {
1605        return Err(CaptureError::InvalidOptions);
1606    }
1607    let mut ordered = observed
1608        .into_iter()
1609        .map(|(path, observation)| (Some(observation), path))
1610        .collect::<Vec<_>>();
1611    ordered.sort_by(|(_, left), (_, right)| {
1612        left.depth()
1613            .cmp(&right.depth())
1614            .then_with(|| left.cmp(right))
1615    });
1616    let mut absent = checkout_paths;
1617    absent.sort_by(|left, right| {
1618        right
1619            .depth()
1620            .cmp(&left.depth())
1621            .then_with(|| left.cmp(right))
1622    });
1623    ordered.extend(absent.into_iter().map(|path| (None, path)));
1624    Ok(ordered)
1625}
1626
1627fn combined_path_count_exceeds(observed: usize, checkout_only: usize, maximum: usize) -> bool {
1628    observed
1629        .checked_add(checkout_only)
1630        .is_none_or(|combined| combined > maximum)
1631}
1632
1633fn insert_host_observation(
1634    observed: &mut BTreeMap<NamespacePath, HostObservation>,
1635    path: NamespacePath,
1636    metadata: cap_std::fs::Metadata,
1637    maximum: usize,
1638    work: &mut WorkCounters,
1639    budget: WorkBudget,
1640) -> Result<(), OperationFailure<CaptureError>> {
1641    let encoded_bytes = u64::from(path.encoded_bytes());
1642    observed.insert(path, HostObservation::from_metadata(metadata));
1643    if observed.len() > maximum {
1644        return Err(OperationFailure::new(CaptureError::InvalidOptions, *work));
1645    }
1646    *work = add_work(
1647        *work,
1648        WorkCounters {
1649            bytes_copied: encoded_bytes,
1650            items_examined: 1,
1651            allocation_operations: 1,
1652            peak_allocation_bytes: encoded_bytes,
1653            ..WorkCounters::default()
1654        },
1655    )?;
1656    work.verify(budget)
1657        .map_err(|error| OperationFailure::new(CaptureError::Work(error), *work))
1658}
1659
1660#[allow(clippy::too_many_arguments)]
1661fn collect_host_subtree_roots(
1662    source_root: &HostRoot,
1663    profile: FilesystemProfile,
1664    limits: crate::model::VolumeLimits,
1665    maximum: usize,
1666    roots: &[NamespacePath],
1667    policy: &CapturePolicy,
1668    observed: &mut BTreeMap<NamespacePath, HostObservation>,
1669    work: &mut WorkCounters,
1670    budget: WorkBudget,
1671    cancellation: &CancellationToken,
1672) -> Result<(), OperationFailure<CaptureError>> {
1673    for root in roots {
1674        let host_root = namespace_to_host_path(root).map_err(OperationFailure::before_work)?;
1675        match source_root.symlink_metadata_held(&host_root) {
1676            Ok(metadata) => {
1677                let file_type = metadata.file_type();
1678                insert_host_observation(observed, root.clone(), metadata, maximum, work, budget)?;
1679                if file_type.is_dir() && !file_type.is_symlink() {
1680                    collect_host_subtree_observations(
1681                        source_root,
1682                        profile,
1683                        limits,
1684                        maximum,
1685                        host_root,
1686                        root.clone(),
1687                        policy,
1688                        observed,
1689                        work,
1690                        budget,
1691                        cancellation,
1692                    )?;
1693                }
1694            }
1695            Err(error) if host_path_is_absent(&error) => {}
1696            Err(error) => return Err(OperationFailure::new(error.into(), *work)),
1697        }
1698    }
1699    Ok(())
1700}
1701
1702#[allow(clippy::too_many_arguments)]
1703fn collect_host_subtree_observations(
1704    root: &HostRoot,
1705    profile: FilesystemProfile,
1706    limits: crate::model::VolumeLimits,
1707    maximum: usize,
1708    host_root: PathBuf,
1709    volume_root: NamespacePath,
1710    policy: &CapturePolicy,
1711    observed: &mut BTreeMap<NamespacePath, HostObservation>,
1712    work: &mut WorkCounters,
1713    budget: WorkBudget,
1714    cancellation: &CancellationToken,
1715) -> Result<(), OperationFailure<CaptureError>> {
1716    let mut pending = vec![(host_root, volume_root)];
1717    while let Some((host_parent, volume_parent)) = pending.pop() {
1718        cancellation.check().map_err(|error| {
1719            OperationFailure::new(CaptureError::Engine(error.to_string()), *work)
1720        })?;
1721        let directory = root
1722            .open_dir_held(&host_parent)
1723            .map_err(|error| OperationFailure::new(error.into(), *work))?;
1724        let entries = HostRoot::scan_held_dir(&directory)
1725            .map_err(|error| OperationFailure::new(error.into(), *work))?;
1726        for entry in entries {
1727            let entry = entry.map_err(|error| OperationFailure::new(error.into(), *work))?;
1728            let name = entry.name;
1729            let child = append_path(
1730                &volume_parent,
1731                logical_host_name(&name, profile, limits)
1732                    .map_err(|error| OperationFailure::new(error, *work))?,
1733                limits,
1734            )
1735            .map_err(|error| OperationFailure::new(error, *work))?;
1736            if policy.excludes(&child) {
1737                continue;
1738            }
1739            let metadata = directory
1740                .symlink_metadata(&name)
1741                .map_err(|error| OperationFailure::new(error.into(), *work))?;
1742            let file_type = metadata.file_type();
1743            insert_host_observation(observed, child.clone(), metadata, maximum, work, budget)?;
1744            if file_type.is_dir() && !file_type.is_symlink() {
1745                pending.push((host_parent.join(name), child));
1746            }
1747        }
1748    }
1749    Ok(())
1750}
1751
1752#[allow(clippy::too_many_arguments)]
1753fn collect_host_subtree_paths<P: ScannedPaths>(
1754    root: &HostRoot,
1755    profile: FilesystemProfile,
1756    limits: crate::model::VolumeLimits,
1757    host_root: PathBuf,
1758    volume_root: NamespacePath,
1759    policy: &CapturePolicy,
1760    maximum: usize,
1761    paths: &mut P,
1762    work: &mut WorkCounters,
1763    budget: WorkBudget,
1764    cancellation: &CancellationToken,
1765) -> Result<(), OperationFailure<CaptureError>> {
1766    let mut pending = vec![(host_root, volume_root)];
1767    while let Some((host_parent, volume_parent)) = pending.pop() {
1768        cancellation.check().map_err(|error| {
1769            OperationFailure::new(CaptureError::Engine(error.to_string()), *work)
1770        })?;
1771        let directory = root
1772            .open_dir_held(&host_parent)
1773            .map_err(|error| OperationFailure::new(error.into(), *work))?;
1774        let entries = HostRoot::scan_held_dir(&directory)
1775            .map_err(|error| OperationFailure::new(error.into(), *work))?;
1776        for entry in entries {
1777            let entry = entry.map_err(|error| OperationFailure::new(error.into(), *work))?;
1778            let name = logical_host_name(&entry.name, profile, limits)
1779                .map_err(|error| OperationFailure::new(error, *work))?;
1780            let child = append_path(&volume_parent, name, limits)
1781                .map_err(|error| OperationFailure::new(error, *work))?;
1782            if policy.excludes(&child) {
1783                continue;
1784            }
1785            append_scanned_path(paths, child.clone(), maximum, work, budget)?;
1786            let host_child = host_parent.join(entry.name);
1787            // Enumeration never follows the leaf. Capture reopens and validates
1788            // every selected path; this only identifies descendants to scan.
1789            if entry.is_dir {
1790                pending.push((host_child, child));
1791            }
1792        }
1793    }
1794    Ok(())
1795}
1796
1797#[allow(clippy::too_many_arguments)]
1798async fn collect_checkout_paths<A: AsyncAuthorityStore, O: AsyncObjectStore, P: ScannedPaths>(
1799    checkout: &mut Checkout<A, O>,
1800    limits: crate::model::VolumeLimits,
1801    policy: &CapturePolicy,
1802    maximum: usize,
1803    paths: &mut P,
1804    work: &mut WorkCounters,
1805    budget: WorkBudget,
1806    cancellation: &CancellationToken,
1807) -> Result<(), OperationFailure<CaptureError>> {
1808    let root = NamespacePath::new(Vec::new(), limits)
1809        .map_err(|error| OperationFailure::before_work(CaptureError::Engine(error.to_string())))?;
1810    collect_checkout_subtree_paths(
1811        checkout,
1812        limits,
1813        root,
1814        policy,
1815        maximum,
1816        paths,
1817        work,
1818        budget,
1819        cancellation,
1820    )
1821    .await
1822}
1823
1824#[allow(clippy::too_many_arguments)]
1825async fn collect_checkout_subtree_paths<
1826    A: AsyncAuthorityStore,
1827    O: AsyncObjectStore,
1828    P: ScannedPaths,
1829>(
1830    checkout: &mut Checkout<A, O>,
1831    limits: crate::model::VolumeLimits,
1832    root: NamespacePath,
1833    policy: &CapturePolicy,
1834    maximum: usize,
1835    paths: &mut P,
1836    work: &mut WorkCounters,
1837    budget: WorkBudget,
1838    cancellation: &CancellationToken,
1839) -> Result<(), OperationFailure<CaptureError>> {
1840    const PAGE_ENTRIES: u32 = 1024;
1841    let mut pending = vec![root];
1842    while let Some(directory) = pending.pop() {
1843        let mut after = None;
1844        loop {
1845            let remaining = work
1846                .remaining(budget)
1847                .map_err(|error| OperationFailure::new(CaptureError::Work(error), *work))?;
1848            let page = checkout
1849                .list_directory_records(
1850                    &directory,
1851                    after.as_ref(),
1852                    PAGE_ENTRIES,
1853                    remaining,
1854                    cancellation,
1855                )
1856                .await
1857                .map_err(|failure| map_engine_failure(failure, *work))?;
1858            *work = add_work(*work, page.work)?;
1859            if page.value.has_more && page.value.entries.is_empty() {
1860                return Err(OperationFailure::new(
1861                    CaptureError::Engine("directory pagination made no progress".to_owned()),
1862                    *work,
1863                ));
1864            }
1865            for entry in &page.value.entries {
1866                let child = append_path(&directory, entry.name.clone(), limits)
1867                    .map_err(|error| OperationFailure::new(error, *work))?;
1868                if policy.excludes(&child) {
1869                    continue;
1870                }
1871                append_scanned_path(paths, child.clone(), maximum, work, budget)?;
1872                if entry.record.kind == FileKind::Directory {
1873                    pending.push(child);
1874                }
1875            }
1876            after = page.value.entries.last().map(|entry| entry.name.clone());
1877            if !page.value.has_more {
1878                break;
1879            }
1880        }
1881    }
1882    Ok(())
1883}
1884
1885trait ScannedPaths {
1886    fn len(&self) -> usize;
1887    fn contains(&self, path: &NamespacePath) -> bool;
1888    fn insert(&mut self, path: NamespacePath);
1889}
1890
1891impl ScannedPaths for Vec<NamespacePath> {
1892    fn len(&self) -> usize {
1893        Vec::len(self)
1894    }
1895
1896    fn contains(&self, _path: &NamespacePath) -> bool {
1897        false
1898    }
1899
1900    fn insert(&mut self, path: NamespacePath) {
1901        self.push(path);
1902    }
1903}
1904
1905impl ScannedPaths for BTreeSet<NamespacePath> {
1906    fn len(&self) -> usize {
1907        BTreeSet::len(self)
1908    }
1909
1910    fn contains(&self, path: &NamespacePath) -> bool {
1911        BTreeSet::contains(self, path)
1912    }
1913
1914    fn insert(&mut self, path: NamespacePath) {
1915        BTreeSet::insert(self, path);
1916    }
1917}
1918
1919fn append_scanned_path<P: ScannedPaths>(
1920    paths: &mut P,
1921    path: NamespacePath,
1922    maximum: usize,
1923    work: &mut WorkCounters,
1924    budget: WorkBudget,
1925) -> Result<(), OperationFailure<CaptureError>> {
1926    if paths.contains(&path) {
1927        return Ok(());
1928    }
1929    if paths.len() >= maximum {
1930        return Err(OperationFailure::new(CaptureError::InvalidOptions, *work));
1931    }
1932    let encoded_bytes = u64::from(path.encoded_bytes());
1933    paths.insert(path);
1934    *work = add_work(
1935        *work,
1936        WorkCounters {
1937            bytes_copied: encoded_bytes,
1938            items_examined: 1,
1939            allocation_operations: 1,
1940            peak_allocation_bytes: encoded_bytes,
1941            ..WorkCounters::default()
1942        },
1943    )?;
1944    work.verify(budget)
1945        .map_err(|error| OperationFailure::new(CaptureError::Work(error), *work))?;
1946    Ok(())
1947}
1948
1949fn append_path(
1950    parent: &NamespacePath,
1951    name: LogicalName,
1952    limits: crate::model::VolumeLimits,
1953) -> Result<NamespacePath, CaptureError> {
1954    let mut components = parent.components().to_vec();
1955    components.push(name);
1956    NamespacePath::new(components, limits).map_err(|error| CaptureError::Engine(error.to_string()))
1957}
1958
1959fn logical_host_name(
1960    name: &std::ffi::OsStr,
1961    profile: FilesystemProfile,
1962    limits: crate::model::VolumeLimits,
1963) -> Result<LogicalName, CaptureError> {
1964    let (encoding, bytes) =
1965        crate::native_name::host_name_bytes(name, profile, limits.maximum_component_bytes)
1966            .map_err(|_| CaptureError::UnrepresentablePath)?;
1967    LogicalName::new(encoding, bytes, limits.maximum_component_bytes)
1968        .map_err(|error| CaptureError::Engine(error.to_string()))
1969}
1970
1971/// Converts one host-relative path to an exact bounded namespace path.
1972///
1973/// The supplied profile selects the canonical name encoding. Absolute paths,
1974/// parent traversal, platform prefixes, empty paths, and names that cannot be
1975/// represented exactly by that profile are rejected.
1976///
1977/// # Errors
1978///
1979/// Returns [`CaptureError::UnrepresentablePath`] unless every component can be
1980/// represented exactly within `limits`.
1981pub fn host_path_to_namespace(
1982    path: &std::path::Path,
1983    profile: FilesystemProfile,
1984    limits: crate::model::VolumeLimits,
1985) -> Result<NamespacePath, CaptureError> {
1986    use std::path::Component;
1987
1988    let mut components = Vec::new();
1989    for component in path.components() {
1990        match component {
1991            Component::Normal(name) => components.push(logical_host_name(name, profile, limits)?),
1992            Component::CurDir => {}
1993            Component::Prefix(_) | Component::RootDir | Component::ParentDir => {
1994                return Err(CaptureError::UnrepresentablePath);
1995            }
1996        }
1997    }
1998    if components.is_empty() {
1999        return Err(CaptureError::UnrepresentablePath);
2000    }
2001    NamespacePath::new(components, limits).map_err(|_| CaptureError::UnrepresentablePath)
2002}
2003
2004/// Atomically captures one contiguous native-watcher batch.
2005///
2006/// Paired renames are replayed in watcher order and preserve the exact
2007/// path-independent file identity, including rename chains. Final destination
2008/// state is then read from the host and appended to the same authored
2009/// transaction. The returned `next_sequence` is therefore safe to acknowledge
2010/// only after this function succeeds.
2011///
2012/// # Errors
2013///
2014/// Returns [`CaptureError::RescanRequired`] without touching the checkout for
2015/// an invalidated epoch or a new hard link whose source lies outside the hint
2016/// batch. Other failures are the union of exact rename,
2017/// host-state capture, cancellation, engine, allocation, and bounded-work
2018/// failures from [`capture_paths`].
2019pub async fn capture_watch_batch<A: AsyncAuthorityStore, O: AsyncObjectStore>(
2020    checkout: &mut Checkout<A, O>,
2021    batch: WatchBatch,
2022    options: &CaptureOptions,
2023    budget: WorkBudget,
2024    cancellation: &CancellationToken,
2025) -> Result<OperationReceipt<WatchCaptureReceipt>, OperationFailure<CaptureError>> {
2026    capture_watch_batch_with_policy(
2027        checkout,
2028        batch,
2029        options,
2030        &CapturePolicy::allow_all(),
2031        budget,
2032        cancellation,
2033    )
2034    .await
2035}
2036
2037/// Captures one watcher batch while omitting excluded paths symmetrically.
2038pub async fn capture_watch_batch_with_policy<A: AsyncAuthorityStore, O: AsyncObjectStore>(
2039    checkout: &mut Checkout<A, O>,
2040    batch: WatchBatch,
2041    options: &CaptureOptions,
2042    policy: &CapturePolicy,
2043    budget: WorkBudget,
2044    cancellation: &CancellationToken,
2045) -> Result<OperationReceipt<WatchCaptureReceipt>, OperationFailure<CaptureError>> {
2046    Box::pin(capture_watch_batch_with_policy_inner(
2047        checkout,
2048        batch,
2049        options,
2050        policy,
2051        budget,
2052        cancellation,
2053    ))
2054    .await
2055}
2056
2057#[allow(clippy::too_many_lines)]
2058async fn capture_watch_batch_with_policy_inner<A: AsyncAuthorityStore, O: AsyncObjectStore>(
2059    checkout: &mut Checkout<A, O>,
2060    batch: WatchBatch,
2061    options: &CaptureOptions,
2062    policy: &CapturePolicy,
2063    budget: WorkBudget,
2064    cancellation: &CancellationToken,
2065) -> Result<OperationReceipt<WatchCaptureReceipt>, OperationFailure<CaptureError>> {
2066    let WatchBatch::Changes {
2067        epoch,
2068        first_sequence,
2069        next_sequence,
2070        changes,
2071    } = batch
2072    else {
2073        let WatchBatch::RescanRequired { epoch, reason } = batch else {
2074            unreachable!("watch batch variants are exhaustive")
2075        };
2076        return Err(OperationFailure::before_work(
2077            CaptureError::RescanRequired {
2078                epoch: epoch.get(),
2079                reason,
2080            },
2081        ));
2082    };
2083    if options.maximum_paths == 0
2084        || options.maximum_extent_spans == 0
2085        || changes.len() > usize::try_from(options.maximum_paths).unwrap_or(usize::MAX)
2086    {
2087        return Err(OperationFailure::before_work(CaptureError::InvalidOptions));
2088    }
2089    let source_root = open_source_root(options).map_err(OperationFailure::before_work)?;
2090
2091    let mut receipt = CaptureReceipt::default();
2092    let mut mutations = Vec::new();
2093    mutations
2094        .try_reserve(changes.len().saturating_mul(4))
2095        .map_err(|_| OperationFailure::before_work(CaptureError::InvalidOptions))?;
2096    let mut ordinary = BTreeMap::new();
2097    let mut rename_records = BTreeMap::<NamespacePath, FileRecord>::new();
2098    let mut renamed_directories = Vec::new();
2099    let mut rename_destinations = Vec::new();
2100    let mut moved_away = BTreeSet::new();
2101
2102    for change in changes.into_iter().filter_map(|change| match &change {
2103        WatchChange::Created(path)
2104        | WatchChange::Modified(path)
2105        | WatchChange::Removed(path)
2106        | WatchChange::MetadataChanged(path) => (!policy.excludes(path)).then_some(change),
2107        WatchChange::Renamed { from, to } => match (policy.excludes(from), policy.excludes(to)) {
2108            (false, false) => Some(change),
2109            (true, false) => Some(WatchChange::Created(to.clone())),
2110            (false, true) => Some(WatchChange::Removed(from.clone())),
2111            (true, true) => None,
2112        },
2113    }) {
2114        match change {
2115            WatchChange::Created(path) => {
2116                let current = current_record(&rename_records, &moved_away, &path);
2117                ordinary.insert(path, (current, CaptureIntent::Replace));
2118            }
2119            WatchChange::Modified(path) | WatchChange::Removed(path) => {
2120                let current = current_record(&rename_records, &moved_away, &path);
2121                ordinary
2122                    .entry(path)
2123                    .or_insert((current, CaptureIntent::Complete));
2124            }
2125            WatchChange::MetadataChanged(path) => {
2126                let current = current_record(&rename_records, &moved_away, &path);
2127                ordinary
2128                    .entry(path)
2129                    .or_insert((current, CaptureIntent::MetadataOnly));
2130            }
2131            WatchChange::Renamed { from, to } => {
2132                cancellation.check().map_err(|error| {
2133                    OperationFailure::new(CaptureError::Engine(error.to_string()), receipt.work)
2134                })?;
2135                let prior_source = ordinary.remove(&from);
2136                ordinary.remove(&to);
2137                let record = if let Some(record) = rename_records.remove(&from) {
2138                    Some(record)
2139                } else if matches!(prior_source, Some((CurrentRecord::Known(None), _)))
2140                    || moved_away.contains(&from)
2141                {
2142                    None
2143                } else {
2144                    let remaining = receipt.work.remaining(budget).map_err(|error| {
2145                        OperationFailure::new(CaptureError::Work(error), receipt.work)
2146                    })?;
2147                    let source = checkout
2148                        .lookup_no_follow(&from, remaining, cancellation)
2149                        .await
2150                        .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2151                    receipt.work = add_work(receipt.work, source.work)?;
2152                    match source.value.record {
2153                        Some(record) => Some(record),
2154                        None if prior_source.is_some() => None,
2155                        None => None,
2156                    }
2157                };
2158                if let Some(record) = record {
2159                    if record.kind == FileKind::Directory {
2160                        renamed_directories.push((from.clone(), to.clone()));
2161                    }
2162                    rename_destinations.push(to.clone());
2163                    mutations.push(AuthoredMutation::Rename {
2164                        source: from.clone(),
2165                        destination: to.clone(),
2166                        replace: true,
2167                    });
2168                    rename_records.insert(to.clone(), record);
2169                } else {
2170                    ordinary.insert(to.clone(), (CurrentRecord::Lookup, CaptureIntent::Replace));
2171                }
2172                moved_away.insert(from);
2173                moved_away.remove(&to);
2174            }
2175        }
2176    }
2177
2178    Box::pin(expand_directory_hints(
2179        checkout,
2180        &mut ordinary,
2181        &source_root,
2182        options.maximum_paths,
2183        policy,
2184        &mut receipt,
2185        budget,
2186        cancellation,
2187    ))
2188    .await?;
2189
2190    // A watcher may report a nested file before any ancestor has been
2191    // observed in this lazy checkout. Capture only the missing ancestors,
2192    // rather than expanding their entire host subtrees as event roots.
2193    let maximum_paths = usize::try_from(options.maximum_paths).unwrap_or(usize::MAX);
2194    let ancestors = watch_ancestor_candidates(
2195        ordinary
2196            .keys()
2197            .chain(rename_records.keys())
2198            .chain(rename_destinations.iter()),
2199        &ordinary,
2200        &rename_records,
2201        policy,
2202        maximum_paths,
2203        receipt.work,
2204        cancellation,
2205    )?;
2206    let mut missing_ancestors = hydrate_watch_ancestors(
2207        checkout,
2208        &mut ordinary,
2209        ancestors,
2210        WatchAncestorScope {
2211            source_root: &source_root,
2212            epoch,
2213            maximum_paths: maximum_paths.saturating_sub(rename_records.len()),
2214            budget,
2215            cancellation,
2216        },
2217        &mut receipt,
2218    )
2219    .await?;
2220
2221    let lookup_paths = ordinary
2222        .iter()
2223        .filter(|(_, (current, _))| matches!(current, CurrentRecord::Lookup))
2224        .map(|(path, _)| path.clone())
2225        .collect::<Vec<_>>();
2226    if !lookup_paths.is_empty() {
2227        let remaining = receipt
2228            .work
2229            .remaining(budget)
2230            .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
2231        let lookup = checkout
2232            .lookup_batch_no_follow(&lookup_paths, remaining, cancellation)
2233            .await
2234            .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2235        receipt.work = add_work(receipt.work, lookup.work)?;
2236        for (path, entry) in lookup_paths.into_iter().zip(lookup.value.entries) {
2237            let mut record = entry.record;
2238            if record.is_none()
2239                && let Some(prior_path) = remap_renamed_directory_path(
2240                    &path,
2241                    &renamed_directories,
2242                    checkout.volume_config().limits,
2243                )
2244                .map_err(|error| OperationFailure::new(error, receipt.work))?
2245            {
2246                let remaining = receipt.work.remaining(budget).map_err(|error| {
2247                    OperationFailure::new(CaptureError::Work(error), receipt.work)
2248                })?;
2249                let prior = checkout
2250                    .lookup_no_follow(&prior_path, remaining, cancellation)
2251                    .await
2252                    .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2253                receipt.work = add_work(receipt.work, prior.work)?;
2254                record = prior.value.record;
2255            }
2256            let Some((current, _)) = ordinary.get_mut(&path) else {
2257                return Err(OperationFailure::new(
2258                    CaptureError::Engine("capture lookup path disappeared".into()),
2259                    receipt.work,
2260                ));
2261            };
2262            *current = CurrentRecord::Known(record);
2263        }
2264    }
2265
2266    let mut host_links = BTreeMap::new();
2267    let mut prepared = Vec::new();
2268    prepared
2269        .try_reserve(rename_records.len().saturating_add(ordinary.len()))
2270        .map_err(|_| OperationFailure::new(CaptureError::InvalidOptions, receipt.work))?;
2271    // A rename can target a directory that has never been observed in this
2272    // lazy checkout. Materialize only those host ancestors before the rename,
2273    // preserving the event order for all remaining mutations.
2274    missing_ancestors.sort_by(|left, right| {
2275        left.depth()
2276            .cmp(&right.depth())
2277            .then_with(|| left.cmp(right))
2278    });
2279    let rename_mutations = std::mem::take(&mut mutations);
2280    for path in missing_ancestors {
2281        let Some((current, intent)) = ordinary.remove(&path) else {
2282            return Err(OperationFailure::new(
2283                CaptureError::Engine("capture ancestor path disappeared".into()),
2284                receipt.work,
2285            ));
2286        };
2287        let plan = prepare_final_path(
2288            checkout,
2289            path,
2290            current,
2291            intent,
2292            &source_root,
2293            None,
2294            &mut host_links,
2295            Some(epoch.get()),
2296            None,
2297            &mut mutations,
2298            &mut receipt,
2299            budget,
2300            cancellation,
2301        )
2302        .await?;
2303        if let Some(plan) = plan {
2304            prepared.push(plan);
2305        }
2306    }
2307    mutations.extend(rename_mutations);
2308    let namespace_boundary = mutations.len();
2309    for (path, record) in rename_records {
2310        if ordinary.contains_key(&path) {
2311            continue;
2312        }
2313        let plan = prepare_final_path(
2314            checkout,
2315            path,
2316            CurrentRecord::Known(Some(record)),
2317            CaptureIntent::Complete,
2318            &source_root,
2319            None,
2320            &mut host_links,
2321            Some(epoch.get()),
2322            None,
2323            &mut mutations,
2324            &mut receipt,
2325            budget,
2326            cancellation,
2327        )
2328        .await?;
2329        if let Some(plan) = plan {
2330            prepared.push(plan);
2331        }
2332    }
2333    let mut ordinary = ordinary
2334        .into_iter()
2335        .map(|(path, (current, intent))| {
2336            let host_path = namespace_to_host_path(&path)?;
2337            let observation = observe_host_path(&source_root, &host_path)?;
2338            Ok((path, current, intent, observation))
2339        })
2340        .collect::<Result<Vec<_>, CaptureError>>()
2341        .map_err(|error| OperationFailure::new(error, receipt.work))?;
2342    ordinary.sort_by(|left, right| match (left.3.is_some(), right.3.is_some()) {
2343        (true, true) => left
2344            .0
2345            .depth()
2346            .cmp(&right.0.depth())
2347            .then_with(|| left.0.cmp(&right.0)),
2348        (false, false) => right
2349            .0
2350            .depth()
2351            .cmp(&left.0.depth())
2352            .then_with(|| left.0.cmp(&right.0)),
2353        (true, false) => std::cmp::Ordering::Less,
2354        (false, true) => std::cmp::Ordering::Greater,
2355    });
2356    for (path, current, intent, observation) in ordinary {
2357        let plan = prepare_final_path(
2358            checkout,
2359            path,
2360            current,
2361            intent,
2362            &source_root,
2363            observation,
2364            &mut host_links,
2365            Some(epoch.get()),
2366            None,
2367            &mut mutations,
2368            &mut receipt,
2369            budget,
2370            cancellation,
2371        )
2372        .await?;
2373        if let Some(plan) = plan {
2374            prepared.push(plan);
2375        }
2376    }
2377    finish_prepared_paths(
2378        &checkout.content_stager(),
2379        &source_root,
2380        prepared,
2381        options.maximum_extent_spans,
2382        &mut mutations,
2383        &mut receipt,
2384        budget,
2385        cancellation,
2386    )
2387    .await?;
2388    if namespace_boundary == 0 || namespace_boundary == mutations.len() {
2389        apply_capture_transaction(checkout, mutations, &mut receipt, budget, cancellation).await?;
2390    } else {
2391        // Keep the watch batch atomic to callers, but compile the path-shape
2392        // changes before mutations that address their resulting bindings.
2393        // The authored compiler resolves those bindings against its input
2394        // checkout, even though the kernel executes each batch in order.
2395        let mut candidate = checkout.private_candidate();
2396        let final_state = mutations.split_off(namespace_boundary);
2397        apply_capture_transaction(
2398            &mut candidate,
2399            mutations,
2400            &mut receipt,
2401            budget,
2402            cancellation,
2403        )
2404        .await?;
2405        apply_capture_transaction(
2406            &mut candidate,
2407            final_state,
2408            &mut receipt,
2409            budget,
2410            cancellation,
2411        )
2412        .await?;
2413        *checkout = candidate;
2414    }
2415    Ok(OperationReceipt {
2416        value: WatchCaptureReceipt {
2417            epoch,
2418            first_sequence,
2419            next_sequence,
2420            capture: receipt,
2421        },
2422        work: receipt.work,
2423    })
2424}
2425
2426#[derive(Clone, Copy)]
2427enum CurrentRecord {
2428    Lookup,
2429    Known(Option<FileRecord>),
2430}
2431
2432/// What a hinted path held before this batch: a renamed record, nothing if
2433/// it was moved away, or the checkout's current binding.
2434fn current_record(
2435    rename_records: &BTreeMap<NamespacePath, FileRecord>,
2436    moved_away: &BTreeSet<NamespacePath>,
2437    path: &NamespacePath,
2438) -> CurrentRecord {
2439    match rename_records.get(path) {
2440        Some(record) => CurrentRecord::Known(Some(*record)),
2441        None if moved_away.contains(path) => CurrentRecord::Known(None),
2442        None => CurrentRecord::Lookup,
2443    }
2444}
2445
2446fn watch_ancestor_candidates<'a>(
2447    paths: impl Iterator<Item = &'a NamespacePath>,
2448    ordinary: &BTreeMap<NamespacePath, (CurrentRecord, CaptureIntent)>,
2449    rename_records: &BTreeMap<NamespacePath, FileRecord>,
2450    policy: &CapturePolicy,
2451    maximum_paths: usize,
2452    work: WorkCounters,
2453    cancellation: &CancellationToken,
2454) -> Result<Vec<NamespacePath>, OperationFailure<CaptureError>> {
2455    let mut ancestors = BTreeSet::new();
2456    for path in paths {
2457        let mut parent = path.parent();
2458        while let Some(path) = parent {
2459            cancellation.check().map_err(|error| {
2460                OperationFailure::new(CaptureError::Engine(error.to_string()), work)
2461            })?;
2462            if path.is_root() {
2463                break;
2464            }
2465            if !ordinary.contains_key(&path)
2466                && !rename_records.contains_key(&path)
2467                && !policy.excludes(&path)
2468            {
2469                ancestors.insert(path.clone());
2470                if ancestors
2471                    .len()
2472                    .saturating_add(ordinary.len())
2473                    .saturating_add(rename_records.len())
2474                    > maximum_paths
2475                {
2476                    return Err(OperationFailure::new(CaptureError::InvalidOptions, work));
2477                }
2478            }
2479            parent = path.parent();
2480        }
2481    }
2482    let mut ancestors = ancestors.into_iter().collect::<Vec<_>>();
2483    ancestors.sort_by(|left, right| {
2484        left.depth()
2485            .cmp(&right.depth())
2486            .then_with(|| left.cmp(right))
2487    });
2488    Ok(ancestors)
2489}
2490
2491struct WatchAncestorScope<'a> {
2492    source_root: &'a HostRoot,
2493    epoch: WatchEpoch,
2494    maximum_paths: usize,
2495    budget: WorkBudget,
2496    cancellation: &'a CancellationToken,
2497}
2498
2499async fn hydrate_watch_ancestors<A: AsyncAuthorityStore, O: AsyncObjectStore>(
2500    checkout: &mut Checkout<A, O>,
2501    ordinary: &mut BTreeMap<NamespacePath, (CurrentRecord, CaptureIntent)>,
2502    ancestors: Vec<NamespacePath>,
2503    scope: WatchAncestorScope<'_>,
2504    receipt: &mut CaptureReceipt,
2505) -> Result<Vec<NamespacePath>, OperationFailure<CaptureError>> {
2506    let mut missing = Vec::new();
2507    let mut replaced = Vec::new();
2508    for path in ancestors {
2509        let current = if replaced.iter().any(|ancestor| path.is_within(ancestor)) {
2510            None
2511        } else {
2512            let remaining = receipt
2513                .work
2514                .remaining(scope.budget)
2515                .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
2516            let lookup = checkout
2517                .lookup_no_follow(&path, remaining, scope.cancellation)
2518                .await
2519                .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2520            receipt.work = add_work(receipt.work, lookup.work)?;
2521            lookup.value.record
2522        };
2523        let host_path = namespace_to_host_path(&path)
2524            .map_err(|error| OperationFailure::new(error, receipt.work))?;
2525        match scope.source_root.symlink_metadata_held(&host_path) {
2526            Ok(metadata) if metadata.is_dir() => {
2527                if current.is_none_or(|record| record.kind != FileKind::Directory) {
2528                    if current.is_some() {
2529                        replaced.push(path.clone());
2530                    }
2531                    missing.push(path.clone());
2532                    ordinary.insert(
2533                        path,
2534                        (CurrentRecord::Known(current), CaptureIntent::Replace),
2535                    );
2536                }
2537            }
2538            Ok(_) | Err(_) => {
2539                return Err(OperationFailure::new(
2540                    CaptureError::RescanRequired {
2541                        epoch: scope.epoch.get(),
2542                        reason: WatchInvalidationReason::NativeRescanRequired,
2543                    },
2544                    receipt.work,
2545                ));
2546            }
2547        }
2548    }
2549    if ordinary.len() > scope.maximum_paths {
2550        return Err(OperationFailure::new(
2551            CaptureError::InvalidOptions,
2552            receipt.work,
2553        ));
2554    }
2555
2556    // Descendants of a replaced file or symlink were absent in the old view.
2557    for (path, (current, _)) in ordinary {
2558        if matches!(current, CurrentRecord::Lookup)
2559            && replaced
2560                .iter()
2561                .any(|ancestor| path != ancestor && path.is_within(ancestor))
2562        {
2563            *current = CurrentRecord::Known(None);
2564        }
2565    }
2566    Ok(missing)
2567}
2568
2569fn remap_renamed_directory_path(
2570    path: &NamespacePath,
2571    renames: &[(NamespacePath, NamespacePath)],
2572    limits: crate::model::VolumeLimits,
2573) -> Result<Option<NamespacePath>, CaptureError> {
2574    let mut prior = path.clone();
2575    for (from, to) in renames.iter().rev() {
2576        if prior == *to || !prior.is_within(to) {
2577            continue;
2578        }
2579        let mut components = from.components().to_vec();
2580        let suffix = prior
2581            .components()
2582            .get(to.depth()..)
2583            .ok_or(CaptureError::InvalidOptions)?;
2584        components.extend_from_slice(suffix);
2585        prior = NamespacePath::new(components, limits).map_err(|_| CaptureError::InvalidOptions)?;
2586    }
2587    Ok((prior != *path).then_some(prior))
2588}
2589
2590#[derive(Clone, Copy, Eq, PartialEq)]
2591enum CaptureIntent {
2592    MetadataOnly,
2593    Complete,
2594    Replace,
2595}
2596
2597struct PreparedRegular {
2598    path: NamespacePath,
2599    host_path: PathBuf,
2600    snapshot: HostSnapshot,
2601    exists_with_kind: bool,
2602    canonical_metadata: FileMetadata,
2603}
2604
2605enum PreparedPath {
2606    Regular(Box<PreparedRegular>),
2607    PendingHardLink {
2608        source: NamespacePath,
2609        destination: NamespacePath,
2610        current: Option<FileRecord>,
2611    },
2612}
2613
2614#[derive(Clone)]
2615enum HostLinkSource {
2616    Pending(NamespacePath),
2617    Ready(NamespacePath),
2618}
2619
2620#[allow(clippy::too_many_arguments)]
2621async fn expand_directory_hints<A: AsyncAuthorityStore, O: AsyncObjectStore>(
2622    checkout: &mut Checkout<A, O>,
2623    ordinary: &mut BTreeMap<NamespacePath, (CurrentRecord, CaptureIntent)>,
2624    source_root: &HostRoot,
2625    maximum_paths: u32,
2626    policy: &CapturePolicy,
2627    receipt: &mut CaptureReceipt,
2628    budget: WorkBudget,
2629    cancellation: &CancellationToken,
2630) -> Result<(), OperationFailure<CaptureError>> {
2631    let maximum = usize::try_from(maximum_paths)
2632        .map_err(|_| OperationFailure::new(CaptureError::InvalidOptions, receipt.work))?;
2633    if ordinary.len() > maximum {
2634        return Err(OperationFailure::new(
2635            CaptureError::InvalidOptions,
2636            receipt.work,
2637        ));
2638    }
2639    let limits = checkout.volume_config().limits;
2640    let profile = checkout.volume_config().profile;
2641    let mut roots = Vec::new();
2642    for path in ordinary.keys() {
2643        let host_path = namespace_to_host_path(path)
2644            .map_err(|error| OperationFailure::new(error, receipt.work))?;
2645        let observation = observe_host_path(source_root, &host_path)
2646            .map_err(|error| OperationFailure::new(error, receipt.work))?;
2647        if observation.is_some_and(|observation| observation.metadata.is_dir()) {
2648            roots.push((host_path, path.clone()));
2649        }
2650    }
2651    let mut paths = ordinary.keys().cloned().collect::<BTreeSet<_>>();
2652    for (host_path, volume_path) in roots {
2653        collect_host_subtree_paths(
2654            source_root,
2655            profile,
2656            limits,
2657            host_path,
2658            volume_path.clone(),
2659            policy,
2660            maximum,
2661            &mut paths,
2662            &mut receipt.work,
2663            budget,
2664            cancellation,
2665        )?;
2666        let remaining = receipt
2667            .work
2668            .remaining(budget)
2669            .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
2670        let lookup = checkout
2671            .lookup_no_follow(&volume_path, remaining, cancellation)
2672            .await
2673            .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2674        receipt.work = add_work(receipt.work, lookup.work)?;
2675        if lookup
2676            .value
2677            .record
2678            .is_some_and(|record| record.kind == FileKind::Directory)
2679        {
2680            collect_checkout_subtree_paths(
2681                checkout,
2682                limits,
2683                volume_path,
2684                policy,
2685                maximum,
2686                &mut paths,
2687                &mut receipt.work,
2688                budget,
2689                cancellation,
2690            )
2691            .await?;
2692        }
2693    }
2694    for path in paths {
2695        ordinary
2696            .entry(path)
2697            .or_insert((CurrentRecord::Lookup, CaptureIntent::Complete));
2698    }
2699    Ok(())
2700}
2701
2702#[allow(clippy::too_many_arguments)]
2703#[allow(clippy::too_many_lines)]
2704async fn prepare_final_path<A: AsyncAuthorityStore, O: AsyncObjectStore>(
2705    checkout: &mut Checkout<A, O>,
2706    path: NamespacePath,
2707    current: CurrentRecord,
2708    intent: CaptureIntent,
2709    source_root: &HostRoot,
2710    observation: Option<HostObservation>,
2711    host_links: &mut BTreeMap<[u8; 16], HostLinkSource>,
2712    watch_epoch: Option<u64>,
2713    baseline: Option<&NativeViewBaseline>,
2714    mutations: &mut Vec<AuthoredMutation>,
2715    receipt: &mut CaptureReceipt,
2716    budget: WorkBudget,
2717    cancellation: &CancellationToken,
2718) -> Result<Option<PreparedPath>, OperationFailure<CaptureError>> {
2719    #[cfg(windows)]
2720    let _ = baseline;
2721    cancellation.check().map_err(|error| {
2722        OperationFailure::new(CaptureError::Engine(error.to_string()), receipt.work)
2723    })?;
2724    let host_path = namespace_to_host_path(&path)
2725        .map_err(|error| OperationFailure::new(error, receipt.work))?;
2726    let current = match current {
2727        CurrentRecord::Known(record) => record,
2728        CurrentRecord::Lookup => {
2729            let remaining = receipt
2730                .work
2731                .remaining(budget)
2732                .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
2733            let lookup = checkout
2734                .lookup_no_follow(&path, remaining, cancellation)
2735                .await
2736                .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2737            receipt.work = add_work(receipt.work, lookup.work)?;
2738            lookup.value.record
2739        }
2740    };
2741    let metadata = observation.map_or_else(
2742        || {
2743            source_root
2744                .symlink_metadata_held(&host_path)
2745                .map(HostObservation::from_metadata)
2746        },
2747        Ok,
2748    );
2749    match metadata {
2750        Ok(observation) => {
2751            let metadata = observation.metadata;
2752            let host_kind = host_kind(&metadata)?;
2753            let snapshot = HostSnapshot::from_metadata(&metadata)
2754                .map_err(|error| OperationFailure::new(error, receipt.work))?;
2755            let link_probe =
2756                if host_kind == FileKind::Regular && checkout.volume_config().hard_links {
2757                    host_link_count(source_root, &host_path, &snapshot, &metadata)
2758                        .map_err(|error| OperationFailure::new(error, receipt.work))?
2759                } else {
2760                    HostLinkProbe::unlinked()
2761                };
2762            let linked_regular = link_probe.count > 1;
2763            if linked_regular {
2764                let identity = snapshot.identity.to_bytes();
2765                if let Some(source) = host_links.get(&identity) {
2766                    ensure_current_host_node(source_root, &host_path, &snapshot)
2767                        .map_err(|error| OperationFailure::new(error, receipt.work))?;
2768                    receipt.changed_paths = checked_increment(receipt.changed_paths, receipt.work)?;
2769                    receipt.examined_paths =
2770                        checked_increment(receipt.examined_paths, receipt.work)?;
2771                    return Ok(match source {
2772                        HostLinkSource::Pending(source) => Some(PreparedPath::PendingHardLink {
2773                            source: source.clone(),
2774                            destination: path,
2775                            current,
2776                        }),
2777                        HostLinkSource::Ready(source) => {
2778                            append_hard_link(source.clone(), path, current, mutations);
2779                            None
2780                        }
2781                    });
2782                }
2783                if let Some(epoch) = watch_epoch {
2784                    // An existing alias may lie outside this hint batch.
2785                    // Rebaseline instead of recording a second FileId.
2786                    return Err(OperationFailure::new(
2787                        CaptureError::RescanRequired {
2788                            epoch,
2789                            reason: WatchInvalidationReason::NativeRescanRequired,
2790                        },
2791                        receipt.work,
2792                    ));
2793                }
2794            }
2795            // A created hint replaces same-kind regular files with a fresh
2796            // identity, but a directory that is still a directory keeps its
2797            // identity: its replacement cannot be expressed as one remove
2798            // (the directory may be non-empty), its children are captured
2799            // through their own paths, and native watchers (FSEvents) may
2800            // replay creation hints for paths that already exist.
2801            let replace = intent == CaptureIntent::Replace
2802                && !(host_kind == FileKind::Directory
2803                    && current.is_some_and(|record| record.kind == FileKind::Directory));
2804            if let Some(record) = current
2805                && (record.kind != host_kind || replace)
2806            {
2807                if record.kind == FileKind::Directory {
2808                    push_subtree_removals(
2809                        checkout,
2810                        &path,
2811                        mutations,
2812                        receipt,
2813                        budget,
2814                        cancellation,
2815                    )
2816                    .await?;
2817                }
2818                mutations.push(AuthoredMutation::Remove {
2819                    path: path.clone(),
2820                    expected_file_id: Some(record.file_id),
2821                });
2822            }
2823            let exists_with_kind =
2824                !replace && current.is_some_and(|record| record.kind == host_kind);
2825            let mut canonical_metadata = if host_kind == FileKind::SymbolicLink && cfg!(unix) {
2826                unrestorable_metadata()
2827            } else {
2828                snapshot.metadata
2829            };
2830            if let Some(record) = current.filter(|record| {
2831                exists_with_kind
2832                    && record.kind == host_kind
2833                    && (host_kind != FileKind::SymbolicLink || cfg!(windows))
2834            }) {
2835                let remaining = receipt.work.remaining(budget).map_err(|error| {
2836                    OperationFailure::new(CaptureError::Work(error), receipt.work)
2837                })?;
2838                let prior = checkout
2839                    .read_metadata_by_id(record.file_id, remaining, cancellation)
2840                    .await
2841                    .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2842                receipt.work = add_work(receipt.work, prior.work)?;
2843                canonical_metadata = preserve_unobserved_metadata(canonical_metadata, prior.value);
2844                #[cfg(unix)]
2845                if let Some(baseline) = baseline {
2846                    baseline.restore_canonical_stamps(
2847                        source_root.identity(),
2848                        &host_path,
2849                        &snapshot,
2850                        prior.value,
2851                        &mut canonical_metadata,
2852                    );
2853                }
2854            }
2855            let linked_path = path.clone();
2856            let prepared = if intent == CaptureIntent::MetadataOnly && exists_with_kind {
2857                ensure_current_host_node(source_root, &host_path, &snapshot)
2858                    .map_err(|error| OperationFailure::new(error, receipt.work))?;
2859                mutations.push(AuthoredMutation::SetMetadata {
2860                    path,
2861                    metadata: canonical_metadata,
2862                });
2863                None
2864            } else if host_kind == FileKind::Regular {
2865                let file = if let Some(file) = link_probe.file {
2866                    file
2867                } else {
2868                    source_root
2869                        .open_file(&host_path)
2870                        .map_err(|error| OperationFailure::new(error.into(), receipt.work))?
2871                };
2872                let opened = file
2873                    .metadata()
2874                    .map_err(|error| OperationFailure::new(error.into(), receipt.work))?;
2875                ensure_same_host_node(&snapshot, &opened)
2876                    .map_err(|error| OperationFailure::new(error, receipt.work))?;
2877                drop(file);
2878                Some(PreparedPath::Regular(Box::new(PreparedRegular {
2879                    path,
2880                    host_path,
2881                    snapshot,
2882                    exists_with_kind,
2883                    canonical_metadata,
2884                })))
2885            } else {
2886                append_final_state(
2887                    path,
2888                    source_root,
2889                    &host_path,
2890                    &metadata,
2891                    snapshot,
2892                    host_kind,
2893                    exists_with_kind,
2894                    canonical_metadata,
2895                    mutations,
2896                    receipt.work,
2897                )?;
2898                None
2899            };
2900            receipt.changed_paths = checked_increment(receipt.changed_paths, receipt.work)?;
2901            if linked_regular {
2902                host_links.insert(
2903                    snapshot.identity.to_bytes(),
2904                    if prepared.is_some() {
2905                        HostLinkSource::Pending(linked_path)
2906                    } else {
2907                        HostLinkSource::Ready(linked_path)
2908                    },
2909                );
2910            }
2911            receipt.examined_paths = checked_increment(receipt.examined_paths, receipt.work)?;
2912            return Ok(prepared);
2913        }
2914        Err(error) if host_path_is_absent(&error) => {
2915            if let Some(record) = current {
2916                // A directory that vanished takes its descendants with it.
2917                // A watcher may say so with one hint on the directory alone
2918                // (inotify on a directory moved out of the root, a rename
2919                // half on any platform), and a namespace remove of a
2920                // non-empty directory is refused, so remove the subtree
2921                // the checkout still holds, deepest first.
2922                if record.kind == FileKind::Directory {
2923                    push_subtree_removals(
2924                        checkout,
2925                        &path,
2926                        mutations,
2927                        receipt,
2928                        budget,
2929                        cancellation,
2930                    )
2931                    .await?;
2932                }
2933                mutations.push(AuthoredMutation::Remove {
2934                    path,
2935                    expected_file_id: Some(record.file_id),
2936                });
2937                receipt.changed_paths = checked_increment(receipt.changed_paths, receipt.work)?;
2938            }
2939        }
2940        Err(error) => return Err(OperationFailure::new(error.into(), receipt.work)),
2941    }
2942    receipt.examined_paths = checked_increment(receipt.examined_paths, receipt.work)?;
2943    Ok(None)
2944}
2945
2946fn host_path_is_absent(error: &std::io::Error) -> bool {
2947    matches!(
2948        error.kind(),
2949        std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory
2950    ) || cfg!(windows) && error.raw_os_error() == Some(267)
2951}
2952
2953/// Queues a remove for every descendant of `root` that the checkout holds,
2954/// deepest first, so that a following remove of `root` itself finds it
2955/// empty. The host has already lost the subtree; only the checkout's view
2956/// is walked.
2957async fn push_subtree_removals<A: AsyncAuthorityStore, O: AsyncObjectStore>(
2958    checkout: &mut Checkout<A, O>,
2959    root: &NamespacePath,
2960    mutations: &mut Vec<AuthoredMutation>,
2961    receipt: &mut CaptureReceipt,
2962    budget: WorkBudget,
2963    cancellation: &CancellationToken,
2964) -> Result<(), OperationFailure<CaptureError>> {
2965    const PAGE: u32 = 256;
2966    let limits = checkout.volume_config().limits;
2967    // Descendants that carried their own hint are already queued (absent
2968    // paths are captured deepest first); a second remove of the same path
2969    // would find its source missing.
2970    let queued: BTreeSet<NamespacePath> = mutations
2971        .iter()
2972        .filter_map(|mutation| match mutation {
2973            AuthoredMutation::Remove { path, .. } => Some(path.clone()),
2974            _ => None,
2975        })
2976        .collect();
2977    let mut pending = vec![root.clone()];
2978    let mut found: Vec<(NamespacePath, FileRecord)> = Vec::new();
2979    while let Some(directory) = pending.pop() {
2980        let mut after: Option<LogicalName> = None;
2981        loop {
2982            let remaining = receipt
2983                .work
2984                .remaining(budget)
2985                .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
2986            let page = checkout
2987                .list_directory_records(&directory, after.as_ref(), PAGE, remaining, cancellation)
2988                .await
2989                .map_err(|failure| map_engine_failure(failure, receipt.work))?;
2990            receipt.work = add_work(receipt.work, page.work)?;
2991            let page = page.value;
2992            for entry in &page.entries {
2993                let mut components = directory.components().to_vec();
2994                components.push(entry.name.clone());
2995                let child = NamespacePath::new(components, limits).map_err(|_| {
2996                    OperationFailure::new(CaptureError::InvalidOptions, receipt.work)
2997                })?;
2998                if entry.record.kind == FileKind::Directory {
2999                    pending.push(child.clone());
3000                }
3001                if !queued.contains(&child) {
3002                    found.push((child, entry.record));
3003                }
3004            }
3005            match page.entries.last() {
3006                Some(last) if page.has_more => after = Some(last.name.clone()),
3007                _ => break,
3008            }
3009        }
3010    }
3011    found.sort_by(|left, right| right.0.depth().cmp(&left.0.depth()));
3012    for (path, record) in found {
3013        mutations.push(AuthoredMutation::Remove {
3014            path,
3015            expected_file_id: Some(record.file_id),
3016        });
3017        receipt.changed_paths = checked_increment(receipt.changed_paths, receipt.work)?;
3018    }
3019    Ok(())
3020}
3021
3022async fn apply_capture_transaction<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3023    checkout: &mut Checkout<A, O>,
3024    mutations: Vec<AuthoredMutation>,
3025    receipt: &mut CaptureReceipt,
3026    budget: WorkBudget,
3027    cancellation: &CancellationToken,
3028) -> Result<(), OperationFailure<CaptureError>> {
3029    if mutations.is_empty() {
3030        return Ok(());
3031    }
3032    let remaining = receipt
3033        .work
3034        .remaining(budget)
3035        .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
3036    let applied = checkout
3037        .apply_authored_bulk_transaction(mutations, remaining, cancellation)
3038        .await
3039        .map_err(|failure| map_engine_failure(failure, receipt.work))?;
3040    receipt.work = add_work(receipt.work, applied.work)?;
3041    Ok(())
3042}
3043
3044fn checked_increment(
3045    value: u64,
3046    work: WorkCounters,
3047) -> Result<u64, OperationFailure<CaptureError>> {
3048    value
3049        .checked_add(1)
3050        .ok_or_else(|| OperationFailure::new(CaptureError::Work(WorkError::Overflow), work))
3051}
3052
3053fn append_hard_link(
3054    source: NamespacePath,
3055    destination: NamespacePath,
3056    current: Option<FileRecord>,
3057    mutations: &mut Vec<AuthoredMutation>,
3058) {
3059    if let Some(record) = current {
3060        mutations.push(AuthoredMutation::Remove {
3061            path: destination.clone(),
3062            expected_file_id: Some(record.file_id),
3063        });
3064    }
3065    mutations.push(AuthoredMutation::HardLink {
3066        source,
3067        destination,
3068    });
3069}
3070
3071#[allow(clippy::too_many_arguments)]
3072fn append_final_state(
3073    path: NamespacePath,
3074    source_root: &HostRoot,
3075    host_path: &Path,
3076    metadata: &cap_std::fs::Metadata,
3077    snapshot: HostSnapshot,
3078    kind: FileKind,
3079    exists_with_kind: bool,
3080    canonical_metadata: FileMetadata,
3081    mutations: &mut Vec<AuthoredMutation>,
3082    work: WorkCounters,
3083) -> Result<(), OperationFailure<CaptureError>> {
3084    match kind {
3085        FileKind::Regular => unreachable!("regular files are staged after preparation"),
3086        FileKind::Directory => {
3087            if exists_with_kind {
3088                mutations.push(AuthoredMutation::SetMetadata {
3089                    path,
3090                    metadata: canonical_metadata,
3091                });
3092            } else {
3093                mutations.push(AuthoredMutation::CreateDirectory {
3094                    path,
3095                    metadata: canonical_metadata,
3096                });
3097            }
3098        }
3099        FileKind::SymbolicLink => {
3100            let target = read_link_bytes(source_root, host_path)
3101                .map_err(|error| OperationFailure::new(error, work))?;
3102            if exists_with_kind {
3103                mutations.push(AuthoredMutation::Remove {
3104                    path: path.clone(),
3105                    expected_file_id: None,
3106                });
3107            }
3108            mutations.push(AuthoredMutation::CreateSymbolicLink {
3109                path,
3110                target: target.into(),
3111                metadata: canonical_metadata,
3112            });
3113        }
3114        FileKind::Fifo | FileKind::Socket | FileKind::CharacterDevice | FileKind::BlockDevice => {
3115            append_special_state(
3116                path,
3117                metadata,
3118                kind,
3119                exists_with_kind,
3120                canonical_metadata,
3121                mutations,
3122                work,
3123            )?;
3124        }
3125        FileKind::ReparsePoint | FileKind::MountBoundary => {
3126            return Err(OperationFailure::new(CaptureError::UnsupportedKind, work));
3127        }
3128    }
3129    ensure_current_host_node(source_root, host_path, &snapshot)
3130        .map_err(|error| OperationFailure::new(error, work))?;
3131    Ok(())
3132}
3133
3134#[allow(clippy::too_many_arguments)]
3135async fn finish_prepared_regular<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3136    stager: &ContentStager<A, O>,
3137    source_root: &HostRoot,
3138    prepared: PreparedRegular,
3139    maximum_extent_spans: u32,
3140    mutations: &mut Vec<AuthoredMutation>,
3141    receipt: &mut CaptureReceipt,
3142    budget: WorkBudget,
3143    cancellation: &CancellationToken,
3144) -> Result<(), OperationFailure<CaptureError>> {
3145    let staged = stage_regular_body(
3146        stager,
3147        source_root,
3148        &prepared.host_path,
3149        &prepared.snapshot,
3150        None,
3151        maximum_extent_spans,
3152        receipt.work,
3153        budget,
3154        cancellation,
3155    )
3156    .await?;
3157    receipt.work = add_work(receipt.work, staged.content.work)?;
3158    receipt.staged_file_bytes = receipt
3159        .staged_file_bytes
3160        .checked_add(staged.content.bytes)
3161        .ok_or_else(|| {
3162            OperationFailure::new(CaptureError::Work(WorkError::Overflow), receipt.work)
3163        })?;
3164    append_staged_regular_state(
3165        prepared.path,
3166        staged.logical_bytes,
3167        prepared.exists_with_kind,
3168        prepared.canonical_metadata,
3169        staged.content.ranges,
3170        mutations,
3171    );
3172    Ok(())
3173}
3174
3175async fn finish_prepared_regular_batch<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3176    stager: &ContentStager<A, O>,
3177    source_root: &HostRoot,
3178    prepared: PreparedRegularBatch,
3179    maximum_extent_spans: u32,
3180    mutations: &mut Vec<AuthoredMutation>,
3181    receipt: &mut CaptureReceipt,
3182    cancellation: &CancellationToken,
3183) -> Result<(), OperationFailure<CaptureError>> {
3184    let PreparedRegularBatch {
3185        regular,
3186        mut aliases,
3187    } = prepared;
3188    let concurrency = std::thread::available_parallelism()
3189        .map_or(1, std::num::NonZero::get)
3190        .min(8)
3191        .min(regular.len().max(1));
3192    let results = stream::iter(regular.into_iter().enumerate().map(|(index, prepared)| {
3193        let stager = stager.clone();
3194        async move {
3195            let PreparedRegular {
3196                path,
3197                host_path,
3198                snapshot,
3199                exists_with_kind,
3200                canonical_metadata,
3201            } = prepared;
3202            let result = stage_regular_body(
3203                &stager,
3204                source_root,
3205                &host_path,
3206                &snapshot,
3207                None,
3208                maximum_extent_spans,
3209                WorkCounters::default(),
3210                WorkBudget::UNBOUNDED,
3211                cancellation,
3212            )
3213            .await;
3214            (index, (path, exists_with_kind, canonical_metadata), result)
3215        }
3216    }))
3217    .buffer_unordered(concurrency)
3218    .collect::<Vec<_>>()
3219    .await;
3220    let mut results = results;
3221    results.sort_by_key(|(index, _, _)| *index);
3222    let mut staged_work = WorkCounters::default();
3223    for (_, _, result) in &results {
3224        let work = match result {
3225            Ok(staged) => staged.content.work,
3226            Err(failure) => *failure.work,
3227        };
3228        staged_work = staged_work
3229            .checked_add(work)
3230            .map_err(|error| OperationFailure::new(CaptureError::Work(error), receipt.work))?;
3231    }
3232    receipt.work = add_work(receipt.work, staged_work)?;
3233    for (_, (path, exists_with_kind, canonical_metadata), result) in results {
3234        let source = path.clone();
3235        let staged =
3236            result.map_err(|failure| OperationFailure::new(failure.error, receipt.work))?;
3237        receipt.staged_file_bytes = receipt
3238            .staged_file_bytes
3239            .checked_add(staged.content.bytes)
3240            .ok_or_else(|| {
3241                OperationFailure::new(CaptureError::Work(WorkError::Overflow), receipt.work)
3242            })?;
3243        append_staged_regular_state(
3244            path,
3245            staged.logical_bytes,
3246            exists_with_kind,
3247            canonical_metadata,
3248            staged.content.ranges,
3249            mutations,
3250        );
3251        if let Some(pending) = aliases.remove(&source) {
3252            for (destination, current) in pending {
3253                append_hard_link(source.clone(), destination, current, mutations);
3254            }
3255        }
3256    }
3257    Ok(())
3258}
3259
3260#[allow(clippy::too_many_arguments)]
3261async fn finish_prepared_paths<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3262    stager: &ContentStager<A, O>,
3263    source_root: &HostRoot,
3264    prepared: Vec<PreparedPath>,
3265    maximum_extent_spans: u32,
3266    mutations: &mut Vec<AuthoredMutation>,
3267    receipt: &mut CaptureReceipt,
3268    budget: WorkBudget,
3269    cancellation: &CancellationToken,
3270) -> Result<(), OperationFailure<CaptureError>> {
3271    let mut regular = Vec::new();
3272    let mut aliases = PendingHardLinks::new();
3273    regular
3274        .try_reserve(prepared.len())
3275        .map_err(|_| OperationFailure::new(CaptureError::InvalidOptions, receipt.work))?;
3276    for path in prepared {
3277        match path {
3278            PreparedPath::Regular(plan) => regular.push(*plan),
3279            PreparedPath::PendingHardLink {
3280                source,
3281                destination,
3282                current,
3283            } => aliases
3284                .entry(source)
3285                .or_default()
3286                .push((destination, current)),
3287        }
3288    }
3289    if budget == WorkBudget::UNBOUNDED {
3290        finish_prepared_regular_batch(
3291            stager,
3292            source_root,
3293            PreparedRegularBatch { regular, aliases },
3294            maximum_extent_spans,
3295            mutations,
3296            receipt,
3297            cancellation,
3298        )
3299        .await?;
3300        return Ok(());
3301    } else {
3302        for plan in regular {
3303            let source = plan.path.clone();
3304            finish_prepared_regular(
3305                stager,
3306                source_root,
3307                plan,
3308                maximum_extent_spans,
3309                mutations,
3310                receipt,
3311                budget,
3312                cancellation,
3313            )
3314            .await?;
3315            if let Some(pending) = aliases.remove(&source) {
3316                for (destination, current) in pending {
3317                    append_hard_link(source.clone(), destination, current, mutations);
3318                }
3319            }
3320        }
3321    }
3322    if !aliases.is_empty() {
3323        return Err(OperationFailure::new(
3324            CaptureError::Engine("pending hard-link source was not staged".into()),
3325            receipt.work,
3326        ));
3327    }
3328    Ok(())
3329}
3330
3331struct StagedRegularBody {
3332    logical_bytes: u64,
3333    content: StagedHostRanges,
3334}
3335
3336type PendingHardLinks = BTreeMap<NamespacePath, Vec<(NamespacePath, Option<FileRecord>)>>;
3337
3338struct PreparedRegularBatch {
3339    regular: Vec<PreparedRegular>,
3340    aliases: PendingHardLinks,
3341}
3342
3343#[allow(clippy::too_many_arguments)]
3344async fn stage_regular_body<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3345    stager: &ContentStager<A, O>,
3346    source_root: &HostRoot,
3347    host_path: &Path,
3348    snapshot: &HostSnapshot,
3349    opened_file: Option<cap_std::fs::File>,
3350    maximum_extent_spans: u32,
3351    prior_work: WorkCounters,
3352    budget: WorkBudget,
3353    cancellation: &CancellationToken,
3354) -> Result<StagedRegularBody, OperationFailure<CaptureError>> {
3355    let file = if let Some(file) = opened_file {
3356        file
3357    } else {
3358        source_root
3359            .open_file(host_path)
3360            .map_err(|error| OperationFailure::new(error.into(), prior_work))?
3361    };
3362    let metadata = file
3363        .metadata()
3364        .map_err(|error| OperationFailure::new(error.into(), prior_work))?;
3365    if !metadata.is_file() {
3366        return Err(OperationFailure::new(
3367            CaptureError::Io(std::io::Error::other(
3368                "host file kind changed during capture",
3369            )),
3370            prior_work,
3371        ));
3372    }
3373    ensure_same_host_node(snapshot, &metadata)
3374        .map_err(|error| OperationFailure::new(error, prior_work))?;
3375    let logical_bytes = metadata.len();
3376    let ranges = allocated_data_ranges(&file, logical_bytes, maximum_extent_spans)
3377        .map_err(|error| OperationFailure::new(error.into(), prior_work))?;
3378    let content =
3379        stage_host_ranges(stager, &file, &ranges, prior_work, budget, cancellation).await?;
3380    let accumulated = prior_work
3381        .checked_add(content.work)
3382        .map_err(|error| OperationFailure::new(CaptureError::Work(error), prior_work))?;
3383    let after = file
3384        .metadata()
3385        .map_err(|error| OperationFailure::new(error.into(), accumulated))?;
3386    ensure_same_host_node(snapshot, &after)
3387        .map_err(|error| OperationFailure::new(error, accumulated))?;
3388    ensure_current_host_node(source_root, host_path, snapshot)
3389        .map_err(|error| OperationFailure::new(error, accumulated))?;
3390    Ok(StagedRegularBody {
3391        logical_bytes,
3392        content,
3393    })
3394}
3395
3396fn append_staged_regular_state(
3397    path: NamespacePath,
3398    logical_bytes: u64,
3399    exists_with_kind: bool,
3400    canonical_metadata: FileMetadata,
3401    ranges: Vec<(HostDataRange, StagedContent)>,
3402    mutations: &mut Vec<AuthoredMutation>,
3403) {
3404    if exists_with_kind {
3405        mutations.push(AuthoredMutation::Resize {
3406            path: path.clone(),
3407            logical_bytes: 0,
3408        });
3409    } else {
3410        mutations.push(AuthoredMutation::CreateFile {
3411            path: path.clone(),
3412            bytes: bytes::Bytes::new(),
3413            metadata: canonical_metadata,
3414        });
3415    }
3416    if logical_bytes != 0 {
3417        mutations.push(AuthoredMutation::Resize {
3418            path: path.clone(),
3419            logical_bytes,
3420        });
3421    }
3422    mutations.extend(ranges.into_iter().map(|(range, content)| {
3423        AuthoredMutation::WriteFromContent {
3424            path: path.clone(),
3425            offset: range.offset,
3426            content,
3427        }
3428    }));
3429    mutations.push(AuthoredMutation::SetMetadata {
3430        path,
3431        metadata: canonical_metadata,
3432    });
3433}
3434
3435#[derive(Clone, Copy)]
3436struct HostSnapshot {
3437    identity: NativeRootIdentity,
3438    length: u64,
3439    metadata: FileMetadata,
3440}
3441
3442struct HostObservation {
3443    metadata: cap_std::fs::Metadata,
3444}
3445
3446impl HostObservation {
3447    fn from_metadata(metadata: cap_std::fs::Metadata) -> Self {
3448        Self { metadata }
3449    }
3450}
3451
3452struct HostLinkProbe {
3453    count: u64,
3454    file: Option<cap_std::fs::File>,
3455}
3456
3457impl HostLinkProbe {
3458    const fn unlinked() -> Self {
3459        Self {
3460            count: 1,
3461            file: None,
3462        }
3463    }
3464}
3465
3466#[cfg(unix)]
3467fn host_link_count(
3468    _source_root: &HostRoot,
3469    _host_path: &Path,
3470    _snapshot: &HostSnapshot,
3471    metadata: &cap_std::fs::Metadata,
3472) -> Result<HostLinkProbe, CaptureError> {
3473    use cap_std::fs::MetadataExt;
3474    Ok(HostLinkProbe {
3475        count: metadata.nlink(),
3476        file: None,
3477    })
3478}
3479
3480#[cfg(windows)]
3481#[allow(
3482    unsafe_code,
3483    reason = "reads FILE_STANDARD_INFO through a held capability-opened file handle"
3484)]
3485fn host_link_count(
3486    source_root: &HostRoot,
3487    host_path: &Path,
3488    snapshot: &HostSnapshot,
3489    _metadata: &cap_std::fs::Metadata,
3490) -> Result<HostLinkProbe, CaptureError> {
3491    use std::mem::size_of;
3492    use std::os::windows::io::AsRawHandle;
3493    use windows::Win32::Foundation::HANDLE;
3494    use windows::Win32::Storage::FileSystem::{
3495        FILE_STANDARD_INFO, FileStandardInfo, GetFileInformationByHandleEx,
3496    };
3497
3498    let file = source_root.open_file(host_path)?;
3499    ensure_same_host_node(snapshot, &file.metadata()?)?;
3500    let mut information = FILE_STANDARD_INFO::default();
3501    // SAFETY: file stays open and information is a correctly sized output.
3502    unsafe {
3503        GetFileInformationByHandleEx(
3504            HANDLE(file.as_raw_handle()),
3505            FileStandardInfo,
3506            (&raw mut information).cast(),
3507            u32::try_from(size_of::<FILE_STANDARD_INFO>())
3508                .map_err(|_| CaptureError::InvalidOptions)?,
3509        )
3510        .map_err(|error| CaptureError::Io(std::io::Error::other(error)))?;
3511    }
3512    Ok(HostLinkProbe {
3513        count: u64::from(information.NumberOfLinks),
3514        file: Some(file),
3515    })
3516}
3517
3518impl HostSnapshot {
3519    fn from_metadata(metadata: &cap_std::fs::Metadata) -> Result<Self, CaptureError> {
3520        Ok(Self {
3521            identity: NativeRootIdentity::from_metadata(metadata)?,
3522            length: metadata.len(),
3523            metadata: capture_metadata(metadata),
3524        })
3525    }
3526}
3527
3528fn ensure_same_host_node(
3529    expected: &HostSnapshot,
3530    observed: &cap_std::fs::Metadata,
3531) -> Result<(), CaptureError> {
3532    let observed_identity = NativeRootIdentity::from_metadata(observed)?;
3533    let mut expected_metadata = expected.metadata;
3534    let mut observed_metadata = capture_metadata(observed);
3535    // Reading the file can legitimately update its access time.
3536    expected_metadata.accessed_ns = MetadataField::Unavailable;
3537    observed_metadata.accessed_ns = MetadataField::Unavailable;
3538    if expected.identity != observed_identity
3539        || expected.length != observed.len()
3540        || expected_metadata != observed_metadata
3541    {
3542        return Err(CaptureError::Io(std::io::Error::other(
3543            "host file changed during capture",
3544        )));
3545    }
3546    Ok(())
3547}
3548
3549fn ensure_current_host_node(
3550    source_root: &HostRoot,
3551    host_path: &Path,
3552    expected: &HostSnapshot,
3553) -> Result<(), CaptureError> {
3554    let observed = source_root.symlink_metadata_held(host_path)?;
3555    ensure_same_host_node(expected, &observed)
3556}
3557
3558#[cfg(test)]
3559#[allow(clippy::items_after_test_module)]
3560mod host_file_race_tests {
3561    use super::*;
3562
3563    #[test]
3564    fn rejects_a_replaced_file_between_path_metadata_and_open()
3565    -> Result<(), Box<dyn std::error::Error>> {
3566        let temporary = tempfile::tempdir()?;
3567        let current = temporary.path().join("current");
3568        let replacement = temporary.path().join("replacement");
3569        std::fs::write(&current, b"first")?;
3570        std::fs::write(&replacement, b"other")?;
3571        let root = HostRoot::open(temporary.path())?;
3572        let expected = HostSnapshot::from_metadata(&root.symlink_metadata(Path::new("current"))?)?;
3573        std::fs::rename(&current, temporary.path().join("displaced"))?;
3574        std::fs::rename(&replacement, &current)?;
3575        let opened = root.open_file(Path::new("current"))?;
3576        assert!(matches!(
3577            ensure_same_host_node(&expected, &opened.metadata()?),
3578            Err(CaptureError::Io(_))
3579        ));
3580        Ok(())
3581    }
3582
3583    #[test]
3584    fn rejects_a_changed_file_after_open() -> Result<(), Box<dyn std::error::Error>> {
3585        let temporary = tempfile::tempdir()?;
3586        let current = temporary.path().join("current");
3587        std::fs::write(&current, b"first")?;
3588        let root = HostRoot::open(temporary.path())?;
3589        let expected = HostSnapshot::from_metadata(&root.symlink_metadata(Path::new("current"))?)?;
3590        let opened = root.open_file(Path::new("current"))?;
3591        ensure_same_host_node(&expected, &opened.metadata()?)?;
3592        std::fs::write(&current, b"changed body")?;
3593        assert!(matches!(
3594            ensure_same_host_node(&expected, &opened.metadata()?),
3595            Err(CaptureError::Io(_))
3596        ));
3597        Ok(())
3598    }
3599
3600    #[test]
3601    fn rejects_a_path_replaced_after_open() -> Result<(), Box<dyn std::error::Error>> {
3602        let temporary = tempfile::tempdir()?;
3603        let current = temporary.path().join("current");
3604        let replacement = temporary.path().join("replacement");
3605        std::fs::write(&current, b"first")?;
3606        std::fs::write(&replacement, b"other")?;
3607        let root = HostRoot::open(temporary.path())?;
3608        let expected = HostSnapshot::from_metadata(&root.symlink_metadata(Path::new("current"))?)?;
3609        let opened = root.open_file(Path::new("current"))?;
3610        std::fs::rename(&current, temporary.path().join("displaced"))?;
3611        std::fs::rename(&replacement, &current)?;
3612        let _held_metadata = opened.metadata()?;
3613        let path_matches =
3614            ensure_same_host_node(&expected, &root.symlink_metadata(Path::new("current"))?).is_ok();
3615        assert!(!path_matches);
3616        Ok(())
3617    }
3618
3619    #[test]
3620    fn rejects_a_replaced_directory_before_capture_admission()
3621    -> Result<(), Box<dyn std::error::Error>> {
3622        let temporary = tempfile::tempdir()?;
3623        let current = temporary.path().join("current");
3624        let replacement = temporary.path().join("replacement");
3625        std::fs::create_dir(&current)?;
3626        std::fs::create_dir(&replacement)?;
3627        let root = HostRoot::open(temporary.path())?;
3628        let expected = HostSnapshot::from_metadata(&root.symlink_metadata(Path::new("current"))?)?;
3629        std::fs::rename(&current, temporary.path().join("displaced"))?;
3630        std::fs::rename(&replacement, &current)?;
3631        assert!(matches!(
3632            ensure_current_host_node(&root, Path::new("current"), &expected),
3633            Err(CaptureError::Io(_))
3634        ));
3635        Ok(())
3636    }
3637}
3638
3639struct StagedHostRanges {
3640    ranges: Vec<(HostDataRange, StagedContent)>,
3641    work: WorkCounters,
3642    bytes: u64,
3643}
3644
3645async fn stage_host_ranges<A: AsyncAuthorityStore, O: AsyncObjectStore>(
3646    stager: &ContentStager<A, O>,
3647    file: &cap_std::fs::File,
3648    ranges: &[HostDataRange],
3649    prior_work: WorkCounters,
3650    budget: WorkBudget,
3651    cancellation: &CancellationToken,
3652) -> Result<StagedHostRanges, OperationFailure<CaptureError>> {
3653    let mut staged = Vec::new();
3654    staged
3655        .try_reserve(ranges.len())
3656        .map_err(|_| OperationFailure::new(CaptureError::InvalidOptions, prior_work))?;
3657    let mut work = WorkCounters::default();
3658    let mut bytes = 0_u64;
3659    for range in ranges {
3660        let accumulated = prior_work
3661            .checked_add(work)
3662            .map_err(|error| OperationFailure::new(CaptureError::Work(error), prior_work))?;
3663        let native = file
3664            .try_clone()
3665            .map(cap_std::fs::File::into_std)
3666            .map_err(|error| OperationFailure::new(error.into(), accumulated))?;
3667        let mut bounded = NativeRangeSource(
3668            acyclic_native_runtime::AsyncRangeReader::new(native, range.offset, range.length)
3669                .map_err(|error| OperationFailure::new(error.into(), accumulated))?,
3670        );
3671        let remaining = accumulated
3672            .remaining(budget)
3673            .map_err(|error| OperationFailure::new(CaptureError::Work(error), accumulated))?;
3674        let content = stager
3675            .stage(&mut bounded, range.length, remaining, cancellation)
3676            .await
3677            .map_err(|failure| map_engine_failure(failure, accumulated))?;
3678        work = add_work(work, content.work)?;
3679        let accumulated = prior_work
3680            .checked_add(work)
3681            .map_err(|error| OperationFailure::new(CaptureError::Work(error), prior_work))?;
3682        if content.value.logical_bytes() != range.length {
3683            return Err(OperationFailure::new(
3684                CaptureError::Io(std::io::Error::new(
3685                    std::io::ErrorKind::UnexpectedEof,
3686                    "host file changed while capturing a sparse range",
3687                )),
3688                accumulated,
3689            ));
3690        }
3691        bytes = bytes.checked_add(range.length).ok_or_else(|| {
3692            OperationFailure::new(CaptureError::Work(WorkError::Overflow), accumulated)
3693        })?;
3694        staged.push((*range, content.value));
3695    }
3696    Ok(StagedHostRanges {
3697        ranges: staged,
3698        work,
3699        bytes,
3700    })
3701}
3702
3703struct NativeRangeSource(acyclic_native_runtime::AsyncRangeReader);
3704
3705impl crate::kernel::AsyncBlobSource for NativeRangeSource {
3706    async fn read<'a>(
3707        &'a mut self,
3708        destination: &'a mut [u8],
3709        cancellation: &'a CancellationToken,
3710    ) -> std::io::Result<usize> {
3711        if cancellation.is_cancelled() {
3712            return Err(std::io::Error::new(
3713                std::io::ErrorKind::Interrupted,
3714                "native capture cancelled",
3715            ));
3716        }
3717        let bytes = self.0.read(destination.len()).await?;
3718        destination
3719            .get_mut(..bytes.len())
3720            .ok_or_else(|| std::io::Error::other("native read exceeded destination"))?
3721            .copy_from_slice(&bytes);
3722        Ok(bytes.len())
3723    }
3724
3725    async fn read_owned(
3726        &mut self,
3727        maximum: usize,
3728        cancellation: &CancellationToken,
3729    ) -> std::io::Result<Option<bytes::Bytes>> {
3730        if cancellation.is_cancelled() {
3731            return Err(std::io::Error::new(
3732                std::io::ErrorKind::Interrupted,
3733                "native capture cancelled",
3734            ));
3735        }
3736        self.0.read(maximum).await.map(Some)
3737    }
3738}
3739
3740fn append_special_state(
3741    path: NamespacePath,
3742    metadata: &cap_std::fs::Metadata,
3743    kind: FileKind,
3744    exists_with_kind: bool,
3745    canonical_metadata: FileMetadata,
3746    mutations: &mut Vec<AuthoredMutation>,
3747    work: WorkCounters,
3748) -> Result<(), OperationFailure<CaptureError>> {
3749    if matches!(kind, FileKind::Fifo | FileKind::Socket) {
3750        mutations.push(if exists_with_kind {
3751            AuthoredMutation::SetMetadata {
3752                path,
3753                metadata: canonical_metadata,
3754            }
3755        } else {
3756            AuthoredMutation::CreateEmptySpecial {
3757                path,
3758                kind,
3759                metadata: canonical_metadata,
3760            }
3761        });
3762        return Ok(());
3763    }
3764    let (major, minor) =
3765        host_device_identity(metadata).map_err(|error| OperationFailure::new(error, work))?;
3766    if exists_with_kind {
3767        mutations.push(AuthoredMutation::Remove {
3768            path: path.clone(),
3769            expected_file_id: None,
3770        });
3771    }
3772    mutations.push(AuthoredMutation::CreateDevice {
3773        path,
3774        kind,
3775        major,
3776        minor,
3777        metadata: canonical_metadata,
3778    });
3779    Ok(())
3780}
3781
3782fn validate_path_count(
3783    paths: &[NamespacePath],
3784    options: &CaptureOptions,
3785) -> Result<(), CaptureError> {
3786    if options.maximum_paths == 0
3787        || options.maximum_extent_spans == 0
3788        || paths.len() > usize::try_from(options.maximum_paths).unwrap_or(usize::MAX)
3789    {
3790        return Err(CaptureError::InvalidOptions);
3791    }
3792    Ok(())
3793}
3794
3795fn open_source_root(options: &CaptureOptions) -> Result<HostRoot, CaptureError> {
3796    let root = HostRoot::open(&options.source_root).map_err(|_| CaptureError::InvalidOptions)?;
3797    if root.identity() != options.expected_root_identity {
3798        return Err(CaptureError::RootChanged);
3799    }
3800    Ok(root)
3801}
3802
3803/// Converts one exact namespace path to a host-relative path without lossy text projection.
3804///
3805/// Native profile names retain their platform representation. A foreign profile is accepted only
3806/// when every component has an exact representation on this host.
3807///
3808/// # Errors
3809///
3810/// Returns [`CaptureError::UnrepresentablePath`] when any component cannot be represented exactly.
3811pub fn namespace_to_host_path(path: &NamespacePath) -> Result<PathBuf, CaptureError> {
3812    let mut result = PathBuf::new();
3813    for component in path.components() {
3814        result.push(capture_host_name(component)?);
3815    }
3816    Ok(result)
3817}
3818
3819/// Converts one canonical name to its exact host spelling.
3820///
3821/// Mirrors `host_name_bytes`: a Windows-profile volume is capturable on a
3822/// Unix host exactly when its names are UTF-8 representable, so UTF-16
3823/// canonical names decode back to the UTF-8 host names they were captured
3824/// from instead of failing as unrepresentable.
3825#[cfg(unix)]
3826fn capture_host_name(component: &LogicalName) -> Result<std::ffi::OsString, CaptureError> {
3827    use std::os::unix::ffi::OsStringExt;
3828    match component.encoding() {
3829        NameEncoding::Utf8 | NameEncoding::PosixBytes => {
3830            Ok(std::ffi::OsString::from_vec(component.as_bytes().to_vec()))
3831        }
3832        NameEncoding::WindowsUtf16Le => {
3833            let units = component
3834                .as_bytes()
3835                .chunks_exact(2)
3836                .map(|pair| {
3837                    let [high, low] = pair else {
3838                        unreachable!("chunks_exact(2) always yields exactly 2-byte chunks")
3839                    };
3840                    u16::from_le_bytes([*high, *low])
3841                })
3842                .collect::<Vec<_>>();
3843            String::from_utf16(&units)
3844                .map(std::ffi::OsString::from)
3845                .map_err(|_| CaptureError::UnrepresentablePath)
3846        }
3847    }
3848}
3849
3850#[cfg(not(unix))]
3851fn capture_host_name(component: &LogicalName) -> Result<std::ffi::OsString, CaptureError> {
3852    use std::os::windows::ffi::OsStringExt;
3853    match component.encoding() {
3854        NameEncoding::Utf8 | NameEncoding::PosixBytes => {
3855            let name = std::str::from_utf8(component.as_bytes())
3856                .map_err(|_| CaptureError::UnrepresentablePath)?;
3857            if name.contains(['/', '\\']) {
3858                return Err(CaptureError::UnrepresentablePath);
3859            }
3860            Ok(std::ffi::OsString::from(name))
3861        }
3862        NameEncoding::WindowsUtf16Le => {
3863            let units = component
3864                .as_bytes()
3865                .chunks_exact(2)
3866                .map(|unit| {
3867                    let [high, low] = unit else {
3868                        unreachable!("chunks_exact(2) always yields exactly 2-byte chunks")
3869                    };
3870                    u16::from_le_bytes([*high, *low])
3871                })
3872                .collect::<Vec<_>>();
3873            Ok(std::ffi::OsString::from_wide(&units))
3874        }
3875    }
3876}
3877
3878#[cfg(all(test, windows))]
3879mod windows_name_tests {
3880    use super::*;
3881
3882    #[test]
3883    fn posix_profile_names_round_trip_through_utf8_on_windows()
3884    -> Result<(), Box<dyn std::error::Error>> {
3885        let valid = LogicalName::new(
3886            NameEncoding::PosixBytes,
3887            "uni-é中.txt".as_bytes().to_vec(),
3888            255,
3889        )?;
3890        assert_eq!(
3891            capture_host_name(&valid)?,
3892            std::ffi::OsString::from("uni-é中.txt")
3893        );
3894
3895        let invalid = LogicalName::new(NameEncoding::PosixBytes, vec![0xff], 255)?;
3896        assert!(matches!(
3897            capture_host_name(&invalid),
3898            Err(CaptureError::UnrepresentablePath)
3899        ));
3900        let alias = LogicalName::new(NameEncoding::PosixBytes, b"a\\b".to_vec(), 255)?;
3901        assert!(matches!(
3902            capture_host_name(&alias),
3903            Err(CaptureError::UnrepresentablePath)
3904        ));
3905        Ok(())
3906    }
3907
3908    #[test]
3909    fn host_path_round_trips_through_windows_profile() -> Result<(), Box<dyn std::error::Error>> {
3910        let limits = crate::model::VolumeLimits {
3911            maximum_component_bytes: 510,
3912            ..crate::model::VolumeLimits::default()
3913        };
3914        let host = std::path::Path::new("src/ünïcøde/日本語.rs");
3915        let namespace = host_path_to_namespace(host, FilesystemProfile::Windows, limits)?;
3916        assert_eq!(namespace_to_host_path(&namespace)?, host);
3917        Ok(())
3918    }
3919}
3920
3921#[cfg(all(test, unix))]
3922mod unix_name_tests {
3923    use super::*;
3924    use std::os::unix::ffi::OsStrExt;
3925
3926    #[test]
3927    fn host_path_round_trips_arbitrary_posix_bytes() -> Result<(), Box<dyn std::error::Error>> {
3928        let raw = std::ffi::OsStr::from_bytes(b"src/\xff.rs");
3929        let host = std::path::Path::new(raw);
3930        let namespace = host_path_to_namespace(
3931            host,
3932            FilesystemProfile::Posix,
3933            crate::model::VolumeLimits::default(),
3934        )?;
3935        assert_eq!(namespace_to_host_path(&namespace)?, host);
3936        Ok(())
3937    }
3938}
3939
3940fn host_kind(metadata: &cap_std::fs::Metadata) -> Result<FileKind, OperationFailure<CaptureError>> {
3941    let kind = metadata.file_type();
3942    if kind.is_file() {
3943        return Ok(FileKind::Regular);
3944    }
3945    if kind.is_dir() {
3946        return Ok(FileKind::Directory);
3947    }
3948    if kind.is_symlink() {
3949        return Ok(FileKind::SymbolicLink);
3950    }
3951    #[cfg(unix)]
3952    {
3953        use cap_std::fs::FileTypeExt;
3954        if kind.is_fifo() {
3955            return Ok(FileKind::Fifo);
3956        }
3957        if kind.is_socket() {
3958            return Ok(FileKind::Socket);
3959        }
3960        if kind.is_char_device() {
3961            return Ok(FileKind::CharacterDevice);
3962        }
3963        if kind.is_block_device() {
3964            return Ok(FileKind::BlockDevice);
3965        }
3966    }
3967    Err(OperationFailure::before_work(CaptureError::UnsupportedKind))
3968}
3969
3970#[cfg(unix)]
3971#[allow(clippy::unnecessary_wraps)]
3972fn host_device_identity(metadata: &cap_std::fs::Metadata) -> Result<(u32, u32), CaptureError> {
3973    use cap_std::fs::MetadataExt;
3974    Ok(split_device(metadata.rdev()))
3975}
3976
3977#[cfg(target_os = "linux")]
3978fn split_device(device: u64) -> (u32, u32) {
3979    (libc::major(device), libc::minor(device))
3980}
3981
3982#[cfg(target_os = "macos")]
3983fn split_device(device: u64) -> (u32, u32) {
3984    let low = u32::try_from(device & u64::from(u32::MAX)).unwrap_or(u32::MAX);
3985    let native = i32::from_ne_bytes(low.to_ne_bytes());
3986    (
3987        u32::try_from(libc::major(native)).unwrap_or(u32::MAX),
3988        u32::try_from(libc::minor(native)).unwrap_or(u32::MAX),
3989    )
3990}
3991
3992#[cfg(not(unix))]
3993fn host_device_identity(_: &cap_std::fs::Metadata) -> Result<(u32, u32), CaptureError> {
3994    Err(CaptureError::UnsupportedKind)
3995}
3996
3997#[cfg(unix)]
3998fn read_link_bytes(root: &HostRoot, path: &Path) -> Result<Vec<u8>, CaptureError> {
3999    use std::os::unix::ffi::OsStringExt;
4000    Ok(root.read_link(path)?.into_os_string().into_vec())
4001}
4002
4003#[cfg(windows)]
4004fn read_link_bytes(root: &HostRoot, path: &Path) -> Result<Vec<u8>, CaptureError> {
4005    use std::os::windows::ffi::OsStrExt;
4006    Ok(root
4007        .read_link(path)?
4008        .as_os_str()
4009        .encode_wide()
4010        .flat_map(u16::to_le_bytes)
4011        .collect())
4012}
4013
4014/// Unix symbolic-link metadata is not restored by the current native view.
4015/// Windows link-leaf attributes and timestamps are applied by `HostRoot` and
4016/// must remain in the canonical capture.
4017fn unrestorable_metadata() -> FileMetadata {
4018    FileMetadata {
4019        posix_mode: MetadataField::Unavailable,
4020        posix_uid: MetadataField::Unavailable,
4021        posix_gid: MetadataField::Unavailable,
4022        posix_flags: MetadataField::Unavailable,
4023        windows_attributes: MetadataField::Unavailable,
4024        created_ns: MetadataField::Unavailable,
4025        modified_ns: MetadataField::Unavailable,
4026        accessed_ns: MetadataField::Unavailable,
4027        changed_ns: MetadataField::Unavailable,
4028        named_attributes: MetadataField::Unavailable,
4029        acl: MetadataField::Unavailable,
4030        security_descriptor: MetadataField::Unavailable,
4031    }
4032}
4033
4034fn capture_metadata(metadata: &cap_std::fs::Metadata) -> FileMetadata {
4035    let mut result = FileMetadata {
4036        posix_mode: MetadataField::Unavailable,
4037        posix_uid: MetadataField::Unavailable,
4038        posix_gid: MetadataField::Unavailable,
4039        posix_flags: MetadataField::Unavailable,
4040        windows_attributes: MetadataField::Unavailable,
4041        created_ns: system_time(metadata.created()),
4042        modified_ns: system_time(metadata.modified()),
4043        accessed_ns: system_time(metadata.accessed()),
4044        changed_ns: MetadataField::Unavailable,
4045        named_attributes: MetadataField::Unavailable,
4046        acl: MetadataField::Unavailable,
4047        security_descriptor: MetadataField::Unavailable,
4048    };
4049    populate_platform_metadata(metadata, &mut result);
4050    result
4051}
4052
4053fn preserve_unobserved_metadata(observed: FileMetadata, prior: FileMetadata) -> FileMetadata {
4054    FileMetadata {
4055        posix_mode: preserve_field(observed.posix_mode, prior.posix_mode),
4056        posix_uid: preserve_field(observed.posix_uid, prior.posix_uid),
4057        posix_gid: preserve_field(observed.posix_gid, prior.posix_gid),
4058        posix_flags: preserve_field(observed.posix_flags, prior.posix_flags),
4059        windows_attributes: preserve_field(observed.windows_attributes, prior.windows_attributes),
4060        created_ns: preserve_field(observed.created_ns, prior.created_ns),
4061        modified_ns: preserve_field(observed.modified_ns, prior.modified_ns),
4062        accessed_ns: preserve_field(observed.accessed_ns, prior.accessed_ns),
4063        changed_ns: preserve_field(observed.changed_ns, prior.changed_ns),
4064        named_attributes: preserve_field(observed.named_attributes, prior.named_attributes),
4065        acl: preserve_field(observed.acl, prior.acl),
4066        security_descriptor: preserve_field(
4067            observed.security_descriptor,
4068            prior.security_descriptor,
4069        ),
4070    }
4071}
4072
4073fn preserve_field<T: Copy>(
4074    observed: MetadataField<T>,
4075    prior: MetadataField<T>,
4076) -> MetadataField<T> {
4077    match observed {
4078        MetadataField::Unavailable => prior,
4079        MetadataField::Value(_) => observed,
4080    }
4081}
4082
4083#[allow(clippy::needless_pass_by_value)]
4084fn system_time(value: std::io::Result<cap_std::time::SystemTime>) -> MetadataField<i64> {
4085    let Ok(value) = value else {
4086        return MetadataField::Unavailable;
4087    };
4088    let nanos = match value.into_std().duration_since(std::time::UNIX_EPOCH) {
4089        Ok(duration) => {
4090            i128::from(duration.as_secs()) * 1_000_000_000 + i128::from(duration.subsec_nanos())
4091        }
4092        Err(error) => {
4093            let duration = error.duration();
4094            -(i128::from(duration.as_secs()) * 1_000_000_000 + i128::from(duration.subsec_nanos()))
4095        }
4096    };
4097    i64::try_from(nanos).map_or(MetadataField::Unavailable, MetadataField::Value)
4098}
4099
4100#[cfg(unix)]
4101fn populate_platform_metadata(metadata: &cap_std::fs::Metadata, result: &mut FileMetadata) {
4102    use cap_std::fs::MetadataExt;
4103    result.posix_mode = MetadataField::Value(metadata.mode());
4104    result.posix_uid = MetadataField::Value(metadata.uid());
4105    result.posix_gid = MetadataField::Value(metadata.gid());
4106    let nanos = i128::from(metadata.ctime()) * 1_000_000_000 + i128::from(metadata.ctime_nsec());
4107    result.changed_ns =
4108        i64::try_from(nanos).map_or(MetadataField::Unavailable, MetadataField::Value);
4109}
4110
4111#[cfg(windows)]
4112fn populate_platform_metadata(metadata: &cap_std::fs::Metadata, result: &mut FileMetadata) {
4113    use cap_std::fs::MetadataExt;
4114    result.windows_attributes = MetadataField::Value(metadata.file_attributes());
4115}
4116
4117#[allow(clippy::needless_pass_by_value)]
4118fn map_engine_failure<E: std::fmt::Display>(
4119    failure: OperationFailure<E>,
4120    prior: WorkCounters,
4121) -> OperationFailure<CaptureError> {
4122    match prior.checked_add(*failure.work) {
4123        Ok(work) => OperationFailure::new(CaptureError::Engine(failure.error.to_string()), work),
4124        Err(error) => OperationFailure::new(CaptureError::Work(error), prior),
4125    }
4126}
4127
4128fn map_capture_failure(
4129    failure: OperationFailure<CaptureError>,
4130    prior: WorkCounters,
4131) -> OperationFailure<CaptureError> {
4132    match prior.checked_add(*failure.work) {
4133        Ok(work) => OperationFailure::new(failure.error, work),
4134        Err(error) => OperationFailure::new(CaptureError::Work(error), prior),
4135    }
4136}
4137
4138fn add_work(
4139    left: WorkCounters,
4140    right: WorkCounters,
4141) -> Result<WorkCounters, OperationFailure<CaptureError>> {
4142    left.checked_add(right)
4143        .map_err(|error| OperationFailure::new(error.into(), left))
4144}