Skip to main content

coreutils_rs/hash/
core.rs

1use std::cell::RefCell;
2use std::fs::{self, File};
3use std::io::{self, BufRead, BufReader, Read, Write};
4use std::path::Path;
5
6#[cfg(target_os = "linux")]
7use std::sync::atomic::{AtomicBool, Ordering};
8
9use md5::Md5;
10use memmap2::MmapOptions;
11use sha2::{Digest, Sha256};
12
13/// Supported hash algorithms.
14#[derive(Debug, Clone, Copy)]
15pub enum HashAlgorithm {
16    Sha256,
17    Md5,
18    Blake2b,
19}
20
21impl HashAlgorithm {
22    pub fn name(self) -> &'static str {
23        match self {
24            HashAlgorithm::Sha256 => "SHA256",
25            HashAlgorithm::Md5 => "MD5",
26            HashAlgorithm::Blake2b => "BLAKE2b",
27        }
28    }
29}
30
31// ── Generic hash helpers ────────────────────────────────────────────
32
33fn hash_digest<D: Digest>(data: &[u8]) -> String {
34    hex_encode(&D::digest(data))
35}
36
37fn hash_reader_impl<D: Digest>(mut reader: impl Read) -> io::Result<String> {
38    let mut hasher = D::new();
39    let mut buf = vec![0u8; 16 * 1024 * 1024]; // 16MB buffer — fewer syscalls
40    loop {
41        let n = reader.read(&mut buf)?;
42        if n == 0 {
43            break;
44        }
45        hasher.update(&buf[..n]);
46    }
47    Ok(hex_encode(&hasher.finalize()))
48}
49
50// ── Public hashing API ──────────────────────────────────────────────
51
52/// Compute hash of a byte slice directly (zero-copy fast path).
53pub fn hash_bytes(algo: HashAlgorithm, data: &[u8]) -> String {
54    match algo {
55        HashAlgorithm::Sha256 => hash_digest::<Sha256>(data),
56        HashAlgorithm::Md5 => hash_digest::<Md5>(data),
57        HashAlgorithm::Blake2b => {
58            let hash = blake2b_simd::blake2b(data);
59            hex_encode(hash.as_bytes())
60        }
61    }
62}
63
64/// Compute hash of data from a reader, returning hex string.
65pub fn hash_reader<R: Read>(algo: HashAlgorithm, reader: R) -> io::Result<String> {
66    match algo {
67        HashAlgorithm::Sha256 => hash_reader_impl::<Sha256>(reader),
68        HashAlgorithm::Md5 => hash_reader_impl::<Md5>(reader),
69        HashAlgorithm::Blake2b => blake2b_hash_reader(reader, 64),
70    }
71}
72
73/// Threshold below which read() is faster than mmap() due to mmap setup overhead.
74/// For small files, the page table setup + madvise syscalls cost more than a simple read.
75const MMAP_THRESHOLD: u64 = 256 * 1024; // 256KB
76
77// Thread-local reusable buffer for small file reads.
78// Avoids per-file heap allocation when processing many small files sequentially or in parallel.
79// Each rayon worker thread gets its own buffer automatically.
80thread_local! {
81    static READ_BUF: RefCell<Vec<u8>> = RefCell::new(Vec::with_capacity(MMAP_THRESHOLD as usize));
82}
83
84/// Track whether O_NOATIME is supported to avoid repeated failed open() attempts.
85/// After the first EPERM, we never try O_NOATIME again (saves one syscall per file).
86#[cfg(target_os = "linux")]
87static NOATIME_SUPPORTED: AtomicBool = AtomicBool::new(true);
88
89/// Open a file with O_NOATIME on Linux to avoid atime update overhead.
90/// Caches whether O_NOATIME works to avoid double-open on every file.
91#[cfg(target_os = "linux")]
92fn open_noatime(path: &Path) -> io::Result<File> {
93    use std::os::unix::fs::OpenOptionsExt;
94    if NOATIME_SUPPORTED.load(Ordering::Relaxed) {
95        match fs::OpenOptions::new()
96            .read(true)
97            .custom_flags(libc::O_NOATIME)
98            .open(path)
99        {
100            Ok(f) => return Ok(f),
101            Err(ref e) if e.raw_os_error() == Some(libc::EPERM) => {
102                // O_NOATIME requires file ownership or CAP_FOWNER — disable globally
103                NOATIME_SUPPORTED.store(false, Ordering::Relaxed);
104            }
105            Err(e) => return Err(e), // Real error, propagate
106        }
107    }
108    File::open(path)
109}
110
111#[cfg(not(target_os = "linux"))]
112fn open_noatime(path: &Path) -> io::Result<File> {
113    File::open(path)
114}
115
116/// Hash a file by path. Single open + fstat to minimize syscalls.
117/// Uses read() for small files, mmap for large files.
118pub fn hash_file(algo: HashAlgorithm, path: &Path) -> io::Result<String> {
119    // Single open — reuse fd for fstat + read/mmap (saves separate stat + open)
120    let file = open_noatime(path)?;
121    let metadata = file.metadata()?; // fstat on existing fd, cheaper than stat(path)
122    let len = metadata.len();
123    let is_regular = metadata.file_type().is_file();
124
125    if is_regular && len == 0 {
126        return Ok(hash_bytes(algo, &[]));
127    }
128
129    if is_regular && len > 0 {
130        // Small files: read into thread-local buffer (zero allocation after first call)
131        if len < MMAP_THRESHOLD {
132            return READ_BUF.with(|cell| {
133                let mut buf = cell.borrow_mut();
134                buf.clear();
135                // Reserve is a no-op if capacity >= len (which it is after first call)
136                buf.reserve(len as usize);
137                Read::read_to_end(&mut &file, &mut buf)?;
138                Ok(hash_bytes(algo, &buf))
139            });
140        }
141
142        // Large files: mmap the already-open fd for zero-copy
143        return mmap_and_hash(algo, &file);
144    }
145
146    // Fallback: buffered read (special files, pipes, etc.) — fd already open
147    let reader = BufReader::with_capacity(16 * 1024 * 1024, file);
148    hash_reader(algo, reader)
149}
150
151/// Mmap a file and hash it. Shared by hash_file and blake2b_hash_file.
152fn mmap_and_hash(algo: HashAlgorithm, file: &File) -> io::Result<String> {
153    match unsafe {
154        MmapOptions::new()
155            .populate() // Eagerly populate page tables — avoids page faults during hash
156            .map(file)
157    } {
158        Ok(mmap) => {
159            #[cfg(target_os = "linux")]
160            {
161                let _ = mmap.advise(memmap2::Advice::Sequential);
162                if mmap.len() >= 2 * 1024 * 1024 {
163                    unsafe {
164                        libc::madvise(
165                            mmap.as_ptr() as *mut libc::c_void,
166                            mmap.len(),
167                            libc::MADV_HUGEPAGE,
168                        );
169                    }
170                }
171            }
172            Ok(hash_bytes(algo, &mmap))
173        }
174        Err(_) => {
175            // mmap failed — fall back to buffered read from the same fd
176            let reader = BufReader::with_capacity(16 * 1024 * 1024, file);
177            hash_reader(algo, reader)
178        }
179    }
180}
181
182/// Mmap a file and hash with BLAKE2b. Shared helper for blake2b_hash_file.
183fn mmap_and_hash_blake2b(file: &File, output_bytes: usize) -> io::Result<String> {
184    match unsafe { MmapOptions::new().populate().map(file) } {
185        Ok(mmap) => {
186            #[cfg(target_os = "linux")]
187            {
188                let _ = mmap.advise(memmap2::Advice::Sequential);
189                if mmap.len() >= 2 * 1024 * 1024 {
190                    unsafe {
191                        libc::madvise(
192                            mmap.as_ptr() as *mut libc::c_void,
193                            mmap.len(),
194                            libc::MADV_HUGEPAGE,
195                        );
196                    }
197                }
198            }
199            Ok(blake2b_hash_data(&mmap, output_bytes))
200        }
201        Err(_) => {
202            let reader = BufReader::with_capacity(16 * 1024 * 1024, file);
203            blake2b_hash_reader(reader, output_bytes)
204        }
205    }
206}
207
208/// Hash stdin. Reads all data first, then hashes in one pass for optimal throughput.
209pub fn hash_stdin(algo: HashAlgorithm) -> io::Result<String> {
210    // Try to mmap stdin if it's a regular file (shell redirect)
211    #[cfg(unix)]
212    {
213        use std::os::unix::io::AsRawFd;
214        let stdin = io::stdin();
215        let fd = stdin.as_raw_fd();
216        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
217        if unsafe { libc::fstat(fd, &mut stat) } == 0
218            && (stat.st_mode & libc::S_IFMT) == libc::S_IFREG
219            && stat.st_size > 0
220        {
221            use std::os::unix::io::FromRawFd;
222            let file = unsafe { File::from_raw_fd(fd) };
223            let result = unsafe { MmapOptions::new().populate().map(&file) };
224            std::mem::forget(file); // Don't close stdin
225            if let Ok(mmap) = result {
226                #[cfg(target_os = "linux")]
227                {
228                    let _ = mmap.advise(memmap2::Advice::Sequential);
229                }
230                return Ok(hash_bytes(algo, &mmap));
231            }
232        }
233    }
234    // Fallback: read all then hash in one pass (avoids per-read update overhead)
235    let mut data = Vec::new();
236    io::stdin().lock().read_to_end(&mut data)?;
237    Ok(hash_bytes(algo, &data))
238}
239
240/// Parallel hashing threshold: only use rayon when total data exceeds this.
241/// Below this, sequential processing avoids rayon overhead (thread pool, work stealing).
242const PARALLEL_THRESHOLD: u64 = 8 * 1024 * 1024; // 8MB
243
244/// Estimate total file size for parallel/sequential decision.
245/// Uses a quick heuristic: samples first file and extrapolates.
246/// Returns 0 if estimation fails.
247pub fn estimate_total_size(paths: &[&Path]) -> u64 {
248    if paths.is_empty() {
249        return 0;
250    }
251    // Sample first file to estimate
252    if let Ok(meta) = fs::metadata(paths[0]) {
253        meta.len().saturating_mul(paths.len() as u64)
254    } else {
255        0
256    }
257}
258
259/// Check if parallel hashing is worthwhile for the given file paths.
260pub fn should_use_parallel(paths: &[&Path]) -> bool {
261    if paths.len() < 2 {
262        return false;
263    }
264    estimate_total_size(paths) >= PARALLEL_THRESHOLD
265}
266
267/// Issue readahead hints for a list of file paths to warm the page cache.
268/// Uses POSIX_FADV_WILLNEED which is non-blocking and batches efficiently.
269#[cfg(target_os = "linux")]
270pub fn readahead_files(paths: &[&Path]) {
271    use std::os::unix::io::AsRawFd;
272    for path in paths {
273        if let Ok(file) = open_noatime(path) {
274            if let Ok(meta) = file.metadata() {
275                let len = meta.len();
276                if meta.file_type().is_file() && len > 0 {
277                    unsafe {
278                        libc::posix_fadvise(
279                            file.as_raw_fd(),
280                            0,
281                            len as i64,
282                            libc::POSIX_FADV_WILLNEED,
283                        );
284                    }
285                }
286            }
287        }
288    }
289}
290
291#[cfg(not(target_os = "linux"))]
292pub fn readahead_files(_paths: &[&Path]) {
293    // No-op on non-Linux
294}
295
296// --- BLAKE2b variable-length functions (using blake2b_simd) ---
297
298/// Hash raw data with BLAKE2b variable output length.
299/// `output_bytes` is the output size in bytes (e.g., 32 for 256-bit).
300pub fn blake2b_hash_data(data: &[u8], output_bytes: usize) -> String {
301    let hash = blake2b_simd::Params::new()
302        .hash_length(output_bytes)
303        .hash(data);
304    hex_encode(hash.as_bytes())
305}
306
307/// Hash a reader with BLAKE2b variable output length.
308pub fn blake2b_hash_reader<R: Read>(mut reader: R, output_bytes: usize) -> io::Result<String> {
309    let mut state = blake2b_simd::Params::new()
310        .hash_length(output_bytes)
311        .to_state();
312    let mut buf = vec![0u8; 16 * 1024 * 1024]; // 16MB buffer
313    loop {
314        let n = reader.read(&mut buf)?;
315        if n == 0 {
316            break;
317        }
318        state.update(&buf[..n]);
319    }
320    Ok(hex_encode(state.finalize().as_bytes()))
321}
322
323/// Hash a file with BLAKE2b variable output length. Single open + fstat.
324/// Uses read() for small files, mmap for large.
325pub fn blake2b_hash_file(path: &Path, output_bytes: usize) -> io::Result<String> {
326    // Single open — reuse fd for fstat + read/mmap
327    let file = open_noatime(path)?;
328    let metadata = file.metadata()?;
329    let len = metadata.len();
330    let is_regular = metadata.file_type().is_file();
331
332    if is_regular && len == 0 {
333        return Ok(blake2b_hash_data(&[], output_bytes));
334    }
335
336    if is_regular && len > 0 {
337        // Small files: read into thread-local buffer (zero allocation after first call)
338        if len < MMAP_THRESHOLD {
339            return READ_BUF.with(|cell| {
340                let mut buf = cell.borrow_mut();
341                buf.clear();
342                buf.reserve(len as usize);
343                Read::read_to_end(&mut &file, &mut buf)?;
344                Ok(blake2b_hash_data(&buf, output_bytes))
345            });
346        }
347
348        // Large files: mmap the already-open fd for zero-copy
349        return mmap_and_hash_blake2b(&file, output_bytes);
350    }
351
352    // Fallback: buffered read — fd already open
353    let reader = BufReader::with_capacity(16 * 1024 * 1024, file);
354    blake2b_hash_reader(reader, output_bytes)
355}
356
357/// Hash stdin with BLAKE2b variable output length.
358/// Tries mmap if stdin is a regular file (shell redirect), falls back to read.
359pub fn blake2b_hash_stdin(output_bytes: usize) -> io::Result<String> {
360    // Try to mmap stdin if it's a regular file (shell redirect)
361    #[cfg(unix)]
362    {
363        use std::os::unix::io::AsRawFd;
364        let stdin = io::stdin();
365        let fd = stdin.as_raw_fd();
366        let mut stat: libc::stat = unsafe { std::mem::zeroed() };
367        if unsafe { libc::fstat(fd, &mut stat) } == 0
368            && (stat.st_mode & libc::S_IFMT) == libc::S_IFREG
369            && stat.st_size > 0
370        {
371            use std::os::unix::io::FromRawFd;
372            let file = unsafe { File::from_raw_fd(fd) };
373            let result = unsafe { MmapOptions::new().populate().map(&file) };
374            std::mem::forget(file); // Don't close stdin
375            if let Ok(mmap) = result {
376                #[cfg(target_os = "linux")]
377                {
378                    let _ = mmap.advise(memmap2::Advice::Sequential);
379                }
380                return Ok(blake2b_hash_data(&mmap, output_bytes));
381            }
382        }
383    }
384    // Fallback: read all then hash in one pass
385    let mut data = Vec::new();
386    io::stdin().lock().read_to_end(&mut data)?;
387    Ok(blake2b_hash_data(&data, output_bytes))
388}
389
390/// Print hash result in GNU format: "hash  filename\n"
391pub fn print_hash(
392    out: &mut impl Write,
393    hash: &str,
394    filename: &str,
395    binary: bool,
396) -> io::Result<()> {
397    let mode_char = if binary { '*' } else { ' ' };
398    writeln!(out, "{} {}{}", hash, mode_char, filename)
399}
400
401/// Print hash in GNU format with NUL terminator instead of newline.
402pub fn print_hash_zero(
403    out: &mut impl Write,
404    hash: &str,
405    filename: &str,
406    binary: bool,
407) -> io::Result<()> {
408    let mode_char = if binary { '*' } else { ' ' };
409    write!(out, "{} {}{}\0", hash, mode_char, filename)
410}
411
412/// Print hash result in BSD tag format: "ALGO (filename) = hash\n"
413pub fn print_hash_tag(
414    out: &mut impl Write,
415    algo: HashAlgorithm,
416    hash: &str,
417    filename: &str,
418) -> io::Result<()> {
419    writeln!(out, "{} ({}) = {}", algo.name(), filename, hash)
420}
421
422/// Print hash in BSD tag format with NUL terminator.
423pub fn print_hash_tag_zero(
424    out: &mut impl Write,
425    algo: HashAlgorithm,
426    hash: &str,
427    filename: &str,
428) -> io::Result<()> {
429    write!(out, "{} ({}) = {}\0", algo.name(), filename, hash)
430}
431
432/// Print hash in BSD tag format with BLAKE2b length info:
433/// "BLAKE2b (filename) = hash" for 512-bit, or
434/// "BLAKE2b-256 (filename) = hash" for other lengths.
435pub fn print_hash_tag_b2sum(
436    out: &mut impl Write,
437    hash: &str,
438    filename: &str,
439    bits: usize,
440) -> io::Result<()> {
441    if bits == 512 {
442        writeln!(out, "BLAKE2b ({}) = {}", filename, hash)
443    } else {
444        writeln!(out, "BLAKE2b-{} ({}) = {}", bits, filename, hash)
445    }
446}
447
448/// Print hash in BSD tag format with BLAKE2b length info and NUL terminator.
449pub fn print_hash_tag_b2sum_zero(
450    out: &mut impl Write,
451    hash: &str,
452    filename: &str,
453    bits: usize,
454) -> io::Result<()> {
455    if bits == 512 {
456        write!(out, "BLAKE2b ({}) = {}\0", filename, hash)
457    } else {
458        write!(out, "BLAKE2b-{} ({}) = {}\0", bits, filename, hash)
459    }
460}
461
462/// Options for check mode.
463pub struct CheckOptions {
464    pub quiet: bool,
465    pub status_only: bool,
466    pub strict: bool,
467    pub warn: bool,
468    pub ignore_missing: bool,
469    /// Prefix for per-line format warnings, e.g., "fmd5sum: checksums.txt".
470    /// When non-empty, warnings use GNU format: "{prefix}: {line}: message".
471    /// When empty, uses generic format: "line {line}: message".
472    pub warn_prefix: String,
473}
474
475/// Result of check mode verification.
476pub struct CheckResult {
477    pub ok: usize,
478    pub mismatches: usize,
479    pub format_errors: usize,
480    pub read_errors: usize,
481    /// Number of files skipped because they were missing and --ignore-missing was set.
482    pub ignored_missing: usize,
483}
484
485/// Verify checksums from a check file.
486/// Each line should be "hash  filename" or "hash *filename" or "ALGO (filename) = hash".
487pub fn check_file<R: BufRead>(
488    algo: HashAlgorithm,
489    reader: R,
490    opts: &CheckOptions,
491    out: &mut impl Write,
492    err_out: &mut impl Write,
493) -> io::Result<CheckResult> {
494    let quiet = opts.quiet;
495    let status_only = opts.status_only;
496    let warn = opts.warn;
497    let ignore_missing = opts.ignore_missing;
498    let mut ok_count = 0;
499    let mut mismatch_count = 0;
500    let mut format_errors = 0;
501    let mut read_errors = 0;
502    let mut ignored_missing_count = 0;
503    let mut line_num = 0;
504
505    for line_result in reader.lines() {
506        line_num += 1;
507        let line = line_result?;
508        let line = line.trim_end();
509
510        if line.is_empty() {
511            continue;
512        }
513
514        // Parse "hash  filename" or "hash *filename" or "ALGO (file) = hash"
515        let (expected_hash, filename) = match parse_check_line(line) {
516            Some(v) => v,
517            None => {
518                format_errors += 1;
519                if warn {
520                    out.flush()?;
521                    if opts.warn_prefix.is_empty() {
522                        writeln!(
523                            err_out,
524                            "line {}: improperly formatted {} checksum line",
525                            line_num,
526                            algo.name()
527                        )?;
528                    } else {
529                        writeln!(
530                            err_out,
531                            "{}: {}: improperly formatted {} checksum line",
532                            opts.warn_prefix,
533                            line_num,
534                            algo.name()
535                        )?;
536                    }
537                }
538                continue;
539            }
540        };
541
542        // Compute actual hash
543        let actual = match hash_file(algo, Path::new(filename)) {
544            Ok(h) => h,
545            Err(e) => {
546                if ignore_missing && e.kind() == io::ErrorKind::NotFound {
547                    ignored_missing_count += 1;
548                    continue;
549                }
550                read_errors += 1;
551                if !status_only {
552                    out.flush()?;
553                    writeln!(err_out, "{}: {}", filename, e)?;
554                    writeln!(out, "{}: FAILED open or read", filename)?;
555                }
556                continue;
557            }
558        };
559
560        if actual.eq_ignore_ascii_case(expected_hash) {
561            ok_count += 1;
562            if !quiet && !status_only {
563                writeln!(out, "{}: OK", filename)?;
564            }
565        } else {
566            mismatch_count += 1;
567            if !status_only {
568                writeln!(out, "{}: FAILED", filename)?;
569            }
570        }
571    }
572
573    Ok(CheckResult {
574        ok: ok_count,
575        mismatches: mismatch_count,
576        format_errors,
577        read_errors,
578        ignored_missing: ignored_missing_count,
579    })
580}
581
582/// Parse a checksum line in any supported format.
583pub fn parse_check_line(line: &str) -> Option<(&str, &str)> {
584    // Try BSD tag format: "ALGO (filename) = hash"
585    let rest = line
586        .strip_prefix("MD5 (")
587        .or_else(|| line.strip_prefix("SHA256 ("))
588        .or_else(|| line.strip_prefix("BLAKE2b ("))
589        .or_else(|| {
590            // Handle BLAKE2b-NNN (filename) = hash
591            if line.starts_with("BLAKE2b-") {
592                let after = &line["BLAKE2b-".len()..];
593                if let Some(sp) = after.find(" (") {
594                    if after[..sp].bytes().all(|b| b.is_ascii_digit()) {
595                        return Some(&after[sp + 2..]);
596                    }
597                }
598            }
599            None
600        });
601    if let Some(rest) = rest {
602        if let Some(paren_idx) = rest.find(") = ") {
603            let filename = &rest[..paren_idx];
604            let hash = &rest[paren_idx + 4..];
605            return Some((hash, filename));
606        }
607    }
608
609    // Handle backslash-escaped lines (leading '\')
610    let line = line.strip_prefix('\\').unwrap_or(line);
611
612    // Standard format: "hash  filename"
613    if let Some(idx) = line.find("  ") {
614        let hash = &line[..idx];
615        let rest = &line[idx + 2..];
616        return Some((hash, rest));
617    }
618    // Binary mode: "hash *filename"
619    if let Some(idx) = line.find(" *") {
620        let hash = &line[..idx];
621        let rest = &line[idx + 2..];
622        return Some((hash, rest));
623    }
624    None
625}
626
627/// Parse a BSD-style tag line: "ALGO (filename) = hash"
628/// Returns (expected_hash, filename, optional_bits).
629/// `bits` is the hash length parsed from the algo name (e.g., BLAKE2b-256 -> Some(256)).
630pub fn parse_check_line_tag(line: &str) -> Option<(&str, &str, Option<usize>)> {
631    let paren_start = line.find(" (")?;
632    let algo_part = &line[..paren_start];
633    let rest = &line[paren_start + 2..];
634    let paren_end = rest.find(") = ")?;
635    let filename = &rest[..paren_end];
636    let hash = &rest[paren_end + 4..];
637
638    // Parse optional bit length from algo name (e.g., "BLAKE2b-256" -> Some(256))
639    let bits = if let Some(dash_pos) = algo_part.rfind('-') {
640        algo_part[dash_pos + 1..].parse::<usize>().ok()
641    } else {
642        None
643    };
644
645    Some((hash, filename, bits))
646}
647
648/// Compile-time generated 2-byte hex pair lookup table.
649/// Each byte maps directly to its 2-char hex representation — single lookup per byte.
650const fn generate_hex_table() -> [[u8; 2]; 256] {
651    let hex = b"0123456789abcdef";
652    let mut table = [[0u8; 2]; 256];
653    let mut i = 0;
654    while i < 256 {
655        table[i] = [hex[i >> 4], hex[i & 0xf]];
656        i += 1;
657    }
658    table
659}
660
661const HEX_TABLE: [[u8; 2]; 256] = generate_hex_table();
662
663/// Fast hex encoding using 2-byte pair lookup table — one lookup per input byte.
664/// Uses String directly instead of Vec<u8> to avoid the from_utf8 conversion overhead.
665pub(crate) fn hex_encode(bytes: &[u8]) -> String {
666    let len = bytes.len() * 2;
667    let mut hex = String::with_capacity(len);
668    // SAFETY: We write exactly `len` valid ASCII hex bytes into the String's buffer.
669    unsafe {
670        let buf = hex.as_mut_vec();
671        buf.set_len(len);
672        let ptr = buf.as_mut_ptr();
673        for (i, &b) in bytes.iter().enumerate() {
674            let pair = *HEX_TABLE.get_unchecked(b as usize);
675            *ptr.add(i * 2) = pair[0];
676            *ptr.add(i * 2 + 1) = pair[1];
677        }
678    }
679    hex
680}