rustgenhash/rgh/audit/
runner.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Project: rustgenhash
3// File: runner.rs
4// Author: Volker Schwaberow <volker@schwaberow.de>
5// Copyright (c) 2022 Volker Schwaberow
6
7use 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}