Skip to main content

reth_cli_commands/download/
verify.rs

1use super::{
2    extract::ensure_no_symlinks, manifest::OutputFileChecksum,
3    progress::ArchiveVerificationProgress,
4};
5use blake3::Hasher;
6use eyre::Result;
7use rayon::prelude::*;
8use reth_fs_util::{self as fs, FsPathError};
9use std::{
10    io::{ErrorKind, Read},
11    mem,
12    path::{Path, PathBuf},
13};
14use tracing::warn;
15
16/// Read size per hash update. Large enough for `update_rayon` to split across threads.
17const HASH_BUFFER_SIZE: usize = 4 * 1024 * 1024;
18
19/// Verifies and cleans up extracted output files in one target directory.
20pub(crate) struct OutputVerifier<'a> {
21    /// Directory containing the output files declared by the manifest.
22    target_dir: &'a Path,
23    /// Custom location of static file outputs.
24    static_files_dir: Option<&'a Path>,
25}
26
27impl<'a> OutputVerifier<'a> {
28    /// Creates a verifier for one extraction target directory.
29    pub(crate) const fn new(target_dir: &'a Path, static_files_dir: Option<&'a Path>) -> Self {
30        Self { target_dir, static_files_dir }
31    }
32
33    /// Returns `true` only when every declared output file exists and matches size and BLAKE3.
34    /// Returns `false` if any file is missing, mismatched, or no outputs were declared, and an
35    /// error if a file exists but cannot be inspected or read.
36    pub(crate) fn verify(&self, output_files: &[OutputFileChecksum]) -> Result<bool> {
37        self.verify_with_progress(output_files, None)
38    }
39
40    /// Returns `true` only when every declared output file exists and matches size and BLAKE3,
41    /// updating the optional verification progress as file bytes are hashed.
42    ///
43    /// Sizes are checked before any file is hashed, then files are hashed in parallel.
44    pub(crate) fn verify_with_progress(
45        &self,
46        output_files: &[OutputFileChecksum],
47        progress: Option<&ArchiveVerificationProgress<'_>>,
48    ) -> Result<bool> {
49        if output_files.is_empty() {
50            return Ok(false);
51        }
52
53        let mut paths = Vec::with_capacity(output_files.len());
54        for expected in output_files {
55            let output_path = self.output_path(expected)?;
56            match fs::metadata(&output_path) {
57                Ok(meta) if meta.len() == expected.size => paths.push(output_path),
58                Ok(_) => return Ok(false),
59                Err(FsPathError::Metadata { source, .. })
60                    if source.kind() == ErrorKind::NotFound =>
61                {
62                    return Ok(false)
63                }
64                Err(error) => return Err(error.into()),
65            }
66        }
67
68        output_files
69            .par_iter()
70            .zip(paths)
71            .map(|(expected, path)| {
72                Self::file_blake3_hex(&path, expected.size, progress)
73                    .map(|actual| actual.eq_ignore_ascii_case(&expected.blake3))
74            })
75            .find_any(|verified| !matches!(verified, Ok(true)))
76            .unwrap_or(Ok(true))
77    }
78
79    /// Removes any declared output files so a fresh archive attempt can restart cleanly.
80    ///
81    /// Files are only removed if their path stays below the output root and no existing parent
82    /// directory is a symlink. Extraction rejects link entries, so this guards against links
83    /// already present in the datadir; the check is not atomic with the removal.
84    pub(crate) fn cleanup(&self, output_files: &[OutputFileChecksum]) {
85        for output in output_files {
86            match self.removal_path(output) {
87                Ok(path) => {
88                    let _ = fs::remove_file(path);
89                }
90                Err(error) => {
91                    warn!(target: "reth::cli", path = %output.path, %error, "Skipping output cleanup");
92                }
93            }
94        }
95    }
96
97    /// Returns the path to remove for `output`, or an error if removing it could touch anything
98    /// outside the output root.
99    fn removal_path(&self, output: &OutputFileChecksum) -> Result<PathBuf> {
100        let (root, relative) = self.resolve(output)?;
101        if let Some(parent) = relative.parent() {
102            ensure_no_symlinks(root, parent)?;
103        }
104        Ok(root.join(relative))
105    }
106
107    /// Resolves archive paths consistently for verification and retry cleanup.
108    pub(crate) fn output_path(&self, output: &OutputFileChecksum) -> Result<PathBuf> {
109        self.resolve(output).map(|(root, relative)| root.join(relative))
110    }
111
112    /// Returns the output root and the validated path of `output` relative to it.
113    fn resolve<'p>(&'p self, output: &'p OutputFileChecksum) -> Result<(&'p Path, &'p Path)> {
114        let path = output.relative_path()?;
115        if let Some(static_files_dir) = self.static_files_dir &&
116            let Some(relative_path) = super::extract::static_file_relative_path(path)
117        {
118            return Ok((static_files_dir, relative_path))
119        }
120        Ok((self.target_dir, path))
121    }
122
123    /// Computes the hex-encoded BLAKE3 checksum for one plain output file.
124    ///
125    /// The next buffer is read while the current one is hashed, so disk reads overlap hashing
126    /// instead of alternating with it.
127    fn file_blake3_hex(
128        path: &Path,
129        size: u64,
130        progress: Option<&ArchiveVerificationProgress<'_>>,
131    ) -> Result<String> {
132        let mut file = fs::open(path)?;
133        let mut hasher = Hasher::new();
134        let buf_len = size.min(HASH_BUFFER_SIZE as u64) as usize;
135        let mut current = vec![0_u8; buf_len];
136        let mut next = vec![0_u8; buf_len];
137
138        let mut filled = read_full(&mut file, &mut current)?;
139        while filled > 0 {
140            let (_, read) = rayon::join(
141                || {
142                    hasher.update_rayon(&current[..filled]);
143                },
144                || read_full(&mut file, &mut next),
145            );
146            if let Some(progress) = progress {
147                progress.record_verified(filled as u64);
148            }
149            filled = read?;
150            mem::swap(&mut current, &mut next);
151        }
152
153        Ok(hasher.finalize().to_hex().to_string())
154    }
155}
156
157/// Reads until `buf` is full or the reader is exhausted, returning the number of bytes read.
158///
159/// Full buffers keep every `update_rayon` call aligned, so BLAKE3 can hash it as parallel
160/// subtrees.
161fn read_full(reader: &mut impl Read, buf: &mut [u8]) -> std::io::Result<usize> {
162    let mut filled = 0;
163    while filled < buf.len() {
164        match reader.read(&mut buf[filled..]) {
165            Ok(0) => break,
166            Ok(n) => filled += n,
167            Err(error) if error.kind() == ErrorKind::Interrupted => {}
168            Err(error) => return Err(error),
169        }
170    }
171    Ok(filled)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn checksum(path: &str, data: &[u8]) -> OutputFileChecksum {
179        OutputFileChecksum {
180            path: path.into(),
181            size: data.len() as u64,
182            blake3: blake3::hash(data).to_hex().to_string(),
183        }
184    }
185
186    #[test]
187    fn verify_detects_each_kind_of_output_mismatch() {
188        let dir = tempfile::tempdir().unwrap();
189        let large: Vec<u8> = (0..2 * HASH_BUFFER_SIZE + 123).map(|i| (i % 251) as u8).collect();
190        let small = b"small".to_vec();
191        fs::write(dir.path().join("large"), &large).unwrap();
192        fs::write(dir.path().join("small"), &small).unwrap();
193        let outputs = vec![checksum("large", &large), checksum("small", &small)];
194        let verifier = OutputVerifier::new(dir.path(), None);
195
196        assert!(verifier.verify(&outputs).unwrap(), "matching multi-buffer outputs verify");
197
198        let mut uppercase = outputs.clone();
199        uppercase[0].blake3 = uppercase[0].blake3.to_ascii_uppercase();
200        assert!(verifier.verify(&uppercase).unwrap(), "checksums compare case-insensitively");
201
202        let mut flipped = large.clone();
203        flipped[HASH_BUFFER_SIZE + 7] ^= 1;
204        fs::write(dir.path().join("large"), &flipped).unwrap();
205        assert!(!verifier.verify(&outputs).unwrap(), "one flipped byte fails verification");
206
207        fs::write(dir.path().join("large"), &large[1..]).unwrap();
208        assert!(!verifier.verify(&outputs).unwrap(), "size mismatch fails verification");
209
210        fs::remove_file(dir.path().join("large")).unwrap();
211        assert!(!verifier.verify(&outputs).unwrap(), "missing file fails verification");
212
213        assert!(!verifier.verify(&[]).unwrap(), "archives without outputs never verify");
214    }
215
216    #[cfg(unix)]
217    #[test]
218    fn verify_returns_metadata_errors_other_than_not_found() {
219        let dir = tempfile::tempdir().unwrap();
220        fs::write(dir.path().join("file"), b"data").unwrap();
221        let verifier = OutputVerifier::new(dir.path(), None);
222
223        assert!(!verifier.verify(&[checksum("missing", b"data")]).unwrap());
224        assert!(
225            verifier.verify(&[checksum("file/child", b"data")]).is_err(),
226            "a path through a regular file is an error, not a missing output"
227        );
228    }
229
230    #[test]
231    fn custom_static_files_verification_and_cleanup() {
232        let datadir = tempfile::tempdir().unwrap();
233        let static_dir = tempfile::tempdir().unwrap();
234        let default_file = datadir.path().join("static_files/headers");
235        fs::create_dir_all(default_file.parent().unwrap()).unwrap();
236        fs::write(&default_file, b"headers").unwrap();
237        let outputs = [OutputFileChecksum {
238            path: "./static_files/headers".into(),
239            size: 7,
240            blake3: blake3::hash(b"headers").to_hex().to_string(),
241        }];
242        let verifier = OutputVerifier::new(datadir.path(), Some(static_dir.path()));
243        assert!(!verifier.verify(&outputs).unwrap());
244        let custom_file = static_dir.path().join("headers");
245        fs::write(&custom_file, b"headers").unwrap();
246        assert!(verifier.verify(&outputs).unwrap());
247        verifier.cleanup(&outputs);
248        assert!(!custom_file.exists());
249        assert_eq!(fs::read(default_file).unwrap(), b"headers");
250    }
251
252    #[test]
253    fn cleanup_ignores_output_paths_outside_target() {
254        let root = tempfile::tempdir().unwrap();
255        let datadir = root.path().join("datadir");
256        fs::create_dir_all(&datadir).unwrap();
257        let outside = root.path().join("outside");
258        fs::write(&outside, b"keep").unwrap();
259        let inside = datadir.join("inside");
260        fs::write(&inside, b"remove").unwrap();
261
262        let outputs = [
263            checksum(outside.to_str().unwrap(), b"keep"),
264            checksum("../outside", b"keep"),
265            checksum("db/../../outside", b"keep"),
266            checksum("inside", b"remove"),
267        ];
268        let verifier = OutputVerifier::new(&datadir, None);
269        assert!(verifier.verify(&outputs).is_err(), "invalid paths are never verified");
270        verifier.cleanup(&outputs);
271
272        assert_eq!(fs::read(&outside).unwrap(), b"keep");
273        assert!(!inside.exists());
274    }
275
276    #[cfg(unix)]
277    #[test]
278    fn cleanup_does_not_follow_symlinked_parents() {
279        let root = tempfile::tempdir().unwrap();
280        let datadir = root.path().join("datadir");
281        let static_dir = root.path().join("static");
282        let outside = root.path().join("outside");
283        fs::create_dir_all(&datadir).unwrap();
284        fs::create_dir_all(&static_dir).unwrap();
285        fs::create_dir_all(outside.join("nested")).unwrap();
286        for path in ["mdbx.dat", "headers", "nested/headers"] {
287            fs::write(outside.join(path), b"keep").unwrap();
288        }
289        std::os::unix::fs::symlink(&outside, datadir.join("db")).unwrap();
290        std::os::unix::fs::symlink(&outside, static_dir.join("link")).unwrap();
291        std::os::unix::fs::symlink(&outside, static_dir.join("nested")).unwrap();
292
293        let outputs = [
294            checksum("db/mdbx.dat", b"keep"),
295            checksum("static_files/link/headers", b"keep"),
296            checksum("static_files/nested/nested/headers", b"keep"),
297        ];
298        for static_files_dir in [None, Some(static_dir.as_path())] {
299            OutputVerifier::new(&datadir, static_files_dir).cleanup(&outputs);
300        }
301
302        for path in ["mdbx.dat", "headers", "nested/headers"] {
303            assert_eq!(fs::read(outside.join(path)).unwrap(), b"keep");
304        }
305
306        // A symlink as the output itself is unlinked without touching its target.
307        std::os::unix::fs::symlink(outside.join("mdbx.dat"), datadir.join("link")).unwrap();
308        OutputVerifier::new(&datadir, None).cleanup(&[checksum("link", b"keep")]);
309        assert!(datadir.join("link").symlink_metadata().is_err());
310        assert_eq!(fs::read(outside.join("mdbx.dat")).unwrap(), b"keep");
311    }
312}