use std::collections::HashMap;
use keyhog_core::{
sha256_hash, Chunk, ChunkMetadata, MatchLocation, RawMatch, SensitiveString, Severity,
};
const SECRET: &str = "kh_live_serialization_boundary_secret";
fn raw_match() -> RawMatch {
RawMatch {
detector_id: "serialization-boundary".into(),
detector_name: "Serialization Boundary".into(),
service: "keyhog-test".into(),
severity: Severity::Critical,
credential: SECRET.into(),
credential_hash: sha256_hash(SECRET),
companions: HashMap::new(),
location: MatchLocation {
source: "memory".into(),
file_path: Some("fixture.env".into()),
line: Some(1),
offset: 0,
commit: None,
author: None,
date: None,
},
entropy: Some(4.25),
confidence: Some(0.99),
}
}
#[test]
fn standalone_sensitive_string_refuses_implicit_serialization() {
let secret = SensitiveString::from(SECRET);
let error = serde_json::to_string(&secret)
.expect_err("implicit SensitiveString serialization must fail closed")
.to_string();
assert_eq!(
error,
"SensitiveString refuses implicit plaintext serialization; call as_str() explicitly only for a protected private channel"
);
assert!(!error.contains(SECRET));
}
#[test]
fn raw_match_serialization_fails_without_emitting_the_credential() {
let mut output = Vec::new();
let error = serde_json::to_writer(&mut output, &raw_match())
.expect_err("RawMatch must not serialize its plaintext credential")
.to_string();
assert!(!String::from_utf8_lossy(&output).contains(SECRET));
assert!(!error.contains(SECRET));
assert!(error.contains("SensitiveString refuses implicit plaintext serialization"));
}
#[test]
fn source_chunk_serialization_fails_without_emitting_source_text() {
let chunk = Chunk {
data: SECRET.into(),
metadata: ChunkMetadata {
source_type: "memory".into(),
path: Some("fixture.env".into()),
..ChunkMetadata::default()
},
};
let mut output = Vec::new();
let error = serde_json::to_writer(&mut output, &chunk)
.expect_err("Chunk must not serialize its sensitive source bytes")
.to_string();
assert!(!String::from_utf8_lossy(&output).contains(SECRET));
assert!(!error.contains(SECRET));
assert!(error.contains("SensitiveString refuses implicit plaintext serialization"));
}
#[test]
fn nested_sensitive_strings_fail_closed_before_plaintext_is_written() {
let secrets = vec![
SensitiveString::from(SECRET),
SensitiveString::from("second-secret"),
];
let mut output = Vec::new();
let error = serde_json::to_writer(&mut output, &secrets)
.expect_err("nested SensitiveString values must retain the same fail-closed boundary")
.to_string();
let partial = String::from_utf8_lossy(&output);
assert!(!partial.contains(SECRET));
assert!(!partial.contains("second-secret"));
assert!(error.contains("SensitiveString refuses implicit plaintext serialization"));
}
#[test]
fn explicit_as_str_serialization_preserves_exact_private_channel_bytes() {
let secret = SensitiveString::from(SECRET);
let encoded = serde_json::to_string(secret.as_str())
.expect("an explicit as_str reveal is an intentional serialization boundary");
assert_eq!(encoded, format!("\"{SECRET}\""));
}
#[test]
fn sensitive_string_deserialization_still_accepts_exact_private_wire_text() {
let encoded = format!("\"{SECRET}\"");
let decoded: SensitiveString =
serde_json::from_str(&encoded).expect("historical private wire text must remain readable");
assert_eq!(decoded.as_str(), SECRET);
}