Skip to main content

happy_cracking/crypto/
hashcrack.rs

1use anyhow::{Context, Result};
2use clap::{Subcommand, ValueEnum};
3use md4::Md4;
4use md5::Md5;
5use rayon::prelude::*;
6use sha1::Sha1;
7use sha2::{Digest, Sha256, Sha512};
8use std::path::PathBuf;
9
10const MAX_BRUTE_SPACE: u128 = 1_000_000_000;
11
12const MAX_CHARSET_LEN: usize = 256;
13
14#[derive(Clone, Copy, ValueEnum)]
15pub enum HashAlgo {
16    Md5,
17    Sha1,
18    Sha256,
19    Sha512,
20    Md4,
21    Ntlm,
22}
23
24impl HashAlgo {
25    fn hex_len(self) -> usize {
26        match self {
27            HashAlgo::Md5 | HashAlgo::Md4 | HashAlgo::Ntlm => 32,
28            HashAlgo::Sha1 => 40,
29            HashAlgo::Sha256 => 64,
30            HashAlgo::Sha512 => 128,
31        }
32    }
33}
34
35#[derive(Clone, Copy, ValueEnum)]
36pub enum SaltPosition {
37    Prefix,
38    Suffix,
39}
40
41#[derive(Clone, Copy, ValueEnum)]
42pub enum CharsetPreset {
43    Lower,
44    Upper,
45    Digits,
46    Alnum,
47    All,
48}
49
50#[derive(Subcommand)]
51pub enum HashcrackAction {
52    #[command(about = "Dictionary attack against a target hash using a wordlist file")]
53    Dict {
54        #[arg(help = "Target hash (case-insensitive hex)")]
55        hash: String,
56        #[arg(short, long, help = "Wordlist file, one candidate per line")]
57        wordlist: PathBuf,
58        #[arg(
59            short,
60            long,
61            value_enum,
62            help = "Hash algorithm (auto-detect if omitted)"
63        )]
64        algo: Option<HashAlgo>,
65        #[arg(short, long, help = "Salt to combine with each candidate")]
66        salt: Option<String>,
67        #[arg(
68            long,
69            value_enum,
70            default_value = "suffix",
71            help = "Where to place the salt relative to the candidate"
72        )]
73        salt_position: SaltPosition,
74    },
75    #[command(about = "Incremental brute-force attack against a target hash")]
76    Brute {
77        #[arg(help = "Target hash (case-insensitive hex)")]
78        hash: String,
79        #[arg(short, long, value_enum, help = "Hash algorithm")]
80        algo: HashAlgo,
81        #[arg(
82            short,
83            long,
84            default_value = "abcdefghijklmnopqrstuvwxyz0123456789",
85            help = "Characters to try (ignored when --preset is given)"
86        )]
87        charset: String,
88        #[arg(
89            short,
90            long,
91            value_enum,
92            help = "Predefined charset overriding --charset"
93        )]
94        preset: Option<CharsetPreset>,
95        #[arg(long, default_value = "1", help = "Minimum candidate length")]
96        min_len: usize,
97        #[arg(long, default_value = "4", help = "Maximum candidate length")]
98        max_len: usize,
99        #[arg(short, long, help = "Salt to combine with each candidate")]
100        salt: Option<String>,
101        #[arg(
102            long,
103            value_enum,
104            default_value = "suffix",
105            help = "Where to place the salt relative to the candidate"
106        )]
107        salt_position: SaltPosition,
108    },
109    #[command(about = "Reverse-lookup a hash in a precomputed table file")]
110    Lookup {
111        #[arg(help = "Target hash (case-insensitive hex)")]
112        hash: String,
113        #[arg(short, long, help = "Table file with `hash<sep>plaintext` lines")]
114        table: PathBuf,
115    },
116    #[command(about = "Dictionary attack with hashcat-style rules applied to each word")]
117    Rule {
118        #[arg(help = "Target hash (case-insensitive hex)")]
119        hash: String,
120        #[arg(short, long, help = "Wordlist file")]
121        wordlist: PathBuf,
122        #[arg(
123            short,
124            long,
125            help = "Rules file (one rule per line); if omitted, built-in rules are used"
126        )]
127        rules: Option<PathBuf>,
128        #[arg(
129            short,
130            long,
131            value_enum,
132            help = "Hash algorithm (auto-detect if omitted)"
133        )]
134        algo: Option<HashAlgo>,
135    },
136    #[command(about = "Mask attack (?l ?u ?d ?s ?a and literals, hashcat-style)")]
137    Mask {
138        #[arg(help = "Target hash (case-insensitive hex)")]
139        hash: String,
140        #[arg(short, long, help = "Mask, e.g. '?l?l?l?d?d' or 'flag{?d?d?d}'")]
141        mask: String,
142        #[arg(short, long, value_enum, help = "Hash algorithm")]
143        algo: HashAlgo,
144    },
145    #[command(about = "Hybrid attack: wordlist candidates with numeric suffixes")]
146    Hybrid {
147        #[arg(help = "Target hash (case-insensitive hex)")]
148        hash: String,
149        #[arg(short, long, help = "Wordlist file")]
150        wordlist: PathBuf,
151        #[arg(
152            short,
153            long,
154            value_enum,
155            help = "Hash algorithm (auto-detect if omitted)"
156        )]
157        algo: Option<HashAlgo>,
158        #[arg(long, default_value = "0", help = "Minimum suffix digits (inclusive)")]
159        min_digits: u32,
160        #[arg(long, default_value = "2", help = "Maximum suffix digits (inclusive)")]
161        max_digits: u32,
162        #[arg(long, help = "Also try prefixes instead of only suffixes")]
163        also_prefix: bool,
164    },
165}
166
167pub fn run(action: HashcrackAction) -> Result<()> {
168    match action {
169        HashcrackAction::Dict {
170            hash,
171            wordlist,
172            algo,
173            salt,
174            salt_position,
175        } => run_dict(&hash, &wordlist, algo, salt.as_deref(), salt_position),
176        HashcrackAction::Brute {
177            hash,
178            algo,
179            charset,
180            preset,
181            min_len,
182            max_len,
183            salt,
184            salt_position,
185        } => {
186            let charset = match preset {
187                Some(p) => preset_charset(p).to_string(),
188                None => charset,
189            };
190            run_brute(
191                &hash,
192                algo,
193                &charset,
194                min_len,
195                max_len,
196                salt.as_deref(),
197                salt_position,
198            )
199        }
200        HashcrackAction::Lookup { hash, table } => run_lookup(&hash, &table),
201        HashcrackAction::Rule {
202            hash,
203            wordlist,
204            rules,
205            algo,
206        } => run_rule(&hash, &wordlist, rules.as_ref(), algo),
207        HashcrackAction::Mask { hash, mask, algo } => run_mask(&hash, &mask, algo),
208        HashcrackAction::Hybrid {
209            hash,
210            wordlist,
211            algo,
212            min_digits,
213            max_digits,
214            also_prefix,
215        } => run_hybrid(&hash, &wordlist, algo, min_digits, max_digits, also_prefix),
216    }
217}
218
219fn run_dict(
220    hash: &str,
221    wordlist: &PathBuf,
222    algo: Option<HashAlgo>,
223    salt: Option<&str>,
224    pos: SaltPosition,
225) -> Result<()> {
226    let target = normalize_hash(hash);
227    let candidates = read_wordlist(wordlist)?;
228
229    let algos = match algo {
230        Some(a) => vec![a],
231        None => algos_for_hex_len(target.len()),
232    };
233    if algos.is_empty() {
234        anyhow::bail!(
235            "Could not auto-detect an algorithm for a {}-character hash; pass --algo explicitly",
236            target.len()
237        );
238    }
239
240    for a in algos {
241        if let Some(found) = find_in_candidates(&target, a, salt, pos, &candidates) {
242            println!("Found: {}", found);
243            return Ok(());
244        }
245    }
246    println!("Not found");
247    Ok(())
248}
249
250fn run_brute(
251    hash: &str,
252    algo: HashAlgo,
253    charset: &str,
254    min_len: usize,
255    max_len: usize,
256    salt: Option<&str>,
257    pos: SaltPosition,
258) -> Result<()> {
259    let target = normalize_hash(hash);
260    match brute_force(&target, algo, charset, min_len, max_len, salt, pos)? {
261        Some(found) => println!("Found: {}", found),
262        None => println!("Not found"),
263    }
264    Ok(())
265}
266
267fn run_lookup(hash: &str, table: &PathBuf) -> Result<()> {
268    let target = normalize_hash(hash);
269    match lookup_in_table_file(&target, table)? {
270        Some(plain) => println!("Found: {}", plain),
271        None => println!("Not found"),
272    }
273    Ok(())
274}
275
276pub fn compute_hash(algo: HashAlgo, input: &str) -> String {
277    match algo {
278        HashAlgo::Md5 => {
279            let mut hasher = Md5::new();
280            hasher.update(input.as_bytes());
281            hex::encode(hasher.finalize())
282        }
283        HashAlgo::Sha1 => {
284            let mut hasher = Sha1::new();
285            hasher.update(input.as_bytes());
286            hex::encode(hasher.finalize())
287        }
288        HashAlgo::Sha256 => {
289            let mut hasher = Sha256::new();
290            hasher.update(input.as_bytes());
291            hex::encode(hasher.finalize())
292        }
293        HashAlgo::Sha512 => {
294            let mut hasher = Sha512::new();
295            hasher.update(input.as_bytes());
296            hex::encode(hasher.finalize())
297        }
298        HashAlgo::Md4 => {
299            let mut hasher = Md4::new();
300            hasher.update(input.as_bytes());
301            hex::encode(hasher.finalize())
302        }
303        HashAlgo::Ntlm => {
304            let mut hasher = Md4::new();
305            for unit in input.encode_utf16() {
306                hasher.update(unit.to_le_bytes());
307            }
308            hex::encode(hasher.finalize())
309        }
310    }
311}
312
313fn apply_salt(word: &str, salt: Option<&str>, pos: SaltPosition) -> String {
314    match salt {
315        None => word.to_string(),
316        Some(s) => match pos {
317            SaltPosition::Prefix => format!("{}{}", s, word),
318            SaltPosition::Suffix => format!("{}{}", word, s),
319        },
320    }
321}
322
323fn normalize_hash(hash: &str) -> String {
324    hash.trim().to_ascii_lowercase()
325}
326
327fn algos_for_hex_len(hex_len: usize) -> Vec<HashAlgo> {
328    [
329        HashAlgo::Md5,
330        HashAlgo::Md4,
331        HashAlgo::Ntlm,
332        HashAlgo::Sha1,
333        HashAlgo::Sha256,
334        HashAlgo::Sha512,
335    ]
336    .into_iter()
337    .filter(|a| a.hex_len() == hex_len)
338    .collect()
339}
340
341fn preset_charset(preset: CharsetPreset) -> &'static str {
342    match preset {
343        CharsetPreset::Lower => "abcdefghijklmnopqrstuvwxyz",
344        CharsetPreset::Upper => "ABCDEFGHIJKLMNOPQRSTUVWXYZ",
345        CharsetPreset::Digits => "0123456789",
346        CharsetPreset::Alnum => "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
347        CharsetPreset::All => {
348            "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~"
349        }
350    }
351}
352
353fn read_wordlist(path: &PathBuf) -> Result<Vec<String>> {
354    let bytes = std::fs::read(path)
355        .with_context(|| format!("Failed to read wordlist: {}", path.display()))?;
356    Ok(bytes
357        .split(|&b| b == b'\n')
358        .map(|line| {
359            let line = line.strip_suffix(b"\r").unwrap_or(line);
360            String::from_utf8_lossy(line).into_owned()
361        })
362        .filter(|line| !line.is_empty())
363        .collect())
364}
365
366pub fn find_in_candidates(
367    target: &str,
368    algo: HashAlgo,
369    salt: Option<&str>,
370    pos: SaltPosition,
371    candidates: &[String],
372) -> Option<String> {
373    let target = normalize_hash(target);
374    candidates.par_iter().find_map_any(|word| {
375        let salted = apply_salt(word, salt, pos);
376        if compute_hash(algo, &salted) == target {
377            Some(word.clone())
378        } else {
379            None
380        }
381    })
382}
383
384pub fn brute_force(
385    target: &str,
386    algo: HashAlgo,
387    charset: &str,
388    min_len: usize,
389    max_len: usize,
390    salt: Option<&str>,
391    pos: SaltPosition,
392) -> Result<Option<String>> {
393    let target = normalize_hash(target);
394    let chars: Vec<char> = charset.chars().collect();
395    if chars.is_empty() {
396        anyhow::bail!("Charset must not be empty");
397    }
398    if chars.len() > MAX_CHARSET_LEN {
399        anyhow::bail!(
400            "Charset too large ({} characters); maximum is {}",
401            chars.len(),
402            MAX_CHARSET_LEN
403        );
404    }
405    if min_len == 0 {
406        anyhow::bail!("--min-len must be at least 1");
407    }
408    if max_len < min_len {
409        anyhow::bail!("--max-len ({}) must be >= --min-len ({})", max_len, min_len);
410    }
411
412    let base = chars.len() as u128;
413    let mut total: u128 = 0;
414    for len in min_len..=max_len {
415        let count = base
416            .checked_pow(len as u32)
417            .context("Brute-force search space overflowed")?;
418        total = total
419            .checked_add(count)
420            .context("Brute-force search space overflowed")?;
421        if total > MAX_BRUTE_SPACE {
422            anyhow::bail!(
423                "Search space exceeds the limit of {} candidates; narrow the charset or length range",
424                MAX_BRUTE_SPACE
425            );
426        }
427    }
428
429    for len in min_len..=max_len {
430        let count = base.pow(len as u32);
431        if let Some(found) = (0..count).into_par_iter().find_map_any(|index| {
432            let word = index_to_word(index, &chars, len);
433            let salted = apply_salt(&word, salt, pos);
434            if compute_hash(algo, &salted) == target {
435                Some(word)
436            } else {
437                None
438            }
439        }) {
440            return Ok(Some(found));
441        }
442    }
443    Ok(None)
444}
445
446fn index_to_word(index: u128, chars: &[char], len: usize) -> String {
447    let base = chars.len() as u128;
448    let mut digits = vec![chars[0]; len];
449    let mut remaining = index;
450    for slot in digits.iter_mut().rev() {
451        *slot = chars[(remaining % base) as usize];
452        remaining /= base;
453    }
454    digits.into_iter().collect()
455}
456
457pub fn lookup_in_pairs<'a, I, S>(target: &str, pairs: I) -> Option<String>
458where
459    I: IntoIterator<Item = (S, S)>,
460    S: AsRef<str> + 'a,
461{
462    let target = normalize_hash(target);
463    for (hash, plain) in pairs {
464        if normalize_hash(hash.as_ref()) == target {
465            return Some(plain.as_ref().to_string());
466        }
467    }
468    None
469}
470
471pub fn parse_table_line(line: &str) -> Option<(String, String)> {
472    let line = line.trim();
473    if line.is_empty() {
474        return None;
475    }
476    let (hash, plain) = if let Some((h, p)) = line.split_once(':') {
477        (h, p)
478    } else {
479        line.split_once(char::is_whitespace)?
480    };
481    let hash = hash.trim();
482    let plain = plain.trim();
483    if hash.is_empty() {
484        return None;
485    }
486    Some((hash.to_string(), plain.to_string()))
487}
488
489fn lookup_in_table_file(target: &str, path: &PathBuf) -> Result<Option<String>> {
490    use std::io::BufRead;
491    let file = std::fs::File::open(path)
492        .with_context(|| format!("Failed to open table: {}", path.display()))?;
493    let target = normalize_hash(target);
494    let reader = std::io::BufReader::new(file);
495    for line in reader.lines() {
496        let line = line.context("Failed to read table line")?;
497        if let Some((hash, plain)) = parse_table_line(&line)
498            && normalize_hash(&hash) == target
499        {
500            return Ok(Some(plain));
501        }
502    }
503    Ok(None)
504}
505
506const BUILTIN_RULES: &[&str] = &[
507    ":", // identity
508    "l", // lowercase
509    "u", // uppercase
510    "c", // capitalize
511    "r", // reverse
512    "d", // duplicate
513    "$1",
514    "$2",
515    "$!",
516    "$1$2$3",
517    "^!",
518    "c$1",
519    "c$!",
520    "u$1",
521    "l$1",
522    "l$2$0$2$4",
523];
524
525fn run_rule(
526    hash: &str,
527    wordlist: &PathBuf,
528    rules_path: Option<&PathBuf>,
529    algo: Option<HashAlgo>,
530) -> Result<()> {
531    let target = normalize_hash(hash);
532    let words = read_wordlist(wordlist)?;
533    let rules: Vec<String> = if let Some(path) = rules_path {
534        read_wordlist(path)?
535    } else {
536        BUILTIN_RULES.iter().map(|s| (*s).to_string()).collect()
537    };
538    let algos = match algo {
539        Some(a) => vec![a],
540        None => algos_for_hex_len(target.len()),
541    };
542    if algos.is_empty() {
543        anyhow::bail!(
544            "Could not auto-detect an algorithm for a {}-character hash; pass --algo explicitly",
545            target.len()
546        );
547    }
548
549    for a in algos {
550        if let Some(found) = rule_attack(&target, a, &words, &rules) {
551            println!("Found: {}", found);
552            return Ok(());
553        }
554    }
555    println!("Not found");
556    Ok(())
557}
558
559fn run_mask(hash: &str, mask: &str, algo: HashAlgo) -> Result<()> {
560    let target = normalize_hash(hash);
561    match mask_attack(&target, algo, mask)? {
562        Some(found) => println!("Found: {}", found),
563        None => println!("Not found"),
564    }
565    Ok(())
566}
567
568fn run_hybrid(
569    hash: &str,
570    wordlist: &PathBuf,
571    algo: Option<HashAlgo>,
572    min_digits: u32,
573    max_digits: u32,
574    also_prefix: bool,
575) -> Result<()> {
576    let target = normalize_hash(hash);
577    let words = read_wordlist(wordlist)?;
578    let algos = match algo {
579        Some(a) => vec![a],
580        None => algos_for_hex_len(target.len()),
581    };
582    if algos.is_empty() {
583        anyhow::bail!(
584            "Could not auto-detect an algorithm for a {}-character hash; pass --algo explicitly",
585            target.len()
586        );
587    }
588    for a in algos {
589        if let Some(found) = hybrid_attack(&target, a, &words, min_digits, max_digits, also_prefix)?
590        {
591            println!("Found: {}", found);
592            return Ok(());
593        }
594    }
595    println!("Not found");
596    Ok(())
597}
598
599/// Apply a minimal hashcat-like rule to a word.
600/// Supported: `:` identity, `l` lower, `u` upper, `c` capitalize, `t` toggle,
601/// `r` reverse, `d` duplicate, `$X` append, `^X` prepend.
602pub fn apply_rule(word: &str, rule: &str) -> String {
603    let mut out = word.to_string();
604    let chars: Vec<char> = rule.chars().collect();
605    let mut i = 0usize;
606    while i < chars.len() {
607        match chars[i] {
608            ':' => {}
609            'l' => out = out.to_ascii_lowercase(),
610            'u' => out = out.to_ascii_uppercase(),
611            'c' => {
612                let mut cs: Vec<char> = out.chars().collect();
613                if let Some(first) = cs.first_mut() {
614                    *first = first.to_ascii_uppercase();
615                }
616                for ch in cs.iter_mut().skip(1) {
617                    *ch = ch.to_ascii_lowercase();
618                }
619                out = cs.into_iter().collect();
620            }
621            't' => {
622                out = out
623                    .chars()
624                    .map(|c| {
625                        if c.is_ascii_uppercase() {
626                            c.to_ascii_lowercase()
627                        } else {
628                            c.to_ascii_uppercase()
629                        }
630                    })
631                    .collect();
632            }
633            'r' => out = out.chars().rev().collect(),
634            'd' => out = format!("{}{}", out, out),
635            '$' => {
636                i += 1;
637                if i < chars.len() {
638                    out.push(chars[i]);
639                }
640            }
641            '^' => {
642                i += 1;
643                if i < chars.len() {
644                    out.insert(0, chars[i]);
645                }
646            }
647            _ => {
648                // Unknown rule atom: ignore
649            }
650        }
651        i += 1;
652    }
653    out
654}
655
656pub fn rule_attack(
657    target: &str,
658    algo: HashAlgo,
659    words: &[String],
660    rules: &[String],
661) -> Option<String> {
662    let target = normalize_hash(target);
663    let rules: Vec<&str> = rules.iter().map(|s| s.as_str()).collect();
664    words.par_iter().find_map_any(|word| {
665        for rule in &rules {
666            let candidate = apply_rule(word, rule);
667            if compute_hash(algo, &candidate) == target {
668                return Some(candidate);
669            }
670        }
671        None
672    })
673}
674
675/// Expand a mask into character class choices per position.
676/// `?l` lower, `?u` upper, `?d` digits, `?s` special, `?a` all printable ascii,
677/// `?h` hex lower, `??` literal `?`, otherwise literal char.
678pub fn expand_mask(mask: &str) -> Result<Vec<Vec<char>>> {
679    let chars: Vec<char> = mask.chars().collect();
680    let mut positions: Vec<Vec<char>> = Vec::new();
681    let mut i = 0usize;
682    while i < chars.len() {
683        if chars[i] == '?' {
684            i += 1;
685            if i >= chars.len() {
686                anyhow::bail!("Mask ends with dangling '?'");
687            }
688            let class = match chars[i] {
689                'l' => "abcdefghijklmnopqrstuvwxyz".chars().collect(),
690                'u' => "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(),
691                'd' => "0123456789".chars().collect(),
692                's' => " !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~".chars().collect(),
693                'a' => (0x20u8..=0x7eu8).map(|b| b as char).collect(),
694                'h' => "0123456789abcdef".chars().collect(),
695                '?' => vec!['?'],
696                other => anyhow::bail!("Unknown mask class '?{}'", other),
697            };
698            positions.push(class);
699        } else {
700            positions.push(vec![chars[i]]);
701        }
702        i += 1;
703    }
704    if positions.is_empty() {
705        anyhow::bail!("Mask is empty");
706    }
707    Ok(positions)
708}
709
710pub fn mask_attack(target: &str, algo: HashAlgo, mask: &str) -> Result<Option<String>> {
711    let target = normalize_hash(target);
712    let positions = expand_mask(mask)?;
713    let mut total: u128 = 1;
714    for pos in &positions {
715        total = total
716            .checked_mul(pos.len() as u128)
717            .context("Mask search space overflowed")?;
718        if total > MAX_BRUTE_SPACE {
719            anyhow::bail!(
720                "Mask search space exceeds the limit of {} candidates",
721                MAX_BRUTE_SPACE
722            );
723        }
724    }
725
726    let bases: Vec<u128> = positions.iter().map(|p| p.len() as u128).collect();
727    let found = (0..total).into_par_iter().find_map_any(|index| {
728        let mut rem = index;
729        let mut word = String::with_capacity(positions.len());
730        // Most-significant position first
731        let mut digits = vec![0u128; positions.len()];
732        for i in (0..positions.len()).rev() {
733            digits[i] = rem % bases[i];
734            rem /= bases[i];
735        }
736        for (i, d) in digits.iter().enumerate() {
737            word.push(positions[i][*d as usize]);
738        }
739        if compute_hash(algo, &word) == target {
740            Some(word)
741        } else {
742            None
743        }
744    });
745    Ok(found)
746}
747
748pub fn hybrid_attack(
749    target: &str,
750    algo: HashAlgo,
751    words: &[String],
752    min_digits: u32,
753    max_digits: u32,
754    also_prefix: bool,
755) -> Result<Option<String>> {
756    if max_digits > 6 {
757        anyhow::bail!("--max-digits must be <= 6 (got {})", max_digits);
758    }
759    if min_digits > max_digits {
760        anyhow::bail!("--min-digits must be <= --max-digits");
761    }
762    let target = normalize_hash(target);
763
764    // Estimate space
765    let mut total: u128 = 0;
766    for d in min_digits..=max_digits {
767        let count = 10u128.pow(d);
768        let variants = if also_prefix { 2u128 } else { 1u128 };
769        // include bare word once when d range covers 0
770        total = total
771            .checked_add(
772                (words.len() as u128)
773                    .checked_mul(count)
774                    .and_then(|v| v.checked_mul(variants))
775                    .context("Hybrid search space overflowed")?,
776            )
777            .context("Hybrid search space overflowed")?;
778        if total > MAX_BRUTE_SPACE {
779            anyhow::bail!(
780                "Hybrid search space exceeds the limit of {} candidates",
781                MAX_BRUTE_SPACE
782            );
783        }
784    }
785
786    let found = words.par_iter().find_map_any(|word| {
787        for digits in min_digits..=max_digits {
788            let max_n = 10u32.pow(digits);
789            for n in 0..max_n {
790                let suffix = if digits == 0 {
791                    String::new()
792                } else {
793                    format!("{:0width$}", n, width = digits as usize)
794                };
795                let candidate = format!("{}{}", word, suffix);
796                if compute_hash(algo, &candidate) == target {
797                    return Some(candidate);
798                }
799                if also_prefix && digits > 0 {
800                    let candidate = format!("{}{}", suffix, word);
801                    if compute_hash(algo, &candidate) == target {
802                        return Some(candidate);
803                    }
804                }
805            }
806        }
807        None
808    });
809    Ok(found)
810}
811
812/// Built-in mutations used by rule mode (exported for tests).
813pub fn builtin_rules() -> Vec<&'static str> {
814    BUILTIN_RULES.to_vec()
815}