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 ":", "l", "u", "c", "r", "d", "$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
599pub 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 }
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
675pub 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 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 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 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
812pub fn builtin_rules() -> Vec<&'static str> {
814 BUILTIN_RULES.to_vec()
815}