Skip to main content

ripsync_core/
verify.rs

1//! Optional post-apply verification.
2
3use std::collections::HashMap;
4use std::io::Read;
5use std::path::{Path, PathBuf};
6
7use crate::apply::MetadataOptions;
8use crate::control::RunControl;
9use crate::index::RIPSYNC_DIR;
10use crate::meta::{FileTypeKind, meta_min};
11use crate::plan::{Action, SyncPlan};
12use crate::report::{Event, Reporter, RunPhase};
13use crate::walk::{Entry, EntryKind, walk_controlled};
14use crate::{Error, Result};
15
16/// Verification scope.
17#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
18pub enum VerifyMode {
19    /// Do not verify.
20    #[default]
21    None,
22    /// Verify entries changed by this run.
23    Changed,
24    /// Verify the complete source and destination trees.
25    All,
26}
27
28/// One source/destination mismatch.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct VerificationMismatch {
31    /// Relative path.
32    pub rel: PathBuf,
33    /// Human-readable mismatch category.
34    pub detail: String,
35}
36
37/// Verification summary.
38#[derive(Debug, Clone, Default, PartialEq, Eq)]
39pub struct VerificationSummary {
40    /// Number of entries checked.
41    pub checked: usize,
42    /// Mismatch details.
43    pub mismatches: Vec<VerificationMismatch>,
44}
45
46/// Files at or above this size are hashed with a memory-mapped, rayon-parallel
47/// BLAKE3 pass; smaller (or uncacheable) files stream through a single buffer,
48/// which avoids mmap setup cost and keeps tiny-file throughput high.
49const MMAP_HASH_THRESHOLD: u64 = 16 * 1024 * 1024;
50
51fn hash_file(path: &Path) -> Result<[u8; 32]> {
52    let mut hasher = blake3::Hasher::new();
53    let len = std::fs::metadata(path)
54        .map_err(|error| Error::io(path, error))?
55        .len();
56
57    if len >= MMAP_HASH_THRESHOLD {
58        // Falls back internally if the file cannot be mapped.
59        hasher
60            .update_mmap_rayon(path)
61            .map_err(|error| Error::io(path, error))?;
62        return Ok(*hasher.finalize().as_bytes());
63    }
64
65    let mut file = std::fs::File::open(path).map_err(|error| Error::io(path, error))?;
66    let mut buffer = vec![0; 1024 * 1024];
67    loop {
68        let read = file
69            .read(&mut buffer)
70            .map_err(|error| Error::io(path, error))?;
71        if read == 0 {
72            return Ok(*hasher.finalize().as_bytes());
73        }
74        hasher.update(&buffer[..read]);
75    }
76}
77
78fn mismatch(rel: &Path, detail: impl Into<String>) -> VerificationMismatch {
79    VerificationMismatch {
80        rel: rel.to_path_buf(),
81        detail: detail.into(),
82    }
83}
84
85fn live_entry(root: &Path, rel: &Path) -> Result<Entry> {
86    let path = root.join(rel);
87    let metadata = meta_min(&path)?;
88    let kind = match metadata.kind {
89        FileTypeKind::File => EntryKind::File,
90        FileTypeKind::Dir => EntryKind::Dir,
91        FileTypeKind::Symlink => {
92            EntryKind::Symlink(std::fs::read_link(&path).map_err(|error| Error::io(&path, error))?)
93        }
94        FileTypeKind::Other => {
95            return Err(Error::io(
96                &path,
97                std::io::Error::other("unsupported file kind"),
98            ));
99        }
100    };
101    Ok(Entry {
102        rel: rel.to_path_buf(),
103        len: if matches!(kind, EntryKind::File) {
104            metadata.len
105        } else {
106            0
107        },
108        kind,
109        mtime: metadata.mtime,
110        mode: metadata.mode,
111        ino: metadata.ino,
112        dev: metadata.dev,
113        uid: metadata.uid,
114        gid: metadata.gid,
115    })
116}
117
118fn compare_entry(
119    source: &Entry,
120    destination: Option<&Entry>,
121    src: &Path,
122    dst: &Path,
123    metadata: MetadataOptions,
124) -> Result<Option<VerificationMismatch>> {
125    let Some(destination) = destination else {
126        return Ok(Some(mismatch(&source.rel, "missing from destination")));
127    };
128    if std::mem::discriminant(&source.kind) != std::mem::discriminant(&destination.kind) {
129        return Ok(Some(mismatch(&source.rel, "file kind differs")));
130    }
131    if source.mode & 0o7777 != destination.mode & 0o7777 {
132        return Ok(Some(mismatch(&source.rel, "mode differs")));
133    }
134    if source.mtime != destination.mtime {
135        return Ok(Some(mismatch(&source.rel, "mtime differs")));
136    }
137    if metadata.owner && source.uid != destination.uid {
138        return Ok(Some(mismatch(&source.rel, "uid differs")));
139    }
140    if metadata.group && source.gid != destination.gid {
141        return Ok(Some(mismatch(&source.rel, "gid differs")));
142    }
143    match (&source.kind, &destination.kind) {
144        (EntryKind::File, EntryKind::File) => {
145            if source.len != destination.len
146                || hash_file(&src.join(&source.rel))? != hash_file(&dst.join(&source.rel))?
147            {
148                return Ok(Some(mismatch(&source.rel, "content differs")));
149            }
150            if metadata.sparse
151                && sparse_blocks(&src.join(&source.rel)) != sparse_blocks(&dst.join(&source.rel))
152            {
153                return Ok(Some(mismatch(&source.rel, "sparse allocation differs")));
154            }
155        }
156        (EntryKind::Symlink(a), EntryKind::Symlink(b)) if a != b => {
157            return Ok(Some(mismatch(&source.rel, "symlink target differs")));
158        }
159        _ => {}
160    }
161    if (metadata.xattrs || metadata.acls)
162        && extended_attributes(&src.join(&source.rel), metadata)
163            != extended_attributes(&dst.join(&source.rel), metadata)
164    {
165        return Ok(Some(mismatch(&source.rel, "extended attributes differ")));
166    }
167    Ok(None)
168}
169
170#[cfg(unix)]
171fn sparse_blocks(path: &Path) -> Option<u64> {
172    use std::os::unix::fs::MetadataExt;
173    std::fs::symlink_metadata(path)
174        .ok()
175        .map(|metadata| metadata.blocks())
176}
177
178#[cfg(not(unix))]
179fn sparse_blocks(_path: &Path) -> Option<u64> {
180    None
181}
182
183#[cfg(unix)]
184fn extended_attributes(path: &Path, metadata: MetadataOptions) -> Option<Vec<(String, Vec<u8>)>> {
185    let mut attributes = Vec::new();
186    for name in xattr::list(path).ok()? {
187        let text = name.to_string_lossy();
188        let is_acl = text.starts_with("system.posix_acl_");
189        if (is_acl && !metadata.acls) || (!is_acl && !metadata.xattrs) {
190            continue;
191        }
192        attributes.push((
193            text.into_owned(),
194            xattr::get(path, &name).ok().flatten().unwrap_or_default(),
195        ));
196    }
197    attributes.sort_unstable();
198    Some(attributes)
199}
200
201#[cfg(not(unix))]
202fn extended_attributes(_path: &Path, _metadata: MetadataOptions) -> Option<Vec<(String, Vec<u8>)>> {
203    None
204}
205
206/// Verify a completed apply before manifest persistence.
207///
208/// # Errors
209///
210/// Returns an I/O, containment, metadata, or cancellation error.
211#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
212pub fn verify<R: Reporter>(
213    plan: &SyncPlan,
214    src: &Path,
215    dst: &Path,
216    mode: VerifyMode,
217    metadata: MetadataOptions,
218    threads: usize,
219    control: &RunControl,
220    reporter: &R,
221) -> Result<VerificationSummary> {
222    if mode == VerifyMode::None {
223        return Ok(VerificationSummary::default());
224    }
225    reporter.event(Event::Phase(RunPhase::Verifying));
226    control.checkpoint()?;
227    let filter = crate::filter::Filter::none();
228    let source_entries = if mode == VerifyMode::All {
229        walk_controlled(src, threads, &filter, control)?
230    } else {
231        Vec::new()
232    };
233    let mut destination_entries = if mode == VerifyMode::All {
234        walk_controlled(dst, threads, &filter, control)?
235    } else {
236        plan.actions
237            .iter()
238            .filter(|planned| metadata.hard_links || planned.action != Action::Skip)
239            .filter_map(|planned| live_entry(dst, &planned.entry.rel).ok())
240            .collect()
241    };
242    destination_entries.retain(|entry| !entry.rel.starts_with(RIPSYNC_DIR));
243    let destination: HashMap<&Path, &Entry> = destination_entries
244        .iter()
245        .map(|entry| (entry.rel.as_path(), entry))
246        .collect();
247    let selected: Vec<&Entry> = match mode {
248        VerifyMode::None => Vec::new(),
249        VerifyMode::Changed => plan
250            .actions
251            .iter()
252            .filter(|planned| planned.action != Action::Skip)
253            .map(|planned| &planned.entry)
254            .collect(),
255        VerifyMode::All => source_entries.iter().collect(),
256    };
257    let total = selected.len();
258    let mut summary = VerificationSummary::default();
259    for entry in &selected {
260        control.checkpoint()?;
261        summary.checked += 1;
262        if let Some(problem) = compare_entry(
263            entry,
264            destination.get(entry.rel.as_path()).copied(),
265            src,
266            dst,
267            metadata,
268        )? {
269            reporter.event(Event::VerificationFailed {
270                rel: problem.rel.clone(),
271                detail: problem.detail.clone(),
272            });
273            summary.mismatches.push(problem);
274        }
275        reporter.event(Event::VerificationProgress {
276            checked: summary.checked,
277            total,
278            mismatches: summary.mismatches.len(),
279        });
280    }
281    if metadata.hard_links {
282        let mut groups: HashMap<(u64, u64), (u64, u64)> = HashMap::new();
283        let hardlink_entries: Vec<&Entry> = if mode == VerifyMode::Changed {
284            plan.actions.iter().map(|planned| &planned.entry).collect()
285        } else {
286            selected.clone()
287        };
288        for entry in hardlink_entries {
289            if !entry.is_file() {
290                continue;
291            }
292            let Some(destination) = destination.get(entry.rel.as_path()) else {
293                continue;
294            };
295            let source_id = (entry.dev, entry.ino);
296            let destination_id = (destination.dev, destination.ino);
297            let expected = groups.entry(source_id).or_insert(destination_id);
298            if *expected != destination_id {
299                let problem = mismatch(&entry.rel, "hardlink identity differs");
300                reporter.event(Event::VerificationFailed {
301                    rel: problem.rel.clone(),
302                    detail: problem.detail.clone(),
303                });
304                summary.mismatches.push(problem);
305            }
306        }
307    }
308    if mode == VerifyMode::All {
309        let source: std::collections::HashSet<&Path> = source_entries
310            .iter()
311            .map(|entry| entry.rel.as_path())
312            .collect();
313        for entry in destination_entries {
314            control.checkpoint()?;
315            if !source.contains(entry.rel.as_path()) {
316                let problem = mismatch(&entry.rel, "extra destination entry");
317                reporter.event(Event::VerificationFailed {
318                    rel: problem.rel.clone(),
319                    detail: problem.detail.clone(),
320                });
321                summary.mismatches.push(problem);
322            }
323        }
324    }
325    Ok(summary)
326}