Skip to main content

coreutils_rs/hash/
core.rs

1use std::cell::RefCell;
2use std::fs::File;
3use std::io::{self, BufRead, Read, Write};
4use std::path::Path;
5
6use std::sync::atomic::AtomicUsize;
7#[cfg(target_os = "linux")]
8use std::sync::atomic::{AtomicBool, Ordering};
9
10use digest::Digest;
11use md5::Md5;
12use sha1::Sha1;
13
14/// Supported hash algorithms.
15#[derive(Debug, Clone, Copy)]
16pub enum HashAlgorithm {
17    Sha1,
18    Sha224,
19    Sha256,
20    Sha384,
21    Sha512,
22    Md5,
23    Blake2b,
24}
25
26impl HashAlgorithm {
27    pub fn name(self) -> &'static str {
28        match self {
29            HashAlgorithm::Sha1 => "SHA1",
30            HashAlgorithm::Sha224 => "SHA224",
31            HashAlgorithm::Sha256 => "SHA256",
32            HashAlgorithm::Sha384 => "SHA384",
33            HashAlgorithm::Sha512 => "SHA512",
34            HashAlgorithm::Md5 => "MD5",
35            HashAlgorithm::Blake2b => "BLAKE2b",
36        }
37    }
38}
39
40// ── Generic hash helpers ────────────────────────────────────────────
41
42/// Single-shot hash using the Digest trait (non-Linux fallback).
43fn hash_digest<D: Digest>(data: &[u8]) -> String {
44    hex_encode(&D::digest(data))
45}
46
47/// Streaming hash using thread-local buffer (non-Linux fallback).
48fn hash_reader_impl<D: Digest>(mut reader: impl Read) -> io::Result<String> {
49    STREAM_BUF.with(|cell| {
50        let mut buf = cell.borrow_mut();
51        ensure_stream_buf(&mut buf);
52        let mut hasher = D::new();
53        loop {
54            let n = read_full(&mut reader, &mut buf)?;
55            if n == 0 {
56                break;
57            }
58            hasher.update(&buf[..n]);
59        }
60        Ok(hex_encode(&hasher.finalize()))
61    })
62}
63
64// ── Public hashing API ──────────────────────────────────────────────
65
66/// Buffer size for streaming hash I/O.
67/// 8MB: amortizes syscall overhead while still fitting in L3 cache on modern CPUs.
68/// Larger buffer means fewer read() calls per file (e.g., 13 reads for 100MB vs 25).
69const HASH_READ_BUF: usize = 8 * 1024 * 1024;
70
71// Thread-local reusable buffer for streaming hash I/O.
72// Allocated LAZILY (only on first streaming-hash call) to avoid 8MB cost for
73// small-file-only workloads (e.g., "sha256sum *.txt" where every file is <1MB).
74thread_local! {
75    static STREAM_BUF: RefCell<Vec<u8>> = const { RefCell::new(Vec::new()) };
76}
77
78/// Ensure the streaming buffer is at least HASH_READ_BUF bytes.
79/// Called only on the streaming path, so small-file workloads never allocate 8MB.
80#[inline]
81fn ensure_stream_buf(buf: &mut Vec<u8>) {
82    if buf.len() < HASH_READ_BUF {
83        buf.resize(HASH_READ_BUF, 0);
84    }
85}
86
87// ── SHA-256 ───────────────────────────────────────────────────────────
88
89/// Single-shot SHA-256 using sha2 crate (asm feature provides SHA-NI on x86 — ring doesn't compile on Apple Silicon).
90fn sha256_bytes(data: &[u8]) -> String {
91    hash_digest::<sha2::Sha256>(data)
92}
93
94/// Streaming SHA-256 using sha2 crate.
95fn sha256_reader(reader: impl Read) -> io::Result<String> {
96    hash_reader_impl::<sha2::Sha256>(reader)
97}
98
99// ── SHA-1 ─────────────────────────────────────────────────────────────
100
101/// Single-shot SHA-1 using sha1 crate (non-Linux fallback).
102fn sha1_bytes(data: &[u8]) -> String {
103    hash_digest::<Sha1>(data)
104}
105
106/// Streaming SHA-1 using sha1 crate (non-Linux fallback).
107fn sha1_reader(reader: impl Read) -> io::Result<String> {
108    hash_reader_impl::<Sha1>(reader)
109}
110
111// ── SHA-224 ───────────────────────────────────────────────────────────
112
113/// Single-shot SHA-224 using sha2 crate (non-Linux fallback).
114fn sha224_bytes(data: &[u8]) -> String {
115    hex_encode(&sha2::Sha224::digest(data))
116}
117
118/// Streaming SHA-224 using sha2 crate (non-Linux fallback).
119fn sha224_reader(reader: impl Read) -> io::Result<String> {
120    STREAM_BUF.with(|cell| {
121        let mut buf = cell.borrow_mut();
122        ensure_stream_buf(&mut buf);
123        let mut hasher = <sha2::Sha224 as digest::Digest>::new();
124        let mut reader = reader;
125        loop {
126            let n = read_full(&mut reader, &mut buf)?;
127            if n == 0 {
128                break;
129            }
130            digest::Digest::update(&mut hasher, &buf[..n]);
131        }
132        Ok(hex_encode(&digest::Digest::finalize(hasher)))
133    })
134}
135
136// ── SHA-384 ───────────────────────────────────────────────────────────
137
138/// Single-shot SHA-384 using sha2 crate (non-Linux fallback).
139fn sha384_bytes(data: &[u8]) -> String {
140    hex_encode(&sha2::Sha384::digest(data))
141}
142
143/// Streaming SHA-384 using sha2 crate (non-Linux fallback).
144fn sha384_reader(reader: impl Read) -> io::Result<String> {
145    STREAM_BUF.with(|cell| {
146        let mut buf = cell.borrow_mut();
147        ensure_stream_buf(&mut buf);
148        let mut hasher = <sha2::Sha384 as digest::Digest>::new();
149        let mut reader = reader;
150        loop {
151            let n = read_full(&mut reader, &mut buf)?;
152            if n == 0 {
153                break;
154            }
155            digest::Digest::update(&mut hasher, &buf[..n]);
156        }
157        Ok(hex_encode(&digest::Digest::finalize(hasher)))
158    })
159}
160
161// ── SHA-512 ───────────────────────────────────────────────────────────
162
163/// Single-shot SHA-512 using sha2 crate (non-Linux fallback).
164fn sha512_bytes(data: &[u8]) -> String {
165    hex_encode(&sha2::Sha512::digest(data))
166}
167
168/// Streaming SHA-512 using sha2 crate (non-Linux fallback).
169fn sha512_reader(reader: impl Read) -> io::Result<String> {
170    STREAM_BUF.with(|cell| {
171        let mut buf = cell.borrow_mut();
172        ensure_stream_buf(&mut buf);
173        let mut hasher = <sha2::Sha512 as digest::Digest>::new();
174        let mut reader = reader;
175        loop {
176            let n = read_full(&mut reader, &mut buf)?;
177            if n == 0 {
178                break;
179            }
180            digest::Digest::update(&mut hasher, &buf[..n]);
181        }
182        Ok(hex_encode(&digest::Digest::finalize(hasher)))
183    })
184}
185
186/// Compute hash of a byte slice directly (zero-copy fast path).
187pub fn hash_bytes(algo: HashAlgorithm, data: &[u8]) -> String {
188    match algo {
189        HashAlgorithm::Sha1 => sha1_bytes(data),
190        HashAlgorithm::Sha224 => sha224_bytes(data),
191        HashAlgorithm::Sha256 => sha256_bytes(data),
192        HashAlgorithm::Sha384 => sha384_bytes(data),
193        HashAlgorithm::Sha512 => sha512_bytes(data),
194        HashAlgorithm::Md5 => md5_bytes(data),
195        HashAlgorithm::Blake2b => {
196            let hash = blake2b_simd::blake2b(data);
197            hex_encode(hash.as_bytes())
198        }
199    }
200}
201
202/// Hash data and write hex result directly into an output buffer.
203/// Returns the number of hex bytes written. Avoids String allocation
204/// on the critical single-file fast path.
205/// `out` must be at least 128 bytes for BLAKE2b/SHA512 (64 * 2), 64 for SHA256, 32 for MD5, etc.
206#[cfg(target_os = "linux")]
207pub fn hash_bytes_to_buf(algo: HashAlgorithm, data: &[u8], out: &mut [u8]) -> usize {
208    match algo {
209        HashAlgorithm::Md5 => {
210            let digest = md5::Md5::digest(data);
211            hex_encode_to_slice(&digest, out);
212            32
213        }
214        HashAlgorithm::Sha1 => {
215            let digest = sha1::Sha1::digest(data);
216            hex_encode_to_slice(&digest, out);
217            40
218        }
219        HashAlgorithm::Sha224 => {
220            let digest = sha2::Sha224::digest(data);
221            hex_encode_to_slice(&digest, out);
222            56
223        }
224        HashAlgorithm::Sha256 => {
225            let digest = sha2::Sha256::digest(data);
226            hex_encode_to_slice(&digest, out);
227            64
228        }
229        HashAlgorithm::Sha384 => {
230            let digest = sha2::Sha384::digest(data);
231            hex_encode_to_slice(&digest, out);
232            96
233        }
234        HashAlgorithm::Sha512 => {
235            let digest = sha2::Sha512::digest(data);
236            hex_encode_to_slice(&digest, out);
237            128
238        }
239        HashAlgorithm::Blake2b => {
240            let hash = blake2b_simd::blake2b(data);
241            let bytes = hash.as_bytes();
242            hex_encode_to_slice(bytes, out);
243            bytes.len() * 2
244        }
245    }
246}
247
248/// Hash a single file using raw syscalls and write hex directly to output buffer.
249/// Returns number of hex bytes written.
250/// This is the absolute minimum-overhead path for single-file hashing:
251/// raw open + fstat + read + hash + hex encode, with zero String allocation.
252#[cfg(target_os = "linux")]
253pub fn hash_file_raw_to_buf(algo: HashAlgorithm, path: &Path, out: &mut [u8]) -> io::Result<usize> {
254    use std::os::unix::ffi::OsStrExt;
255
256    let path_bytes = path.as_os_str().as_bytes();
257    let c_path = std::ffi::CString::new(path_bytes)
258        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains null byte"))?;
259
260    let mut flags = libc::O_RDONLY | libc::O_CLOEXEC;
261    if NOATIME_SUPPORTED.load(Ordering::Relaxed) {
262        flags |= libc::O_NOATIME;
263    }
264
265    let fd = unsafe { libc::open(c_path.as_ptr(), flags) };
266    if fd < 0 {
267        let err = io::Error::last_os_error();
268        if err.raw_os_error() == Some(libc::EPERM) && flags & libc::O_NOATIME != 0 {
269            NOATIME_SUPPORTED.store(false, Ordering::Relaxed);
270            let fd2 = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
271            if fd2 < 0 {
272                return Err(io::Error::last_os_error());
273            }
274            return hash_from_raw_fd_to_buf(algo, fd2, out);
275        }
276        return Err(err);
277    }
278    hash_from_raw_fd_to_buf(algo, fd, out)
279}
280
281/// Hash from raw fd and write hex directly to output buffer.
282/// For tiny files (<8KB), the entire path is raw syscalls + stack buffer — zero heap.
283/// For larger files, falls back to hash_file_raw() which allocates a String.
284#[cfg(target_os = "linux")]
285fn hash_from_raw_fd_to_buf(algo: HashAlgorithm, fd: i32, out: &mut [u8]) -> io::Result<usize> {
286    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
287    if unsafe { libc::fstat(fd, &mut stat) } != 0 {
288        let err = io::Error::last_os_error();
289        unsafe {
290            libc::close(fd);
291        }
292        return Err(err);
293    }
294    let size = stat.st_size as u64;
295    let is_regular = (stat.st_mode & libc::S_IFMT) == libc::S_IFREG;
296
297    // Empty regular file
298    if is_regular && size == 0 {
299        unsafe {
300            libc::close(fd);
301        }
302        return Ok(hash_bytes_to_buf(algo, &[], out));
303    }
304
305    // Tiny files (<8KB): fully raw path — zero heap allocation
306    if is_regular && size < TINY_FILE_LIMIT {
307        let mut buf = [0u8; 8192];
308        let mut total = 0usize;
309        while total < size as usize {
310            let n = unsafe {
311                libc::read(
312                    fd,
313                    buf[total..].as_mut_ptr() as *mut libc::c_void,
314                    (size as usize) - total,
315                )
316            };
317            if n < 0 {
318                let err = io::Error::last_os_error();
319                if err.kind() == io::ErrorKind::Interrupted {
320                    continue;
321                }
322                unsafe {
323                    libc::close(fd);
324                }
325                return Err(err);
326            }
327            if n == 0 {
328                break;
329            }
330            total += n as usize;
331        }
332        unsafe {
333            libc::close(fd);
334        }
335        return Ok(hash_bytes_to_buf(algo, &buf[..total], out));
336    }
337
338    // Larger files: fall back to hash_from_raw_fd which returns a String,
339    // then copy the hex into out.
340    use std::os::unix::io::FromRawFd;
341    let file = unsafe { File::from_raw_fd(fd) };
342    let hash_str = if is_regular && size > 0 {
343        if size >= SMALL_FILE_LIMIT {
344            let mmap_result = unsafe { memmap2::MmapOptions::new().map(&file) };
345            if let Ok(mmap) = mmap_result {
346                if size >= 2 * 1024 * 1024 {
347                    let _ = mmap.advise(memmap2::Advice::HugePage);
348                }
349                let _ = mmap.advise(memmap2::Advice::Sequential);
350                if mmap.advise(memmap2::Advice::PopulateRead).is_err() {
351                    let _ = mmap.advise(memmap2::Advice::WillNeed);
352                }
353                hash_bytes(algo, &mmap)
354            } else {
355                hash_file_small(algo, file, size as usize)?
356            }
357        } else {
358            hash_file_small(algo, file, size as usize)?
359        }
360    } else {
361        hash_reader(algo, file)?
362    };
363    let hex_bytes = hash_str.as_bytes();
364    out[..hex_bytes.len()].copy_from_slice(hex_bytes);
365    Ok(hex_bytes.len())
366}
367
368// ── MD5 ─────────────────────────────────────────────────────────────
369
370/// Single-shot MD5 using md-5 crate (non-Linux fallback).
371fn md5_bytes(data: &[u8]) -> String {
372    hash_digest::<Md5>(data)
373}
374
375/// Compute hash of data from a reader, returning hex string.
376pub fn hash_reader<R: Read>(algo: HashAlgorithm, reader: R) -> io::Result<String> {
377    match algo {
378        HashAlgorithm::Sha1 => sha1_reader(reader),
379        HashAlgorithm::Sha224 => sha224_reader(reader),
380        HashAlgorithm::Sha256 => sha256_reader(reader),
381        HashAlgorithm::Sha384 => sha384_reader(reader),
382        HashAlgorithm::Sha512 => sha512_reader(reader),
383        HashAlgorithm::Md5 => md5_reader(reader),
384        HashAlgorithm::Blake2b => blake2b_hash_reader(reader, 64),
385    }
386}
387
388/// Streaming MD5 using md-5 crate (non-Linux fallback).
389fn md5_reader(reader: impl Read) -> io::Result<String> {
390    hash_reader_impl::<Md5>(reader)
391}
392
393/// Track whether O_NOATIME is supported to avoid repeated failed open() attempts.
394/// After the first EPERM, we never try O_NOATIME again (saves one syscall per file).
395#[cfg(target_os = "linux")]
396static NOATIME_SUPPORTED: AtomicBool = AtomicBool::new(true);
397
398/// Open a file with O_NOATIME on Linux to avoid atime update overhead.
399/// Caches whether O_NOATIME works to avoid double-open on every file.
400#[cfg(target_os = "linux")]
401fn open_noatime(path: &Path) -> io::Result<File> {
402    use std::os::unix::fs::OpenOptionsExt;
403    if NOATIME_SUPPORTED.load(Ordering::Relaxed) {
404        match std::fs::OpenOptions::new()
405            .read(true)
406            .custom_flags(libc::O_NOATIME)
407            .open(path)
408        {
409            Ok(f) => return Ok(f),
410            Err(ref e) if e.raw_os_error() == Some(libc::EPERM) => {
411                // O_NOATIME requires file ownership or CAP_FOWNER — disable globally
412                NOATIME_SUPPORTED.store(false, Ordering::Relaxed);
413            }
414            Err(e) => return Err(e), // Real error, propagate
415        }
416    }
417    File::open(path)
418}
419
420#[cfg(not(target_os = "linux"))]
421fn open_noatime(path: &Path) -> io::Result<File> {
422    File::open(path)
423}
424
425/// Open a file and get its metadata in one step.
426/// On Linux uses fstat directly on the fd to avoid an extra syscall layer.
427#[cfg(target_os = "linux")]
428#[inline]
429fn open_and_stat(path: &Path) -> io::Result<(File, u64, bool)> {
430    let file = open_noatime(path)?;
431    let fd = {
432        use std::os::unix::io::AsRawFd;
433        file.as_raw_fd()
434    };
435    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
436    if unsafe { libc::fstat(fd, &mut stat) } != 0 {
437        return Err(io::Error::last_os_error());
438    }
439    let is_regular = (stat.st_mode & libc::S_IFMT) == libc::S_IFREG;
440    let size = stat.st_size as u64;
441    Ok((file, size, is_regular))
442}
443
444#[cfg(not(target_os = "linux"))]
445#[inline]
446fn open_and_stat(path: &Path) -> io::Result<(File, u64, bool)> {
447    let file = open_noatime(path)?;
448    let metadata = file.metadata()?;
449    Ok((file, metadata.len(), metadata.file_type().is_file()))
450}
451
452/// Minimum file size to issue fadvise hint (1MB).
453/// For small files, the syscall overhead exceeds the readahead benefit.
454#[cfg(target_os = "linux")]
455const FADVISE_MIN_SIZE: u64 = 1024 * 1024;
456
457/// Maximum file size for single-read hash optimization.
458/// Files up to this size are read entirely into a thread-local buffer and hashed
459/// with single-shot hash. This avoids mmap/munmap overhead (~100µs each) and
460/// MAP_POPULATE page faults (~300ns/page). The thread-local buffer is reused
461/// across files in sequential mode, saving re-allocation.
462/// 16MB covers typical benchmark files (10MB) while keeping memory usage bounded.
463const SMALL_FILE_LIMIT: u64 = 16 * 1024 * 1024;
464
465/// Threshold for tiny files that can be read into a stack buffer.
466/// Below this size, we use a stack-allocated buffer + single read() syscall,
467/// completely avoiding any heap allocation for the data path.
468const TINY_FILE_LIMIT: u64 = 8 * 1024;
469
470// Thread-local reusable buffer for single-read hash.
471// Grows lazily up to SMALL_FILE_LIMIT (16MB). Initial 64KB allocation
472// handles tiny files; larger files trigger one grow that persists for reuse.
473thread_local! {
474    static SMALL_FILE_BUF: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(64 * 1024));
475}
476
477/// Optimized hash for large files (>=16MB) on Linux.
478/// Primary path: mmap with HUGEPAGE + POPULATE_READ for zero-copy, single-shot hash.
479/// Falls back to streaming I/O with double-buffered reader thread if mmap fails.
480#[cfg(target_os = "linux")]
481fn hash_file_pipelined(algo: HashAlgorithm, file: File, file_size: u64) -> io::Result<String> {
482    // Primary path: mmap with huge pages for zero-copy single-shot hash.
483    match unsafe { memmap2::MmapOptions::new().map(&file) } {
484        Ok(mmap) => {
485            if file_size >= 2 * 1024 * 1024 {
486                let _ = mmap.advise(memmap2::Advice::HugePage);
487            }
488            let _ = mmap.advise(memmap2::Advice::Sequential);
489            if file_size >= 4 * 1024 * 1024 {
490                if mmap.advise(memmap2::Advice::PopulateRead).is_err() {
491                    let _ = mmap.advise(memmap2::Advice::WillNeed);
492                }
493            } else {
494                let _ = mmap.advise(memmap2::Advice::WillNeed);
495            }
496            Ok(hash_bytes(algo, &mmap))
497        }
498        Err(_) => hash_file_pipelined_read(algo, file, file_size),
499    }
500}
501
502/// Streaming fallback for large files when mmap is unavailable.
503/// Uses double-buffered reader thread with fadvise hints.
504/// Fixed: uses blocking recv() to eliminate triple-buffer allocation bug.
505#[cfg(target_os = "linux")]
506fn hash_file_pipelined_read(
507    algo: HashAlgorithm,
508    mut file: File,
509    file_size: u64,
510) -> io::Result<String> {
511    use std::os::unix::io::AsRawFd;
512
513    const PIPE_BUF_SIZE: usize = 4 * 1024 * 1024; // 4MB per buffer
514
515    unsafe {
516        libc::posix_fadvise(
517            file.as_raw_fd(),
518            0,
519            file_size as i64,
520            libc::POSIX_FADV_SEQUENTIAL,
521        );
522    }
523
524    let (tx, rx) = std::sync::mpsc::sync_channel::<(Vec<u8>, usize)>(1);
525    let (buf_tx, buf_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(1);
526    let _ = buf_tx.send(vec![0u8; PIPE_BUF_SIZE]);
527
528    let reader_handle = std::thread::spawn(move || -> io::Result<()> {
529        while let Ok(mut buf) = buf_rx.recv() {
530            let mut total = 0;
531            while total < buf.len() {
532                match file.read(&mut buf[total..]) {
533                    Ok(0) => break,
534                    Ok(n) => total += n,
535                    Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
536                    Err(e) => return Err(e),
537                }
538            }
539            if total == 0 {
540                break;
541            }
542            if tx.send((buf, total)).is_err() {
543                break;
544            }
545        }
546        Ok(())
547    });
548
549    macro_rules! hash_pipelined_digest {
550        ($hasher:expr) => {{
551            let mut hasher = $hasher;
552            while let Ok((buf, n)) = rx.recv() {
553                Digest::update(&mut hasher, &buf[..n]);
554                let _ = buf_tx.send(buf);
555            }
556            Ok(hex_encode(&hasher.finalize()))
557        }};
558    }
559
560    let hash_result: io::Result<String> = match algo {
561        HashAlgorithm::Sha1 => hash_pipelined_digest!(Sha1::new()),
562        HashAlgorithm::Sha224 => hash_pipelined_digest!(sha2::Sha224::new()),
563        HashAlgorithm::Sha256 => hash_pipelined_digest!(sha2::Sha256::new()),
564        HashAlgorithm::Sha384 => hash_pipelined_digest!(sha2::Sha384::new()),
565        HashAlgorithm::Sha512 => hash_pipelined_digest!(sha2::Sha512::new()),
566        HashAlgorithm::Md5 => hash_pipelined_digest!(Md5::new()),
567        HashAlgorithm::Blake2b => {
568            let mut state = blake2b_simd::Params::new().to_state();
569            while let Ok((buf, n)) = rx.recv() {
570                state.update(&buf[..n]);
571                let _ = buf_tx.send(buf);
572            }
573            Ok(hex_encode(state.finalize().as_bytes()))
574        }
575    };
576
577    match reader_handle.join() {
578        Ok(Ok(())) => {}
579        Ok(Err(e)) => {
580            if hash_result.is_ok() {
581                return Err(e);
582            }
583        }
584        Err(payload) => {
585            let msg = if let Some(s) = payload.downcast_ref::<&str>() {
586                format!("reader thread panicked: {}", s)
587            } else if let Some(s) = payload.downcast_ref::<String>() {
588                format!("reader thread panicked: {}", s)
589            } else {
590                "reader thread panicked".to_string()
591            };
592            return Err(io::Error::other(msg));
593        }
594    }
595
596    hash_result
597}
598
599/// Hash a file by path. Uses I/O pipelining for large files on Linux,
600/// mmap with HUGEPAGE hints as fallback, single-read for small files,
601/// and streaming read for non-regular files.
602pub fn hash_file(algo: HashAlgorithm, path: &Path) -> io::Result<String> {
603    let (file, file_size, is_regular) = open_and_stat(path)?;
604
605    if is_regular && file_size == 0 {
606        return Ok(hash_bytes(algo, &[]));
607    }
608
609    if file_size > 0 && is_regular {
610        // Tiny files (<8KB): stack buffer + single read() — zero heap allocation
611        if file_size < TINY_FILE_LIMIT {
612            return hash_file_tiny(algo, file, file_size as usize);
613        }
614        // Large files (>=16MB): use I/O pipelining on Linux to overlap read + hash
615        if file_size >= SMALL_FILE_LIMIT {
616            #[cfg(target_os = "linux")]
617            {
618                return hash_file_pipelined(algo, file, file_size);
619            }
620            // Non-Linux: mmap fallback
621            #[cfg(not(target_os = "linux"))]
622            {
623                let mmap_result = unsafe { memmap2::MmapOptions::new().map(&file) };
624                if let Ok(mmap) = mmap_result {
625                    return Ok(hash_bytes(algo, &mmap));
626                }
627            }
628        }
629        // Small files (8KB..16MB): single read into thread-local buffer, then single-shot hash.
630        // This avoids Hasher context allocation + streaming overhead for each file.
631        if file_size < SMALL_FILE_LIMIT {
632            return hash_file_small(algo, file, file_size as usize);
633        }
634    }
635
636    // Non-regular files or fallback: stream
637    #[cfg(target_os = "linux")]
638    if file_size >= FADVISE_MIN_SIZE {
639        use std::os::unix::io::AsRawFd;
640        unsafe {
641            libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_SEQUENTIAL);
642        }
643    }
644    hash_reader(algo, file)
645}
646
647/// Hash a tiny file (<8KB) using a stack-allocated buffer.
648/// Single read() syscall, zero heap allocation on the data path.
649/// Optimal for the "100 small files" benchmark where per-file overhead dominates.
650#[inline]
651fn hash_file_tiny(algo: HashAlgorithm, mut file: File, size: usize) -> io::Result<String> {
652    let mut buf = [0u8; 8192];
653    let mut total = 0;
654    // Read with known size — usually completes in a single read() for regular files
655    while total < size {
656        match file.read(&mut buf[total..size]) {
657            Ok(0) => break,
658            Ok(n) => total += n,
659            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
660            Err(e) => return Err(e),
661        }
662    }
663    Ok(hash_bytes(algo, &buf[..total]))
664}
665
666/// Hash a small file by reading it entirely into a thread-local buffer,
667/// then using the single-shot hash function. Avoids per-file Hasher allocation.
668#[inline]
669fn hash_file_small(algo: HashAlgorithm, mut file: File, size: usize) -> io::Result<String> {
670    SMALL_FILE_BUF.with(|cell| {
671        let mut buf = cell.borrow_mut();
672        // Reset length but keep allocation, then grow if needed
673        buf.clear();
674        buf.reserve(size);
675        // SAFETY: capacity >= size after clear+reserve. We read into the buffer
676        // directly and only access buf[..total] where total <= size <= capacity.
677        unsafe {
678            buf.set_len(size);
679        }
680        let mut total = 0;
681        while total < size {
682            match file.read(&mut buf[total..size]) {
683                Ok(0) => break,
684                Ok(n) => total += n,
685                Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
686                Err(e) => return Err(e),
687            }
688        }
689        Ok(hash_bytes(algo, &buf[..total]))
690    })
691}
692
693/// Hash stdin. Uses fadvise for file redirects, streaming for pipes.
694pub fn hash_stdin(algo: HashAlgorithm) -> io::Result<String> {
695    let stdin = io::stdin();
696    // Hint kernel for sequential access if stdin is a regular file (redirect)
697    #[cfg(target_os = "linux")]
698    {
699        use std::os::unix::io::AsRawFd;
700        let fd = stdin.as_raw_fd();
701        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
702        if unsafe { libc::fstat(fd, &mut stat) } == 0
703            && (stat.st_mode & libc::S_IFMT) == libc::S_IFREG
704            && stat.st_size > 0
705        {
706            unsafe {
707                libc::posix_fadvise(fd, 0, stat.st_size, libc::POSIX_FADV_SEQUENTIAL);
708            }
709        }
710    }
711    // Streaming hash — works for both pipe and file-redirect stdin
712    hash_reader(algo, stdin.lock())
713}
714
715/// Check if parallel hashing is worthwhile for the given file paths.
716/// Always parallelize with 2+ files — rayon's thread pool is lazily initialized
717/// once and reused, so per-file work-stealing overhead is negligible (~1µs).
718/// Removing the stat()-based size check eliminates N extra syscalls for N files.
719pub fn should_use_parallel(paths: &[&Path]) -> bool {
720    paths.len() >= 2
721}
722
723/// Issue readahead hints for a list of file paths to warm the page cache.
724/// Uses POSIX_FADV_WILLNEED which is non-blocking and batches efficiently.
725/// Only issues hints for files >= 1MB; small files are read fast enough
726/// that the fadvise syscall overhead isn't worth it.
727#[cfg(target_os = "linux")]
728pub fn readahead_files(paths: &[&Path]) {
729    use std::os::unix::io::AsRawFd;
730    for path in paths {
731        if let Ok(file) = open_noatime(path) {
732            if let Ok(meta) = file.metadata() {
733                let len = meta.len();
734                if meta.file_type().is_file() && len >= FADVISE_MIN_SIZE {
735                    unsafe {
736                        libc::posix_fadvise(
737                            file.as_raw_fd(),
738                            0,
739                            len as i64,
740                            libc::POSIX_FADV_WILLNEED,
741                        );
742                    }
743                }
744            }
745        }
746    }
747}
748
749#[cfg(not(target_os = "linux"))]
750pub fn readahead_files(_paths: &[&Path]) {
751    // No-op on non-Linux
752}
753
754// --- BLAKE2b variable-length functions (using blake2b_simd) ---
755
756/// Hash raw data with BLAKE2b variable output length.
757/// `output_bytes` is the output size in bytes (e.g., 32 for 256-bit).
758pub fn blake2b_hash_data(data: &[u8], output_bytes: usize) -> String {
759    let hash = blake2b_simd::Params::new()
760        .hash_length(output_bytes)
761        .hash(data);
762    hex_encode(hash.as_bytes())
763}
764
765/// Hash a reader with BLAKE2b variable output length.
766/// Uses thread-local buffer for cache-friendly streaming.
767pub fn blake2b_hash_reader<R: Read>(mut reader: R, output_bytes: usize) -> io::Result<String> {
768    STREAM_BUF.with(|cell| {
769        let mut buf = cell.borrow_mut();
770        ensure_stream_buf(&mut buf);
771        let mut state = blake2b_simd::Params::new()
772            .hash_length(output_bytes)
773            .to_state();
774        loop {
775            let n = read_full(&mut reader, &mut buf)?;
776            if n == 0 {
777                break;
778            }
779            state.update(&buf[..n]);
780        }
781        Ok(hex_encode(state.finalize().as_bytes()))
782    })
783}
784
785/// Hash a file with BLAKE2b variable output length.
786/// Uses mmap for large files (zero-copy), single-read for small files,
787/// and streaming read as fallback.
788pub fn blake2b_hash_file(path: &Path, output_bytes: usize) -> io::Result<String> {
789    let (file, file_size, is_regular) = open_and_stat(path)?;
790
791    if is_regular && file_size == 0 {
792        return Ok(blake2b_hash_data(&[], output_bytes));
793    }
794
795    if file_size > 0 && is_regular {
796        // Tiny files (<8KB): stack buffer + single read() — zero heap allocation
797        if file_size < TINY_FILE_LIMIT {
798            return blake2b_hash_file_tiny(file, file_size as usize, output_bytes);
799        }
800        // Large files (>=16MB): I/O pipelining on Linux, mmap on other platforms
801        if file_size >= SMALL_FILE_LIMIT {
802            #[cfg(target_os = "linux")]
803            {
804                return blake2b_hash_file_pipelined(file, file_size, output_bytes);
805            }
806            #[cfg(not(target_os = "linux"))]
807            {
808                let mmap_result = unsafe { memmap2::MmapOptions::new().map(&file) };
809                if let Ok(mmap) = mmap_result {
810                    return Ok(blake2b_hash_data(&mmap, output_bytes));
811                }
812            }
813        }
814        // Small files (8KB..1MB): single read into thread-local buffer, then single-shot hash
815        if file_size < SMALL_FILE_LIMIT {
816            return blake2b_hash_file_small(file, file_size as usize, output_bytes);
817        }
818    }
819
820    // Non-regular files or fallback: stream
821    #[cfg(target_os = "linux")]
822    if file_size >= FADVISE_MIN_SIZE {
823        use std::os::unix::io::AsRawFd;
824        unsafe {
825            libc::posix_fadvise(file.as_raw_fd(), 0, 0, libc::POSIX_FADV_SEQUENTIAL);
826        }
827    }
828    blake2b_hash_reader(file, output_bytes)
829}
830
831/// Hash a tiny BLAKE2b file (<8KB) using a stack-allocated buffer.
832#[inline]
833fn blake2b_hash_file_tiny(mut file: File, size: usize, output_bytes: usize) -> io::Result<String> {
834    let mut buf = [0u8; 8192];
835    let mut total = 0;
836    while total < size {
837        match file.read(&mut buf[total..size]) {
838            Ok(0) => break,
839            Ok(n) => total += n,
840            Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
841            Err(e) => return Err(e),
842        }
843    }
844    Ok(blake2b_hash_data(&buf[..total], output_bytes))
845}
846
847/// Hash a small file with BLAKE2b by reading it entirely into a thread-local buffer.
848#[inline]
849fn blake2b_hash_file_small(mut file: File, size: usize, output_bytes: usize) -> io::Result<String> {
850    SMALL_FILE_BUF.with(|cell| {
851        let mut buf = cell.borrow_mut();
852        buf.clear();
853        buf.reserve(size);
854        // SAFETY: capacity >= size after clear+reserve
855        unsafe {
856            buf.set_len(size);
857        }
858        let mut total = 0;
859        while total < size {
860            match file.read(&mut buf[total..size]) {
861                Ok(0) => break,
862                Ok(n) => total += n,
863                Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
864                Err(e) => return Err(e),
865            }
866        }
867        Ok(blake2b_hash_data(&buf[..total], output_bytes))
868    })
869}
870
871/// Optimized BLAKE2b hash for large files (>=16MB) on Linux.
872/// Primary path: mmap with HUGEPAGE + POPULATE_READ for zero-copy, single-shot hash.
873/// Eliminates thread spawn, channel synchronization, buffer allocation (24MB→0),
874/// and read() memcpy overhead. Falls back to streaming I/O if mmap fails.
875#[cfg(target_os = "linux")]
876fn blake2b_hash_file_pipelined(
877    file: File,
878    file_size: u64,
879    output_bytes: usize,
880) -> io::Result<String> {
881    // Primary path: mmap with huge pages for zero-copy single-shot hash.
882    // Eliminates: thread spawn (~50µs), channel sync, buffer allocs (24MB),
883    // 13+ read() syscalls, and page-cache → user-buffer memcpy.
884    match unsafe { memmap2::MmapOptions::new().map(&file) } {
885        Ok(mmap) => {
886            // HUGEPAGE MUST come before any page faults: reduces 25,600 minor
887            // faults (4KB) to ~50 faults (2MB) for 100MB. Saves ~12ms overhead.
888            if file_size >= 2 * 1024 * 1024 {
889                let _ = mmap.advise(memmap2::Advice::HugePage);
890            }
891            let _ = mmap.advise(memmap2::Advice::Sequential);
892            // POPULATE_READ (Linux 5.14+): synchronously prefaults all pages with
893            // huge pages before hashing begins. Falls back to WillNeed on older kernels.
894            if file_size >= 4 * 1024 * 1024 {
895                if mmap.advise(memmap2::Advice::PopulateRead).is_err() {
896                    let _ = mmap.advise(memmap2::Advice::WillNeed);
897                }
898            } else {
899                let _ = mmap.advise(memmap2::Advice::WillNeed);
900            }
901            // Single-shot hash: processes entire file in one call, streaming
902            // directly from page cache with no user-space buffer copies.
903            Ok(blake2b_hash_data(&mmap, output_bytes))
904        }
905        Err(_) => {
906            // mmap failed (FUSE, NFS without mmap support, etc.) — fall back
907            // to streaming pipelined I/O.
908            blake2b_hash_file_streamed(file, file_size, output_bytes)
909        }
910    }
911}
912
913/// Streaming fallback for BLAKE2b large files when mmap is unavailable.
914/// Uses double-buffered reader thread with fadvise hints.
915/// Fixed: uses blocking recv() to eliminate triple-buffer allocation bug.
916#[cfg(target_os = "linux")]
917fn blake2b_hash_file_streamed(
918    mut file: File,
919    file_size: u64,
920    output_bytes: usize,
921) -> io::Result<String> {
922    use std::os::unix::io::AsRawFd;
923
924    const PIPE_BUF_SIZE: usize = 8 * 1024 * 1024; // 8MB per buffer
925
926    // Hint kernel for sequential access
927    unsafe {
928        libc::posix_fadvise(
929            file.as_raw_fd(),
930            0,
931            file_size as i64,
932            libc::POSIX_FADV_SEQUENTIAL,
933        );
934    }
935
936    // Double-buffered channels: reader fills one buffer while hasher processes another.
937    let (tx, rx) = std::sync::mpsc::sync_channel::<(Vec<u8>, usize)>(1);
938    let (buf_tx, buf_rx) = std::sync::mpsc::sync_channel::<Vec<u8>>(1);
939    let _ = buf_tx.send(vec![0u8; PIPE_BUF_SIZE]);
940
941    let reader_handle = std::thread::spawn(move || -> io::Result<()> {
942        // Blocking recv reuses hasher's returned buffer (2 buffers total, not 3).
943        while let Ok(mut buf) = buf_rx.recv() {
944            let mut total = 0;
945            while total < buf.len() {
946                match file.read(&mut buf[total..]) {
947                    Ok(0) => break,
948                    Ok(n) => total += n,
949                    Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
950                    Err(e) => return Err(e),
951                }
952            }
953            if total == 0 {
954                break;
955            }
956            if tx.send((buf, total)).is_err() {
957                break;
958            }
959        }
960        Ok(())
961    });
962
963    let mut state = blake2b_simd::Params::new()
964        .hash_length(output_bytes)
965        .to_state();
966    while let Ok((buf, n)) = rx.recv() {
967        state.update(&buf[..n]);
968        let _ = buf_tx.send(buf);
969    }
970    let hash_result = Ok(hex_encode(state.finalize().as_bytes()));
971
972    match reader_handle.join() {
973        Ok(Ok(())) => {}
974        Ok(Err(e)) => {
975            if hash_result.is_ok() {
976                return Err(e);
977            }
978        }
979        Err(payload) => {
980            let msg = if let Some(s) = payload.downcast_ref::<&str>() {
981                format!("reader thread panicked: {}", s)
982            } else if let Some(s) = payload.downcast_ref::<String>() {
983                format!("reader thread panicked: {}", s)
984            } else {
985                "reader thread panicked".to_string()
986            };
987            return Err(io::Error::other(msg));
988        }
989    }
990
991    hash_result
992}
993
994/// Hash stdin with BLAKE2b variable output length.
995/// Tries fadvise if stdin is a regular file (shell redirect), then streams.
996pub fn blake2b_hash_stdin(output_bytes: usize) -> io::Result<String> {
997    let stdin = io::stdin();
998    #[cfg(target_os = "linux")]
999    {
1000        use std::os::unix::io::AsRawFd;
1001        let fd = stdin.as_raw_fd();
1002        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
1003        if unsafe { libc::fstat(fd, &mut stat) } == 0
1004            && (stat.st_mode & libc::S_IFMT) == libc::S_IFREG
1005            && stat.st_size > 0
1006        {
1007            unsafe {
1008                libc::posix_fadvise(fd, 0, stat.st_size, libc::POSIX_FADV_SEQUENTIAL);
1009            }
1010        }
1011    }
1012    blake2b_hash_reader(stdin.lock(), output_bytes)
1013}
1014
1015/// Internal enum for file content in batch hashing.
1016/// Keeps data alive (either as mmap or owned Vec) while hash_many references it.
1017enum FileContent {
1018    Mmap(memmap2::Mmap),
1019    Buf(Vec<u8>),
1020}
1021
1022impl AsRef<[u8]> for FileContent {
1023    fn as_ref(&self) -> &[u8] {
1024        match self {
1025            FileContent::Mmap(m) => m,
1026            FileContent::Buf(v) => v,
1027        }
1028    }
1029}
1030
1031/// Open a file and load its content for batch hashing.
1032/// Uses read for tiny files (avoids mmap syscall overhead), mmap for large
1033/// files (zero-copy), and read-to-end for non-regular files.
1034fn open_file_content(path: &Path) -> io::Result<FileContent> {
1035    let (file, size, is_regular) = open_and_stat(path)?;
1036    if is_regular && size == 0 {
1037        return Ok(FileContent::Buf(Vec::new()));
1038    }
1039    if is_regular && size > 0 {
1040        // Tiny files: read directly into Vec. The mmap syscall + page fault
1041        // overhead exceeds the data transfer cost for files under 8KB.
1042        // For the 100-file benchmark (55 bytes each), this saves ~100 mmap calls.
1043        if size < TINY_FILE_LIMIT {
1044            let mut buf = vec![0u8; size as usize];
1045            let mut total = 0;
1046            let mut f = file;
1047            while total < size as usize {
1048                match f.read(&mut buf[total..]) {
1049                    Ok(0) => break,
1050                    Ok(n) => total += n,
1051                    Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
1052                    Err(e) => return Err(e),
1053                }
1054            }
1055            buf.truncate(total);
1056            return Ok(FileContent::Buf(buf));
1057        }
1058        // HUGEPAGE + PopulateRead for optimal page faulting
1059        let mmap_result = unsafe { memmap2::MmapOptions::new().map(&file) };
1060        if let Ok(mmap) = mmap_result {
1061            #[cfg(target_os = "linux")]
1062            {
1063                if size >= 2 * 1024 * 1024 {
1064                    let _ = mmap.advise(memmap2::Advice::HugePage);
1065                }
1066                let _ = mmap.advise(memmap2::Advice::Sequential);
1067                if mmap.advise(memmap2::Advice::PopulateRead).is_err() {
1068                    let _ = mmap.advise(memmap2::Advice::WillNeed);
1069                }
1070            }
1071            return Ok(FileContent::Mmap(mmap));
1072        }
1073        // Fallback: read into Vec
1074        let mut buf = vec![0u8; size as usize];
1075        let mut total = 0;
1076        let mut f = file;
1077        while total < size as usize {
1078            match f.read(&mut buf[total..]) {
1079                Ok(0) => break,
1080                Ok(n) => total += n,
1081                Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
1082                Err(e) => return Err(e),
1083            }
1084        }
1085        buf.truncate(total);
1086        return Ok(FileContent::Buf(buf));
1087    }
1088    // Non-regular: read to end
1089    let mut buf = Vec::new();
1090    let mut f = file;
1091    f.read_to_end(&mut buf)?;
1092    Ok(FileContent::Buf(buf))
1093}
1094
1095/// Read remaining file content from an already-open fd into a Vec.
1096/// Used when the initial stack buffer is exhausted and we need to read
1097/// the rest without re-opening the file.
1098fn read_remaining_to_vec(prefix: &[u8], mut file: File) -> io::Result<FileContent> {
1099    let mut buf = Vec::with_capacity(prefix.len() + 65536);
1100    buf.extend_from_slice(prefix);
1101    file.read_to_end(&mut buf)?;
1102    Ok(FileContent::Buf(buf))
1103}
1104
1105/// Open a file and read all content without fstat — just open+read+close.
1106/// For many-file workloads (100+ files), skipping fstat saves ~5µs/file
1107/// (~0.5ms for 100 files). Uses a small initial buffer for tiny files (< 4KB),
1108/// then falls back to larger buffer or read_to_end for bigger files.
1109fn open_file_content_fast(path: &Path) -> io::Result<FileContent> {
1110    let mut file = open_noatime(path)?;
1111    // Try small stack buffer first — optimal for benchmark's ~55 byte files.
1112    // For tiny files, allocate exact-size Vec to avoid waste.
1113    let mut small_buf = [0u8; 4096];
1114    match file.read(&mut small_buf) {
1115        Ok(0) => return Ok(FileContent::Buf(Vec::new())),
1116        Ok(n) if n < small_buf.len() => {
1117            // File fits in small buffer — allocate exact size
1118            let mut vec = Vec::with_capacity(n);
1119            vec.extend_from_slice(&small_buf[..n]);
1120            return Ok(FileContent::Buf(vec));
1121        }
1122        Ok(n) => {
1123            // Might be more data — allocate heap buffer and read into it directly
1124            let mut buf = vec![0u8; 65536];
1125            buf[..n].copy_from_slice(&small_buf[..n]);
1126            let mut total = n;
1127            loop {
1128                match file.read(&mut buf[total..]) {
1129                    Ok(0) => {
1130                        buf.truncate(total);
1131                        return Ok(FileContent::Buf(buf));
1132                    }
1133                    Ok(n) => {
1134                        total += n;
1135                        if total >= buf.len() {
1136                            // File > 64KB: read rest from existing fd
1137                            return read_remaining_to_vec(&buf[..total], file);
1138                        }
1139                    }
1140                    Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
1141                    Err(e) => return Err(e),
1142                }
1143            }
1144        }
1145        Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {
1146            let mut buf = vec![0u8; 65536];
1147            let mut total = 0;
1148            loop {
1149                match file.read(&mut buf[total..]) {
1150                    Ok(0) => {
1151                        buf.truncate(total);
1152                        return Ok(FileContent::Buf(buf));
1153                    }
1154                    Ok(n) => {
1155                        total += n;
1156                        if total >= buf.len() {
1157                            // File > 64KB: read rest from existing fd
1158                            return read_remaining_to_vec(&buf[..total], file);
1159                        }
1160                    }
1161                    Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
1162                    Err(e) => return Err(e),
1163                }
1164            }
1165        }
1166        Err(e) => return Err(e),
1167    }
1168}
1169
1170/// Batch-hash multiple files with BLAKE2b using multi-buffer SIMD.
1171///
1172/// Uses blake2b_simd::many::hash_many for 4-way AVX2 parallel hashing.
1173/// All files are pre-loaded into memory (mmap for large, read for small),
1174/// then hashed simultaneously. Returns results in input order.
1175///
1176/// For 100 files on AVX2: 4x throughput from SIMD parallelism.
1177pub fn blake2b_hash_files_many(paths: &[&Path], output_bytes: usize) -> Vec<io::Result<String>> {
1178    use blake2b_simd::many::{HashManyJob, hash_many};
1179
1180    // Phase 1: Read all files into memory.
1181    // For small file counts (≤10), load sequentially to avoid thread::scope
1182    // overhead (~120µs). For many files, use parallel loading with lightweight
1183    // OS threads. For 100+ files, use fast path that skips fstat.
1184    let use_fast = paths.len() >= 20;
1185
1186    let file_data: Vec<io::Result<FileContent>> = if paths.len() <= 10 {
1187        // Sequential loading — avoids thread spawn overhead for small batches
1188        paths.iter().map(|&path| open_file_content(path)).collect()
1189    } else {
1190        let num_threads = std::thread::available_parallelism()
1191            .map(|n| n.get())
1192            .unwrap_or(4)
1193            .min(paths.len());
1194        let chunk_size = (paths.len() + num_threads - 1) / num_threads;
1195
1196        std::thread::scope(|s| {
1197            let handles: Vec<_> = paths
1198                .chunks(chunk_size)
1199                .map(|chunk| {
1200                    s.spawn(move || {
1201                        chunk
1202                            .iter()
1203                            .map(|&path| {
1204                                if use_fast {
1205                                    open_file_content_fast(path)
1206                                } else {
1207                                    open_file_content(path)
1208                                }
1209                            })
1210                            .collect::<Vec<_>>()
1211                    })
1212                })
1213                .collect();
1214
1215            handles
1216                .into_iter()
1217                .flat_map(|h| h.join().unwrap())
1218                .collect()
1219        })
1220    };
1221
1222    // Phase 2: Build hash_many jobs for successful reads
1223    let hash_results = {
1224        let mut params = blake2b_simd::Params::new();
1225        params.hash_length(output_bytes);
1226
1227        let ok_entries: Vec<(usize, &[u8])> = file_data
1228            .iter()
1229            .enumerate()
1230            .filter_map(|(i, r)| r.as_ref().ok().map(|c| (i, c.as_ref())))
1231            .collect();
1232
1233        let mut jobs: Vec<HashManyJob> = ok_entries
1234            .iter()
1235            .map(|(_, data)| HashManyJob::new(&params, data))
1236            .collect();
1237
1238        // Phase 3: Run multi-buffer SIMD hash (4-way AVX2)
1239        hash_many(jobs.iter_mut());
1240
1241        // Extract hashes into a map
1242        let mut hm: Vec<Option<String>> = vec![None; paths.len()];
1243        for (j, &(orig_i, _)) in ok_entries.iter().enumerate() {
1244            hm[orig_i] = Some(hex_encode(jobs[j].to_hash().as_bytes()));
1245        }
1246        hm
1247    }; // file_data borrow released here
1248
1249    // Phase 4: Combine hashes and errors in original order
1250    hash_results
1251        .into_iter()
1252        .zip(file_data)
1253        .map(|(hash_opt, result)| match result {
1254            Ok(_) => Ok(hash_opt.unwrap()),
1255            Err(e) => Err(e),
1256        })
1257        .collect()
1258}
1259
1260/// Batch-hash multiple files with BLAKE2b using the best strategy for the workload.
1261/// Samples a few files to estimate total data size. For small workloads, uses
1262/// single-core SIMD batch hashing (`blake2b_hash_files_many`) to avoid stat and
1263/// thread spawn overhead. For larger workloads, uses multi-core work-stealing
1264/// parallelism where each worker calls `blake2b_hash_file` (with I/O pipelining
1265/// for large files on Linux).
1266/// Returns results in input order.
1267pub fn blake2b_hash_files_parallel(
1268    paths: &[&Path],
1269    output_bytes: usize,
1270) -> Vec<io::Result<String>> {
1271    let n = paths.len();
1272
1273    // Sample a few files to estimate whether parallel processing is worthwhile.
1274    // This avoids the cost of statting ALL files (~70µs/file) when the workload
1275    // is too small for parallelism to help.
1276    let sample_count = n.min(5);
1277    let mut sample_max: u64 = 0;
1278    let mut sample_total: u64 = 0;
1279    for &p in paths.iter().take(sample_count) {
1280        let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
1281        sample_total += size;
1282        sample_max = sample_max.max(size);
1283    }
1284    let estimated_total = if sample_count > 0 {
1285        sample_total * (n as u64) / (sample_count as u64)
1286    } else {
1287        0
1288    };
1289
1290    // For small workloads, thread spawn overhead (~120µs × N_threads) exceeds
1291    // any parallelism benefit. Use SIMD batch hashing directly (no stat pass).
1292    if estimated_total < 1024 * 1024 && sample_max < SMALL_FILE_LIMIT {
1293        return blake2b_hash_files_many(paths, output_bytes);
1294    }
1295
1296    // Full stat pass for parallel scheduling — worth it for larger workloads.
1297    let mut indexed: Vec<(usize, &Path, u64)> = paths
1298        .iter()
1299        .enumerate()
1300        .map(|(i, &p)| {
1301            let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
1302            (i, p, size)
1303        })
1304        .collect();
1305
1306    // Sort largest first: ensures big files start hashing immediately while
1307    // small files fill in gaps, minimizing tail latency.
1308    indexed.sort_by(|a, b| b.2.cmp(&a.2));
1309
1310    // Warm page cache for the largest files using async readahead(2).
1311    // Each hash call handles its own mmap prefaulting, but issuing readahead
1312    // here lets the kernel start I/O for upcoming files while workers process
1313    // current ones. readahead(2) returns immediately (non-blocking).
1314    #[cfg(target_os = "linux")]
1315    {
1316        use std::os::unix::io::AsRawFd;
1317        for &(_, path, size) in indexed.iter().take(20) {
1318            if size >= 1024 * 1024 {
1319                if let Ok(file) = open_noatime(path) {
1320                    unsafe {
1321                        libc::readahead(file.as_raw_fd(), 0, size as usize);
1322                    }
1323                }
1324            }
1325        }
1326    }
1327
1328    let num_threads = std::thread::available_parallelism()
1329        .map(|n| n.get())
1330        .unwrap_or(4)
1331        .min(n);
1332
1333    // Atomic work index for dynamic work-stealing.
1334    let work_idx = AtomicUsize::new(0);
1335
1336    std::thread::scope(|s| {
1337        let work_idx = &work_idx;
1338        let indexed = &indexed;
1339
1340        let handles: Vec<_> = (0..num_threads)
1341            .map(|_| {
1342                s.spawn(move || {
1343                    let mut local_results = Vec::new();
1344                    loop {
1345                        let idx = work_idx.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1346                        if idx >= indexed.len() {
1347                            break;
1348                        }
1349                        let (orig_idx, path, _size) = indexed[idx];
1350                        let result = blake2b_hash_file(path, output_bytes);
1351                        local_results.push((orig_idx, result));
1352                    }
1353                    local_results
1354                })
1355            })
1356            .collect();
1357
1358        // Collect results and reorder to match original input order.
1359        let mut results: Vec<Option<io::Result<String>>> = (0..n).map(|_| None).collect();
1360        for handle in handles {
1361            for (orig_idx, result) in handle.join().unwrap() {
1362                results[orig_idx] = Some(result);
1363            }
1364        }
1365        results
1366            .into_iter()
1367            .map(|opt| opt.unwrap_or_else(|| Err(io::Error::other("missing result"))))
1368            .collect()
1369    })
1370}
1371
1372/// Batch-hash multiple files with SHA-256/MD5 using work-stealing parallelism.
1373/// Files are sorted by size (largest first) so the biggest files start processing
1374/// immediately. Each worker thread grabs the next unprocessed file via atomic index,
1375/// eliminating tail latency from uneven file sizes.
1376/// Returns results in input order.
1377pub fn hash_files_parallel(paths: &[&Path], algo: HashAlgorithm) -> Vec<io::Result<String>> {
1378    let n = paths.len();
1379
1380    // Build (original_index, path, size) tuples — stat all files for scheduling.
1381    // The stat cost (~5µs/file) is repaid by better work distribution.
1382    let mut indexed: Vec<(usize, &Path, u64)> = paths
1383        .iter()
1384        .enumerate()
1385        .map(|(i, &p)| {
1386            let size = std::fs::metadata(p).map(|m| m.len()).unwrap_or(0);
1387            (i, p, size)
1388        })
1389        .collect();
1390
1391    // Sort largest first: ensures big files start hashing immediately while
1392    // small files fill in gaps, minimizing tail latency.
1393    indexed.sort_by(|a, b| b.2.cmp(&a.2));
1394
1395    // Warm page cache for the largest files using async readahead(2).
1396    // Each hash call handles its own mmap prefaulting, but issuing readahead
1397    // here lets the kernel start I/O for upcoming files while workers process
1398    // current ones. readahead(2) returns immediately (non-blocking).
1399    #[cfg(target_os = "linux")]
1400    {
1401        use std::os::unix::io::AsRawFd;
1402        for &(_, path, size) in indexed.iter().take(20) {
1403            if size >= 1024 * 1024 {
1404                if let Ok(file) = open_noatime(path) {
1405                    unsafe {
1406                        libc::readahead(file.as_raw_fd(), 0, size as usize);
1407                    }
1408                }
1409            }
1410        }
1411    }
1412
1413    let num_threads = std::thread::available_parallelism()
1414        .map(|n| n.get())
1415        .unwrap_or(4)
1416        .min(n);
1417
1418    // Atomic work index for dynamic work-stealing.
1419    let work_idx = AtomicUsize::new(0);
1420
1421    std::thread::scope(|s| {
1422        let work_idx = &work_idx;
1423        let indexed = &indexed;
1424
1425        let handles: Vec<_> = (0..num_threads)
1426            .map(|_| {
1427                s.spawn(move || {
1428                    let mut local_results = Vec::new();
1429                    loop {
1430                        let idx = work_idx.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1431                        if idx >= indexed.len() {
1432                            break;
1433                        }
1434                        let (orig_idx, path, _size) = indexed[idx];
1435                        let result = hash_file(algo, path);
1436                        local_results.push((orig_idx, result));
1437                    }
1438                    local_results
1439                })
1440            })
1441            .collect();
1442
1443        // Collect results and reorder to match original input order.
1444        let mut results: Vec<Option<io::Result<String>>> = (0..n).map(|_| None).collect();
1445        for handle in handles {
1446            for (orig_idx, result) in handle.join().unwrap() {
1447                results[orig_idx] = Some(result);
1448            }
1449        }
1450        results
1451            .into_iter()
1452            .map(|opt| opt.unwrap_or_else(|| Err(io::Error::other("missing result"))))
1453            .collect()
1454    })
1455}
1456
1457/// Fast parallel hash for multi-file workloads. Skips the stat-all-and-sort phase
1458/// of `hash_files_parallel()` and uses `hash_file_nostat()` per worker to minimize
1459/// per-file syscall overhead. For 100 tiny files, this eliminates ~200 stat() calls
1460/// (100 from the sort phase + 100 from open_and_stat inside each worker).
1461/// Returns results in input order.
1462pub fn hash_files_parallel_fast(paths: &[&Path], algo: HashAlgorithm) -> Vec<io::Result<String>> {
1463    let n = paths.len();
1464    if n == 0 {
1465        return Vec::new();
1466    }
1467    if n == 1 {
1468        return vec![hash_file_nostat(algo, paths[0])];
1469    }
1470
1471    // Issue readahead for all files (no size threshold — even tiny files benefit
1472    // from batched WILLNEED hints when processing 100+ files)
1473    #[cfg(target_os = "linux")]
1474    readahead_files_all(paths);
1475
1476    let num_threads = std::thread::available_parallelism()
1477        .map(|n| n.get())
1478        .unwrap_or(4)
1479        .min(n);
1480
1481    let work_idx = AtomicUsize::new(0);
1482
1483    std::thread::scope(|s| {
1484        let work_idx = &work_idx;
1485
1486        let handles: Vec<_> = (0..num_threads)
1487            .map(|_| {
1488                s.spawn(move || {
1489                    let mut local_results = Vec::new();
1490                    loop {
1491                        let idx = work_idx.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1492                        if idx >= n {
1493                            break;
1494                        }
1495                        let result = hash_file_nostat(algo, paths[idx]);
1496                        local_results.push((idx, result));
1497                    }
1498                    local_results
1499                })
1500            })
1501            .collect();
1502
1503        let mut results: Vec<Option<io::Result<String>>> = (0..n).map(|_| None).collect();
1504        for handle in handles {
1505            for (idx, result) in handle.join().unwrap() {
1506                results[idx] = Some(result);
1507            }
1508        }
1509        results
1510            .into_iter()
1511            .map(|opt| opt.unwrap_or_else(|| Err(io::Error::other("missing result"))))
1512            .collect()
1513    })
1514}
1515
1516/// Batch-hash multiple files: pre-read all files into memory in parallel,
1517/// then hash all data in parallel. Optimal for many small files where per-file
1518/// overhead (open/read/close syscalls) dominates over hash computation.
1519///
1520/// Reuses the same parallel file loading pattern as `blake2b_hash_files_many()`.
1521/// For 100 × 55-byte files: all 5500 bytes are loaded in parallel across threads,
1522/// then hashed in parallel — minimizing wall-clock time for syscall-bound workloads.
1523/// Returns results in input order.
1524pub fn hash_files_batch(paths: &[&Path], algo: HashAlgorithm) -> Vec<io::Result<String>> {
1525    let n = paths.len();
1526    if n == 0 {
1527        return Vec::new();
1528    }
1529
1530    // Issue readahead for all files
1531    #[cfg(target_os = "linux")]
1532    readahead_files_all(paths);
1533
1534    // Phase 1: Load all files into memory in parallel.
1535    // For 20+ files, use fast path that skips fstat.
1536    let use_fast = n >= 20;
1537
1538    let file_data: Vec<io::Result<FileContent>> = if n <= 10 {
1539        // Sequential loading — avoids thread spawn overhead for small batches
1540        paths
1541            .iter()
1542            .map(|&path| {
1543                if use_fast {
1544                    open_file_content_fast(path)
1545                } else {
1546                    open_file_content(path)
1547                }
1548            })
1549            .collect()
1550    } else {
1551        let num_threads = std::thread::available_parallelism()
1552            .map(|t| t.get())
1553            .unwrap_or(4)
1554            .min(n);
1555        let chunk_size = (n + num_threads - 1) / num_threads;
1556
1557        std::thread::scope(|s| {
1558            let handles: Vec<_> = paths
1559                .chunks(chunk_size)
1560                .map(|chunk| {
1561                    s.spawn(move || {
1562                        chunk
1563                            .iter()
1564                            .map(|&path| {
1565                                if use_fast {
1566                                    open_file_content_fast(path)
1567                                } else {
1568                                    open_file_content(path)
1569                                }
1570                            })
1571                            .collect::<Vec<_>>()
1572                    })
1573                })
1574                .collect();
1575
1576            handles
1577                .into_iter()
1578                .flat_map(|h| h.join().unwrap())
1579                .collect()
1580        })
1581    };
1582
1583    // Phase 2: Hash all loaded data. For tiny files hash is negligible;
1584    // for larger files the parallel hashing across threads helps.
1585    let num_hash_threads = std::thread::available_parallelism()
1586        .map(|t| t.get())
1587        .unwrap_or(4)
1588        .min(n);
1589    let work_idx = AtomicUsize::new(0);
1590
1591    std::thread::scope(|s| {
1592        let work_idx = &work_idx;
1593        let file_data = &file_data;
1594
1595        let handles: Vec<_> = (0..num_hash_threads)
1596            .map(|_| {
1597                s.spawn(move || {
1598                    let mut local_results = Vec::new();
1599                    loop {
1600                        let idx = work_idx.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1601                        if idx >= n {
1602                            break;
1603                        }
1604                        let result = match &file_data[idx] {
1605                            Ok(content) => Ok(hash_bytes(algo, content.as_ref())),
1606                            Err(e) => Err(io::Error::new(e.kind(), e.to_string())),
1607                        };
1608                        local_results.push((idx, result));
1609                    }
1610                    local_results
1611                })
1612            })
1613            .collect();
1614
1615        let mut results: Vec<Option<io::Result<String>>> = (0..n).map(|_| None).collect();
1616        for handle in handles {
1617            for (idx, result) in handle.join().unwrap() {
1618                results[idx] = Some(result);
1619            }
1620        }
1621        results
1622            .into_iter()
1623            .map(|opt| opt.unwrap_or_else(|| Err(io::Error::other("missing result"))))
1624            .collect()
1625    })
1626}
1627
1628/// Stream-hash a file that already has a prefix read into memory.
1629/// Feeds `prefix` into the hasher first, then streams the rest from `file`.
1630/// Avoids re-opening and re-reading the file when the initial buffer is exhausted.
1631fn hash_stream_with_prefix(
1632    algo: HashAlgorithm,
1633    prefix: &[u8],
1634    mut file: File,
1635) -> io::Result<String> {
1636    match algo {
1637        HashAlgorithm::Sha1 => hash_stream_with_prefix_digest::<sha1::Sha1>(prefix, file),
1638        HashAlgorithm::Sha224 => hash_stream_with_prefix_digest::<sha2::Sha224>(prefix, file),
1639        HashAlgorithm::Sha256 => hash_stream_with_prefix_digest::<sha2::Sha256>(prefix, file),
1640        HashAlgorithm::Sha384 => hash_stream_with_prefix_digest::<sha2::Sha384>(prefix, file),
1641        HashAlgorithm::Sha512 => hash_stream_with_prefix_digest::<sha2::Sha512>(prefix, file),
1642        HashAlgorithm::Md5 => hash_stream_with_prefix_digest::<md5::Md5>(prefix, file),
1643        HashAlgorithm::Blake2b => {
1644            let mut state = blake2b_simd::Params::new().to_state();
1645            state.update(prefix);
1646            STREAM_BUF.with(|cell| {
1647                let mut buf = cell.borrow_mut();
1648                ensure_stream_buf(&mut buf);
1649                loop {
1650                    let n = read_full(&mut file, &mut buf)?;
1651                    if n == 0 {
1652                        break;
1653                    }
1654                    state.update(&buf[..n]);
1655                }
1656                Ok(hex_encode(state.finalize().as_bytes()))
1657            })
1658        }
1659    }
1660}
1661
1662/// Generic stream-hash with prefix for non-Linux platforms using Digest trait.
1663fn hash_stream_with_prefix_digest<D: digest::Digest>(
1664    prefix: &[u8],
1665    mut file: File,
1666) -> io::Result<String> {
1667    STREAM_BUF.with(|cell| {
1668        let mut buf = cell.borrow_mut();
1669        ensure_stream_buf(&mut buf);
1670        let mut hasher = D::new();
1671        hasher.update(prefix);
1672        loop {
1673            let n = read_full(&mut file, &mut buf)?;
1674            if n == 0 {
1675                break;
1676            }
1677            hasher.update(&buf[..n]);
1678        }
1679        Ok(hex_encode(&hasher.finalize()))
1680    })
1681}
1682
1683/// Hash a file without fstat — just open, read until EOF, hash.
1684/// For many-file workloads (100+ tiny files), skipping fstat saves ~5µs/file.
1685/// Uses a two-tier buffer strategy: small stack buffer (4KB) for the initial read,
1686/// then falls back to a larger stack buffer (64KB) or streaming hash for bigger files.
1687/// For benchmark's 55-byte files: one read() fills the 4KB buffer, hash immediately.
1688pub fn hash_file_nostat(algo: HashAlgorithm, path: &Path) -> io::Result<String> {
1689    let mut file = open_noatime(path)?;
1690    // First try a small stack buffer — optimal for tiny files (< 4KB).
1691    // Most "many_files" benchmark files are ~55 bytes, so this completes
1692    // with a single read() syscall and no fallback.
1693    let mut small_buf = [0u8; 4096];
1694    match file.read(&mut small_buf) {
1695        Ok(0) => return Ok(hash_bytes(algo, &[])),
1696        Ok(n) if n < small_buf.len() => {
1697            // File fits in small buffer — hash directly (common case)
1698            return Ok(hash_bytes(algo, &small_buf[..n]));
1699        }
1700        Ok(n) => {
1701            // Might be more data — fall back to larger buffer
1702            let mut buf = [0u8; 65536];
1703            buf[..n].copy_from_slice(&small_buf[..n]);
1704            let mut total = n;
1705            loop {
1706                match file.read(&mut buf[total..]) {
1707                    Ok(0) => return Ok(hash_bytes(algo, &buf[..total])),
1708                    Ok(n) => {
1709                        total += n;
1710                        if total >= buf.len() {
1711                            // File > 64KB: stream-hash from existing fd instead of
1712                            // re-opening. Feed already-read prefix, continue streaming.
1713                            return hash_stream_with_prefix(algo, &buf[..total], file);
1714                        }
1715                    }
1716                    Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
1717                    Err(e) => return Err(e),
1718                }
1719            }
1720        }
1721        Err(ref e) if e.kind() == io::ErrorKind::Interrupted => {
1722            // Retry with full buffer on interrupt
1723            let mut buf = [0u8; 65536];
1724            let mut total = 0;
1725            loop {
1726                match file.read(&mut buf[total..]) {
1727                    Ok(0) => return Ok(hash_bytes(algo, &buf[..total])),
1728                    Ok(n) => {
1729                        total += n;
1730                        if total >= buf.len() {
1731                            // File > 64KB: stream-hash from existing fd
1732                            return hash_stream_with_prefix(algo, &buf[..total], file);
1733                        }
1734                    }
1735                    Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue,
1736                    Err(e) => return Err(e),
1737                }
1738            }
1739        }
1740        Err(e) => return Err(e),
1741    }
1742}
1743
1744/// Hash a single file using raw Linux syscalls for minimum overhead.
1745/// Bypasses Rust's File abstraction entirely: raw open/fstat/read/close.
1746/// For the single-file fast path, this eliminates OpenOptions builder,
1747/// CString heap allocation, File wrapper overhead, and Read trait dispatch.
1748///
1749/// Size-based dispatch:
1750/// - Tiny (<8KB): stack buffer + raw read + hash_bytes (3 syscalls total)
1751/// - Small (8KB-16MB): wraps fd in File, reads into thread-local buffer
1752/// - Large (>=16MB): wraps fd in File, mmaps with HugePage + PopulateRead
1753/// - Non-regular: wraps fd in File, streaming hash_reader
1754#[cfg(target_os = "linux")]
1755pub fn hash_file_raw(algo: HashAlgorithm, path: &Path) -> io::Result<String> {
1756    use std::os::unix::ffi::OsStrExt;
1757
1758    let path_bytes = path.as_os_str().as_bytes();
1759    let c_path = std::ffi::CString::new(path_bytes)
1760        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "path contains null byte"))?;
1761
1762    // Raw open with O_RDONLY | O_CLOEXEC, optionally O_NOATIME
1763    let mut flags = libc::O_RDONLY | libc::O_CLOEXEC;
1764    if NOATIME_SUPPORTED.load(Ordering::Relaxed) {
1765        flags |= libc::O_NOATIME;
1766    }
1767
1768    let fd = unsafe { libc::open(c_path.as_ptr(), flags) };
1769    if fd < 0 {
1770        let err = io::Error::last_os_error();
1771        if err.raw_os_error() == Some(libc::EPERM) && flags & libc::O_NOATIME != 0 {
1772            NOATIME_SUPPORTED.store(false, Ordering::Relaxed);
1773            let fd2 = unsafe { libc::open(c_path.as_ptr(), libc::O_RDONLY | libc::O_CLOEXEC) };
1774            if fd2 < 0 {
1775                return Err(io::Error::last_os_error());
1776            }
1777            return hash_from_raw_fd(algo, fd2);
1778        }
1779        return Err(err);
1780    }
1781    hash_from_raw_fd(algo, fd)
1782}
1783
1784/// Hash from a raw fd — dispatches by file size for optimal I/O strategy.
1785/// Handles tiny (stack buffer), small (thread-local buffer), large (mmap), and
1786/// non-regular (streaming) files.
1787#[cfg(target_os = "linux")]
1788fn hash_from_raw_fd(algo: HashAlgorithm, fd: i32) -> io::Result<String> {
1789    // Raw fstat to determine size and type
1790    let mut stat: libc::stat = unsafe { std::mem::zeroed() };
1791    if unsafe { libc::fstat(fd, &mut stat) } != 0 {
1792        let err = io::Error::last_os_error();
1793        unsafe {
1794            libc::close(fd);
1795        }
1796        return Err(err);
1797    }
1798    let size = stat.st_size as u64;
1799    let is_regular = (stat.st_mode & libc::S_IFMT) == libc::S_IFREG;
1800
1801    // Empty regular file
1802    if is_regular && size == 0 {
1803        unsafe {
1804            libc::close(fd);
1805        }
1806        return Ok(hash_bytes(algo, &[]));
1807    }
1808
1809    // Tiny files (<8KB): raw read into stack buffer, no File wrapper needed.
1810    // Entire I/O in 3 raw syscalls: open + read + close.
1811    if is_regular && size < TINY_FILE_LIMIT {
1812        let mut buf = [0u8; 8192];
1813        let mut total = 0usize;
1814        while total < size as usize {
1815            let n = unsafe {
1816                libc::read(
1817                    fd,
1818                    buf[total..].as_mut_ptr() as *mut libc::c_void,
1819                    (size as usize) - total,
1820                )
1821            };
1822            if n < 0 {
1823                let err = io::Error::last_os_error();
1824                if err.kind() == io::ErrorKind::Interrupted {
1825                    continue;
1826                }
1827                unsafe {
1828                    libc::close(fd);
1829                }
1830                return Err(err);
1831            }
1832            if n == 0 {
1833                break;
1834            }
1835            total += n as usize;
1836        }
1837        unsafe {
1838            libc::close(fd);
1839        }
1840        return Ok(hash_bytes(algo, &buf[..total]));
1841    }
1842
1843    // For larger files, wrap fd in File for RAII close and existing optimized paths.
1844    use std::os::unix::io::FromRawFd;
1845    let file = unsafe { File::from_raw_fd(fd) };
1846
1847    if is_regular && size > 0 {
1848        // Large files (>=16MB): mmap with HugePage + PopulateRead
1849        if size >= SMALL_FILE_LIMIT {
1850            let mmap_result = unsafe { memmap2::MmapOptions::new().map(&file) };
1851            if let Ok(mmap) = mmap_result {
1852                if size >= 2 * 1024 * 1024 {
1853                    let _ = mmap.advise(memmap2::Advice::HugePage);
1854                }
1855                let _ = mmap.advise(memmap2::Advice::Sequential);
1856                // Prefault pages using huge pages (kernel 5.14+)
1857                if mmap.advise(memmap2::Advice::PopulateRead).is_err() {
1858                    let _ = mmap.advise(memmap2::Advice::WillNeed);
1859                }
1860                return Ok(hash_bytes(algo, &mmap));
1861            }
1862        }
1863        // Small files (8KB-16MB): single-read into thread-local buffer
1864        return hash_file_small(algo, file, size as usize);
1865    }
1866
1867    // Non-regular files: streaming hash
1868    hash_reader(algo, file)
1869}
1870
1871/// Issue readahead hints for ALL file paths (no size threshold).
1872/// For multi-file benchmarks, even small files benefit from batched readahead.
1873#[cfg(target_os = "linux")]
1874pub fn readahead_files_all(paths: &[&Path]) {
1875    use std::os::unix::io::AsRawFd;
1876    for path in paths {
1877        if let Ok(file) = open_noatime(path) {
1878            if let Ok(meta) = file.metadata() {
1879                if meta.file_type().is_file() {
1880                    let len = meta.len();
1881                    unsafe {
1882                        libc::posix_fadvise(
1883                            file.as_raw_fd(),
1884                            0,
1885                            len as i64,
1886                            libc::POSIX_FADV_WILLNEED,
1887                        );
1888                    }
1889                }
1890            }
1891        }
1892    }
1893}
1894
1895#[cfg(not(target_os = "linux"))]
1896pub fn readahead_files_all(_paths: &[&Path]) {}
1897
1898/// Print hash result in GNU format: "hash  filename\n"
1899/// Uses raw byte writes to avoid std::fmt overhead.
1900pub fn print_hash(
1901    out: &mut impl Write,
1902    hash: &str,
1903    filename: &str,
1904    binary: bool,
1905) -> io::Result<()> {
1906    let mode = if binary { b'*' } else { b' ' };
1907    out.write_all(hash.as_bytes())?;
1908    out.write_all(&[b' ', mode])?;
1909    out.write_all(filename.as_bytes())?;
1910    out.write_all(b"\n")
1911}
1912
1913/// Print hash in GNU format with NUL terminator instead of newline.
1914pub fn print_hash_zero(
1915    out: &mut impl Write,
1916    hash: &str,
1917    filename: &str,
1918    binary: bool,
1919) -> io::Result<()> {
1920    let mode = if binary { b'*' } else { b' ' };
1921    out.write_all(hash.as_bytes())?;
1922    out.write_all(&[b' ', mode])?;
1923    out.write_all(filename.as_bytes())?;
1924    out.write_all(b"\0")
1925}
1926
1927// ── Single-write output buffer ─────────────────────────────────────
1928// For multi-file workloads, batch the entire "hash  filename\n" line into
1929// a single write() call. This halves the number of BufWriter flushes.
1930
1931// Thread-local output line buffer for batched writes.
1932// Reused across files to avoid per-file allocation.
1933thread_local! {
1934    static LINE_BUF: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(256));
1935}
1936
1937/// Build and write the standard GNU hash output line in a single write() call.
1938/// Format: "hash  filename\n" or "hash *filename\n" (binary mode).
1939/// For escaped filenames: "\hash  escaped_filename\n".
1940#[inline]
1941pub fn write_hash_line(
1942    out: &mut impl Write,
1943    hash: &str,
1944    filename: &str,
1945    binary: bool,
1946    zero: bool,
1947    escaped: bool,
1948) -> io::Result<()> {
1949    LINE_BUF.with(|cell| {
1950        let mut buf = cell.borrow_mut();
1951        buf.clear();
1952        let mode = if binary { b'*' } else { b' ' };
1953        let term = if zero { b'\0' } else { b'\n' };
1954        if escaped {
1955            buf.push(b'\\');
1956        }
1957        buf.extend_from_slice(hash.as_bytes());
1958        buf.push(b' ');
1959        buf.push(mode);
1960        buf.extend_from_slice(filename.as_bytes());
1961        buf.push(term);
1962        out.write_all(&buf)
1963    })
1964}
1965
1966/// Build and write BSD tag format output in a single write() call.
1967/// Format: "ALGO (filename) = hash\n"
1968#[inline]
1969pub fn write_hash_tag_line(
1970    out: &mut impl Write,
1971    algo_name: &str,
1972    hash: &str,
1973    filename: &str,
1974    zero: bool,
1975) -> io::Result<()> {
1976    LINE_BUF.with(|cell| {
1977        let mut buf = cell.borrow_mut();
1978        buf.clear();
1979        let term = if zero { b'\0' } else { b'\n' };
1980        buf.extend_from_slice(algo_name.as_bytes());
1981        buf.extend_from_slice(b" (");
1982        buf.extend_from_slice(filename.as_bytes());
1983        buf.extend_from_slice(b") = ");
1984        buf.extend_from_slice(hash.as_bytes());
1985        buf.push(term);
1986        out.write_all(&buf)
1987    })
1988}
1989
1990/// Print hash result in BSD tag format: "ALGO (filename) = hash\n"
1991pub fn print_hash_tag(
1992    out: &mut impl Write,
1993    algo: HashAlgorithm,
1994    hash: &str,
1995    filename: &str,
1996) -> io::Result<()> {
1997    out.write_all(algo.name().as_bytes())?;
1998    out.write_all(b" (")?;
1999    out.write_all(filename.as_bytes())?;
2000    out.write_all(b") = ")?;
2001    out.write_all(hash.as_bytes())?;
2002    out.write_all(b"\n")
2003}
2004
2005/// Print hash in BSD tag format with NUL terminator.
2006pub fn print_hash_tag_zero(
2007    out: &mut impl Write,
2008    algo: HashAlgorithm,
2009    hash: &str,
2010    filename: &str,
2011) -> io::Result<()> {
2012    out.write_all(algo.name().as_bytes())?;
2013    out.write_all(b" (")?;
2014    out.write_all(filename.as_bytes())?;
2015    out.write_all(b") = ")?;
2016    out.write_all(hash.as_bytes())?;
2017    out.write_all(b"\0")
2018}
2019
2020/// Print hash in BSD tag format with BLAKE2b length info:
2021/// "BLAKE2b (filename) = hash" for 512-bit, or
2022/// "BLAKE2b-256 (filename) = hash" for other lengths.
2023pub fn print_hash_tag_b2sum(
2024    out: &mut impl Write,
2025    hash: &str,
2026    filename: &str,
2027    bits: usize,
2028) -> io::Result<()> {
2029    if bits == 512 {
2030        out.write_all(b"BLAKE2b (")?;
2031    } else {
2032        // Use write! for the rare non-512 path (negligible overhead per file)
2033        write!(out, "BLAKE2b-{} (", bits)?;
2034    }
2035    out.write_all(filename.as_bytes())?;
2036    out.write_all(b") = ")?;
2037    out.write_all(hash.as_bytes())?;
2038    out.write_all(b"\n")
2039}
2040
2041/// Print hash in BSD tag format with BLAKE2b length info and NUL terminator.
2042pub fn print_hash_tag_b2sum_zero(
2043    out: &mut impl Write,
2044    hash: &str,
2045    filename: &str,
2046    bits: usize,
2047) -> io::Result<()> {
2048    if bits == 512 {
2049        out.write_all(b"BLAKE2b (")?;
2050    } else {
2051        write!(out, "BLAKE2b-{} (", bits)?;
2052    }
2053    out.write_all(filename.as_bytes())?;
2054    out.write_all(b") = ")?;
2055    out.write_all(hash.as_bytes())?;
2056    out.write_all(b"\0")
2057}
2058
2059/// Options for check mode.
2060pub struct CheckOptions {
2061    pub quiet: bool,
2062    pub status_only: bool,
2063    pub strict: bool,
2064    pub warn: bool,
2065    pub ignore_missing: bool,
2066    /// Prefix for per-line format warnings, e.g., "fmd5sum: checksums.txt".
2067    /// When non-empty, warnings use GNU format: "{prefix}: {line}: message".
2068    /// When empty, uses generic format: "line {line}: message".
2069    pub warn_prefix: String,
2070}
2071
2072/// Result of check mode verification.
2073pub struct CheckResult {
2074    pub ok: usize,
2075    pub mismatches: usize,
2076    pub format_errors: usize,
2077    pub read_errors: usize,
2078    /// Number of files skipped because they were missing and --ignore-missing was set.
2079    pub ignored_missing: usize,
2080}
2081
2082/// Verify checksums from a check file.
2083/// Each line should be "hash  filename" or "hash *filename" or "ALGO (filename) = hash".
2084pub fn check_file<R: BufRead>(
2085    algo: HashAlgorithm,
2086    reader: R,
2087    opts: &CheckOptions,
2088    out: &mut impl Write,
2089    err_out: &mut impl Write,
2090) -> io::Result<CheckResult> {
2091    let quiet = opts.quiet;
2092    let status_only = opts.status_only;
2093    let warn = opts.warn;
2094    let ignore_missing = opts.ignore_missing;
2095    let mut ok_count = 0;
2096    let mut mismatch_count = 0;
2097    let mut format_errors = 0;
2098    let mut read_errors = 0;
2099    let mut ignored_missing_count = 0;
2100    let mut line_num = 0;
2101
2102    for line_result in reader.lines() {
2103        line_num += 1;
2104        let line = line_result?;
2105        let line = line.trim_end();
2106
2107        if line.is_empty() {
2108            continue;
2109        }
2110
2111        // Parse "hash  filename" or "hash *filename" or "ALGO (file) = hash"
2112        let (expected_hash, filename) = match parse_check_line(line) {
2113            Some(v) => v,
2114            None => {
2115                format_errors += 1;
2116                if warn {
2117                    out.flush()?;
2118                    if opts.warn_prefix.is_empty() {
2119                        writeln!(
2120                            err_out,
2121                            "line {}: improperly formatted {} checksum line",
2122                            line_num,
2123                            algo.name()
2124                        )?;
2125                    } else {
2126                        writeln!(
2127                            err_out,
2128                            "{}: {}: improperly formatted {} checksum line",
2129                            opts.warn_prefix,
2130                            line_num,
2131                            algo.name()
2132                        )?;
2133                    }
2134                }
2135                continue;
2136            }
2137        };
2138
2139        // Compute actual hash
2140        let actual = match hash_file(algo, Path::new(filename)) {
2141            Ok(h) => h,
2142            Err(e) => {
2143                if ignore_missing && e.kind() == io::ErrorKind::NotFound {
2144                    ignored_missing_count += 1;
2145                    continue;
2146                }
2147                read_errors += 1;
2148                if !status_only {
2149                    out.flush()?;
2150                    writeln!(err_out, "{}: {}", filename, e)?;
2151                    writeln!(out, "{}: FAILED open or read", filename)?;
2152                }
2153                continue;
2154            }
2155        };
2156
2157        if actual.eq_ignore_ascii_case(expected_hash) {
2158            ok_count += 1;
2159            if !quiet && !status_only {
2160                writeln!(out, "{}: OK", filename)?;
2161            }
2162        } else {
2163            mismatch_count += 1;
2164            if !status_only {
2165                writeln!(out, "{}: FAILED", filename)?;
2166            }
2167        }
2168    }
2169
2170    Ok(CheckResult {
2171        ok: ok_count,
2172        mismatches: mismatch_count,
2173        format_errors,
2174        read_errors,
2175        ignored_missing: ignored_missing_count,
2176    })
2177}
2178
2179/// Parse a checksum line in any supported format.
2180pub fn parse_check_line(line: &str) -> Option<(&str, &str)> {
2181    // Try BSD tag format: "ALGO (filename) = hash"
2182    let rest = line
2183        .strip_prefix("MD5 (")
2184        .or_else(|| line.strip_prefix("SHA1 ("))
2185        .or_else(|| line.strip_prefix("SHA224 ("))
2186        .or_else(|| line.strip_prefix("SHA256 ("))
2187        .or_else(|| line.strip_prefix("SHA384 ("))
2188        .or_else(|| line.strip_prefix("SHA512 ("))
2189        .or_else(|| line.strip_prefix("BLAKE2b ("))
2190        .or_else(|| {
2191            // Handle BLAKE2b-NNN (filename) = hash
2192            if line.starts_with("BLAKE2b-") {
2193                let after = &line["BLAKE2b-".len()..];
2194                if let Some(sp) = after.find(" (") {
2195                    if after[..sp].bytes().all(|b| b.is_ascii_digit()) {
2196                        return Some(&after[sp + 2..]);
2197                    }
2198                }
2199            }
2200            None
2201        });
2202    if let Some(rest) = rest {
2203        if let Some(paren_idx) = rest.find(") = ") {
2204            let filename = &rest[..paren_idx];
2205            let hash = &rest[paren_idx + 4..];
2206            return Some((hash, filename));
2207        }
2208    }
2209
2210    // Handle backslash-escaped lines (leading '\')
2211    let line = line.strip_prefix('\\').unwrap_or(line);
2212
2213    // Standard format: "hash  filename"
2214    if let Some(idx) = line.find("  ") {
2215        let hash = &line[..idx];
2216        let rest = &line[idx + 2..];
2217        return Some((hash, rest));
2218    }
2219    // Binary mode: "hash *filename"
2220    if let Some(idx) = line.find(" *") {
2221        let hash = &line[..idx];
2222        let rest = &line[idx + 2..];
2223        return Some((hash, rest));
2224    }
2225    None
2226}
2227
2228/// Parse a BSD-style tag line: "ALGO (filename) = hash"
2229/// Returns (expected_hash, filename, optional_bits).
2230/// `bits` is the hash length parsed from the algo name (e.g., BLAKE2b-256 -> Some(256)).
2231pub fn parse_check_line_tag(line: &str) -> Option<(&str, &str, Option<usize>)> {
2232    let paren_start = line.find(" (")?;
2233    let algo_part = &line[..paren_start];
2234    let rest = &line[paren_start + 2..];
2235    let paren_end = rest.find(") = ")?;
2236    let filename = &rest[..paren_end];
2237    let hash = &rest[paren_end + 4..];
2238
2239    // Parse optional bit length from algo name (e.g., "BLAKE2b-256" -> Some(256))
2240    let bits = if let Some(dash_pos) = algo_part.rfind('-') {
2241        algo_part[dash_pos + 1..].parse::<usize>().ok()
2242    } else {
2243        None
2244    };
2245
2246    Some((hash, filename, bits))
2247}
2248
2249/// Read as many bytes as possible into buf, retrying on partial reads.
2250/// Ensures each hash update gets a full buffer (fewer update calls = less overhead).
2251/// Fast path: regular file reads usually return the full buffer on the first call.
2252#[inline]
2253fn read_full(reader: &mut impl Read, buf: &mut [u8]) -> io::Result<usize> {
2254    // Fast path: first read() usually fills the entire buffer for regular files
2255    let n = reader.read(buf)?;
2256    if n == buf.len() || n == 0 {
2257        return Ok(n);
2258    }
2259    // Slow path: partial read — retry to fill buffer (pipes, slow devices)
2260    let mut total = n;
2261    while total < buf.len() {
2262        match reader.read(&mut buf[total..]) {
2263            Ok(0) => break,
2264            Ok(n) => total += n,
2265            Err(e) if e.kind() == io::ErrorKind::Interrupted => continue,
2266            Err(e) => return Err(e),
2267        }
2268    }
2269    Ok(total)
2270}
2271
2272/// Compile-time generated 2-byte hex pair lookup table.
2273/// Each byte maps directly to its 2-char hex representation — single lookup per byte.
2274const fn generate_hex_table() -> [[u8; 2]; 256] {
2275    let hex = b"0123456789abcdef";
2276    let mut table = [[0u8; 2]; 256];
2277    let mut i = 0;
2278    while i < 256 {
2279        table[i] = [hex[i >> 4], hex[i & 0xf]];
2280        i += 1;
2281    }
2282    table
2283}
2284
2285const HEX_TABLE: [[u8; 2]; 256] = generate_hex_table();
2286
2287/// Fast hex encoding using 2-byte pair lookup table — one lookup per input byte.
2288/// Uses String directly instead of Vec<u8> to avoid the from_utf8 conversion overhead.
2289pub(crate) fn hex_encode(bytes: &[u8]) -> String {
2290    let len = bytes.len() * 2;
2291    let mut hex = String::with_capacity(len);
2292    // SAFETY: We write exactly `len` valid ASCII hex bytes into the String's buffer.
2293    unsafe {
2294        let buf = hex.as_mut_vec();
2295        buf.set_len(len);
2296        hex_encode_to_slice(bytes, buf);
2297    }
2298    hex
2299}
2300
2301/// Encode bytes as hex directly into a pre-allocated output slice.
2302/// Output slice must be at least `bytes.len() * 2` bytes long.
2303#[inline]
2304fn hex_encode_to_slice(bytes: &[u8], out: &mut [u8]) {
2305    // SAFETY: We write exactly bytes.len()*2 bytes into `out`, which must be large enough.
2306    unsafe {
2307        let ptr = out.as_mut_ptr();
2308        for (i, &b) in bytes.iter().enumerate() {
2309            let pair = *HEX_TABLE.get_unchecked(b as usize);
2310            *ptr.add(i * 2) = pair[0];
2311            *ptr.add(i * 2 + 1) = pair[1];
2312        }
2313    }
2314}