Skip to main content

ripsync_core/
apply.rs

1//! Execute a [`SyncPlan`]: atomic file copies, metadata preservation, contained
2//! symlinks, and guarded deletes.
3//!
4//! Files are written to a temporary name in the destination directory, flushed
5//! with `fsync`, given the source's mode and mtime, then atomically `rename`d over
6//! the target — so a crash mid-copy never leaves a half-written file in place.
7//! Every write/symlink/delete target is checked for destination containment first.
8
9use std::collections::HashMap;
10use std::io;
11use std::path::{Path, PathBuf};
12use std::sync::atomic::{AtomicU64, Ordering};
13
14use rayon::prelude::*;
15
16use crate::control::RunControl;
17use crate::copy::{FsyncMode, ReflinkMode, copy_file_into, copy_file_into_sized};
18use crate::meta::{
19    canonical_root, check_relative, contained_target, copy_xattrs, set_mode, set_mtime,
20    set_owner_group, set_symlink_mtime,
21};
22use crate::plan::{Action, SyncPlan};
23use crate::report::{Event, Reporter, RunPhase, Stats};
24use crate::walk::{Entry, EntryKind};
25use crate::{Error, Result};
26
27/// Which file-copy backend to use.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum Backend {
30    /// Portable backend (conservative default).
31    #[default]
32    Auto,
33    /// Force the `io_uring` batched backend (Linux).
34    Uring,
35    /// Force the portable reflink/`copy_file_range`/buffered backend.
36    Portable,
37}
38
39/// Knobs controlling how a plan is applied.
40#[derive(Debug, Clone, Copy, Default)]
41pub struct ApplyOptions {
42    /// Plan only: emit events, change nothing on disk.
43    pub dry_run: bool,
44    /// Confirm destructive actions (required for deletions to happen).
45    pub yes: bool,
46    /// Whether `--delete` was requested (deletions only run with `yes` too).
47    pub delete: bool,
48    /// Worker threads for the parallel copy phase (0 ⇒ rayon default).
49    pub threads: usize,
50    /// Copy-on-write reflink strategy.
51    pub reflink: ReflinkMode,
52    /// Per-file fsync strategy.
53    pub fsync: FsyncMode,
54    /// File-copy backend selection.
55    pub backend: Backend,
56    /// Optional metadata and file-layout preservation.
57    pub metadata: MetadataOptions,
58    /// Buffer size for the portable copy fallback path (bytes).
59    /// `None` uses the default (1 MiB). Set from [`crate::tune::TuneParams::copy_buffer`].
60    pub copy_buffer: Option<usize>,
61}
62
63/// Optional metadata and file-layout preservation controls.
64#[derive(Debug, Clone, Copy, Default)]
65#[allow(clippy::struct_excessive_bools)]
66pub struct MetadataOptions {
67    /// Preserve hardlink groups.
68    pub hard_links: bool,
69    /// Preserve sparse-file holes.
70    pub sparse: bool,
71    /// Preserve non-ACL extended attributes.
72    pub xattrs: bool,
73    /// Preserve POSIX ACL attributes.
74    pub acls: bool,
75    /// Preserve numeric owner id.
76    pub owner: bool,
77    /// Preserve numeric group id.
78    pub group: bool,
79}
80
81/// Thread-safe running totals.
82#[derive(Default)]
83struct Counters {
84    copied: AtomicU64,
85    updated: AtomicU64,
86    skipped: AtomicU64,
87    deleted: AtomicU64,
88    errors: AtomicU64,
89    bytes: AtomicU64,
90}
91
92impl Counters {
93    fn snapshot(&self) -> Stats {
94        Stats {
95            copied: self.copied.load(Ordering::Relaxed),
96            updated: self.updated.load(Ordering::Relaxed),
97            skipped: self.skipped.load(Ordering::Relaxed),
98            deleted: self.deleted.load(Ordering::Relaxed),
99            errors: self.errors.load(Ordering::Relaxed),
100            bytes: self.bytes.load(Ordering::Relaxed),
101        }
102    }
103    fn bump_action(&self, action: Action) {
104        match action {
105            Action::Copy => self.copied.fetch_add(1, Ordering::Relaxed),
106            Action::Update => self.updated.fetch_add(1, Ordering::Relaxed),
107            Action::Skip => self.skipped.fetch_add(1, Ordering::Relaxed),
108        };
109    }
110}
111
112/// Apply `plan`, mirroring `src` into `dst`.
113///
114/// Returns the [`Stats`] tally. Per-entry failures are reported via `reporter`
115/// and counted in [`Stats::errors`]; only setup-level problems (e.g. a
116/// containment violation establishing the root) abort the whole run.
117///
118/// # Errors
119///
120/// Returns an error if the destination root cannot be created/canonicalized, or
121/// a relative path fails its safety check.
122pub fn apply_plan<R: Reporter>(
123    plan: &SyncPlan,
124    src: &Path,
125    dst: &Path,
126    opts: ApplyOptions,
127    reporter: &R,
128) -> Result<Stats> {
129    apply_plan_controlled(plan, src, dst, opts, reporter, &RunControl::default())
130}
131
132/// Apply a plan with cooperative pause and cancellation.
133///
134/// # Errors
135///
136/// Returns setup, containment, or cancellation errors. Per-entry operation
137/// failures continue to be counted in the returned statistics.
138pub fn apply_plan_controlled<R: Reporter>(
139    plan: &SyncPlan,
140    src: &Path,
141    dst: &Path,
142    opts: ApplyOptions,
143    reporter: &R,
144    control: &RunControl,
145) -> Result<Stats> {
146    control.checkpoint()?;
147    reporter.event(Event::Planned {
148        total_files: plan
149            .actions
150            .iter()
151            .filter(|a| a.action != Action::Skip && a.entry.is_file())
152            .count(),
153        total_bytes: plan.bytes_to_transfer(),
154        deletions: plan.deletions.len(),
155    });
156
157    let counters = Counters::default();
158
159    if opts.dry_run {
160        for pa in &plan.actions {
161            control.checkpoint()?;
162            emit_planned_event(reporter, &pa.entry, pa.action);
163            counters.bump_action(pa.action);
164            if pa.action != Action::Skip && pa.entry.is_file() {
165                counters.bytes.fetch_add(pa.entry.len, Ordering::Relaxed);
166            }
167        }
168        for del in &plan.deletions {
169            control.checkpoint()?;
170            reporter.event(Event::Deleted {
171                rel: del.rel.clone(),
172            });
173            counters.deleted.fetch_add(1, Ordering::Relaxed);
174        }
175        return Ok(counters.snapshot());
176    }
177
178    apply_real(plan, src, dst, opts, reporter, &counters, control)?;
179    Ok(counters.snapshot())
180}
181
182/// The on-disk apply phases (everything except dry-run bookkeeping).
183#[allow(clippy::too_many_lines)]
184fn apply_real<R: Reporter>(
185    plan: &SyncPlan,
186    src: &Path,
187    dst: &Path,
188    opts: ApplyOptions,
189    reporter: &R,
190    counters: &Counters,
191    control: &RunControl,
192) -> Result<()> {
193    reporter.event(Event::Phase(RunPhase::Copying));
194    control.checkpoint()?;
195    let root_canon = canonical_root(dst)?;
196
197    // Partition work by kind. Directories are batched by depth, files run in
198    // parallel, and symlinks run sequentially.
199    let mut dirs: Vec<&Entry> = Vec::new();
200    let mut mtime_dirs: Vec<&Entry> = Vec::new();
201    let mut files: Vec<(&Entry, Action)> = Vec::new();
202    let mut hardlinks: Vec<(&Entry, Action, PathBuf)> = Vec::new();
203    let mut symlinks: Vec<(&Entry, Action)> = Vec::new();
204    let mut first_hardlink: HashMap<(u64, u64), PathBuf> = HashMap::new();
205
206    for pa in &plan.actions {
207        if pa.entry.is_dir() {
208            mtime_dirs.push(&pa.entry);
209        }
210        if opts.metadata.hard_links && pa.entry.is_file() {
211            let id = (pa.entry.dev, pa.entry.ino);
212            if let Some(canonical) = first_hardlink.get(&id) {
213                if pa.action == Action::Skip {
214                    reporter.event(Event::Skipped {
215                        rel: pa.entry.rel.clone(),
216                    });
217                    counters.skipped.fetch_add(1, Ordering::Relaxed);
218                } else {
219                    check_relative(&pa.entry.rel)?;
220                    hardlinks.push((&pa.entry, pa.action, canonical.clone()));
221                }
222                continue;
223            }
224            first_hardlink.insert(id, pa.entry.rel.clone());
225        }
226        if pa.action == Action::Skip {
227            reporter.event(Event::Skipped {
228                rel: pa.entry.rel.clone(),
229            });
230            counters.skipped.fetch_add(1, Ordering::Relaxed);
231            continue;
232        }
233        check_relative(&pa.entry.rel)?;
234        match pa.entry.kind {
235            EntryKind::Dir => dirs.push(&pa.entry),
236            EntryKind::File => files.push((&pa.entry, pa.action)),
237            EntryKind::Symlink(_) => symlinks.push((&pa.entry, pa.action)),
238        }
239    }
240
241    // Resolve `auto` now that the file set (and its size distribution) is known,
242    // then report the concrete backend and reason.
243    let (effective_backend, backend_name, backend_reason) = resolve_backend(opts, &files);
244    reporter.event(Event::BackendSelected {
245        backend: backend_name,
246        reason: backend_reason,
247    });
248    let mut copy_opts = opts;
249    copy_opts.backend = effective_backend;
250
251    let pool = build_pool(opts.threads);
252
253    // 1. Directories, parent-depth batches. Peers at one depth are independent.
254    control.checkpoint()?;
255    let run_dirs = || {
256        create_directories(
257            &dirs,
258            src,
259            dst,
260            &root_canon,
261            opts,
262            reporter,
263            counters,
264            control,
265        )
266    };
267    match &pool {
268        Some(thread_pool) => thread_pool.install(run_dirs)?,
269        None => run_dirs()?,
270    }
271
272    // 2. Files.
273    control.checkpoint()?;
274    let run_files = || {
275        copy_files(
276            &files,
277            src,
278            &root_canon,
279            copy_opts,
280            reporter,
281            counters,
282            control,
283        )
284    };
285    match &pool {
286        Some(p) => p.install(run_files)?,
287        None => run_files()?,
288    }
289
290    // 3. Duplicate hardlinks after their canonical files exist.
291    copy_hardlinks(&hardlinks, &root_canon, reporter, counters, control)?;
292
293    // 4. Symlinks, sequentially.
294    for (entry, action) in &symlinks {
295        control.checkpoint()?;
296        if let EntryKind::Symlink(link_target) = &entry.kind {
297            match create_symlink(src, &root_canon, entry, link_target, opts) {
298                Ok(()) => {
299                    reporter.event(Event::SymlinkDone {
300                        rel: entry.rel.clone(),
301                        action: *action,
302                    });
303                    counters.bump_action(*action);
304                }
305                Err(e) => report_fail(reporter, counters, &entry.rel, &e),
306            }
307        }
308    }
309
310    // 5. Directory mtimes last (deepest first) — children writes bump parent times.
311    for entry in mtime_dirs.iter().rev() {
312        control.checkpoint()?;
313        let target = root_canon.join(&entry.rel);
314        let _ = set_mtime(&target, entry.mtime);
315    }
316
317    // 6. Guarded deletions (deepest first).
318    if opts.delete && opts.yes {
319        reporter.event(Event::Phase(RunPhase::Deleting));
320        run_deletions(plan, &root_canon, reporter, counters, control)?;
321    }
322
323    // 7. Batched directory fsync for rename durability (auto mode). `always`
324    // already fsynced each file; `never` skips even this.
325    if opts.fsync == FsyncMode::Auto {
326        control.checkpoint()?;
327        fsync_touched_dirs(&root_canon, &dirs, &files);
328    }
329
330    control.checkpoint()?;
331    Ok(())
332}
333
334#[allow(clippy::too_many_arguments)]
335fn create_directories<R: Reporter>(
336    dirs: &[&Entry],
337    src: &Path,
338    dst: &Path,
339    root_canon: &Path,
340    opts: ApplyOptions,
341    reporter: &R,
342    counters: &Counters,
343    control: &RunControl,
344) -> Result<()> {
345    let mut start = 0;
346    while start < dirs.len() {
347        control.checkpoint()?;
348        let depth = dirs[start].rel.components().count();
349        let end = dirs[start..]
350            .iter()
351            .position(|entry| entry.rel.components().count() != depth)
352            .map_or(dirs.len(), |offset| start + offset);
353        dirs[start..end].par_iter().for_each(|entry| {
354            if control.is_cancelled() {
355                return;
356            }
357            create_directory(entry, src, dst, root_canon, opts, reporter, counters);
358        });
359        control.checkpoint()?;
360        start = end;
361    }
362    Ok(())
363}
364
365fn create_directory<R: Reporter>(
366    entry: &Entry,
367    src: &Path,
368    dst: &Path,
369    root_canon: &Path,
370    opts: ApplyOptions,
371    reporter: &R,
372    counters: &Counters,
373) {
374    let action = action_for_existing(dst, entry);
375    let target = root_canon.join(&entry.rel);
376    if let Ok(meta) = std::fs::symlink_metadata(&target) {
377        if !meta.is_dir() {
378            if let Err(error) =
379                std::fs::remove_file(&target).map_err(|error| Error::io(&target, error))
380            {
381                report_fail(reporter, counters, &entry.rel, &error);
382                return;
383            }
384        }
385    }
386    if let Err(error) = std::fs::create_dir_all(&target).map_err(|error| Error::io(&target, error))
387    {
388        report_fail(reporter, counters, &entry.rel, &error);
389        return;
390    }
391    let result = contained_target(root_canon, &target).and_then(|contained| {
392        let source = src.join(&entry.rel);
393        set_owner_group(
394            &contained,
395            entry.uid,
396            entry.gid,
397            opts.metadata.owner,
398            opts.metadata.group,
399            true,
400        )?;
401        set_mode(&contained, entry.mode)?;
402        copy_xattrs(
403            &source,
404            &contained,
405            opts.metadata.xattrs,
406            opts.metadata.acls,
407        )
408    });
409    if let Err(error) = result {
410        report_fail(reporter, counters, &entry.rel, &error);
411        return;
412    }
413    reporter.event(Event::DirDone {
414        rel: entry.rel.clone(),
415        action,
416    });
417    counters.bump_action(action);
418}
419
420/// Fsync every directory that received a create/rename, once each, so the
421/// directory entries (and thus the atomic renames) are durable.
422fn fsync_touched_dirs(root_canon: &Path, dirs: &[&Entry], files: &[(&Entry, Action)]) {
423    let mut targets: std::collections::HashSet<std::path::PathBuf> =
424        std::collections::HashSet::new();
425    targets.insert(root_canon.to_path_buf());
426    for entry in dirs {
427        targets.insert(root_canon.join(&entry.rel));
428    }
429    for (entry, _) in files {
430        if let Some(parent) = root_canon.join(&entry.rel).parent() {
431            targets.insert(parent.to_path_buf());
432        }
433    }
434    for dir in targets {
435        if let Ok(f) = std::fs::File::open(&dir) {
436            let _ = f.sync_all();
437        }
438    }
439}
440
441/// Execute the (already gated) deletion phase, deepest paths first.
442fn run_deletions<R: Reporter>(
443    plan: &SyncPlan,
444    root_canon: &Path,
445    reporter: &R,
446    counters: &Counters,
447    control: &RunControl,
448) -> Result<()> {
449    for del in &plan.deletions {
450        control.checkpoint()?;
451        match delete_entry(root_canon, &del.rel, del.is_dir) {
452            Ok(()) => {
453                reporter.event(Event::Deleted {
454                    rel: del.rel.clone(),
455                });
456                counters.deleted.fetch_add(1, Ordering::Relaxed);
457            }
458            Err(e) => report_fail(reporter, counters, &del.rel, &e),
459        }
460    }
461    Ok(())
462}
463
464fn build_pool(threads: usize) -> Option<rayon::ThreadPool> {
465    if threads == 0 {
466        None
467    } else {
468        rayon::ThreadPoolBuilder::new()
469            .num_threads(threads)
470            .build()
471            .map_err(|e| tracing::warn!("failed to create thread pool: {e}"))
472            .ok()
473    }
474}
475
476fn action_for_existing(dst: &Path, entry: &Entry) -> Action {
477    if dst.join(&entry.rel).exists() {
478        Action::Update
479    } else {
480        Action::Copy
481    }
482}
483
484fn emit_planned_event<R: Reporter>(reporter: &R, entry: &Entry, action: Action) {
485    if action == Action::Skip {
486        reporter.event(Event::Skipped {
487            rel: entry.rel.clone(),
488        });
489        return;
490    }
491    match entry.kind {
492        EntryKind::Dir => reporter.event(Event::DirDone {
493            rel: entry.rel.clone(),
494            action,
495        }),
496        EntryKind::File => reporter.event(Event::FileDone {
497            rel: entry.rel.clone(),
498            action,
499            bytes: entry.len,
500        }),
501        EntryKind::Symlink(_) => reporter.event(Event::SymlinkDone {
502            rel: entry.rel.clone(),
503            action,
504        }),
505    }
506}
507
508fn report_fail<R: Reporter>(reporter: &R, counters: &Counters, rel: &Path, err: &Error) {
509    counters.errors.fetch_add(1, Ordering::Relaxed);
510    reporter.event(Event::Failed {
511        rel: rel.to_path_buf(),
512        error: err.to_string(),
513    });
514}
515
516/// Resolve the concrete `(target, tmp)` paths for a file, enforcing containment.
517fn prepare_paths(
518    root_canon: &Path,
519    entry: &Entry,
520) -> Result<(std::path::PathBuf, std::path::PathBuf)> {
521    let dst_path = root_canon.join(&entry.rel);
522    let target = contained_target(root_canon, &dst_path)?;
523    let parent = target
524        .parent()
525        .ok_or_else(|| Error::Containment(target.clone()))?;
526    let tmp = parent.join(format!(".ripsync-tmp-{:016x}", rand::random::<u64>()));
527    Ok((target, tmp))
528}
529
530/// Apply metadata to `tmp`, then atomically rename it over `target`.
531fn finalize_file(
532    src: &Path,
533    target: &Path,
534    tmp: &Path,
535    entry: &Entry,
536    opts: ApplyOptions,
537) -> Result<()> {
538    set_owner_group(
539        tmp,
540        entry.uid,
541        entry.gid,
542        opts.metadata.owner,
543        opts.metadata.group,
544        true,
545    )?;
546    set_mode(tmp, entry.mode)?;
547    copy_xattrs(src, tmp, opts.metadata.xattrs, opts.metadata.acls)?;
548    set_mtime(tmp, entry.mtime)?;
549    if opts.fsync == FsyncMode::Always {
550        if let Ok(f) = std::fs::File::open(tmp) {
551            let _ = f.sync_all();
552        }
553    }
554    // A directory in the way (type change dir → file): rename can't replace it.
555    if let Ok(meta) = std::fs::symlink_metadata(target) {
556        if meta.is_dir() {
557            if let Err(e) = std::fs::remove_dir_all(target) {
558                let _ = std::fs::remove_file(tmp);
559                return Err(Error::io(target, e));
560            }
561        }
562    }
563    if let Err(e) = atomic_replace(tmp, target) {
564        let _ = std::fs::remove_file(tmp);
565        return Err(Error::io(target, e));
566    }
567    Ok(())
568}
569
570/// Atomically move `tmp` onto `target`, replacing any existing file. POSIX
571/// `rename(2)` is atomic and replaces in place; Windows needs `MoveFileExW`
572/// (plain `rename` there fails when the target already exists).
573#[cfg(not(windows))]
574fn atomic_replace(tmp: &Path, target: &Path) -> io::Result<()> {
575    std::fs::rename(tmp, target)
576}
577
578#[cfg(windows)]
579fn atomic_replace(tmp: &Path, target: &Path) -> io::Result<()> {
580    crate::io::windows::replace_file(tmp, target)
581}
582
583/// Portable single-file copy: prepare → ladder → finalize. Returns bytes written.
584fn copy_file_atomic(
585    src: &Path,
586    root_canon: &Path,
587    entry: &Entry,
588    opts: ApplyOptions,
589) -> Result<u64> {
590    let src_path = src.join(&entry.rel);
591    let (target, tmp) = prepare_paths(root_canon, entry)?;
592    // Copy ladder: reflink → kernel copy → buffered. `tmp` must not pre-exist.
593    let bytes = match opts.copy_buffer {
594        Some(buf_size) => copy_file_into_sized(&src_path, &tmp, opts.reflink, opts.metadata.sparse, buf_size),
595        None => copy_file_into(&src_path, &tmp, opts.reflink, opts.metadata.sparse),
596    };
597    let bytes = match bytes {
598        Ok(b) => b,
599        Err(e) => {
600            let _ = std::fs::remove_file(&tmp);
601            return Err(Error::io(&src_path, e));
602        }
603    };
604    finalize_file(&src_path, &target, &tmp, entry, opts)?;
605    Ok(bytes)
606}
607
608/// Dispatch the file-copy phase to the selected backend.
609fn copy_files<R: Reporter>(
610    files: &[(&Entry, Action)],
611    src: &Path,
612    root_canon: &Path,
613    opts: ApplyOptions,
614    reporter: &R,
615    counters: &Counters,
616    control: &RunControl,
617) -> Result<()> {
618    #[cfg(all(target_os = "linux", feature = "io-uring"))]
619    {
620        if opts.backend == Backend::Uring && !opts.metadata.sparse {
621            control.checkpoint()?;
622            copy_files_uring(files, src, root_canon, opts, reporter, counters);
623            control.checkpoint()?;
624            return Ok(());
625        }
626    }
627    copy_files_portable(files, src, root_canon, opts, reporter, counters, control)
628}
629
630/// Portable backend: one atomic copy per file across the rayon pool.
631fn copy_files_portable<R: Reporter>(
632    files: &[(&Entry, Action)],
633    src: &Path,
634    root_canon: &Path,
635    opts: ApplyOptions,
636    reporter: &R,
637    counters: &Counters,
638    control: &RunControl,
639) -> Result<()> {
640    let chunk_size = opts.threads.max(1).saturating_mul(2);
641    for chunk in files.chunks(chunk_size) {
642        control.checkpoint()?;
643        chunk.par_iter().for_each(|(entry, action)| {
644            if control.is_cancelled() {
645                return;
646            }
647            reporter.event(Event::FileStart {
648                rel: entry.rel.clone(),
649                len: entry.len,
650            });
651            match copy_file_atomic(src, root_canon, entry, opts) {
652                Ok(bytes) => {
653                    counters.bytes.fetch_add(bytes, Ordering::Relaxed);
654                    counters.bump_action(*action);
655                    reporter.event(Event::FileDone {
656                        rel: entry.rel.clone(),
657                        action: *action,
658                        bytes,
659                    });
660                }
661                Err(e) => report_fail(reporter, counters, &entry.rel, &e),
662            }
663        });
664        control.checkpoint()?;
665    }
666    Ok(())
667}
668
669/// `io_uring` backend: batch the data copy through one ring, then finalize each
670/// file in parallel. Anything the ring rejects falls back to the portable copy.
671#[cfg(all(target_os = "linux", feature = "io-uring"))]
672fn copy_files_uring<R: Reporter>(
673    files: &[(&Entry, Action)],
674    src: &Path,
675    root_canon: &Path,
676    opts: ApplyOptions,
677    reporter: &R,
678    counters: &Counters,
679) {
680    use crate::io::uring::{self, Job};
681
682    // Prepare paths sequentially (containment), reporting prep failures.
683    struct Prepared<'a> {
684        entry: &'a Entry,
685        action: Action,
686        src_path: std::path::PathBuf,
687        target: std::path::PathBuf,
688        tmp: std::path::PathBuf,
689    }
690    let mut prepared: Vec<Prepared> = Vec::with_capacity(files.len());
691    for (entry, action) in files {
692        reporter.event(Event::FileStart {
693            rel: entry.rel.clone(),
694            len: entry.len,
695        });
696        match prepare_paths(root_canon, entry) {
697            Ok((target, tmp)) => prepared.push(Prepared {
698                entry,
699                action: *action,
700                src_path: src.join(&entry.rel),
701                target,
702                tmp,
703            }),
704            Err(e) => report_fail(reporter, counters, &entry.rel, &e),
705        }
706    }
707
708    let jobs: Vec<Job> = prepared
709        .iter()
710        .map(|p| Job {
711            src: &p.src_path,
712            tmp: &p.tmp,
713            len: p.entry.len,
714        })
715        .collect();
716    let batch = uring::copy_batch(&jobs);
717
718    // Finalize in parallel; uring rejects fall back to the portable ladder.
719    prepared.par_iter().zip(batch).for_each(|(p, res)| {
720        let outcome = if let Ok(bytes) = res {
721            finalize_file(&p.src_path, &p.target, &p.tmp, p.entry, opts).map(|()| bytes)
722        } else {
723            // Fall back: remove any partial temp, then portable copy.
724            let _ = std::fs::remove_file(&p.tmp);
725            (match opts.copy_buffer {
726                Some(buf_size) => copy_file_into_sized(&p.src_path, &p.tmp, opts.reflink, opts.metadata.sparse, buf_size),
727                None => copy_file_into(&p.src_path, &p.tmp, opts.reflink, opts.metadata.sparse),
728            })
729                .map_err(|e| Error::io(&p.src_path, e))
730                .and_then(|bytes| {
731                    finalize_file(&p.src_path, &p.target, &p.tmp, p.entry, opts).map(|()| bytes)
732                })
733        };
734        match outcome {
735            Ok(bytes) => {
736                counters.bytes.fetch_add(bytes, Ordering::Relaxed);
737                counters.bump_action(p.action);
738                reporter.event(Event::FileDone {
739                    rel: p.entry.rel.clone(),
740                    action: p.action,
741                    bytes,
742                });
743            }
744            Err(e) => report_fail(reporter, counters, &p.entry.rel, &e),
745        }
746    });
747}
748
749/// Materialize duplicate members after their canonical hardlink targets exist.
750fn copy_hardlinks<R: Reporter>(
751    hardlinks: &[(&Entry, Action, PathBuf)],
752    root_canon: &Path,
753    reporter: &R,
754    counters: &Counters,
755    control: &RunControl,
756) -> Result<()> {
757    for (entry, action, canonical) in hardlinks {
758        control.checkpoint()?;
759        reporter.event(Event::FileStart {
760            rel: entry.rel.clone(),
761            len: entry.len,
762        });
763        match create_hardlink(root_canon, entry, canonical) {
764            Ok(()) => {
765                counters.bump_action(*action);
766                reporter.event(Event::FileDone {
767                    rel: entry.rel.clone(),
768                    action: *action,
769                    bytes: 0,
770                });
771            }
772            Err(error) => report_fail(reporter, counters, &entry.rel, &error),
773        }
774    }
775    Ok(())
776}
777
778/// Resolve `--backend auto` against the concrete file set and platform, and
779/// return the backend to actually use plus a display name and reason.
780///
781/// The heuristic: on Linux with the `io_uring` backend compiled in, a
782/// many-small-files workload (at least [`AUTO_URING_MIN_FILES`] files whose
783/// median size is below [`AUTO_URING_MEDIAN_MAX`]) selects `io_uring` to amortize
784/// syscall overhead; everything else uses the portable ladder. Override with an
785/// explicit `--backend`. The heuristic is documented in `docs/performance.md`.
786fn resolve_backend(
787    opts: ApplyOptions,
788    files: &[(&Entry, Action)],
789) -> (Backend, &'static str, &'static str) {
790    match opts.backend {
791        Backend::Portable => (Backend::Portable, "portable", "explicitly requested"),
792        Backend::Uring if opts.metadata.sparse => (
793            Backend::Portable,
794            "portable",
795            "sparse preservation requires portable",
796        ),
797        Backend::Uring => (Backend::Uring, "uring", "explicitly requested"),
798        Backend::Auto => resolve_auto_backend(opts, files),
799    }
800}
801
802#[cfg(all(target_os = "linux", feature = "io-uring"))]
803fn resolve_auto_backend(
804    opts: ApplyOptions,
805    files: &[(&Entry, Action)],
806) -> (Backend, &'static str, &'static str) {
807    if !opts.metadata.sparse && many_small_files(files) {
808        (
809            Backend::Uring,
810            "uring",
811            "auto: many small files (count high, median < 64 KiB)",
812        )
813    } else {
814        (Backend::Portable, "portable", "auto: portable-first")
815    }
816}
817
818#[cfg(windows)]
819fn resolve_auto_backend(
820    _opts: ApplyOptions,
821    _files: &[(&Entry, Action)],
822) -> (Backend, &'static str, &'static str) {
823    (
824        Backend::Portable,
825        "refs/copyfileex",
826        "auto: Windows block-clone / CopyFileExW",
827    )
828}
829
830#[cfg(not(any(all(target_os = "linux", feature = "io-uring"), windows)))]
831fn resolve_auto_backend(
832    _opts: ApplyOptions,
833    _files: &[(&Entry, Action)],
834) -> (Backend, &'static str, &'static str) {
835    (Backend::Portable, "portable", "auto: portable-first")
836}
837
838/// Minimum file count for the auto heuristic to consider `io_uring`.
839#[cfg(all(target_os = "linux", feature = "io-uring"))]
840const AUTO_URING_MIN_FILES: usize = 4096;
841/// Maximum median file size (bytes) for the auto heuristic to pick `io_uring`.
842#[cfg(all(target_os = "linux", feature = "io-uring"))]
843const AUTO_URING_MEDIAN_MAX: u64 = 64 * 1024;
844
845/// Whether `files` looks like a many-small-files workload (cheap O(n) median).
846#[cfg(all(target_os = "linux", feature = "io-uring"))]
847fn many_small_files(files: &[(&Entry, Action)]) -> bool {
848    if files.len() < AUTO_URING_MIN_FILES {
849        return false;
850    }
851    let mut sizes: Vec<u64> = files.iter().map(|(entry, _)| entry.len).collect();
852    let mid = sizes.len() / 2;
853    sizes.select_nth_unstable(mid);
854    sizes[mid] < AUTO_URING_MEDIAN_MAX
855}
856
857/// Atomically create a hardlink to an already materialized canonical file.
858fn create_hardlink(root_canon: &Path, entry: &Entry, canonical_rel: &Path) -> Result<()> {
859    let canonical = root_canon.join(canonical_rel);
860    let canonical =
861        std::fs::canonicalize(&canonical).map_err(|error| Error::io(&canonical, error))?;
862    if !canonical.starts_with(root_canon) {
863        return Err(Error::Containment(canonical));
864    }
865    let (target, tmp) = prepare_paths(root_canon, entry)?;
866    std::fs::hard_link(&canonical, &tmp).map_err(|error| Error::io(&tmp, error))?;
867    if let Ok(meta) = std::fs::symlink_metadata(&target) {
868        let result = if meta.is_dir() {
869            std::fs::remove_dir_all(&target)
870        } else {
871            std::fs::remove_file(&target)
872        };
873        if let Err(error) = result {
874            let _ = std::fs::remove_file(&tmp);
875            return Err(Error::io(&target, error));
876        }
877    }
878    if let Err(error) = atomic_replace(&tmp, &target) {
879        let _ = std::fs::remove_file(&tmp);
880        return Err(Error::io(&target, error));
881    }
882    Ok(())
883}
884
885/// Create (or replace) a symlink, copying its target verbatim — never followed.
886fn create_symlink(
887    src: &Path,
888    root_canon: &Path,
889    entry: &Entry,
890    link_target: &Path,
891    opts: ApplyOptions,
892) -> Result<()> {
893    let link_path = root_canon.join(&entry.rel);
894    let target = contained_target(root_canon, &link_path)?;
895
896    // Create the symlink atomically: write to a temp name, then rename over the
897    // target. POSIX rename(2) atomically replaces any non-directory destination,
898    // eliminating the remove-then-create race window.
899    let tmp_link = target.with_file_name(format!(
900        ".ripsync-tmp-{}.{}",
901        target
902            .file_name()
903            .and_then(|n| n.to_str())
904            .unwrap_or("symlink"),
905        std::process::id()
906    ));
907    // Clean up any leftover tmp from a previously interrupted run.
908    let _ = std::fs::remove_file(&tmp_link);
909    symlink_impl(link_target, &tmp_link)?;
910    // rename(2) cannot replace a directory — remove it first if present.
911    if let Ok(meta) = std::fs::symlink_metadata(&target) {
912        if meta.is_dir() {
913            std::fs::remove_dir_all(&target).map_err(|e| Error::io(&target, e))?;
914        }
915    }
916    std::fs::rename(&tmp_link, &target).map_err(|e| {
917        let _ = std::fs::remove_file(&tmp_link);
918        Error::io(&target, e)
919    })?;
920    set_owner_group(
921        &target,
922        entry.uid,
923        entry.gid,
924        opts.metadata.owner,
925        opts.metadata.group,
926        false,
927    )?;
928    copy_xattrs(
929        &src.join(&entry.rel),
930        &target,
931        opts.metadata.xattrs,
932        opts.metadata.acls,
933    )?;
934    let _ = set_symlink_mtime(&target, entry.mtime);
935    Ok(())
936}
937
938#[cfg(unix)]
939fn symlink_impl(target: &Path, link: &Path) -> Result<()> {
940    std::os::unix::fs::symlink(target, link).map_err(|e| Error::io(link, e))
941}
942
943/// Windows symlink creation requires `SeCreateSymbolicLinkPrivilege` (admin) or
944/// Developer Mode. When it is missing the OS returns `ERROR_PRIVILEGE_NOT_HELD`
945/// (1314); we warn once and skip the link rather than failing the whole run.
946#[cfg(windows)]
947fn symlink_impl(target: &Path, link: &Path) -> Result<()> {
948    use std::os::windows::fs::{symlink_dir, symlink_file};
949
950    // Pick a directory vs file symlink by what the target resolves to relative
951    // to the link's own directory; default to a file symlink.
952    let resolved = link
953        .parent()
954        .map_or_else(|| target.to_path_buf(), |parent| parent.join(target));
955    let is_dir = std::fs::metadata(&resolved)
956        .map(|m| m.is_dir())
957        .unwrap_or(false);
958
959    let result = if is_dir {
960        symlink_dir(target, link)
961    } else {
962        symlink_file(target, link)
963    };
964    match result {
965        Ok(()) => Ok(()),
966        Err(error) if error.raw_os_error() == Some(1314) => {
967            warn_once_symlink_privilege();
968            Ok(())
969        }
970        Err(error) => Err(Error::io(link, error)),
971    }
972}
973
974#[cfg(windows)]
975fn warn_once_symlink_privilege() {
976    use std::sync::atomic::AtomicBool;
977    static WARNED: AtomicBool = AtomicBool::new(false);
978    if !WARNED.swap(true, Ordering::Relaxed) {
979        tracing::warn!(
980            "skipping symlink(s): creating symbolic links on Windows needs \
981             administrator rights or Developer Mode (ERROR_PRIVILEGE_NOT_HELD)"
982        );
983    }
984}
985
986#[cfg(not(any(unix, windows)))]
987fn symlink_impl(_target: &Path, link: &Path) -> Result<()> {
988    Err(Error::io(
989        link,
990        io::Error::new(
991            io::ErrorKind::Unsupported,
992            "symlinks unsupported on this platform",
993        ),
994    ))
995}
996
997/// Whether any strict ancestor of `rel` (under `root_canon`) is a symlink.
998fn has_symlink_ancestor(root_canon: &Path, rel: &Path) -> bool {
999    let mut prefix = root_canon.to_path_buf();
1000    let comps: Vec<_> = rel.components().collect();
1001    // Iterate ancestors only (exclude the final component itself).
1002    for comp in &comps[..comps.len().saturating_sub(1)] {
1003        prefix.push(comp);
1004        match std::fs::symlink_metadata(&prefix) {
1005            Ok(m) if m.file_type().is_symlink() => return true,
1006            Ok(_) => {}
1007            Err(_) => return true, // ancestor vanished ⇒ entry is gone too
1008        }
1009    }
1010    false
1011}
1012
1013/// Delete one destination entry, after confirming containment.
1014///
1015/// A previously planned entry may already be gone (e.g. it was inside a
1016/// directory that a type-change replacement removed); that is treated as success
1017/// since the goal is its absence.
1018fn delete_entry(root_canon: &Path, rel: &Path, is_dir: bool) -> Result<()> {
1019    check_relative(rel)?;
1020    // If any ancestor component is a symlink, this logical path no longer points
1021    // at the originally-planned entry (e.g. a dir was replaced by a symlink) —
1022    // deleting through it would clobber a sibling. The entry is already gone.
1023    if has_symlink_ancestor(root_canon, rel) {
1024        return Ok(());
1025    }
1026    let path = root_canon.join(rel);
1027    // Already absent (or its parent vanished)? Nothing to do.
1028    if std::fs::symlink_metadata(&path).is_err() {
1029        return Ok(());
1030    }
1031    let target = contained_target(root_canon, &path)?;
1032    let result = if is_dir {
1033        // Directory should be empty by now (deepest-first order); fall back to
1034        // recursive removal if not.
1035        std::fs::remove_dir(&target).or_else(|_| std::fs::remove_dir_all(&target))
1036    } else {
1037        std::fs::remove_file(&target)
1038    };
1039    match result {
1040        Ok(()) => Ok(()),
1041        Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
1042        Err(e) => Err(Error::io(&target, e)),
1043    }
1044}