#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use bytes::Bytes;
use serde::Serialize;
use serde_json::{Map, Value};
use sha2::{Digest, Sha256};
use crate::log::filter::ParameterFilter;
pub const TRUNCATION_MARKER: &str = "…";
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ResponseFacts {
pub status: u16,
pub content_type: Option<String>,
pub content_encoding: Option<String>,
pub body: Bytes,
}
impl ResponseFacts {
#[must_use]
pub const fn new(status: u16, content_type: Option<String>, body: Bytes) -> Self {
Self {
status,
content_type,
content_encoding: None,
body,
}
}
#[must_use]
pub const fn encoded(
status: u16,
content_type: Option<String>,
content_encoding: Option<String>,
body: Bytes,
) -> Self {
Self {
status,
content_type,
content_encoding,
body,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum NormalizedBody {
Empty,
Json(Value),
Text(String),
Bytes(Bytes),
}
impl NormalizedBody {
#[must_use]
fn canonical_bytes(&self) -> Vec<u8> {
match self {
Self::Empty => Vec::new(),
Self::Json(value) => serde_json::to_vec(value).unwrap_or_default(),
Self::Text(text) => text.as_bytes().to_vec(),
Self::Bytes(bytes) => bytes.to_vec(),
}
}
#[must_use]
const fn kind_label(&self) -> &'static str {
match self {
Self::Empty => "empty",
Self::Json(_) => "json",
Self::Text(_) => "text",
Self::Bytes(_) => "bytes",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum DivergenceKind {
StatusClass,
Body,
}
impl DivergenceKind {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::StatusClass => "status_class",
Self::Body => "body",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct Divergence {
pub kind: DivergenceKind,
pub primary_status: u16,
pub shadow_status: u16,
pub primary_body_kind: &'static str,
pub shadow_body_kind: &'static str,
pub primary_body_bytes: usize,
pub shadow_body_bytes: usize,
pub primary_digest: String,
pub shadow_digest: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_sample: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub shadow_sample: Option<Value>,
pub fingerprint: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum Comparison {
Match,
Diverged(Box<Divergence>),
}
impl Comparison {
#[must_use]
pub const fn outcome_label(&self) -> &'static str {
match self {
Self::Match => "match",
Self::Diverged(_) => "diverged",
}
}
}
#[must_use]
pub fn compare(
primary: &ResponseFacts,
shadow: &ResponseFacts,
filter: &ParameterFilter,
sample_limit: usize,
) -> Comparison {
let primary_body = normalize_body(primary);
let shadow_body = normalize_body(shadow);
let status_class_differs = status_class(primary.status) != status_class(shadow.status);
let body_differs = primary_body != shadow_body;
if !status_class_differs && !body_differs {
return Comparison::Match;
}
let kind = if status_class_differs {
DivergenceKind::StatusClass
} else {
DivergenceKind::Body
};
let primary_digest = digest(&primary_body);
let shadow_digest = digest(&shadow_body);
let fingerprint = fingerprint(
kind,
primary.status,
&primary_digest,
shadow.status,
&shadow_digest,
);
Comparison::Diverged(Box::new(Divergence {
kind,
primary_status: primary.status,
shadow_status: shadow.status,
primary_body_kind: primary_body.kind_label(),
shadow_body_kind: shadow_body.kind_label(),
primary_body_bytes: primary.body.len(),
shadow_body_bytes: shadow.body.len(),
primary_digest,
shadow_digest,
primary_sample: sample(&primary_body, filter, sample_limit),
shadow_sample: sample(&shadow_body, filter, sample_limit),
fingerprint,
}))
}
#[must_use]
pub const fn status_class(status: u16) -> u16 {
status.saturating_div(100)
}
#[must_use]
pub fn normalize_body(facts: &ResponseFacts) -> NormalizedBody {
if facts.body.is_empty() {
return NormalizedBody::Empty;
}
if let Ok(value) = serde_json::from_slice::<Value>(&facts.body) {
return NormalizedBody::Json(canonicalize(&value));
}
if let Ok(text) = std::str::from_utf8(&facts.body) {
return NormalizedBody::Text(normalize_text(text));
}
NormalizedBody::Bytes(facts.body.clone())
}
fn normalize_text(text: &str) -> String {
text.replace("\r\n", "\n").trim().to_owned()
}
fn canonicalize(value: &Value) -> Value {
match value {
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_unstable();
let mut out = Map::new();
for key in keys {
if let Some(child) = map.get(key) {
out.insert(key.clone(), canonicalize(child));
}
}
Value::Object(out)
}
Value::Array(items) => Value::Array(items.iter().map(canonicalize).collect()),
other => other.clone(),
}
}
fn digest(body: &NormalizedBody) -> String {
let mut hasher = Sha256::new();
hasher.update(body.kind_label().as_bytes());
hasher.update([0]);
hasher.update(body.canonical_bytes());
hex::encode(hasher.finalize())
}
fn fingerprint(
kind: DivergenceKind,
primary_status: u16,
primary_digest: &str,
shadow_status: u16,
shadow_digest: &str,
) -> String {
let mut hasher = Sha256::new();
hasher.update(kind.as_str().as_bytes());
hasher.update([0]);
hasher.update(primary_status.to_string().as_bytes());
hasher.update([0]);
hasher.update(primary_digest.as_bytes());
hasher.update([0]);
hasher.update(shadow_status.to_string().as_bytes());
hasher.update([0]);
hasher.update(shadow_digest.as_bytes());
hex::encode(hasher.finalize()).chars().take(16).collect()
}
fn sample(body: &NormalizedBody, filter: &ParameterFilter, sample_limit: usize) -> Option<Value> {
let NormalizedBody::Json(value) = body else {
return None;
};
if !scalars_are_all_keyed(value) {
return None;
}
let scrubbed = filter.scrub_json(value);
let rendered = serde_json::to_string(&scrubbed).unwrap_or_default();
if rendered.len() <= sample_limit {
return Some(scrubbed);
}
truncate_to_serialized_budget(&rendered, sample_limit).map(Value::String)
}
fn truncate_to_serialized_budget(rendered: &str, sample_limit: usize) -> Option<String> {
const QUOTES: usize = 2;
let reserved = QUOTES.saturating_add(TRUNCATION_MARKER.len());
let budget = sample_limit.checked_sub(reserved)?;
let mut truncated = String::new();
let mut cost = 0usize;
for character in rendered.chars() {
cost = cost.saturating_add(serialized_len(character));
if cost > budget {
break;
}
truncated.push(character);
}
truncated.push_str(TRUNCATION_MARKER);
Some(truncated)
}
const fn serialized_len(character: char) -> usize {
match character {
'"' | '\\' | '\n' | '\r' | '\t' | '\u{8}' | '\u{c}' => 2,
c if (c as u32) < 0x20 => 6,
c => c.len_utf8(),
}
}
fn scalars_are_all_keyed(value: &Value) -> bool {
match value {
Value::Object(_) => true,
Value::Array(items) => items.iter().all(scalars_are_all_keyed),
Value::Null | Value::Bool(_) | Value::Number(_) | Value::String(_) => false,
}
}
#[must_use]
pub fn redact_path_and_query(target: &str, filter: &ParameterFilter) -> String {
let Some((path, query)) = target.split_once('?') else {
return target.to_owned();
};
let redacted: Vec<String> = query
.split('&')
.map(|pair| match pair.split_once('=') {
Some((key, _)) if key_is_filtered(key, filter) => {
format!("{key}={}", crate::log::filter::FILTERED_PLACEHOLDER)
}
None if !pair.is_empty() && key_is_filtered(pair, filter) => {
crate::log::filter::FILTERED_PLACEHOLDER.to_owned()
}
_ => pair.to_owned(),
})
.collect();
format!("{path}?{}", redacted.join("&"))
}
fn key_is_filtered(raw_key: &str, filter: &ParameterFilter) -> bool {
if filter.matches_key(raw_key) {
return true;
}
let decoded = percent_decode(raw_key);
if filter.matches_key(&decoded) {
return true;
}
decoded
.split(['[', ']', '.'])
.any(|segment| !segment.is_empty() && filter.matches_key(segment))
}
fn percent_decode(raw: &str) -> String {
let replaced = raw.replace('+', " ");
let mut out: Vec<u8> = Vec::with_capacity(replaced.len());
let mut parts = replaced.as_bytes().split(|byte| *byte == b'%');
if let Some(first) = parts.next() {
out.extend_from_slice(first);
}
for part in parts {
if let (Some(high), Some(low)) = (
part.first().copied().and_then(hex_value),
part.get(1).copied().and_then(hex_value),
) {
out.push(high.wrapping_shl(4) | low);
out.extend_from_slice(part.get(2..).unwrap_or_default());
} else {
out.push(b'%');
out.extend_from_slice(part);
}
}
String::from_utf8_lossy(&out).into_owned()
}
const fn hex_value(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte.wrapping_sub(b'0')),
b'a'..=b'f' => Some(byte.wrapping_sub(b'a').wrapping_add(10)),
b'A'..=b'F' => Some(byte.wrapping_sub(b'A').wrapping_add(10)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::log::filter::ParameterFilter;
use bytes::Bytes;
fn json(status: u16, body: &str) -> ResponseFacts {
ResponseFacts {
status,
content_type: Some("application/json".to_owned()),
content_encoding: None,
body: Bytes::from(body.to_owned()),
}
}
#[test]
fn identical_json_matches() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1,"name":"ada"}"#);
let b = json(200, r#"{"id":1,"name":"ada"}"#);
assert!(matches!(compare(&a, &b, &filter, 2048), Comparison::Match));
}
#[test]
fn json_object_key_order_is_normalized_away() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1,"name":"ada"}"#);
let b = json(200, r#"{"name":"ada","id":1}"#);
assert!(matches!(compare(&a, &b, &filter, 2048), Comparison::Match));
}
#[test]
fn json_array_order_is_a_divergence() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"ids":[1,2,3]}"#);
let b = json(200, r#"{"ids":[3,2,1]}"#);
assert!(matches!(
compare(&a, &b, &filter, 2048),
Comparison::Diverged(_)
));
}
#[test]
fn dropped_json_field_is_a_body_divergence() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1,"total":42}"#);
let b = json(200, r#"{"id":1}"#);
let Comparison::Diverged(d) = compare(&a, &b, &filter, 2048) else {
panic!("expected divergence");
};
assert_eq!(d.kind, DivergenceKind::Body);
}
#[test]
fn differing_status_class_is_a_status_divergence() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1}"#);
let b = json(500, r#"{"id":1}"#);
let Comparison::Diverged(d) = compare(&a, &b, &filter, 2048) else {
panic!("expected divergence");
};
assert_eq!(d.kind, DivergenceKind::StatusClass);
assert_eq!(d.primary_status, 200);
assert_eq!(d.shadow_status, 500);
}
#[test]
fn same_status_class_different_code_is_not_a_status_divergence() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1}"#);
let b = json(201, r#"{"id":1}"#);
assert!(matches!(compare(&a, &b, &filter, 2048), Comparison::Match));
}
#[test]
fn comparison_is_deterministic_and_reproducible() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1,"total":42}"#);
let b = json(200, r#"{"id":1}"#);
let first = compare(&a, &b, &filter, 2048);
let second = compare(&a, &b, &filter, 2048);
let (Comparison::Diverged(x), Comparison::Diverged(y)) = (first, second) else {
panic!("expected divergences");
};
assert_eq!(x, y, "the same captured pair must produce the same record");
assert!(!x.fingerprint.is_empty());
}
#[test]
fn samples_are_pii_redacted() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1,"password":"hunter2","total":1}"#);
let b = json(200, r#"{"id":1,"password":"hunter2"}"#);
let Comparison::Diverged(d) = compare(&a, &b, &filter, 2048) else {
panic!("expected divergence");
};
let rendered = serde_json::to_string(&d.primary_sample).unwrap();
assert!(
!rendered.contains("hunter2"),
"recorded sample must not carry a filtered value: {rendered}"
);
assert!(rendered.contains("[FILTERED]"));
}
#[test]
fn non_json_bodies_record_no_sample() {
let filter = ParameterFilter::default();
let html = |body: &str| ResponseFacts {
status: 200,
content_type: Some("text/html; charset=utf-8".to_owned()),
content_encoding: None,
body: Bytes::from(body.to_owned()),
};
let Comparison::Diverged(d) = compare(
&html("<p>ada@example.com</p>"),
&html("<p>grace@example.com</p>"),
&filter,
2048,
) else {
panic!("expected divergence");
};
assert!(d.primary_sample.is_none());
assert!(d.shadow_sample.is_none());
assert_ne!(d.primary_digest, d.shadow_digest);
}
#[test]
fn identical_bytes_do_not_diverge_because_only_one_side_named_a_content_type() {
let filter = ParameterFilter::default();
let labelled = ResponseFacts::new(
200,
Some("text/plain".to_owned()),
Bytes::from_static(b"hello"),
);
let unlabelled = ResponseFacts::new(200, None, Bytes::from_static(b"hello"));
assert!(matches!(
compare(&labelled, &unlabelled, &filter, 2048),
Comparison::Match
));
let other = ResponseFacts::new(200, None, Bytes::from_static(b"goodbye"));
assert!(matches!(
compare(&labelled, &other, &filter, 2048),
Comparison::Diverged(_)
));
}
#[test]
fn scalar_json_is_recognised_without_a_content_type() {
let filter = ParameterFilter::default();
for body in ["null", "true", "1", "\"ok\"", "1.5"] {
let labelled = ResponseFacts::new(
200,
Some("application/json".to_owned()),
Bytes::from(body.to_owned()),
);
let unlabelled = ResponseFacts::new(200, None, Bytes::from(body.to_owned()));
assert!(
matches!(
compare(&labelled, &unlabelled, &filter, 2048),
Comparison::Match
),
"scalar JSON body {body:?} diverged on a header-only difference"
);
}
}
#[test]
fn a_scalar_body_is_never_sampled_however_it_is_labelled() {
let filter = ParameterFilter::default();
for (content_type, body) in [
(Some("text/plain".to_owned()), "482913"),
(Some("application/json".to_owned()), "482913"),
(Some("application/json".to_owned()), "\"a-bearer-token\""),
(None, "482913"),
] {
let primary = ResponseFacts::new(200, content_type.clone(), Bytes::from(body));
let shadow = ResponseFacts::new(200, content_type, Bytes::from_static(b"0"));
let Comparison::Diverged(d) = compare(&primary, &shadow, &filter, 2048) else {
panic!("expected divergence for {body}");
};
assert!(
d.primary_sample.is_none(),
"a scalar body must record a digest only, got a sample for {body}"
);
assert!(!d.primary_digest.is_empty());
}
}
#[test]
fn an_array_of_bare_scalars_is_not_sampled_either() {
let filter = ParameterFilter::default();
let list = |body: &str| {
ResponseFacts::new(
200,
Some("application/json".to_owned()),
Bytes::from(body.to_owned()),
)
};
let Comparison::Diverged(d) =
compare(&list(r#"["a-bearer-token"]"#), &list("[]"), &filter, 2048)
else {
panic!("expected divergence");
};
assert!(d.primary_sample.is_none());
}
#[test]
fn a_list_of_strings_under_a_key_is_still_sampled() {
let filter = ParameterFilter::default();
let body = |raw: &str| {
ResponseFacts::new(
200,
Some("application/json".to_owned()),
Bytes::from(raw.to_owned()),
)
};
let Comparison::Diverged(d) = compare(
&body(r#"{"id":7,"total":42,"items":["a","b"]}"#),
&body(r#"{"id":7,"items":["a","b"]}"#),
&filter,
2048,
) else {
panic!("expected divergence");
};
let sample = d.primary_sample.expect("a keyed body must be sampled");
assert_eq!(sample["total"], serde_json::json!(42));
assert_eq!(sample["items"], serde_json::json!(["a", "b"]));
}
#[test]
fn a_list_under_a_filtered_key_is_redacted_whole() {
let filter = ParameterFilter::default();
let body = |raw: &str| {
ResponseFacts::new(
200,
Some("application/json".to_owned()),
Bytes::from(raw.to_owned()),
)
};
let Comparison::Diverged(d) = compare(
&body(r#"{"id":1,"token":["abc","def"]}"#),
&body(r#"{"id":2,"token":["abc","def"]}"#),
&filter,
2048,
) else {
panic!("expected divergence");
};
let rendered = serde_json::to_string(&d.primary_sample).unwrap();
assert!(!rendered.contains("abc"), "{rendered}");
assert!(rendered.contains("[FILTERED]"), "{rendered}");
}
#[test]
fn keyed_bodies_are_still_sampled_including_arrays_of_objects() {
let filter = ParameterFilter::default();
let json_body = |body: &str| {
ResponseFacts::new(
200,
Some("application/json".to_owned()),
Bytes::from(body.to_owned()),
)
};
let Comparison::Diverged(d) = compare(
&json_body(r#"[{"id":1,"password":"hunter2"},{"id":2}]"#),
&json_body("[]"),
&filter,
2048,
) else {
panic!("expected divergence");
};
let rendered = serde_json::to_string(&d.primary_sample).unwrap();
assert!(rendered.contains("[FILTERED]"), "{rendered}");
assert!(!rendered.contains("hunter2"), "{rendered}");
}
#[test]
fn scalar_json_still_diverges_on_a_real_difference() {
let filter = ParameterFilter::default();
let scalar = |body: &str| ResponseFacts::new(200, None, Bytes::from(body.to_owned()));
assert!(matches!(
compare(&scalar("true"), &scalar("false"), &filter, 2048),
Comparison::Diverged(_)
));
assert!(matches!(
compare(&scalar("1"), &scalar("2"), &filter, 2048),
Comparison::Diverged(_)
));
}
#[test]
fn a_body_that_is_not_utf8_is_compared_byte_for_byte() {
let filter = ParameterFilter::default();
let png = |trailer: u8| {
ResponseFacts::new(
200,
Some("image/png".to_owned()),
Bytes::from(vec![0x89, b'P', b'N', b'G', 0xFF, trailer]),
)
};
assert!(matches!(
compare(&png(1), &png(1), &filter, 2048),
Comparison::Match
));
assert!(matches!(
compare(&png(1), &png(2), &filter, 2048),
Comparison::Diverged(_)
));
}
#[test]
fn text_bodies_normalize_line_endings_and_outer_whitespace() {
let filter = ParameterFilter::default();
let text = |body: &str| ResponseFacts {
status: 200,
content_type: Some("text/plain".to_owned()),
content_encoding: None,
body: Bytes::from(body.to_owned()),
};
assert!(matches!(
compare(
&text("hello\r\nworld"),
&text(" hello\nworld\n"),
&filter,
2048
),
Comparison::Match
));
}
#[test]
fn oversized_samples_are_truncated_to_the_byte_budget() {
let filter = ParameterFilter::default();
let big = format!(r#"{{"blob":"{}"}}"#, "x".repeat(4096));
let Comparison::Diverged(d) = compare(&json(200, &big), &json(200, "{}"), &filter, 64)
else {
panic!("expected divergence");
};
let Some(Value::String(sample)) = d.primary_sample else {
panic!("a truncated sample is recorded as a string");
};
assert!(sample.ends_with(TRUNCATION_MARKER), "{sample}");
let body = sample.strip_suffix(TRUNCATION_MARKER).expect("marker");
assert!(
body.len() <= 64,
"sample body is {} bytes, budget was 64",
body.len()
);
}
#[test]
fn a_truncated_sample_fits_its_budget_once_serialized() {
let filter = ParameterFilter::default();
let quoted = format!(r#"{{"blob":"{}"}}"#, "a\"b\\c".repeat(400));
for limit in [8usize, 16, 64, 512] {
let Comparison::Diverged(d) =
compare(&json(200, "ed), &json(200, "{}"), &filter, limit)
else {
panic!("expected divergence at limit {limit}");
};
let serialized = serde_json::to_string(&d.primary_sample).expect("sample serializes");
assert!(
serialized.len() <= limit,
"limit {limit}: serialized sample is {} bytes: {serialized}",
serialized.len()
);
}
}
#[test]
fn a_budget_too_small_for_the_marker_records_no_sample_at_all() {
let filter = ParameterFilter::default();
for limit in 0usize..=4 {
let Comparison::Diverged(d) =
compare(&json(200, r#"{"a":1}"#), &json(200, "{}"), &filter, limit)
else {
panic!("expected divergence at limit {limit}");
};
assert!(
d.primary_sample.is_none(),
"limit {limit}: recorded {:?}",
d.primary_sample
);
assert!(!d.primary_digest.is_empty());
}
let Comparison::Diverged(d) =
compare(&json(200, r#"{"a":1}"#), &json(200, "{}"), &filter, 5)
else {
panic!("expected divergence");
};
let serialized = serde_json::to_string(&d.primary_sample).expect("serializes");
assert!(serialized.len() <= 5, "{serialized}");
}
#[test]
fn the_sample_budget_is_bytes_not_characters() {
let filter = ParameterFilter::default();
let wide = format!(r#"{{"note":"{}"}}"#, "€".repeat(200));
let Comparison::Diverged(d) = compare(&json(200, &wide), &json(200, "{}"), &filter, 64)
else {
panic!("expected divergence");
};
let Some(Value::String(sample)) = d.primary_sample else {
panic!("a truncated sample is recorded as a string");
};
let body = sample.strip_suffix(TRUNCATION_MARKER).expect("marker");
assert!(body.len() <= 64, "{} bytes exceeds the budget", body.len());
}
#[test]
fn both_samples_are_redacted_not_just_the_primary() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"id":1,"password":"hunter2","total":1}"#);
let b = json(200, r#"{"id":2,"password":"hunter2"}"#);
let Comparison::Diverged(d) = compare(&a, &b, &filter, 2048) else {
panic!("expected divergence");
};
for (side, sample) in [("primary", &d.primary_sample), ("shadow", &d.shadow_sample)] {
let rendered = serde_json::to_string(sample).unwrap();
assert!(
!rendered.contains("hunter2"),
"{side} sample leaked a filtered value: {rendered}"
);
}
}
#[test]
fn redaction_sees_through_percent_encoded_parameter_names() {
let filter = ParameterFilter::default();
assert_eq!(
redact_path_and_query("/x?%74oken=abc123", &filter),
"/x?%74oken=[FILTERED]"
);
}
#[test]
fn redaction_sees_into_nested_parameter_names() {
let filter = ParameterFilter::default();
for (raw, expected) in [
(
"/x?auth[access_token]=s",
"/x?auth[access_token]=[FILTERED]",
),
("/x?filter.password=s", "/x?filter.password=[FILTERED]"),
("/x?auth%5Bapi_key%5D=s", "/x?auth%5Bapi_key%5D=[FILTERED]"),
] {
assert_eq!(redact_path_and_query(raw, &filter), expected, "{raw}");
}
}
#[test]
fn a_valueless_parameter_that_names_a_secret_is_redacted() {
let filter = ParameterFilter::default();
assert_eq!(
redact_path_and_query("/reset?token", &filter),
"/reset?[FILTERED]"
);
assert_eq!(redact_path_and_query("/list?debug", &filter), "/list?debug");
}
#[test]
fn redaction_preserves_unrelated_parameters_and_their_order() {
let filter = ParameterFilter::default();
assert_eq!(
redact_path_and_query("/x?a=1&token=t&b=2", &filter),
"/x?a=1&token=[FILTERED]&b=2"
);
}
#[test]
fn empty_bodies_on_both_sides_match() {
let filter = ParameterFilter::default();
let head = || ResponseFacts {
status: 204,
content_type: None,
content_encoding: None,
body: Bytes::new(),
};
assert!(matches!(
compare(&head(), &head(), &filter, 2048),
Comparison::Match
));
}
#[test]
fn invalid_json_falls_back_to_byte_comparison() {
let filter = ParameterFilter::default();
let a = json(200, "{not json");
let b = json(200, "{not json");
assert!(matches!(compare(&a, &b, &filter, 2048), Comparison::Match));
let c = json(200, "{not json!");
assert!(matches!(
compare(&a, &c, &filter, 2048),
Comparison::Diverged(_)
));
}
#[test]
fn digests_are_stable_across_equivalent_encodings() {
let filter = ParameterFilter::default();
let a = json(200, r#"{"a":1,"b":2}"#);
let b = json(200, "{ \"b\" : 2 , \"a\" : 1 }");
assert!(matches!(compare(&a, &b, &filter, 2048), Comparison::Match));
}
#[test]
fn redact_query_filters_sensitive_parameters() {
let filter = ParameterFilter::default();
assert_eq!(
redact_path_and_query("/search?q=ada&token=abc123", &filter),
"/search?q=ada&token=[FILTERED]"
);
assert_eq!(redact_path_and_query("/plain", &filter), "/plain");
}
}