1use 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
16pub 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#[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#[derive(Clone, Debug, Eq, PartialEq)]
131pub struct CaptureOptions {
132 pub source_root: PathBuf,
134 pub expected_root_identity: NativeRootIdentity,
136 pub maximum_paths: u32,
138 pub maximum_extent_spans: u32,
140}
141
142#[derive(Clone, Debug, Default, Eq, PartialEq)]
148pub struct CapturePolicy {
149 excluded_prefixes: Vec<NamespacePath>,
150}
151
152impl CapturePolicy {
153 #[must_use]
155 pub const fn allow_all() -> Self {
156 Self {
157 excluded_prefixes: Vec::new(),
158 }
159 }
160
161 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 #[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 #[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#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
665pub struct CaptureReceipt {
666 pub examined_paths: u64,
668 pub changed_paths: u64,
670 pub staged_file_bytes: u64,
672 pub work: WorkCounters,
674}
675
676#[derive(Clone, Copy, Debug, Eq, PartialEq)]
679pub struct WatchCaptureReceipt {
680 pub epoch: WatchEpoch,
682 pub first_sequence: WatchSequence,
684 pub next_sequence: WatchSequence,
686 pub capture: CaptureReceipt,
688}
689
690#[derive(Debug, Error)]
692pub enum CaptureError {
693 #[error("native capture options are invalid")]
695 InvalidOptions,
696 #[error("native capture root identity changed")]
698 RootChanged,
699 #[error("watcher epoch {epoch} requires a new baseline: {reason}")]
701 RescanRequired {
702 epoch: u64,
704 reason: WatchInvalidationReason,
706 },
707 #[error("baseline capture requires a clean checkout")]
709 DirtyCheckout,
710 #[error("native capture path is not exactly representable")]
712 UnrepresentablePath,
713 #[error("host file kind is unsupported for exact capture")]
715 UnsupportedKind,
716 #[error("filesystem engine failed: {0}")]
718 Engine(String),
719 #[error("source filesystem operation failed: {0}")]
721 Io(#[from] std::io::Error),
722 #[error(transparent)]
724 Work(#[from] WorkError),
725}
726
727pub 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
765pub 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#[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
904pub 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
925pub 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
937pub 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 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 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
1466pub 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
1500pub 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 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
1971pub 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
2004pub 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
2037pub 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 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 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 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
2432fn 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 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 return Err(OperationFailure::new(
2787 CaptureError::RescanRequired {
2788 epoch,
2789 reason: WatchInvalidationReason::NativeRescanRequired,
2790 },
2791 receipt.work,
2792 ));
2793 }
2794 }
2795 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 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
2953async 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 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 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 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(¤t, 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(¤t, temporary.path().join("displaced"))?;
3574 std::fs::rename(&replacement, ¤t)?;
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(¤t, 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(¤t, 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(¤t, 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(¤t, temporary.path().join("displaced"))?;
3611 std::fs::rename(&replacement, ¤t)?;
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(¤t)?;
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(¤t, temporary.path().join("displaced"))?;
3630 std::fs::rename(&replacement, ¤t)?;
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
3803pub 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#[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
4014fn 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}