mod support;
use std::io::Write;
use support::paths::detector_dir;
use flate2::write::GzEncoder;
use flate2::Compression;
use keyhog_core::{Chunk, ChunkMetadata, RawMatch};
use keyhog_scanner::decode::{find_hex_strings, hex_decode};
use keyhog_scanner::CompiledScanner;
const AWS_KEY: &str = "AKIAQYLPMN5HFIQR7XYA";
const AWS_KEY_HEX: &str = "414b494151594c504d4e35484649515237585941";
fn full_scanner() -> CompiledScanner {
let detectors = keyhog_core::load_detectors(&detector_dir()).expect("load detectors from disk");
CompiledScanner::compile(detectors).expect("compile full detector scanner")
}
fn make_chunk(text: &str) -> Chunk {
Chunk {
data: text.into(),
metadata: ChunkMetadata {
source_type: "hex-decode-through".into(),
path: Some("hex.txt".into()),
..Default::default()
},
}
}
fn detector_ids_for_credential(matches: &[RawMatch], credential: &str) -> Vec<String> {
let mut ids: Vec<String> = matches
.iter()
.filter(|m| &*m.credential == credential)
.map(|m| (*m.detector_id).to_string())
.collect();
ids.sort();
ids.dedup();
ids
}
#[test]
fn hex_decode_valid_even_bytes_exact() {
let decoded = hex_decode("414243").expect("well-formed even hex decodes");
assert_eq!(decoded, vec![0x41u8, 0x42, 0x43]);
assert_eq!(decoded, b"ABC".to_vec());
}
#[test]
fn hex_decode_aws_key_roundtrip_exact() {
let decoded = hex_decode(AWS_KEY_HEX).expect("aws-key hex decodes");
assert_eq!(decoded, AWS_KEY.as_bytes().to_vec());
assert_eq!(String::from_utf8(decoded).unwrap(), AWS_KEY.to_string());
}
#[test]
fn hex_decode_uppercase_and_lowercase_agree() {
let lower = hex_decode("4a4b").expect("lowercase hex decodes");
let upper = hex_decode("4A4B").expect("uppercase hex decodes");
assert_eq!(lower, vec![0x4au8, 0x4b]);
assert_eq!(upper, vec![0x4au8, 0x4b]);
assert_eq!(lower, upper);
assert_eq!(String::from_utf8(lower).unwrap(), "JK".to_string());
}
#[test]
fn hex_decode_underscore_separated_decodes() {
let decoded = hex_decode("41_42_43").expect("underscore-grouped hex decodes");
assert_eq!(decoded, b"ABC".to_vec());
}
#[test]
fn hex_decode_empty_input_yields_empty_bytes() {
let decoded = hex_decode("").expect("empty hex is well-formed");
assert_eq!(decoded, Vec::<u8>::new());
}
#[test]
fn hex_decode_single_byte_ff_boundary() {
let decoded = hex_decode("ff").expect("two-char hex decodes to one byte");
assert_eq!(decoded, vec![0xffu8]);
assert_eq!(hex_decode("f").unwrap_err(), ());
}
#[test]
fn hex_decode_odd_length_rejected() {
assert_eq!(hex_decode("41424").unwrap_err(), ());
}
#[test]
fn hex_decode_nonhex_chars_rejected() {
assert_eq!(hex_decode("gg41").unwrap_err(), ());
assert_eq!(hex_decode("zzzz").unwrap_err(), ());
}
#[test]
fn hex_decode_underscore_odd_cleaned_rejected() {
assert_eq!(hex_decode("41_4").unwrap_err(), ());
}
#[test]
fn find_hex_strings_extracts_freestanding_run() {
let found = find_hex_strings(AWS_KEY_HEX, 16);
assert_eq!(found.len(), 1);
assert_eq!(found[0].value, AWS_KEY_HEX.to_string());
}
#[test]
fn find_hex_strings_min_length_boundary() {
let text = "key = \"0123456789abcdef\"";
let at_floor = find_hex_strings(text, 16);
assert_eq!(at_floor.len(), 1);
assert_eq!(at_floor[0].value, "0123456789abcdef".to_string());
let above_floor = find_hex_strings(text, 17);
assert_eq!(above_floor.len(), 0);
}
#[test]
fn find_hex_strings_rejects_odd_and_nonhex() {
let odd = find_hex_strings("key = \"0123456789abcde\"", 8);
assert_eq!(odd.len(), 0);
let nonhex = find_hex_strings("key = \"0123456789abcdeg\"", 8);
assert_eq!(nonhex.len(), 0);
let ok = find_hex_strings("key = \"0123456789abcdef\"", 8);
assert_eq!(ok.len(), 1);
assert_eq!(ok[0].value, "0123456789abcdef".to_string());
}
#[test]
fn hex_encoded_aws_key_decodes_through_same_detector() {
let scanner = full_scanner();
scanner.clear_fragment_cache();
let plain = scanner.scan(&make_chunk(AWS_KEY));
let plain_ids = detector_ids_for_credential(&plain, AWS_KEY);
assert!(
plain_ids.iter().any(|id| id == "aws-access-key"),
"plaintext AWS key must fire aws-access-key; got {plain_ids:?}"
);
let encoded_line = format!("aws_access_key_id = {AWS_KEY_HEX}\n");
scanner.clear_fragment_cache();
let decoded = scanner.scan(&make_chunk(&encoded_line));
let decoded_ids = detector_ids_for_credential(&decoded, AWS_KEY);
assert!(
decoded.iter().any(|m| &*m.credential == AWS_KEY),
"hex-encoded AWS key must be recovered verbatim via decode-through"
);
assert!(
decoded_ids.iter().any(|id| id == "aws-access-key"),
"decode-through must fire the SAME aws-access-key detector; got {decoded_ids:?}"
);
}
#[test]
fn hex_encoded_gzip_stays_opaque_no_inflate_stage() {
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder
.write_all(format!("{AWS_KEY} in gzip").as_bytes())
.expect("gzip write");
let gzip_bytes = encoder.finish().expect("gzip finish");
assert_eq!(&gzip_bytes[..2], &[0x1fu8, 0x8b]);
let gzip_hex = hex::encode(&gzip_bytes);
let decoded = hex_decode(&gzip_hex).expect("hex of gzip decodes to raw gzip bytes");
assert_eq!(decoded, gzip_bytes);
assert_eq!(&decoded[..2], &[0x1fu8, 0x8b]);
assert!(
String::from_utf8(decoded).is_err(),
"raw gzip bytes must be non-UTF-8 so the hex decode_chunk gate drops them"
);
let scanner = full_scanner();
scanner.clear_fragment_cache();
let matches = scanner.scan(&make_chunk(&gzip_hex));
let recovered = matches.iter().filter(|m| &*m.credential == AWS_KEY).count();
assert_eq!(recovered, 0);
}