Skip to main content

ripsync_core/
plan.rs

1//! Build a [`SyncPlan`]: classify every source entry as copy/update/skip and,
2//! when `--delete` is set, collect destination entries to remove.
3
4use std::path::{Path, PathBuf};
5
6// Plan classification keys are local relative paths and (dev, ino) pairs, never
7// attacker-controlled, so foldhash's faster non-DoS-hardened hashing is safe.
8use std::collections::{HashMap, HashSet};
9use rayon::prelude::*;
10
11use crate::filter::Filter;
12
13use crate::index::{Manifest, RIPSYNC_DIR};
14use crate::report::{Event, NullReporter, Reporter, RunPhase};
15use crate::walk::{Entry, EntryKind, walk_controlled};
16use crate::{Error, Result, RunControl};
17
18/// What ripsync intends to do with a single entry.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub enum Action {
21    /// Entry is missing in the destination — create it.
22    Copy,
23    /// Entry exists but differs — overwrite it.
24    Update,
25    /// Entry is already up to date — do nothing.
26    Skip,
27}
28
29/// A source entry paired with the action chosen for it.
30#[derive(Debug, Clone)]
31pub struct PlannedAction {
32    /// The source entry.
33    pub entry: Entry,
34    /// What to do with it.
35    pub action: Action,
36}
37
38/// A destination entry slated for deletion (deepest paths first).
39#[derive(Debug, Clone)]
40pub struct Deletion {
41    /// Path relative to the destination root.
42    pub rel: PathBuf,
43    /// Whether the entry is a directory.
44    pub is_dir: bool,
45}
46
47/// Knobs controlling how the plan is built.
48#[derive(Debug, Clone, Copy, Default)]
49#[allow(clippy::struct_excessive_bools)]
50pub struct PlanOptions {
51    /// Compare by content hash rather than size+mtime.
52    pub checksum: bool,
53    /// Mirror deletions (entries in dest but not source).
54    pub delete: bool,
55    /// Worker threads for the walk (0 ⇒ default).
56    pub threads: usize,
57    /// Use the persistent index (manifest) for fast incremental re-syncs.
58    pub index: bool,
59    /// Preserve source hardlink groups.
60    pub hard_links: bool,
61}
62
63/// The full plan: per-entry actions plus pending deletions.
64#[derive(Debug, Clone, Default)]
65pub struct SyncPlan {
66    /// Source-side actions, ordered so parents precede children.
67    pub actions: Vec<PlannedAction>,
68    /// Destination entries to delete (deepest first), only when `delete` is set.
69    pub deletions: Vec<Deletion>,
70}
71
72impl SyncPlan {
73    /// Count of actions equal to `action`.
74    #[must_use]
75    pub fn count(&self, action: Action) -> usize {
76        self.actions.iter().filter(|a| a.action == action).count()
77    }
78
79    /// Total bytes to transfer (sum of files being copied or updated).
80    #[must_use]
81    pub fn bytes_to_transfer(&self) -> u64 {
82        self.actions
83            .iter()
84            .filter(|a| a.action != Action::Skip && a.entry.is_file())
85            .map(|a| a.entry.len)
86            .sum()
87    }
88}
89
90/// BLAKE3 digest of a file, or `None` on read error. Large files use a
91/// memory-mapped, rayon-parallel pass; small files read once into memory.
92fn file_hash(path: &Path) -> Option<[u8; 32]> {
93    const MMAP_HASH_THRESHOLD: u64 = 16 * 1024 * 1024;
94    let len = std::fs::metadata(path).ok()?.len();
95    let mut hasher = blake3::Hasher::new();
96    if len >= MMAP_HASH_THRESHOLD {
97        hasher.update_mmap_rayon(path).ok()?;
98    } else {
99        let bytes = std::fs::read(path).ok()?;
100        hasher.update(&bytes);
101    }
102    Some(*hasher.finalize().as_bytes())
103}
104
105/// Decide whether a source and destination file differ.
106fn files_differ(
107    src_root: &Path,
108    dst_root: &Path,
109    entry: &Entry,
110    dst: &Entry,
111    checksum: bool,
112) -> bool {
113    let metadata_differs = entry.mode & 0o7777 != dst.mode & 0o7777
114        || entry.mtime.unix_seconds() != dst.mtime.unix_seconds();
115    if checksum {
116        let sp = src_root.join(&entry.rel);
117        let dp = dst_root.join(&dst.rel);
118        match (file_hash(&sp), file_hash(&dp)) {
119            (Some(a), Some(b)) => metadata_differs || a != b,
120            _ => true,
121        }
122    } else {
123        // Quick check: size, then mtime at whole-second resolution.
124        entry.len != dst.len || metadata_differs
125    }
126}
127
128/// Build a [`SyncPlan`] for mirroring `src` into `dst`.
129///
130/// # Errors
131///
132/// * the source cannot be walked (unreadable);
133/// * `opts.delete` is set but the source yields no entries (empty-source guard —
134///   refuses to mirror emptiness and wipe the destination).
135pub fn build_plan(
136    src: &Path,
137    dst: &Path,
138    opts: PlanOptions,
139    filter: &Filter,
140) -> Result<SyncPlan> {
141    build_plan_controlled(
142        src,
143        dst,
144        opts,
145        filter,
146        &RunControl::default(),
147        &NullReporter,
148    )
149}
150
151/// Build a plan with cooperative control and lifecycle reporting.
152///
153/// # Errors
154///
155/// Returns an I/O, containment, empty-source, or cancellation error.
156pub fn build_plan_controlled<R: Reporter>(
157    src: &Path,
158    dst: &Path,
159    opts: PlanOptions,
160    filter: &Filter,
161    control: &RunControl,
162    reporter: &R,
163) -> Result<SyncPlan> {
164    reporter.event(Event::Phase(RunPhase::Planning));
165    control.checkpoint()?;
166    let mut src_entries = walk_controlled(src, opts.threads, filter, control)?;
167    control.checkpoint()?;
168    reporter.event(Event::PlanningProgress {
169        entries: src_entries.len(),
170    });
171    // Never sync or delete ripsync's own metadata directory at the dst root.
172    src_entries.retain(|e| !e.rel.starts_with(RIPSYNC_DIR));
173
174    // Empty-source guard: never mirror deletions from nothing.
175    if opts.delete && src_entries.is_empty() {
176        return Err(Error::EmptySource(src.to_path_buf()));
177    }
178
179    // `--delete` still needs a live walk to discover files created outside ripsync.
180    if opts.index && !opts.delete {
181        if let Some(manifest) = Manifest::load(dst) {
182            return Ok(plan_from_manifest(src, dst, &src_entries, &manifest, opts));
183        }
184    }
185
186    let mut dst_entries = if dst.exists() {
187        walk_controlled(dst, opts.threads, filter, control)?
188    } else {
189        Vec::new()
190    };
191    control.checkpoint()?;
192    reporter.event(Event::PlanningProgress {
193        entries: src_entries.len() + dst_entries.len(),
194    });
195    dst_entries.retain(|e| !e.rel.starts_with(RIPSYNC_DIR));
196    let dst_map: HashMap<&Path, &Entry> =
197        dst_entries.iter().map(|e| (e.rel.as_path(), e)).collect();
198
199    // Classify in parallel — the checksum path reads file contents, so spreading
200    // the work across cores matters; the size+mtime path is cheap either way.
201    let mut actions: Vec<PlannedAction> = src_entries
202        .par_iter()
203        .map(|entry| {
204            let action = match dst_map.get(entry.rel.as_path()) {
205                None => Action::Copy,
206                Some(dst_entry) => classify_existing(src, dst, entry, dst_entry, opts.checksum),
207            };
208            PlannedAction {
209                entry: entry.clone(),
210                action,
211            }
212        })
213        .collect();
214    if opts.hard_links {
215        enforce_hardlink_actions(&mut actions, |rel| {
216            dst_map.get(rel).map(|entry| (entry.dev, entry.ino))
217        });
218    }
219
220    let mut deletions = Vec::new();
221    if opts.delete {
222        let src_set: HashSet<&Path> = src_entries.iter().map(|e| e.rel.as_path()).collect();
223        for d in &dst_entries {
224            if !src_set.contains(d.rel.as_path()) {
225                deletions.push(Deletion {
226                    rel: d.rel.clone(),
227                    is_dir: d.is_dir(),
228                });
229            }
230        }
231        // Deepest paths first so directories empty out before removal.
232        deletions.sort_by(|a, b| b.rel.cmp(&a.rel));
233    }
234
235    Ok(SyncPlan { actions, deletions })
236}
237
238/// Build a plan by diffing the source against the persistent index, skipping the
239/// destination walk entirely. Deletions are manifest entries no longer in source
240/// (apply still containment-checks every removal).
241fn plan_from_manifest(
242    src: &Path,
243    dst: &Path,
244    src_entries: &[Entry],
245    manifest: &Manifest,
246    opts: PlanOptions,
247) -> SyncPlan {
248    let mut actions: Vec<PlannedAction> = src_entries
249        .par_iter()
250        .map(|entry| PlannedAction {
251            entry: entry.clone(),
252            action: manifest.classify(entry, opts.checksum, src, dst),
253        })
254        .collect();
255    if opts.hard_links {
256        enforce_hardlink_actions(&mut actions, |rel| {
257            crate::meta::meta_min(&dst.join(rel))
258                .ok()
259                .map(|entry| (entry.dev, entry.ino))
260        });
261    }
262
263    let mut deletions = Vec::new();
264    if opts.delete {
265        let src_set: HashSet<&Path> = src_entries.iter().map(|e| e.rel.as_path()).collect();
266        for (rel, rec) in &manifest.entries {
267            if !src_set.contains(rel.as_path()) {
268                deletions.push(Deletion {
269                    rel: rel.clone(),
270                    is_dir: rec.kind == crate::index::Kind::Dir,
271                });
272            }
273        }
274        deletions.sort_by(|a, b| b.rel.cmp(&a.rel));
275    }
276
277    SyncPlan { actions, deletions }
278}
279
280/// A duplicate source inode must point at the same destination inode as the
281/// first member of its group. Force an update when the topology differs.
282fn enforce_hardlink_actions(
283    actions: &mut [PlannedAction],
284    destination_identity: impl Fn(&Path) -> Option<(u64, u64)>,
285) {
286    let mut first: HashMap<(u64, u64), Option<(u64, u64)>> = HashMap::new();
287    for planned in actions {
288        if !planned.entry.is_file() {
289            continue;
290        }
291        let source_id = (planned.entry.dev, planned.entry.ino);
292        if let Some(canonical_destination) = first.get(&source_id) {
293            if destination_identity(&planned.entry.rel) != *canonical_destination {
294                planned.action = Action::Update;
295            }
296        } else {
297            first.insert(source_id, destination_identity(&planned.entry.rel));
298        }
299    }
300}
301
302/// Classify a source entry whose path also exists in the destination.
303fn classify_existing(
304    src: &Path,
305    dst: &Path,
306    entry: &Entry,
307    dst_entry: &Entry,
308    checksum: bool,
309) -> Action {
310    match (&entry.kind, &dst_entry.kind) {
311        (EntryKind::Dir, EntryKind::Dir) => {
312            if entry.mode & 0o7777 == dst_entry.mode & 0o7777
313                && entry.mtime.unix_seconds() == dst_entry.mtime.unix_seconds()
314            {
315                Action::Skip
316            } else {
317                Action::Update
318            }
319        }
320        (EntryKind::Symlink(a), EntryKind::Symlink(b)) => {
321            if a == b && entry.mtime.unix_seconds() == dst_entry.mtime.unix_seconds() {
322                Action::Skip
323            } else {
324                Action::Update
325            }
326        }
327        (EntryKind::File, EntryKind::File) => {
328            if files_differ(src, dst, entry, dst_entry, checksum) {
329                Action::Update
330            } else {
331                Action::Skip
332            }
333        }
334        // Kind changed (e.g. file ↔ dir ↔ symlink): replace it.
335        _ => Action::Update,
336    }
337}