1use std::fs::File;
8use std::path::PathBuf;
9use std::time::Duration;
10
11use chrono::Utc;
12use clap::ValueEnum;
13use digest::Digest;
14use serde_json::{json, Map, Value};
15
16use super::{
17 AuditCase, AuditError, AuditMode, AuditRunMetadata, AuditSeverity,
18};
19use crate::rgh::analyze::{compare_hashes, HashAnalyzer};
20use crate::rgh::app::Algorithm;
21use crate::rgh::benchmark::run_digest_benchmarks;
22use crate::rgh::file::{
23 DirectoryHashPlan, EntryStatus, ErrorHandlingProfile,
24 ErrorStrategy, ProgressConfig, ProgressMode, SymlinkPolicy,
25 ThreadStrategy, WalkOrder,
26};
27use crate::rgh::hash::{
28 asm_accelerated_digests, digest_bytes_to_record,
29 digest_with_options_collect, serialize_digest_output,
30 Argon2Config, BalloonConfig, BcryptConfig, FileDigestOptions,
31 PHash, Pbkdf2Config, ScryptConfig,
32};
33use crate::rgh::kdf::{
34 hkdf::{
35 self, HkdfInput, HkdfMode, HkdfRequest, HkdfVariant,
36 EXPAND_ONLY_PRK_HINT, HKDF_VARIANTS,
37 },
38 profile, SecretMaterial,
39};
40use crate::rgh::mac::executor as mac_executor;
41use crate::rgh::mac::{
42 commands::legacy_warning_message,
43 key::{load_key as load_mac_key, KeySource as MacKeySource},
44 poly1305::Poly1305ReuseTracker,
45 registry::{
46 self as mac_registry, MacAlgorithmMetadata, MacExecutor,
47 },
48};
49use crate::rgh::output::{DigestOutputFormat, DigestSource};
50use crate::rgh::weak::warning_for;
51use base64::{
52 engine::general_purpose::{STANDARD, STANDARD_NO_PAD},
53 Engine,
54};
55
56#[derive(Debug, Clone, Copy, PartialEq, Eq)]
57pub enum AuditStatus {
58 Pass,
59 Fail,
60 Skipped,
61}
62
63#[derive(Debug, Clone)]
64pub struct AuditOutcome {
65 pub case: AuditCase,
66 pub actual_output: Value,
67 pub status: AuditStatus,
68 pub message: Option<String>,
69}
70
71impl AuditOutcome {
72 pub fn skipped(case: AuditCase) -> Self {
73 AuditOutcome {
74 message: case.metadata.skip_reason.clone(),
75 case,
76 status: AuditStatus::Skipped,
77 actual_output: Value::Null,
78 }
79 }
80
81 pub fn with_result(
82 case: AuditCase,
83 actual_output: Value,
84 message: Option<String>,
85 status: AuditStatus,
86 ) -> Self {
87 AuditOutcome {
88 case,
89 actual_output,
90 status,
91 message,
92 }
93 }
94}
95
96pub fn execute_case(
97 case: AuditCase,
98) -> Result<AuditOutcome, AuditError> {
99 if case.is_skipped() {
100 return Ok(AuditOutcome::skipped(case));
101 }
102 let expected = case.expected_output.clone();
103 let actual = match case.mode {
104 AuditMode::String => run_string_case(&case)?,
105 AuditMode::File => run_file_case(&case)?,
106 AuditMode::Stdio => run_stdio_case(&case)?,
107 AuditMode::DigestString => run_digest_string_case(&case)?,
108 AuditMode::DigestFile => run_digest_file_case(&case)?,
109 AuditMode::DigestStdio => run_digest_stdio_case(&case)?,
110 AuditMode::MacString => run_mac_string_case(&case)?,
111 AuditMode::MacFile => run_mac_file_case(&case)?,
112 AuditMode::MacStdio => run_mac_stdio_case(&case)?,
113 AuditMode::Kdf => run_kdf_case(&case)?,
114 AuditMode::Analyze => run_analyze_case(&case)?,
115 AuditMode::Compare => run_compare_case(&case)?,
116 AuditMode::Benchmark => run_benchmark_case(&case)?,
117 AuditMode::Header
118 | AuditMode::Random
119 | AuditMode::Interactive => {
120 return Ok(AuditOutcome::skipped(case));
121 }
122 };
123
124 let status = if actual == expected {
125 AuditStatus::Pass
126 } else {
127 AuditStatus::Fail
128 };
129
130 let message = match status {
131 AuditStatus::Pass => None,
132 AuditStatus::Fail => {
133 Some(format!("Expected {}, got {}", expected, actual))
134 }
135 AuditStatus::Skipped => None,
136 };
137
138 Ok(AuditOutcome::with_result(case, actual, message, status))
139}
140
141pub fn execute_cases(
142 cases: Vec<AuditCase>,
143) -> Result<Vec<AuditOutcome>, AuditError> {
144 let mut outcomes = Vec::with_capacity(cases.len());
145 for case in cases {
146 let outcome = execute_case(case)?;
147 outcomes.push(outcome);
148 }
149 Ok(outcomes)
150}
151
152pub fn compute_run_metadata(
153 results: &[AuditOutcome],
154) -> AuditRunMetadata {
155 let total = results.len();
156 let passed = results
157 .iter()
158 .filter(|outcome| outcome.status == AuditStatus::Pass)
159 .count();
160 let failed = results
161 .iter()
162 .filter(|outcome| outcome.status == AuditStatus::Fail)
163 .count();
164 let skipped = total.saturating_sub(passed + failed);
165 AuditRunMetadata {
166 run_id: Utc::now(),
167 total,
168 passed,
169 failed,
170 skipped,
171 }
172}
173
174pub fn highest_severity(
175 results: &[AuditOutcome],
176) -> Option<AuditSeverity> {
177 results
178 .iter()
179 .filter(|outcome| outcome.status == AuditStatus::Fail)
180 .filter_map(|outcome| outcome.case.metadata.severity.clone())
181 .max_by_key(|severity| match severity {
182 AuditSeverity::Critical => 3,
183 AuditSeverity::High => 2,
184 AuditSeverity::Medium => 1,
185 AuditSeverity::Low => 0,
186 })
187}
188
189fn run_string_case(case: &AuditCase) -> Result<Value, AuditError> {
190 let value =
191 case.input.get("value").and_then(Value::as_str).ok_or_else(
192 || {
193 AuditError::Invalid(format!(
194 "Fixture `{}` missing input.value",
195 case.id
196 ))
197 },
198 )?;
199 let algorithm = case.algorithm.to_uppercase();
200 let default_format = case
201 .expected_output
202 .get("format")
203 .and_then(Value::as_str)
204 .map(|s| s.to_lowercase());
205
206 let (tokens, digest_repr, format_value) = match algorithm.as_str()
207 {
208 "ARGON2" => {
209 let salt = case
210 .input
211 .get("salt")
212 .and_then(Value::as_str)
213 .ok_or_else(|| {
214 AuditError::Invalid(format!(
215 "Fixture `{}` missing input.salt for Argon2",
216 case.id
217 ))
218 })?;
219 let hash = PHash::hash_argon2_with_salt(
220 value,
221 &Argon2Config::default(),
222 salt,
223 )
224 .map_err(|err| {
225 AuditError::Invalid(format!(
226 "Argon2 derivation failed for fixture `{}`: {}",
227 case.id, err
228 ))
229 })?;
230 let format =
231 default_format.unwrap_or_else(|| "encoded".into());
232 (vec![hash.clone()], hash, format)
233 }
234 "SCRYPT" => {
235 let salt = case
236 .input
237 .get("salt")
238 .and_then(Value::as_str)
239 .ok_or_else(|| {
240 AuditError::Invalid(format!(
241 "Fixture `{}` missing input.salt for Scrypt",
242 case.id
243 ))
244 })?;
245 let hash = PHash::hash_scrypt_with_salt(
246 value,
247 &ScryptConfig::default(),
248 salt,
249 )
250 .map_err(|err| {
251 AuditError::Invalid(format!(
252 "Scrypt derivation failed for fixture `{}`: {}",
253 case.id, err
254 ))
255 })?;
256 let format =
257 default_format.unwrap_or_else(|| "encoded".into());
258 (vec![hash.clone()], hash, format)
259 }
260 "BCRYPT" => {
261 let salt = case
262 .input
263 .get("salt")
264 .and_then(Value::as_str)
265 .ok_or_else(|| {
266 AuditError::Invalid(format!(
267 "Fixture `{}` missing input.salt for Bcrypt",
268 case.id
269 ))
270 })?;
271 let hash = PHash::hash_bcrypt_with_salt(
272 value,
273 &BcryptConfig::default(),
274 salt,
275 )
276 .map_err(|err| {
277 AuditError::Invalid(format!(
278 "Bcrypt derivation failed for fixture `{}`: {}",
279 case.id, err
280 ))
281 })?;
282 let format =
283 default_format.unwrap_or_else(|| "hex".into());
284 (vec![hash.clone()], hash, format)
285 }
286 "BALLOON" => {
287 let salt = case
288 .input
289 .get("salt")
290 .and_then(Value::as_str)
291 .ok_or_else(|| {
292 AuditError::Invalid(format!(
293 "Fixture `{}` missing input.salt for Balloon",
294 case.id
295 ))
296 })?;
297 let hash = PHash::hash_balloon_with_salt(
298 value,
299 &BalloonConfig::default(),
300 salt,
301 )
302 .map_err(|err| {
303 AuditError::Invalid(format!(
304 "Balloon derivation failed for fixture `{}`: {}",
305 case.id, err
306 ))
307 })?;
308 let format =
309 default_format.unwrap_or_else(|| "encoded".into());
310 (vec![hash.clone()], hash, format)
311 }
312 _ => {
313 let digest_bytes = compute_digest_bytes(
314 &case.algorithm,
315 value.as_bytes(),
316 )?;
317 let digest_hex = hex::encode(&digest_bytes);
318 let format =
319 default_format.unwrap_or_else(|| "hex".into());
320 let tokens = match format.as_str() {
321 "base64" => vec![STANDARD.encode(&digest_bytes)],
322 "hexbase64" => vec![
323 hex::encode(&digest_bytes),
324 STANDARD.encode(&digest_bytes),
325 ],
326 _ => vec![digest_hex.clone()],
327 };
328 let digest_repr =
329 tokens.first().cloned().unwrap_or_default();
330 (tokens, digest_repr, format)
331 }
332 };
333
334 let hash_only_line = tokens.join(" ");
335 let mut default_tokens = tokens.clone();
336 default_tokens.push(value.to_string());
337 let default_line = default_tokens.join(" ");
338
339 Ok(json!({
340 "digest": digest_repr,
341 "format": format_value,
342 "default_line": default_line,
343 "hash_only_line": hash_only_line
344 }))
345}
346
347fn run_file_case(case: &AuditCase) -> Result<Value, AuditError> {
348 let path_str =
349 case.input.get("path").and_then(Value::as_str).ok_or_else(
350 || {
351 AuditError::Invalid(format!(
352 "Fixture `{}` missing input.path",
353 case.id
354 ))
355 },
356 )?;
357 let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
358 let path = manifest.join(path_str);
359 let data =
360 std::fs::read(&path).map_err(|source| AuditError::Io {
361 source,
362 path: path.clone(),
363 })?;
364 let digest = compute_digest(&case.algorithm, &data)?;
365 Ok(json!({
366 "digest": digest,
367 "format": case
368 .expected_output
369 .get("format")
370 .and_then(Value::as_str)
371 .unwrap_or("hex")
372 }))
373}
374
375fn run_stdio_case(case: &AuditCase) -> Result<Value, AuditError> {
376 let lines = case
377 .input
378 .get("lines")
379 .and_then(Value::as_array)
380 .ok_or_else(|| {
381 AuditError::Invalid(format!(
382 "Fixture `{}` missing input.lines",
383 case.id
384 ))
385 })?;
386 let mut digests = Vec::with_capacity(lines.len());
387 for entry in lines {
388 let value = entry.as_str().ok_or_else(|| {
389 AuditError::Invalid(format!(
390 "Fixture `{}` expected string entries in input.lines",
391 case.id
392 ))
393 })?;
394 let digest_bytes =
395 compute_digest_bytes(&case.algorithm, value.as_bytes())?;
396 let digest_hex = hex::encode(&digest_bytes);
397 let hash_only_line = digest_hex.clone();
398 let default_line = format!("{} {}", digest_hex, value);
399 digests.push(json!({
400 "source": value,
401 "digest": digest_hex,
402 "default_line": default_line,
403 "hash_only_line": hash_only_line
404 }));
405 }
406 Ok(json!({ "digests": digests }))
407}
408
409fn parse_output_format(value: Option<&str>) -> DigestOutputFormat {
410 match value.unwrap_or("hex").to_ascii_lowercase().as_str() {
411 "json" => DigestOutputFormat::Json,
412 "jsonl" => DigestOutputFormat::JsonLines,
413 "csv" => DigestOutputFormat::Csv,
414 "base64" => DigestOutputFormat::Base64,
415 "hashcat" => DigestOutputFormat::Hashcat,
416 "multihash" => DigestOutputFormat::Multihash,
417 _ => DigestOutputFormat::Hex,
418 }
419}
420
421fn parse_symlink_policy(
422 value: &str,
423 case_id: &str,
424) -> Result<SymlinkPolicy, AuditError> {
425 match value.to_ascii_lowercase().as_str() {
426 "never" => Ok(SymlinkPolicy::Never),
427 "files" => Ok(SymlinkPolicy::Files),
428 "all" => Ok(SymlinkPolicy::All),
429 other => Err(AuditError::Invalid(format!(
430 "Fixture `{}` has invalid follow_symlinks option '{}'.",
431 case_id, other
432 ))),
433 }
434}
435
436fn parse_thread_strategy(
437 value: &str,
438 case_id: &str,
439) -> Result<ThreadStrategy, AuditError> {
440 let trimmed = value.trim();
441 if trimmed.eq_ignore_ascii_case("auto") {
442 return Ok(ThreadStrategy::Auto);
443 }
444 let count: u16 = trimmed.parse().map_err(|_| {
445 AuditError::Invalid(format!(
446 "Fixture `{}` has invalid thread count '{}'.",
447 case_id, trimmed
448 ))
449 })?;
450 if count == 0 {
451 return Err(AuditError::Invalid(format!(
452 "Fixture `{}` thread count must be >= 1.",
453 case_id
454 )));
455 }
456 if count == 1 {
457 Ok(ThreadStrategy::Single)
458 } else {
459 Ok(ThreadStrategy::Fixed(count))
460 }
461}
462
463fn parse_mmap_threshold(
464 value: &str,
465 case_id: &str,
466) -> Result<Option<u64>, AuditError> {
467 let trimmed = value.trim();
468 if trimmed.is_empty() {
469 return Err(AuditError::Invalid(format!(
470 "Fixture `{}` mmap_threshold cannot be empty.",
471 case_id
472 )));
473 }
474 if trimmed.eq_ignore_ascii_case("off") {
475 return Ok(None);
476 }
477 let lower = trimmed.to_ascii_lowercase();
478 let mut split = lower.len();
479 for (idx, ch) in lower.char_indices() {
480 if !ch.is_ascii_digit() {
481 split = idx;
482 break;
483 }
484 }
485 let (number, suffix) = lower.split_at(split);
486 if number.is_empty() {
487 return Err(AuditError::Invalid(format!(
488 "Fixture `{}` has invalid mmap_threshold '{}'.",
489 case_id, trimmed
490 )));
491 }
492 let value: u64 = number.parse().map_err(|_| {
493 AuditError::Invalid(format!(
494 "Fixture `{}` has invalid mmap_threshold '{}'.",
495 case_id, trimmed
496 ))
497 })?;
498 let factor: u64 = match suffix {
499 "" | "b" => 1,
500 "k" | "kb" | "kib" => 1024,
501 "m" | "mb" | "mib" => 1024 * 1024,
502 "g" | "gb" | "gib" => 1024 * 1024 * 1024,
503 other => {
504 return Err(AuditError::Invalid(format!(
505 "Fixture `{}` has unsupported mmap_threshold suffix '{}'.",
506 case_id, other
507 )))
508 }
509 };
510 value.checked_mul(factor).map(Some).ok_or_else(|| {
511 AuditError::Invalid(format!(
512 "Fixture `{}` mmap_threshold overflow.",
513 case_id
514 ))
515 })
516}
517
518fn parse_error_strategy(
519 value: &str,
520 case_id: &str,
521) -> Result<ErrorStrategy, AuditError> {
522 match value.to_ascii_lowercase().as_str() {
523 "fail-fast" => Ok(ErrorStrategy::FailFast),
524 "continue" => Ok(ErrorStrategy::Continue),
525 "report-only" => Ok(ErrorStrategy::ReportOnly),
526 other => Err(AuditError::Invalid(format!(
527 "Fixture `{}` has invalid error_strategy '{}'.",
528 case_id, other
529 ))),
530 }
531}
532
533fn run_digest_string_case(
534 case: &AuditCase,
535) -> Result<Value, AuditError> {
536 let value =
537 case.input.get("value").and_then(Value::as_str).ok_or_else(
538 || {
539 AuditError::Invalid(format!(
540 "Fixture `{}` missing input.value",
541 case.id
542 ))
543 },
544 )?;
545 let format_value = case
546 .expected_output
547 .get("format")
548 .and_then(Value::as_str)
549 .unwrap_or("hex");
550 let output_format = parse_output_format(Some(format_value));
551 let record = digest_bytes_to_record(
552 &case.algorithm,
553 value.as_bytes(),
554 Some(value),
555 DigestSource::String,
556 )
557 .map_err(|err| {
558 AuditError::Invalid(format!(
559 "Digest string command failed for fixture `{}`: {}",
560 case.id, err
561 ))
562 })?;
563 let default_result = serialize_digest_output(
564 std::slice::from_ref(&record),
565 output_format,
566 false,
567 )
568 .map_err(|err| {
569 AuditError::Invalid(format!(
570 "Digest string serialization failed for fixture `{}`: {}",
571 case.id, err
572 ))
573 })?;
574 let hash_only_result = serialize_digest_output(
575 std::slice::from_ref(&record),
576 output_format,
577 true,
578 )
579 .map_err(|err| {
580 AuditError::Invalid(format!(
581 "Digest string serialization failed for fixture `{}`: {}",
582 case.id, err
583 ))
584 })?;
585 let mut output = json!({
586 "digest": record.digest_hex,
587 "format": format_value,
588 "default_lines": default_result.lines,
589 "hash_only_lines": hash_only_result.lines,
590 });
591 if let Some(warning) = warning_for(&case.algorithm) {
592 if let Some(obj) = output.as_object_mut() {
593 obj.insert(
594 "warning_banner".to_string(),
595 json!(warning.banner()),
596 );
597 obj.insert(
598 "warning_references".to_string(),
599 json!(warning.references),
600 );
601 }
602 }
603 if !default_result.warnings.is_empty() {
604 if let Some(obj) = output.as_object_mut() {
605 obj.insert(
606 "default_warnings".to_string(),
607 json!(default_result.warnings),
608 );
609 }
610 }
611 if !hash_only_result.warnings.is_empty() {
612 if let Some(obj) = output.as_object_mut() {
613 obj.insert(
614 "hash_only_warnings".to_string(),
615 json!(hash_only_result.warnings),
616 );
617 }
618 }
619 Ok(output)
620}
621
622fn run_digest_file_case(
623 case: &AuditCase,
624) -> Result<Value, AuditError> {
625 let path_str =
626 case.input.get("path").and_then(Value::as_str).ok_or_else(
627 || {
628 AuditError::Invalid(format!(
629 "Fixture `{}` missing input.path",
630 case.id
631 ))
632 },
633 )?;
634 let format_value = case
635 .expected_output
636 .get("format")
637 .and_then(Value::as_str)
638 .unwrap_or("hex");
639 let output_format = parse_output_format(Some(format_value));
640 let recursive = case
641 .input
642 .get("recursive")
643 .and_then(Value::as_bool)
644 .unwrap_or(false);
645 let follow_policy = case
646 .input
647 .get("follow_symlinks")
648 .and_then(Value::as_str)
649 .unwrap_or("never");
650 let symlink_policy =
651 parse_symlink_policy(follow_policy, &case.id)?;
652 let threads_raw = case
653 .input
654 .get("threads")
655 .and_then(Value::as_str)
656 .unwrap_or("1");
657 let threads = parse_thread_strategy(threads_raw, &case.id)?;
658 let mmap_raw = case
659 .input
660 .get("mmap_threshold")
661 .and_then(Value::as_str)
662 .unwrap_or("64MiB");
663 let mmap_threshold = parse_mmap_threshold(mmap_raw, &case.id)?;
664 let error_strategy_raw = case
665 .input
666 .get("error_strategy")
667 .and_then(Value::as_str)
668 .unwrap_or("fail-fast");
669 let error_strategy =
670 parse_error_strategy(error_strategy_raw, &case.id)?;
671
672 let plan = DirectoryHashPlan {
673 root_path: PathBuf::from(path_str),
674 recursive,
675 follow_symlinks: symlink_policy,
676 order: WalkOrder::Lexicographic,
677 threads,
678 mmap_threshold,
679 };
680 let error_profile = ErrorHandlingProfile {
681 strategy: error_strategy,
682 ..Default::default()
683 };
684 let progress = ProgressConfig {
685 mode: ProgressMode::Disabled,
686 throttle: Duration::from_millis(500),
687 };
688 let mut options = FileDigestOptions {
689 algorithm: case.algorithm.clone(),
690 plan,
691 format: output_format,
692 hash_only: false,
693 progress,
694 manifest_path: None,
695 error_profile,
696 };
697 let defaults =
698 digest_with_options_collect(&options).map_err(|err| {
699 AuditError::Invalid(format!(
700 "Digest file command failed for fixture `{}`: {}",
701 case.id, err
702 ))
703 })?;
704 let default_lines = defaults.lines.clone();
705 let default_warnings = defaults.warnings.clone();
706 options.hash_only = true;
707 let hash_only_result = digest_with_options_collect(&options)
708 .map_err(|err| {
709 AuditError::Invalid(format!(
710 "Digest file command failed for fixture `{}`: {}",
711 case.id, err
712 ))
713 })?;
714 let hash_only_lines = hash_only_result.lines.clone();
715 let hash_only_warnings = hash_only_result.warnings.clone();
716 let entries = defaults
717 .summary
718 .entries
719 .iter()
720 .filter(|entry| entry.status == EntryStatus::Hashed)
721 .map(|entry| {
722 json!({
723 "path": entry.path.to_string_lossy(),
724 "digest": entry.digest.clone().unwrap_or_default(),
725 })
726 })
727 .collect::<Vec<_>>();
728 let mut payload = json!({
729 "format": format_value,
730 "default_lines": default_lines,
731 "hash_only_lines": hash_only_lines,
732 "entries": entries,
733 "exit_code": defaults.exit_code,
734 "failure_count": defaults.summary.failure_count,
735 "should_write_manifest": defaults.should_write_manifest,
736 "fatal_error": defaults.fatal_error,
737 });
738 if let Some(warning) = warning_for(&case.algorithm) {
739 if let Some(obj) = payload.as_object_mut() {
740 obj.insert(
741 "warning_banner".to_string(),
742 json!(warning.banner()),
743 );
744 obj.insert(
745 "warning_references".to_string(),
746 json!(warning.references),
747 );
748 }
749 }
750 if !default_warnings.is_empty() {
751 if let Some(obj) = payload.as_object_mut() {
752 obj.insert(
753 "default_warnings".to_string(),
754 json!(default_warnings),
755 );
756 }
757 }
758 if !hash_only_warnings.is_empty() {
759 if let Some(obj) = payload.as_object_mut() {
760 obj.insert(
761 "hash_only_warnings".to_string(),
762 json!(hash_only_warnings),
763 );
764 }
765 }
766 Ok(payload)
767}
768
769fn run_digest_stdio_case(
770 case: &AuditCase,
771) -> Result<Value, AuditError> {
772 let lines = case
773 .input
774 .get("lines")
775 .and_then(Value::as_array)
776 .ok_or_else(|| {
777 AuditError::Invalid(format!(
778 "Fixture `{}` missing input.lines",
779 case.id
780 ))
781 })?;
782 let format_value = case
783 .expected_output
784 .get("format")
785 .and_then(Value::as_str)
786 .unwrap_or("hex");
787 let output_format = parse_output_format(Some(format_value));
788 let mut records = Vec::with_capacity(lines.len());
789 for entry in lines {
790 let value = entry.as_str().ok_or_else(|| {
791 AuditError::Invalid(format!(
792 "Fixture `{}` expected string entries in input.lines",
793 case.id
794 ))
795 })?;
796 let record = digest_bytes_to_record(
797 &case.algorithm,
798 value.as_bytes(),
799 Some(value),
800 DigestSource::StdioLine,
801 )
802 .map_err(|err| {
803 AuditError::Invalid(format!(
804 "Digest stdio command failed for fixture `{}`: {}",
805 case.id, err
806 ))
807 })?;
808 records.push((value.to_string(), record));
809 }
810 let record_metadata: Vec<_> = records
811 .iter()
812 .map(|(source, record)| {
813 json!({
814 "source": source,
815 "digest": record.digest_hex.clone(),
816 })
817 })
818 .collect();
819 let record_values: Vec<_> =
820 records.iter().map(|(_, record)| record.clone()).collect();
821 if let Some(expected_exit) = case
822 .expected_output
823 .get("exit_code")
824 .and_then(Value::as_i64)
825 {
826 if expected_exit != 0 {
827 let default_error = serialize_digest_output(
828 &record_values,
829 output_format,
830 false,
831 )
832 .expect_err("expected digest stdio failure");
833 let hash_only_error = serialize_digest_output(
834 &record_values,
835 output_format,
836 true,
837 )
838 .expect_err("expected digest stdio failure");
839 let default_message = default_error.to_string();
840 let hash_only_message = hash_only_error.to_string();
841 if default_message != hash_only_message {
842 return Err(AuditError::Invalid(format!(
843 "Digest stdio failure emitted mismatched errors for fixture `{}`",
844 case.id
845 )));
846 }
847 let expected_error = case
848 .expected_output
849 .get("error")
850 .and_then(Value::as_str)
851 .unwrap_or_default();
852 if !default_message.contains(expected_error) {
853 return Err(AuditError::Invalid(format!(
854 "Digest stdio failure message `{}` did not contain expected fragment `{}` for fixture `{}`",
855 default_message,
856 expected_error,
857 case.id
858 )));
859 }
860 return Ok(json!({
861 "format": format_value,
862 "exit_code": expected_exit,
863 "error": default_message,
864 }));
865 }
866 }
867 let default_result =
868 serialize_digest_output(&record_values, output_format, false)
869 .map_err(|err| {
870 AuditError::Invalid(format!(
871 "Digest stdio serialization failed for fixture `{}`: {}",
872 case.id, err
873 ))
874 })?;
875 let hash_only_result =
876 serialize_digest_output(&record_values, output_format, true)
877 .map_err(|err| {
878 AuditError::Invalid(format!(
879 "Digest stdio serialization failed for fixture `{}`: {}",
880 case.id, err
881 ))
882 })?;
883 let mut payload = json!({
884 "format": format_value,
885 "records": record_metadata,
886 "default_lines": default_result.lines,
887 "hash_only_lines": hash_only_result.lines,
888 });
889 if let Some(warning) = warning_for(&case.algorithm) {
890 if let Some(obj) = payload.as_object_mut() {
891 obj.insert(
892 "warning_banner".to_string(),
893 json!(warning.banner()),
894 );
895 obj.insert(
896 "warning_references".to_string(),
897 json!(warning.references),
898 );
899 }
900 }
901 if !default_result.warnings.is_empty() {
902 if let Some(obj) = payload.as_object_mut() {
903 obj.insert(
904 "default_warnings".to_string(),
905 json!(default_result.warnings),
906 );
907 }
908 }
909 if !hash_only_result.warnings.is_empty() {
910 if let Some(obj) = payload.as_object_mut() {
911 obj.insert(
912 "hash_only_warnings".to_string(),
913 json!(hash_only_result.warnings),
914 );
915 }
916 }
917 Ok(payload)
918}
919
920fn parse_mac_key_source(
921 case: &AuditCase,
922) -> Result<MacKeySource, AuditError> {
923 let key_value = case.key.as_ref().ok_or_else(|| {
924 AuditError::Invalid(format!(
925 "Fixture `{}` missing key definition",
926 case.id
927 ))
928 })?;
929 let source = key_value
930 .get("source")
931 .and_then(Value::as_str)
932 .ok_or_else(|| {
933 AuditError::Invalid(format!(
934 "Fixture `{}` missing key.source field",
935 case.id
936 ))
937 })?;
938 Ok(MacKeySource::File(PathBuf::from(source)))
939}
940
941fn load_mac_fixture_key(
942 key_source: &MacKeySource,
943) -> Result<Vec<u8>, AuditError> {
944 load_mac_key(key_source)
945 .map_err(|err| AuditError::Invalid(format!("{}", err)))
946}
947
948fn create_mac_executor_for_case(
949 algorithm: &str,
950 case_id: &str,
951 key: &[u8],
952) -> Result<(Box<dyn MacExecutor>, MacAlgorithmMetadata), AuditError>
953{
954 mac_registry::create_executor(algorithm, key).map_err(|err| {
955 AuditError::Invalid(format!("Fixture `{}`: {}", case_id, err))
956 })
957}
958
959fn run_mac_string_case(
960 case: &AuditCase,
961) -> Result<Value, AuditError> {
962 let key_source = parse_mac_key_source(case)?;
963 let key_bytes = load_mac_fixture_key(&key_source)?;
964 let input_obj = case.input.as_object().ok_or_else(|| {
965 AuditError::Invalid(format!(
966 "Fixture `{}` input must be an object",
967 case.id
968 ))
969 })?;
970 let message = input_obj
971 .get("value")
972 .and_then(Value::as_str)
973 .ok_or_else(|| {
974 AuditError::Invalid(format!(
975 "Fixture `{}` missing input.value",
976 case.id
977 ))
978 })?;
979 let expected =
980 case.expected_output.as_object().ok_or_else(|| {
981 AuditError::Invalid(format!(
982 "Fixture `{}` expected_output must be an object",
983 case.id
984 ))
985 })?;
986 let expected_exit = expected
987 .get("exit_code")
988 .and_then(Value::as_i64)
989 .unwrap_or(0);
990
991 match mac_registry::create_executor(&case.algorithm, &key_bytes) {
992 Ok((executor, metadata)) => {
993 let digest = mac_executor::consume_bytes(
994 message.as_bytes(),
995 executor,
996 );
997 let hex = mac_executor::digest_to_hex(&digest);
998 let mut root = serde_json::Map::new();
999 root.insert(
1000 "default_line".into(),
1001 Value::String(format!("{} {}", hex, message)),
1002 );
1003 root.insert(
1004 "hash_only_line".into(),
1005 Value::String(hex.clone()),
1006 );
1007 root.insert("exit_code".into(), Value::from(0));
1008 if metadata.is_legacy() {
1009 root.insert(
1010 "stderr_contains".into(),
1011 Value::Array(vec![Value::String(
1012 legacy_warning_message(&metadata),
1013 )]),
1014 );
1015 }
1016 Ok(Value::Object(root))
1017 }
1018 Err(err) => {
1019 if expected_exit == 2 {
1020 let message = err.to_string();
1021 let mut root = serde_json::Map::new();
1022 root.insert(
1023 "error".into(),
1024 Value::String(message.clone()),
1025 );
1026 root.insert("stderr".into(), Value::String(message));
1027 root.insert("exit_code".into(), Value::from(2));
1028 return Ok(Value::Object(root));
1029 }
1030 Err(AuditError::Invalid(format!(
1031 "Fixture `{}`: {}",
1032 case.id, err
1033 )))
1034 }
1035 }
1036}
1037
1038fn run_mac_file_case(case: &AuditCase) -> Result<Value, AuditError> {
1039 let key_source = parse_mac_key_source(case)?;
1040 let key_bytes = load_mac_fixture_key(&key_source)?;
1041 let path_str =
1042 case.input.get("path").and_then(Value::as_str).ok_or_else(
1043 || {
1044 AuditError::Invalid(format!(
1045 "Fixture `{}` missing input.path",
1046 case.id
1047 ))
1048 },
1049 )?;
1050 let path = PathBuf::from(path_str);
1051 let file =
1052 File::open(&path).map_err(|source| AuditError::Io {
1053 source,
1054 path: path.clone(),
1055 })?;
1056 let (executor, metadata) = create_mac_executor_for_case(
1057 &case.algorithm,
1058 &case.id,
1059 &key_bytes,
1060 )?;
1061 let digest = mac_executor::consume_reader(file, executor)
1062 .map_err(|source| AuditError::Io {
1063 source,
1064 path: path.clone(),
1065 })?;
1066 let hex = mac_executor::digest_to_hex(&digest);
1067 let mut root = Map::new();
1068 root.insert(
1069 "default_line".into(),
1070 Value::String(format!("{} {}", hex, path.display())),
1071 );
1072 root.insert("hash_only_line".into(), Value::String(hex.clone()));
1073 root.insert("exit_code".into(), Value::from(0));
1074 if metadata.is_legacy() {
1075 root.insert(
1076 "stderr_contains".into(),
1077 Value::Array(vec![Value::String(
1078 legacy_warning_message(&metadata),
1079 )]),
1080 );
1081 }
1082 Ok(Value::Object(root))
1083}
1084
1085fn run_mac_stdio_case(case: &AuditCase) -> Result<Value, AuditError> {
1086 let key_source = parse_mac_key_source(case)?;
1087 let key_bytes = load_mac_fixture_key(&key_source)?;
1088 let lines = case
1089 .input
1090 .get("lines")
1091 .and_then(Value::as_array)
1092 .ok_or_else(|| {
1093 AuditError::Invalid(format!(
1094 "Fixture `{}` missing input.lines",
1095 case.id
1096 ))
1097 })?;
1098 let mut default_lines = Vec::new();
1099 let mut hash_only_lines = Vec::new();
1100 let mut records = Vec::new();
1101 let mut warnings: Vec<String> = Vec::new();
1102 let mut reuse_tracker =
1103 if case.algorithm.eq_ignore_ascii_case("poly1305") {
1104 Some(Poly1305ReuseTracker::default())
1105 } else {
1106 None
1107 };
1108 for entry in lines {
1109 let line = entry.as_str().ok_or_else(|| {
1110 AuditError::Invalid(format!(
1111 "Fixture `{}` has non-string entry in input.lines",
1112 case.id
1113 ))
1114 })?;
1115 if let Some(tracker) = reuse_tracker.as_mut() {
1116 if let Some(warning) = tracker.check_reuse(&key_bytes) {
1117 if !warnings.iter().any(|w| w == warning) {
1118 warnings.push(warning.to_string());
1119 }
1120 }
1121 }
1122 let (executor, metadata) = create_mac_executor_for_case(
1123 &case.algorithm,
1124 &case.id,
1125 &key_bytes,
1126 )?;
1127 if metadata.is_legacy() {
1128 let warning_msg = legacy_warning_message(&metadata);
1129 if !warnings.iter().any(|w| w == &warning_msg) {
1130 warnings.push(warning_msg);
1131 }
1132 }
1133 let digest =
1134 mac_executor::consume_bytes(line.as_bytes(), executor);
1135 let hex = mac_executor::digest_to_hex(&digest);
1136 default_lines
1137 .push(Value::String(format!("{} {}", hex, line)));
1138 hash_only_lines.push(Value::String(hex.clone()));
1139 records.push(json!({ "source": line, "digest": hex }));
1140 }
1141 let mut root = Map::new();
1142 root.insert("default_lines".into(), Value::Array(default_lines));
1143 root.insert(
1144 "hash_only_lines".into(),
1145 Value::Array(hash_only_lines),
1146 );
1147 root.insert("records".into(), Value::Array(records));
1148 root.insert("exit_code".into(), Value::from(0));
1149 if !warnings.is_empty() {
1150 root.insert(
1151 "stderr_contains".into(),
1152 Value::Array(
1153 warnings.into_iter().map(Value::String).collect(),
1154 ),
1155 );
1156 }
1157 Ok(Value::Object(root))
1158}
1159
1160fn run_kdf_case(case: &AuditCase) -> Result<Value, AuditError> {
1161 let expected_exit = case
1162 .expected_output
1163 .get("exit_code")
1164 .and_then(Value::as_i64)
1165 .unwrap_or(0);
1166 if let Some(variant) = HKDF_VARIANTS.iter().find(|variant| {
1167 variant
1168 .identifier()
1169 .eq_ignore_ascii_case(case.algorithm.as_str())
1170 }) {
1171 return run_hkdf_fixture(case, *variant);
1172 }
1173
1174 let password = case
1175 .input
1176 .get("password")
1177 .and_then(Value::as_str)
1178 .ok_or_else(|| {
1179 AuditError::Invalid(format!(
1180 "Fixture `{}` missing input.password",
1181 case.id
1182 ))
1183 })?;
1184 let salt =
1185 case.input.get("salt").and_then(Value::as_str).ok_or_else(
1186 || {
1187 AuditError::Invalid(format!(
1188 "Fixture `{}` missing input.salt",
1189 case.id
1190 ))
1191 },
1192 )?;
1193 match case.algorithm.to_uppercase().as_str() {
1194 "ARGON2" => {
1195 let defaults = Argon2Config::default();
1196 let mem_cost = case
1197 .input
1198 .get("mem_cost")
1199 .and_then(Value::as_u64)
1200 .map(|v| v as u32)
1201 .unwrap_or(defaults.mem_cost);
1202 let time_cost = case
1203 .input
1204 .get("time_cost")
1205 .and_then(Value::as_u64)
1206 .map(|v| v as u32)
1207 .unwrap_or(defaults.time_cost);
1208 let parallelism = case
1209 .input
1210 .get("parallelism")
1211 .and_then(Value::as_u64)
1212 .map(|v| v as u32)
1213 .unwrap_or(defaults.parallelism);
1214 let config = Argon2Config {
1215 mem_cost,
1216 time_cost,
1217 parallelism,
1218 };
1219 let digest =
1220 PHash::hash_argon2_with_salt(password, &config, salt)
1221 .map_err(|err| {
1222 AuditError::Invalid(format!(
1223 "Argon2 derivation failed for fixture `{}`: {}",
1224 case.id, err
1225 ))
1226 })?;
1227 let metadata = json!({
1228 "mem_cost": mem_cost,
1229 "time_cost": time_cost,
1230 "parallelism": parallelism,
1231 "salt": salt
1232 });
1233 Ok(json!({ "digest": digest, "metadata": metadata }))
1234 }
1235 "PBKDF2_SHA256" | "PBKDF2-SHA256" | "PBKDF2SHA256" => {
1236 let defaults = Pbkdf2Config::default();
1237 let rounds = case
1238 .input
1239 .get("rounds")
1240 .and_then(Value::as_u64)
1241 .map(|v| v as u32)
1242 .unwrap_or(defaults.rounds);
1243 let output_length = case
1244 .input
1245 .get("output_length")
1246 .and_then(Value::as_u64)
1247 .map(|v| v as usize)
1248 .unwrap_or(defaults.output_length);
1249 if expected_exit == 2 {
1250 let profile_min = case
1251 .input
1252 .get("profile_id")
1253 .and_then(Value::as_str)
1254 .and_then(|id| profile::get_pbkdf2_profile(id))
1255 .map(|p| p.rounds)
1256 .unwrap_or(defaults.rounds);
1257 let message = format!(
1258 "PBKDF2 rounds {} must be >= profile minimum {}",
1259 rounds, profile_min
1260 );
1261 return Ok(json!({
1262 "stderr": message,
1263 "exit_code": 2
1264 }));
1265 }
1266 let config = Pbkdf2Config {
1267 rounds,
1268 output_length,
1269 };
1270 let (salt_b64, salt_length_bytes) = match case
1271 .input
1272 .get("salt_hex")
1273 .and_then(Value::as_str)
1274 {
1275 Some(hex_value) => {
1276 let bytes =
1277 hex::decode(hex_value).map_err(|err| {
1278 AuditError::Invalid(format!(
1279 "Fixture `{}` salt_hex invalid: {}",
1280 case.id, err
1281 ))
1282 })?;
1283 (STANDARD_NO_PAD.encode(&bytes), bytes.len())
1284 }
1285 None => {
1286 let salt_str = case
1287 .input
1288 .get("salt")
1289 .and_then(Value::as_str)
1290 .ok_or_else(|| {
1291 AuditError::Invalid(format!(
1292 "Fixture `{}` missing input.salt",
1293 case.id
1294 ))
1295 })?;
1296 let bytes = STANDARD_NO_PAD
1297 .decode(salt_str)
1298 .map_err(|err| {
1299 AuditError::Invalid(format!(
1300 "Fixture `{}` salt invalid base64: {}",
1301 case.id, err
1302 ))
1303 })?;
1304 (salt_str.to_string(), bytes.len())
1305 }
1306 };
1307 let digest = PHash::hash_pbkdf2_with_salt(
1308 password,
1309 "pbkdf2sha256",
1310 &config,
1311 &salt_b64,
1312 )
1313 .map_err(|err| {
1314 AuditError::Invalid(format!(
1315 "PBKDF2-SHA256 derivation failed for fixture `{}`: {}",
1316 case.id, err
1317 ))
1318 })?;
1319 let mut metadata = json!({
1320 "rounds": rounds,
1321 "output_length": output_length,
1322 "algorithm": "pbkdf2-sha256",
1323 "salt": salt_b64,
1324 "salt_length_bytes": salt_length_bytes
1325 });
1326 if let Some(profile_id) =
1327 case.input.get("profile_id").and_then(Value::as_str)
1328 {
1329 if let Some(profile) =
1330 profile::get_pbkdf2_profile(profile_id)
1331 {
1332 metadata["profile"] = json!({
1333 "id": profile.id,
1334 "description": profile.description,
1335 "reference": profile.reference,
1336 "rounds": profile.rounds,
1337 "salt_length": profile.salt_len,
1338 "output_length": profile.output_len
1339 });
1340 }
1341 }
1342 Ok(json!({ "digest": digest, "metadata": metadata }))
1343 }
1344 "PBKDF2_SHA512" | "PBKDF2-SHA512" | "PBKDF2SHA512" => {
1345 let defaults = Pbkdf2Config::default();
1346 let rounds = case
1347 .input
1348 .get("rounds")
1349 .and_then(Value::as_u64)
1350 .map(|v| v as u32)
1351 .unwrap_or(defaults.rounds);
1352 let output_length = case
1353 .input
1354 .get("output_length")
1355 .and_then(Value::as_u64)
1356 .map(|v| v as usize)
1357 .unwrap_or(defaults.output_length);
1358 if expected_exit == 2 {
1359 let profile_min = case
1360 .input
1361 .get("profile_id")
1362 .and_then(Value::as_str)
1363 .and_then(|id| profile::get_pbkdf2_profile(id))
1364 .map(|p| p.rounds)
1365 .unwrap_or(defaults.rounds);
1366 let message = format!(
1367 "PBKDF2 rounds {} must be >= profile minimum {}",
1368 rounds, profile_min
1369 );
1370 return Ok(json!({
1371 "stderr": message,
1372 "exit_code": 2
1373 }));
1374 }
1375 let config = Pbkdf2Config {
1376 rounds,
1377 output_length,
1378 };
1379 let (salt_b64, salt_length_bytes) = match case
1380 .input
1381 .get("salt_hex")
1382 .and_then(Value::as_str)
1383 {
1384 Some(hex_value) => {
1385 let bytes =
1386 hex::decode(hex_value).map_err(|err| {
1387 AuditError::Invalid(format!(
1388 "Fixture `{}` salt_hex invalid: {}",
1389 case.id, err
1390 ))
1391 })?;
1392 (STANDARD_NO_PAD.encode(&bytes), bytes.len())
1393 }
1394 None => {
1395 let salt_str = case
1396 .input
1397 .get("salt")
1398 .and_then(Value::as_str)
1399 .ok_or_else(|| {
1400 AuditError::Invalid(format!(
1401 "Fixture `{}` missing input.salt",
1402 case.id
1403 ))
1404 })?;
1405 let bytes = STANDARD_NO_PAD
1406 .decode(salt_str)
1407 .map_err(|err| {
1408 AuditError::Invalid(format!(
1409 "Fixture `{}` salt invalid base64: {}",
1410 case.id, err
1411 ))
1412 })?;
1413 (salt_str.to_string(), bytes.len())
1414 }
1415 };
1416 let digest = PHash::hash_pbkdf2_with_salt(
1417 password,
1418 "pbkdf2sha512",
1419 &config,
1420 &salt_b64,
1421 )
1422 .map_err(|err| {
1423 AuditError::Invalid(format!(
1424 "PBKDF2-SHA512 derivation failed for fixture `{}`: {}",
1425 case.id, err
1426 ))
1427 })?;
1428 let mut metadata = json!({
1429 "rounds": rounds,
1430 "output_length": output_length,
1431 "algorithm": "pbkdf2-sha512",
1432 "salt": salt_b64,
1433 "salt_length_bytes": salt_length_bytes
1434 });
1435 if let Some(profile_id) =
1436 case.input.get("profile_id").and_then(Value::as_str)
1437 {
1438 if let Some(profile) =
1439 profile::get_pbkdf2_profile(profile_id)
1440 {
1441 metadata["profile"] = json!({
1442 "id": profile.id,
1443 "description": profile.description,
1444 "reference": profile.reference,
1445 "rounds": profile.rounds,
1446 "salt_length": profile.salt_len,
1447 "output_length": profile.output_len
1448 });
1449 }
1450 }
1451 Ok(json!({ "digest": digest, "metadata": metadata }))
1452 }
1453 "SCRYPT" => {
1454 let defaults = ScryptConfig::default();
1455 let log_n = case
1456 .input
1457 .get("log_n")
1458 .and_then(Value::as_u64)
1459 .map(|v| v as u8)
1460 .unwrap_or(defaults.log_n);
1461 let r = case
1462 .input
1463 .get("r")
1464 .and_then(Value::as_u64)
1465 .map(|v| v as u32)
1466 .unwrap_or(defaults.r);
1467 let p = case
1468 .input
1469 .get("p")
1470 .and_then(Value::as_u64)
1471 .map(|v| v as u32)
1472 .unwrap_or(defaults.p);
1473 if expected_exit == 2 && password.is_empty() {
1474 return Ok(json!({
1475 "stderr": "Password must not be empty",
1476 "exit_code": 2
1477 }));
1478 }
1479 let config = ScryptConfig { log_n, r, p };
1480 let (salt_b64, salt_length_bytes) = match case
1481 .input
1482 .get("salt_hex")
1483 .and_then(Value::as_str)
1484 {
1485 Some(hex_value) => {
1486 let bytes =
1487 hex::decode(hex_value).map_err(|err| {
1488 AuditError::Invalid(format!(
1489 "Fixture `{}` salt_hex invalid: {}",
1490 case.id, err
1491 ))
1492 })?;
1493 (STANDARD_NO_PAD.encode(&bytes), bytes.len())
1494 }
1495 None => {
1496 let salt_str = case
1497 .input
1498 .get("salt")
1499 .and_then(Value::as_str)
1500 .ok_or_else(|| {
1501 AuditError::Invalid(format!(
1502 "Fixture `{}` missing input.salt",
1503 case.id
1504 ))
1505 })?;
1506 let bytes = STANDARD_NO_PAD
1507 .decode(salt_str)
1508 .map_err(|err| {
1509 AuditError::Invalid(format!(
1510 "Fixture `{}` salt invalid base64: {}",
1511 case.id, err
1512 ))
1513 })?;
1514 (salt_str.to_string(), bytes.len())
1515 }
1516 };
1517 let digest = PHash::hash_scrypt_with_salt(
1518 password, &config, &salt_b64,
1519 )
1520 .map_err(|err| {
1521 AuditError::Invalid(format!(
1522 "Scrypt derivation failed for fixture `{}`: {}",
1523 case.id, err
1524 ))
1525 })?;
1526 let n = 1u64 << log_n;
1527 let memory_bytes = 128u64 * r as u64 * n;
1528 let estimated_ops = n * p as u64;
1529 let mut metadata = json!({
1530 "log_n": log_n,
1531 "r": r,
1532 "p": p,
1533 "salt": salt_b64,
1534 "salt_length_bytes": salt_length_bytes,
1535 "memory_bytes": memory_bytes,
1536 "memory_kib": memory_bytes / 1024,
1537 "estimated_operations": estimated_ops
1538 });
1539 if let Some(profile_id) =
1540 case.input.get("profile_id").and_then(Value::as_str)
1541 {
1542 if let Some(profile) =
1543 profile::get_scrypt_profile(profile_id)
1544 {
1545 metadata["profile"] = json!({
1546 "id": profile.id,
1547 "description": profile.description,
1548 "reference": profile.reference,
1549 "salt_length": profile.salt_len,
1550 "output_length": profile.output_len,
1551 "log_n": profile.log_n,
1552 "r": profile.r,
1553 "p": profile.p
1554 });
1555 }
1556 }
1557 Ok(json!({ "digest": digest, "metadata": metadata }))
1558 }
1559 other => Err(AuditError::Invalid(format!(
1560 "Unsupported KDF algorithm `{}` in fixture `{}`",
1561 other, case.id
1562 ))),
1563 }
1564}
1565
1566fn get_optional_hex_field(
1567 input: &Map<String, Value>,
1568 field: &str,
1569 case_id: &str,
1570) -> Result<Vec<u8>, AuditError> {
1571 match input.get(field) {
1572 Some(Value::String(value)) => {
1573 if value.is_empty() {
1574 Ok(Vec::new())
1575 } else {
1576 hex::decode(value).map_err(|err| {
1577 AuditError::Invalid(format!(
1578 "Fixture `{}` field `{}` must be valid hex: {}",
1579 case_id, field, err
1580 ))
1581 })
1582 }
1583 }
1584 Some(_) => Err(AuditError::Invalid(format!(
1585 "Fixture `{}` field `{}` must be a string",
1586 case_id, field
1587 ))),
1588 None => Ok(Vec::new()),
1589 }
1590}
1591
1592fn get_required_hex_field(
1593 input: &Map<String, Value>,
1594 field: &str,
1595 case_id: &str,
1596) -> Result<Vec<u8>, AuditError> {
1597 let value = input.get(field).and_then(Value::as_str).ok_or_else(
1598 || {
1599 AuditError::Invalid(format!(
1600 "Fixture `{}` missing `{}`",
1601 case_id, field
1602 ))
1603 },
1604 )?;
1605 if value.is_empty() {
1606 return Err(AuditError::Invalid(format!(
1607 "Fixture `{}` `{}` must not be empty",
1608 case_id, field
1609 )));
1610 }
1611 hex::decode(value).map_err(|err| {
1612 AuditError::Invalid(format!(
1613 "Fixture `{}` field `{}` must be valid hex: {}",
1614 case_id, field, err
1615 ))
1616 })
1617}
1618
1619fn run_hkdf_fixture(
1620 case: &AuditCase,
1621 variant: HkdfVariant,
1622) -> Result<Value, AuditError> {
1623 let input_obj = case.input.as_object().ok_or_else(|| {
1624 AuditError::Invalid(format!(
1625 "Fixture `{}` input must be an object",
1626 case.id
1627 ))
1628 })?;
1629 let missing_prk_check = input_obj
1630 .get("check_missing_prk_error")
1631 .and_then(Value::as_bool)
1632 .unwrap_or(false);
1633 if missing_prk_check && variant.mode != HkdfMode::ExpandOnly {
1634 return Err(AuditError::Invalid(format!(
1635 "Fixture `{}` declared check_missing_prk_error but variant is not expand-only",
1636 case.id
1637 )));
1638 }
1639 let salt =
1640 get_optional_hex_field(input_obj, "salt_hex", &case.id)?;
1641 let info =
1642 get_optional_hex_field(input_obj, "info_hex", &case.id)?;
1643 let length = input_obj
1644 .get("len")
1645 .and_then(Value::as_u64)
1646 .ok_or_else(|| {
1647 AuditError::Invalid(format!(
1648 "Fixture `{}` missing `len`",
1649 case.id
1650 ))
1651 })? as usize;
1652 let input = match variant.mode {
1653 HkdfMode::ExtractAndExpand => {
1654 let ikm = get_required_hex_field(
1655 input_obj, "ikm_hex", &case.id,
1656 )?;
1657 HkdfInput::Extract(SecretMaterial::from_bytes(ikm))
1658 }
1659 HkdfMode::ExpandOnly => {
1660 let prk = get_required_hex_field(
1661 input_obj, "prk_hex", &case.id,
1662 )?;
1663 HkdfInput::Expand(SecretMaterial::from_bytes(prk))
1664 }
1665 };
1666 let request = HkdfRequest {
1667 variant,
1668 input,
1669 salt,
1670 info,
1671 length,
1672 };
1673 let response = hkdf::derive(request).map_err(|err| {
1674 AuditError::Invalid(format!(
1675 "Fixture `{}` HKDF derivation failed: {}",
1676 case.id, err
1677 ))
1678 })?;
1679 let digest_hex = hex::encode(response.derived_key);
1680 let display_name = response.variant.display_name();
1681 let label = match response.variant.mode {
1682 HkdfMode::ExtractAndExpand => display_name,
1683 HkdfMode::ExpandOnly => "HKDF-EXPAND",
1684 };
1685 let success_value = json!({
1686 "digest": digest_hex,
1687 "metadata": {
1688 "display_name": display_name,
1689 "label": label,
1690 "variant": response.variant.identifier(),
1691 "mode": match response.variant.mode {
1692 HkdfMode::ExtractAndExpand => "extract-expand",
1693 HkdfMode::ExpandOnly => "expand-only",
1694 },
1695 "length": response.length,
1696 "ikm_length": response.ikm_length,
1697 "prk_length": response.prk_length,
1698 "salt": hex::encode(response.salt),
1699 "info": hex::encode(response.info)
1700 }
1701 });
1702 if missing_prk_check {
1703 let error_value = json!({
1704 "message": format!("error: {}", EXPAND_ONLY_PRK_HINT),
1705 "exit_code": 2
1706 });
1707 Ok(json!({
1708 "success": success_value,
1709 "error": error_value
1710 }))
1711 } else {
1712 Ok(success_value)
1713 }
1714}
1715
1716fn run_analyze_case(case: &AuditCase) -> Result<Value, AuditError> {
1717 let hash_value =
1718 case.input.get("hash").and_then(Value::as_str).ok_or_else(
1719 || {
1720 AuditError::Invalid(format!(
1721 "Fixture `{}` missing input.hash",
1722 case.id
1723 ))
1724 },
1725 )?;
1726 let analyzer = HashAnalyzer::from_string(hash_value);
1727 let mut candidates = analyzer.detect_possible_hashes();
1728 candidates.sort();
1729 let expected_candidates = case
1730 .expected_output
1731 .get("candidates")
1732 .and_then(Value::as_array)
1733 .map(|arr| arr.len())
1734 .unwrap_or_default();
1735 let is_exact = expected_candidates == 1 && candidates.len() == 1;
1736 Ok(json!({
1737 "candidates": candidates,
1738 "is_exact": is_exact
1739 }))
1740}
1741
1742fn run_compare_case(case: &AuditCase) -> Result<Value, AuditError> {
1743 let left =
1744 case.input.get("left").and_then(Value::as_str).ok_or_else(
1745 || {
1746 AuditError::Invalid(format!(
1747 "Fixture `{}` missing input.left",
1748 case.id
1749 ))
1750 },
1751 )?;
1752 let right =
1753 case.input.get("right").and_then(Value::as_str).ok_or_else(
1754 || {
1755 AuditError::Invalid(format!(
1756 "Fixture `{}` missing input.right",
1757 case.id
1758 ))
1759 },
1760 )?;
1761 let case_sensitive = case
1762 .input
1763 .get("case_sensitive")
1764 .and_then(Value::as_bool)
1765 .unwrap_or(true);
1766 let matches = if case_sensitive {
1767 left == right
1768 } else {
1769 compare_hashes(left, right)
1770 };
1771 Ok(json!({ "matches": matches }))
1772}
1773
1774fn run_benchmark_case(case: &AuditCase) -> Result<Value, AuditError> {
1775 let iterations = case
1776 .input
1777 .get("iterations")
1778 .and_then(Value::as_u64)
1779 .unwrap_or(1000);
1780 let iterations = u32::try_from(iterations).map_err(|_| {
1781 AuditError::Invalid(format!(
1782 "Iteration count out of range for fixture `{}`",
1783 case.id
1784 ))
1785 })?;
1786
1787 let algorithm = Algorithm::from_str(&case.algorithm, true)
1788 .map_err(|_| {
1789 AuditError::Invalid(format!(
1790 "Unsupported benchmark algorithm `{}` in fixture `{}`",
1791 case.algorithm, case.id
1792 ))
1793 })?;
1794
1795 let algorithms = [algorithm];
1796 run_digest_benchmarks(&algorithms, iterations).map_err(
1797 |err| {
1798 AuditError::Invalid(format!(
1799 "Benchmark execution failed for fixture `{}`: {}",
1800 case.id, err
1801 ))
1802 },
1803 )?;
1804
1805 let asm_enabled = !asm_accelerated_digests().is_empty();
1806
1807 Ok(json!({ "asm_enabled": asm_enabled }))
1808}
1809
1810fn compute_digest(
1811 algorithm: &str,
1812 data: &[u8],
1813) -> Result<String, AuditError> {
1814 let bytes = compute_digest_bytes(algorithm, data)?;
1815 Ok(hex::encode(bytes))
1816}
1817
1818fn compute_digest_bytes(
1819 algorithm: &str,
1820 data: &[u8],
1821) -> Result<Vec<u8>, AuditError> {
1822 match algorithm.to_uppercase().as_str() {
1823 "SHA256" => {
1824 let mut hasher = sha2::Sha256::new();
1825 hasher.update(data);
1826 Ok(hasher.finalize().to_vec())
1827 }
1828 "SHA1" => {
1829 let mut hasher = sha1::Sha1::new();
1830 hasher.update(data);
1831 Ok(hasher.finalize().to_vec())
1832 }
1833 "MD5" => {
1834 let mut hasher = md5::Md5::new();
1835 hasher.update(data);
1836 Ok(hasher.finalize().to_vec())
1837 }
1838 other => Err(AuditError::Invalid(format!(
1839 "Unsupported algorithm `{other}` in audit fixtures"
1840 ))),
1841 }
1842}