From cf38a28d01e3372505c6a82bb815bd83cb90edce Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Wed, 24 Sep 2025 08:24:26 +0700 Subject: [PATCH] Add metadata redaction tests and builder updates --- CHANGELOG.md | 13 +++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 14 ++++----- src/app_error/context.rs | 31 ++++++++++++++------ src/app_error/tests.rs | 15 ++++++++++ src/response/problem_json.rs | 55 ++++++++++++++++++++++++++++++++++++ src/response/tests.rs | 14 +++++++++ 8 files changed, 129 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d329bab..27be5bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.20.0] - 2025-09-30 + +### Added +- Added a `Context::redact_field_mut` builder method to tweak metadata + redaction policies in place before attaching additional fields. +- Extended response tests to cover JSON serialization of redacted payloads and + hashed metadata along with checks for the opt-in internal formatters. + +### Changed +- Verified `ErrorResponse` and `ProblemJson` serialization respects message and + metadata redaction policies, ensuring secrets stay out of wire payloads while + keeping diagnostic logging intact. + ## [0.19.0] - 2025-09-29 ### Changed diff --git a/Cargo.lock b/Cargo.lock index cd5621a..a3abcc5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.19.0" +version = "0.20.0" dependencies = [ "actix-web", "axum 0.8.4", diff --git a/Cargo.toml b/Cargo.toml index 4bef584..5a58911 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.19.0" +version = "0.20.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" diff --git a/README.md b/README.md index 7d8e8f7..b333634 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.19.0", default-features = false } +masterror = { version = "0.20.0", default-features = false } # or with features: -# masterror = { version = "0.19.0", features = [ +# masterror = { version = "0.20.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.19.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.19.0", default-features = false } +masterror = { version = "0.20.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.19.0", features = [ +# masterror = { version = "0.20.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -720,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.19.0", default-features = false } +masterror = { version = "0.20.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.19.0", features = [ +masterror = { version = "0.20.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -735,7 +735,7 @@ masterror = { version = "0.19.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.19.0", features = [ +masterror = { version = "0.20.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 3539194..a3ad334 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -97,14 +97,18 @@ impl Context { /// Override the redaction policy for a metadata field. #[must_use] pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { - self.field_policies - .retain(|(existing, _)| *existing != name); - self.field_policies.push((name, redaction)); - for field in &mut self.fields { - if field.name() == name { - field.set_redaction(redaction); - } - } + self.set_field_policy(name, redaction); + self + } + + /// Override the redaction policy for a metadata field in place. + #[must_use] + pub fn redact_field_mut( + &mut self, + name: &'static str, + redaction: FieldRedaction + ) -> &mut Self { + self.set_field_policy(name, redaction); self } @@ -180,4 +184,15 @@ impl Context { } } } + + fn set_field_policy(&mut self, name: &'static str, redaction: FieldRedaction) { + self.field_policies + .retain(|(existing, _)| *existing != name); + self.field_policies.push((name, redaction)); + for field in &mut self.fields { + if field.name() == name { + field.set_redaction(redaction); + } + } + } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 3d9812c..d1d33d0 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -185,6 +185,21 @@ fn context_redact_field_overrides_policy() { assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Redact)); } +#[test] +fn context_redact_field_mut_applies_policies() { + let mut context = super::Context::new(AppErrorKind::Service); + let _ = context.redact_field_mut("token", FieldRedaction::Hash); + context = context.with(field::str("token", "super-secret")); + + let err = context.into_error(DummyError); + let metadata = err.metadata(); + assert_eq!( + metadata.get("token"), + Some(&FieldValue::Str(Cow::Borrowed("super-secret"))) + ); + assert_eq!(metadata.redaction("token"), Some(FieldRedaction::Hash)); +} + #[test] fn app_error_redact_field_updates_metadata() { let err = AppError::internal("boom") diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index bdb2a0b..067de0a 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -843,6 +843,11 @@ pub fn mapping_for_code(code: AppCode) -> CodeMapping { #[cfg(test)] mod tests { + use std::fmt::Write; + + use serde_json::Value; + use sha2::{Digest, Sha256}; + use super::*; use crate::AppError; @@ -909,6 +914,56 @@ mod tests { } } + #[test] + fn problem_json_serialization_masks_sensitive_metadata() { + let secret = "super-secret"; + let err = AppError::internal("oops").with_field(crate::field::str("token", secret)); + let problem = ProblemJson::from_ref(&err); + let json = serde_json::to_value(&problem).expect("serialize problem"); + + let metadata = json + .get("metadata") + .and_then(Value::as_object) + .expect("metadata present"); + let hashed = metadata + .get("token") + .and_then(Value::as_str) + .expect("hashed token"); + + let mut hasher = Sha256::new(); + hasher.update(secret.as_bytes()); + let digest = hasher.finalize(); + let expected = digest + .iter() + .fold(String::with_capacity(64), |mut acc, byte| { + let _ = write!(&mut acc, "{:02x}", byte); + acc + }); + + assert_eq!(hashed, expected); + assert!(!json.to_string().contains(secret)); + + let debug_repr = format!("{:?}", problem.internal()); + assert!(debug_repr.contains("metadata")); + assert!(!debug_repr.contains(secret)); + } + + #[test] + fn problem_json_serialization_omits_metadata_when_redacted() { + let secret_value = "sensitive-value"; + let err = AppError::internal("secret") + .redactable() + .with_field(crate::field::str("token", secret_value)); + let problem = ProblemJson::from_ref(&err); + let json = serde_json::to_value(&problem).expect("serialize problem"); + + assert!(json.get("metadata").is_none()); + assert!(!json.to_string().contains(secret_value)); + + let debug_repr = format!("{:?}", problem.internal()); + assert!(debug_repr.contains("ProblemJson")); + } + #[test] fn mapping_for_every_code_matches_http_status() { for (code, mapping) in CODE_MAPPINGS { diff --git a/src/response/tests.rs b/src/response/tests.rs index 89777e5..d7f11b0 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -193,6 +193,20 @@ fn from_app_error_redacts_message_when_policy_allows() { assert_eq!(resp_ref.message, AppErrorKind::Internal.to_string()); } +#[test] +fn error_response_serialization_hides_redacted_message() { + let secret = "super-secret"; + let resp: ErrorResponse = AppError::internal(secret).redactable().into(); + let json = serde_json::to_value(&resp).expect("serialize response"); + + let fallback = AppErrorKind::Internal.to_string(); + assert_eq!( + json.get("message").and_then(|value| value.as_str()), + Some(fallback.as_str()) + ); + assert!(!json.to_string().contains(secret)); +} + // --- Display formatting -------------------------------------------------- #[test]