From 42528fdb967b1d42077d3773a6a50bf161398de3 Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Jul 2026 13:42:10 +0700 Subject: [PATCH 1/2] #450 feat: wire DisplayMode into Display with full redaction --- README.md | 58 ++-- README.template.md | 58 ++-- src/app_error.rs | 9 +- src/app_error/core.rs | 14 +- src/app_error/core/display.rs | 556 ++++++++++++++++++++++++---------- src/app_error/core/error.rs | 44 +-- src/app_error/redaction.rs | 172 +++++++++++ src/app_error/tests.rs | 44 ++- src/lib.rs | 3 +- src/response/problem_json.rs | 150 +-------- tests/display_mode_env.rs | 28 ++ 11 files changed, 707 insertions(+), 429 deletions(-) create mode 100644 src/app_error/redaction.rs create mode 100644 tests/display_mode_env.rs diff --git a/README.md b/README.md index 83823ec..efccc33 100644 --- a/README.md +++ b/README.md @@ -628,12 +628,15 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Environment detection with DisplayMode `DisplayMode` detects the deployment environment (`Prod`, `Local` or -`Staging`) so your code can branch on it. `DisplayMode::current()` resolves -the mode in this order and caches the result on first access: +`Staging`) and drives the `Display` output of `AppError`. +`DisplayMode::current()` resolves the mode in this order and caches the +result on first access (the environment is read once per process): -1. `MASTERROR_ENV` environment variable (`prod`, `local`, or `staging`) +1. `MASTERROR_ENV` environment variable (`prod`/`production`, + `local`/`dev`/`development`, or `staging`/`stage`) 2. `KUBERNETES_SERVICE_HOST` presence (selects `Prod`) -3. Build configuration (`debug_assertions` → `Local`, release → `Prod`) +3. Build configuration (`debug_assertions` → `Local`, release → `Prod`; + the only detection step available without the `std` feature) ~~~rust use masterror::DisplayMode; @@ -647,28 +650,24 @@ match mode { } ~~~ -Note: `Display` for `AppError` does not consult `DisplayMode` yet — the -output is identical in all modes. The only formatting branch today is the -`colored` feature described below; mode-aware formatting is not wired up. +`Display` for `AppError` dispatches on the detected mode: -**Colored Terminal Output:** - -Enable the `colored` feature for enhanced terminal output. It applies -whenever the feature is enabled, regardless of the detected mode: +| Mode | Layout | +|------|--------| +| `Local` | Multi-line human-readable report: kind, code, message, source chain, metadata | +| `Prod` | Compact single-line JSON: `kind`, `code`, optional `message`, metadata | +| `Staging` | Same JSON as `Prod` plus a `source_chain` array | -~~~toml -[dependencies] -masterror = { version = "0.28.0", features = ["colored"] } -~~~ +All layouts apply per-field redaction policies: `Redact` renders the +`[REDACTED]` placeholder, `Hash` renders a SHA-256 hex digest, `Last4` +keeps only the trailing characters (fields that cannot be masked are +omitted). Set `MASTERROR_ENV=local` on any host to force the +human-readable layout, e.g. when debugging inside Kubernetes. -Without the `colored` feature, errors display their `AppErrorKind` label: -~~~ -NotFound -~~~ +In `Local` mode the output looks like: -With `colored`, a full multi-line format with context: ~~~ -Error: NotFound +Error: Not found Code: NOT_FOUND Message: User not found @@ -677,6 +676,23 @@ Context: request_id: abc-def ~~~ +In `Prod` mode the same error renders as: + +~~~ +{"kind":"NotFound","code":"NOT_FOUND","message":"User not found","metadata":{"request_id":"abc-def","user_id":12345}} +~~~ + +**Colored Terminal Output:** + +Enable the `colored` feature for ANSI styling with automatic TTY +detection. It affects only the `Local` layout; `Prod` and `Staging` JSON +never contains escape sequences: + +~~~toml +[dependencies] +masterror = { version = "0.28.0", features = ["colored"] } +~~~ +
diff --git a/README.template.md b/README.template.md index 448747f..ab4005b 100644 --- a/README.template.md +++ b/README.template.md @@ -623,12 +623,15 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Environment detection with DisplayMode `DisplayMode` detects the deployment environment (`Prod`, `Local` or -`Staging`) so your code can branch on it. `DisplayMode::current()` resolves -the mode in this order and caches the result on first access: +`Staging`) and drives the `Display` output of `AppError`. +`DisplayMode::current()` resolves the mode in this order and caches the +result on first access (the environment is read once per process): -1. `MASTERROR_ENV` environment variable (`prod`, `local`, or `staging`) +1. `MASTERROR_ENV` environment variable (`prod`/`production`, + `local`/`dev`/`development`, or `staging`/`stage`) 2. `KUBERNETES_SERVICE_HOST` presence (selects `Prod`) -3. Build configuration (`debug_assertions` → `Local`, release → `Prod`) +3. Build configuration (`debug_assertions` → `Local`, release → `Prod`; + the only detection step available without the `std` feature) ~~~rust use masterror::DisplayMode; @@ -642,28 +645,24 @@ match mode { } ~~~ -Note: `Display` for `AppError` does not consult `DisplayMode` yet — the -output is identical in all modes. The only formatting branch today is the -`colored` feature described below; mode-aware formatting is not wired up. +`Display` for `AppError` dispatches on the detected mode: -**Colored Terminal Output:** - -Enable the `colored` feature for enhanced terminal output. It applies -whenever the feature is enabled, regardless of the detected mode: +| Mode | Layout | +|------|--------| +| `Local` | Multi-line human-readable report: kind, code, message, source chain, metadata | +| `Prod` | Compact single-line JSON: `kind`, `code`, optional `message`, metadata | +| `Staging` | Same JSON as `Prod` plus a `source_chain` array | -~~~toml -[dependencies] -masterror = { version = "{{CRATE_VERSION}}", features = ["colored"] } -~~~ +All layouts apply per-field redaction policies: `Redact` renders the +`[REDACTED]` placeholder, `Hash` renders a SHA-256 hex digest, `Last4` +keeps only the trailing characters (fields that cannot be masked are +omitted). Set `MASTERROR_ENV=local` on any host to force the +human-readable layout, e.g. when debugging inside Kubernetes. -Without the `colored` feature, errors display their `AppErrorKind` label: -~~~ -NotFound -~~~ +In `Local` mode the output looks like: -With `colored`, a full multi-line format with context: ~~~ -Error: NotFound +Error: Not found Code: NOT_FOUND Message: User not found @@ -672,6 +671,23 @@ Context: request_id: abc-def ~~~ +In `Prod` mode the same error renders as: + +~~~ +{"kind":"NotFound","code":"NOT_FOUND","message":"User not found","metadata":{"request_id":"abc-def","user_id":12345}} +~~~ + +**Colored Terminal Output:** + +Enable the `colored` feature for ANSI styling with automatic TTY +detection. It affects only the `Local` layout; `Prod` and `Staging` JSON +never contains escape sequences: + +~~~toml +[dependencies] +masterror = { version = "{{CRATE_VERSION}}", features = ["colored"] } +~~~ +
diff --git a/src/app_error.rs b/src/app_error.rs index 4b74b60..bc72784 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -6,10 +6,10 @@ //! //! [`AppError`] is a thin, framework-agnostic wrapper around a canonical //! error taxonomy [`AppErrorKind`] plus an optional public-facing message. -//! Without the `colored` feature, the `Display` for `AppError` prints only -//! the kind, not the message, to keep logs and errors concise by default. -//! With `colored` enabled, `Display` renders a multi-line report (kind, code, -//! message, source chain, metadata). +//! The `Display` implementation dispatches on [`DisplayMode::current`]: +//! `Local` renders a multi-line human-readable report (kind, code, message, +//! source chain, metadata), while `Prod` and `Staging` render compact JSON. +//! The `colored` feature adds ANSI styling to the `Local` layout only. //! //! ## Design //! @@ -75,6 +75,7 @@ mod context; mod core; mod inline_vec; mod metadata; +pub(crate) mod redaction; pub use core::{AppError, AppResult, DisplayMode, Error, ErrorChain, MessageEditPolicy}; #[cfg(all(test, feature = "backtrace"))] diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 6f35b68..3d72d37 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -52,13 +52,14 @@ pub mod telemetry; /// - `CapturedBacktrace` type alias pub mod types; -/// Environment detection and alternative formatting helpers. +/// Environment detection and mode-aware `Display` layouts. /// /// Provides [`DisplayMode`], which detects the deployment environment from /// `MASTERROR_ENV`, `KUBERNETES_SERVICE_HOST` or build configuration and -/// caches the result. The `Display` implementation for `Error` does not -/// consult this mode; it is a detection API for callers to choose their own -/// formatting. +/// caches the result once per process. The `Display` implementation for +/// `Error` dispatches on this mode: `Local` renders a multi-line +/// human-readable report, while `Prod` and `Staging` render compact JSON +/// with redaction-aware metadata. pub mod display; #[cfg(all(test, feature = "backtrace"))] @@ -183,9 +184,12 @@ mod tests { #[test] fn error_display_shows_kind() { + let _guard = display::force_display_mode(DisplayMode::Local); let err = Error::new(AppErrorKind::Internal, "test"); let display = format!("{}", err); - assert!(!display.is_empty()); + assert!(display.contains("Error: Internal server error")); + assert!(display.contains("Code: INTERNAL")); + assert!(display.contains("Message: test")); } #[cfg(feature = "std")] diff --git a/src/app_error/core/display.rs b/src/app_error/core/display.rs index cfe349f..e0ab9dc 100644 --- a/src/app_error/core/display.rs +++ b/src/app_error/core/display.rs @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -use alloc::string::ToString; +use alloc::string::{String, ToString}; use core::{ error::Error as CoreError, fmt::{Formatter, Result as FmtResult}, @@ -10,17 +10,29 @@ use core::{ }; use super::error::Error; -use crate::{FieldRedaction, FieldValue, MessageEditPolicy}; +use crate::{ + FieldRedaction, FieldValue, MessageEditPolicy, Metadata, + app_error::redaction::{REDACTED_PLACEHOLDER, hash_field_value, mask_last4_field_value} +}; + +/// Sentinel stored in [`CACHED_MODE`] while no mode has been detected yet. +const MODE_CACHE_UNSET: u8 = 255; + +/// Process-wide cache holding the detected [`DisplayMode`] discriminant. +static CACHED_MODE: AtomicU8 = AtomicU8::new(MODE_CACHE_UNSET); -/// Detected deployment environment. +/// Detected deployment environment driving the `Display` layout of +/// [`struct@crate::Error`]. /// /// [`DisplayMode::current`] identifies the environment the process runs in, /// based on the `MASTERROR_ENV` environment variable, Kubernetes detection /// (`KUBERNETES_SERVICE_HOST`) or build configuration, and caches the result. /// -/// Note: the `Display` implementation for [`struct@crate::Error`] does not -/// currently switch its output based on this mode; the mode is a detection -/// API that callers can consult to choose their own formatting. +/// The `Display` implementation for [`struct@crate::Error`] dispatches on +/// this mode: `Local` renders a multi-line human-readable report, while +/// `Prod` and `Staging` render compact single-line JSON. Set +/// `MASTERROR_ENV=local` to force the human-readable layout in any +/// environment. /// /// # Examples /// @@ -40,17 +52,21 @@ pub enum DisplayMode { /// /// Selected when `MASTERROR_ENV` is `prod`/`production`, when /// `KUBERNETES_SERVICE_HOST` is set, or for release builds by default. + /// Errors render as compact JSON without a source chain. Prod = 0, /// Local development environment. /// /// Selected when `MASTERROR_ENV` is `local`/`dev`/`development`, or for - /// debug builds by default. + /// debug builds by default. Errors render as a multi-line + /// human-readable report; the `colored` feature adds ANSI styling to + /// this layout only. Local = 1, /// Staging environment. /// - /// Selected when `MASTERROR_ENV` is `staging`/`stage`. + /// Selected when `MASTERROR_ENV` is `staging`/`stage`. Errors render as + /// compact JSON extended with the source chain. Staging = 2 } @@ -62,7 +78,9 @@ impl DisplayMode { /// 2. Kubernetes environment detection (`KUBERNETES_SERVICE_HOST`) /// 3. Build configuration (`cfg!(debug_assertions)`) /// - /// The result is cached for performance. + /// Without the `std` feature only step 3 applies. The result is cached + /// on first access, so the environment is read once per process and + /// later changes to the variables have no effect. /// /// # Examples /// @@ -77,21 +95,28 @@ impl DisplayMode { /// ``` #[must_use] pub fn current() -> Self { - static CACHED_MODE: AtomicU8 = AtomicU8::new(255); + #[cfg(test)] + if let Some(mode) = test_display_mode_override::get() { + return mode; + } let cached = CACHED_MODE.load(Ordering::Relaxed); - if cached != 255 { - return match cached { - 0 => Self::Prod, - 1 => Self::Local, - 2 => Self::Staging, - _ => unreachable!() - }; + if cached != MODE_CACHE_UNSET { + return Self::from_discriminant(cached); } let mode = Self::detect(); CACHED_MODE.store(mode as u8, Ordering::Relaxed); mode } + /// Converts a cached discriminant back into a mode. + fn from_discriminant(value: u8) -> Self { + match value { + 0 => Self::Prod, + 2 => Self::Staging, + _ => Self::Local + } + } + /// Detects the appropriate display mode from environment. /// /// This is an internal helper called by [`current()`](Self::current). @@ -124,29 +149,108 @@ impl DisplayMode { } } -#[allow(dead_code)] +/// Overrides the detected display mode for testing purposes. +/// +/// Setting an override clears the process-wide cache so the next +/// [`DisplayMode::current`] call observes the new value. Overridden modes are +/// consulted before the cache and never stored in it, keeping detection +/// deterministic for tests that do not override. +/// +/// # Arguments +/// +/// * `mode` - `Some(mode)` to force a mode, `None` to clear the override +#[cfg(test)] +pub(crate) fn set_display_mode_override(mode: Option) { + test_display_mode_override::set(mode); + CACHED_MODE.store(MODE_CACHE_UNSET, Ordering::Relaxed); +} + +/// Resets the display mode cache and override to the uninitialized state. +/// +/// Forces the next [`DisplayMode::current`] call to re-run detection. Tests +/// call it after overriding the mode, mirroring +/// `reset_backtrace_preference`. +#[cfg(test)] +pub(crate) fn reset_display_mode() { + test_display_mode_override::set(None); + CACHED_MODE.store(MODE_CACHE_UNSET, Ordering::Relaxed); +} + +#[cfg(test)] +mod test_display_mode_override { + use core::sync::atomic::{AtomicU8, Ordering}; + + use super::DisplayMode; + + const OVERRIDE_UNSET: u8 = 255; + + static OVERRIDE_STATE: AtomicU8 = AtomicU8::new(OVERRIDE_UNSET); + + pub(super) fn set(mode: Option) { + let state = match mode { + Some(mode) => mode as u8, + None => OVERRIDE_UNSET + }; + OVERRIDE_STATE.store(state, Ordering::Release); + } + + pub(super) fn get() -> Option { + match OVERRIDE_STATE.load(Ordering::Acquire) { + OVERRIDE_UNSET => None, + value => Some(DisplayMode::from_discriminant(value)) + } + } +} + +#[cfg(test)] +pub(crate) use test_support::force_display_mode; + +#[cfg(test)] +mod test_support { + use std::sync::{Mutex, MutexGuard, PoisonError}; + + use super::{DisplayMode, reset_display_mode, set_display_mode_override}; + + static DISPLAY_MODE_LOCK: Mutex<()> = Mutex::new(()); + + /// Guard restoring the default display mode detection on drop. + pub(crate) struct DisplayModeGuard { + _lock: MutexGuard<'static, ()> + } + + impl Drop for DisplayModeGuard { + fn drop(&mut self) { + reset_display_mode(); + } + } + + /// Forces the display mode for the lifetime of the returned guard. + /// + /// Serializes tests that override the mode so concurrent overrides do + /// not observe each other. + pub(crate) fn force_display_mode(mode: DisplayMode) -> DisplayModeGuard { + let lock = DISPLAY_MODE_LOCK + .lock() + .unwrap_or_else(PoisonError::into_inner); + set_display_mode_override(Some(mode)); + DisplayModeGuard { + _lock: lock + } + } +} + impl Error { /// Formats the error as compact JSON (`kind`, `code`, optional `message`, - /// public metadata). + /// redaction-aware metadata). /// - /// Not wired into the `Display` implementation; callers that want this - /// layout must invoke it explicitly. + /// Selected by the `Display` implementation when [`DisplayMode::current`] + /// returns [`DisplayMode::Prod`]. The output never contains ANSI escape + /// sequences. /// /// # Arguments /// /// * `f` - Formatter to write output to - #[cfg(not(test))] pub(crate) fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_prod_impl(f) - } - - #[cfg(test)] - #[allow(missing_docs)] - pub fn fmt_prod(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_prod_impl(f) - } - - fn fmt_prod_impl(&self, f: &mut Formatter<'_>) -> FmtResult { write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?; if !matches!(self.edit_policy, MessageEditPolicy::Redact) && let Some(msg) = &self.message @@ -155,141 +259,73 @@ impl Error { write_json_escaped(f, msg.as_ref())?; write!(f, "\"")?; } - if !self.metadata.is_empty() { - let has_public_fields = self - .metadata - .iter_with_redaction() - .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact)); - if has_public_fields { - write!(f, r#","metadata":{{"#)?; - let mut first = true; - for (name, value, redaction) in self.metadata.iter_with_redaction() { - if matches!(redaction, FieldRedaction::Redact) { - continue; - } - if !first { - write!(f, ",")?; - } - first = false; - write!(f, r#""{}":"#, name)?; - write_metadata_value(f, value)?; - } - write!(f, "}}")?; - } - } + write_json_metadata_section(f, &self.metadata)?; write!(f, "}}") } /// Formats the error as a multi-line human-readable report (kind, code, - /// message, source chain, metadata). + /// message, source chain, redaction-aware metadata). /// - /// Not wired into the `Display` implementation; callers that want this - /// layout must invoke it explicitly. + /// Selected by the `Display` implementation when [`DisplayMode::current`] + /// returns [`DisplayMode::Local`]. The `colored` feature applies ANSI + /// styling to this layout when the terminal supports it. /// /// # Arguments /// /// * `f` - Formatter to write output to - #[cfg(not(test))] pub(crate) fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_local_impl(f) - } - - #[cfg(test)] - #[allow(missing_docs)] - pub fn fmt_local(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_local_impl(f) - } + #[cfg(feature = "colored")] + use crate::colored::style; - fn fmt_local_impl(&self, f: &mut Formatter<'_>) -> FmtResult { + writeln!(f, "Error: {}", self.kind)?; #[cfg(feature = "colored")] - { - use crate::colored::style; - writeln!(f, "Error: {}", self.kind)?; - writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?; - if let Some(msg) = &self.message { - writeln!(f, "Message: {}", style::error_message(msg))?; - } - if let Some(source) = &self.source { - writeln!(f)?; - let mut current: &dyn CoreError = source.as_dyn(); - let mut depth = 0; - while depth < 10 { - writeln!( - f, - " {}: {}", - style::source_context("Caused by"), - style::source_context(current.to_string()) - )?; - if let Some(next) = current.source() { - current = next; - depth += 1; - } else { - break; - } - } - } - if !self.metadata.is_empty() { - writeln!(f)?; - writeln!(f, "Context:")?; - for (key, value) in self.metadata.iter() { - writeln!(f, " {}: {}", style::metadata_key(key), value)?; - } - } - Ok(()) - } + writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?; #[cfg(not(feature = "colored"))] + writeln!(f, "Code: {}", self.code)?; + if !matches!(self.edit_policy, MessageEditPolicy::Redact) + && let Some(msg) = &self.message { - writeln!(f, "Error: {}", self.kind)?; - writeln!(f, "Code: {}", self.code)?; - if let Some(msg) = &self.message { - writeln!(f, "Message: {}", msg)?; - } - if let Some(source) = &self.source { - writeln!(f)?; - let mut current: &dyn CoreError = source.as_dyn(); - let mut depth = 0; - while depth < 10 { - writeln!(f, " Caused by: {}", current)?; - if let Some(next) = current.source() { - current = next; - depth += 1; - } else { - break; - } - } - } - if !self.metadata.is_empty() { - writeln!(f)?; - writeln!(f, "Context:")?; - for (key, value) in self.metadata.iter() { - writeln!(f, " {}: {}", key, value)?; + #[cfg(feature = "colored")] + writeln!(f, "Message: {}", style::error_message(msg))?; + #[cfg(not(feature = "colored"))] + writeln!(f, "Message: {}", msg)?; + } + if let Some(source) = &self.source { + writeln!(f)?; + let mut current: &dyn CoreError = source.as_dyn(); + let mut depth = 0; + while depth < 10 { + #[cfg(feature = "colored")] + writeln!( + f, + " {}: {}", + style::source_context("Caused by"), + style::source_context(current.to_string()) + )?; + #[cfg(not(feature = "colored"))] + writeln!(f, " Caused by: {}", current)?; + if let Some(next) = current.source() { + current = next; + depth += 1; + } else { + break; } } - Ok(()) } + write_local_metadata_section(f, &self.metadata) } /// Formats the error as JSON with additional context (`source_chain` and - /// public metadata). + /// redaction-aware metadata). /// - /// Not wired into the `Display` implementation; callers that want this - /// layout must invoke it explicitly. + /// Selected by the `Display` implementation when [`DisplayMode::current`] + /// returns [`DisplayMode::Staging`]. The output never contains ANSI + /// escape sequences. /// /// # Arguments /// /// * `f` - Formatter to write output to - #[cfg(not(test))] pub(crate) fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_staging_impl(f) - } - - #[cfg(test)] - #[allow(missing_docs)] - pub fn fmt_staging(&self, f: &mut Formatter<'_>) -> FmtResult { - self.fmt_staging_impl(f) - } - - fn fmt_staging_impl(&self, f: &mut Formatter<'_>) -> FmtResult { write!(f, r#"{{"kind":"{:?}","code":"{}""#, self.kind, self.code)?; if !matches!(self.edit_policy, MessageEditPolicy::Redact) && let Some(msg) = &self.message @@ -320,34 +356,97 @@ impl Error { } write!(f, "]")?; } - if !self.metadata.is_empty() { - let has_public_fields = self - .metadata - .iter_with_redaction() - .any(|(_, _, redaction)| !matches!(redaction, FieldRedaction::Redact)); - if has_public_fields { - write!(f, r#","metadata":{{"#)?; - let mut first = true; - for (name, value, redaction) in self.metadata.iter_with_redaction() { - if matches!(redaction, FieldRedaction::Redact) { - continue; - } - if !first { - write!(f, ",")?; - } - first = false; - write!(f, r#""{}":"#, name)?; - write_metadata_value(f, value)?; - } - write!(f, "}}")?; + write_json_metadata_section(f, &self.metadata)?; + write!(f, "}}") + } +} + +/// Metadata value prepared for the local layout after applying redaction. +enum LocalFieldValue<'a> { + /// Public value rendered as-is. + Raw(&'a FieldValue), + /// Value replaced by the redaction placeholder. + Placeholder, + /// Hashed or masked textual representation. + Owned(String) +} + +/// Writes the `,"metadata":{...}` JSON section applying field redaction. +/// +/// Fields marked [`FieldRedaction::Redact`] render as the placeholder, +/// [`FieldRedaction::Hash`] as a SHA-256 hex digest and +/// [`FieldRedaction::Last4`] as a masked value. Fields whose masking yields +/// no value are omitted; if nothing remains, the section is skipped +/// entirely. +fn write_json_metadata_section(f: &mut Formatter<'_>, metadata: &Metadata) -> FmtResult { + let mut wrote_any = false; + for (name, value, redaction) in metadata.iter_with_redaction() { + let masked = match redaction { + FieldRedaction::Last4 => match mask_last4_field_value(value) { + Some(masked) => Some(masked), + None => continue + }, + _ => None + }; + if wrote_any { + write!(f, ",")?; + } else { + write!(f, r#","metadata":{{"#)?; + wrote_any = true; + } + write!(f, r#""{}":"#, name)?; + match redaction { + FieldRedaction::None => write_metadata_value(f, value)?, + FieldRedaction::Redact => write!(f, "\"{}\"", REDACTED_PLACEHOLDER)?, + FieldRedaction::Hash => write!(f, "\"{}\"", hash_field_value(value))?, + FieldRedaction::Last4 => { + write!(f, "\"")?; + write_json_escaped(f, masked.as_deref().unwrap_or_default())?; + write!(f, "\"")?; } } - write!(f, "}}") } + if wrote_any { + write!(f, "}}")?; + } + Ok(()) +} + +/// Writes the `Context:` block of the local layout applying field redaction. +/// +/// Applies the same policies as [`write_json_metadata_section`]; the header +/// is skipped when every field is omitted. +fn write_local_metadata_section(f: &mut Formatter<'_>, metadata: &Metadata) -> FmtResult { + let mut wrote_header = false; + for (name, value, redaction) in metadata.iter_with_redaction() { + let rendered = match redaction { + FieldRedaction::None => LocalFieldValue::Raw(value), + FieldRedaction::Redact => LocalFieldValue::Placeholder, + FieldRedaction::Hash => LocalFieldValue::Owned(hash_field_value(value)), + FieldRedaction::Last4 => match mask_last4_field_value(value) { + Some(masked) => LocalFieldValue::Owned(masked), + None => continue + } + }; + if !wrote_header { + writeln!(f)?; + writeln!(f, "Context:")?; + wrote_header = true; + } + #[cfg(feature = "colored")] + write!(f, " {}: ", crate::colored::style::metadata_key(name))?; + #[cfg(not(feature = "colored"))] + write!(f, " {}: ", name)?; + match rendered { + LocalFieldValue::Raw(value) => writeln!(f, "{}", value)?, + LocalFieldValue::Placeholder => writeln!(f, "{}", REDACTED_PLACEHOLDER)?, + LocalFieldValue::Owned(text) => writeln!(f, "{}", text)? + } + } + Ok(()) } /// Writes a string with JSON escaping. -#[allow(dead_code)] fn write_json_escaped(f: &mut Formatter<'_>, s: &str) -> FmtResult { for ch in s.chars() { match ch { @@ -364,7 +463,6 @@ fn write_json_escaped(f: &mut Formatter<'_>, s: &str) -> FmtResult { } /// Writes a metadata field value in JSON format. -#[allow(dead_code)] fn write_metadata_value(f: &mut Formatter<'_>, value: &FieldValue) -> FmtResult { use crate::app_error::metadata::FieldValue; match value { @@ -400,9 +498,25 @@ fn write_metadata_value(f: &mut Formatter<'_>, value: &FieldValue) -> FmtResult #[cfg(test)] mod tests { + use std::fmt::Write as _; + + use sha2::{Digest, Sha256}; + use super::*; use crate::{AppError, field}; + fn sha256_hex(input: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(input); + hasher + .finalize() + .iter() + .fold(String::with_capacity(64), |mut acc, byte| { + let _ = write!(&mut acc, "{:02x}", byte); + acc + }) + } + #[test] fn display_mode_current_returns_valid_mode() { let mode = DisplayMode::current(); @@ -421,6 +535,45 @@ mod tests { } } + #[test] + fn display_mode_override_takes_priority() { + let _guard = force_display_mode(DisplayMode::Staging); + assert_eq!(DisplayMode::current(), DisplayMode::Staging); + } + + #[test] + fn display_dispatches_prod_layout() { + let _guard = force_display_mode(DisplayMode::Prod); + let error = AppError::not_found("missing user"); + let output = format!("{}", error); + assert!(output.starts_with(r#"{"kind":"NotFound""#), "{output}"); + assert!(output.contains(r#""code":"NOT_FOUND""#)); + assert!(output.contains(r#""message":"missing user""#)); + assert!(!output.contains('\u{1b}')); + } + + #[cfg(feature = "std")] + #[test] + fn display_dispatches_staging_layout() { + use std::io::Error as IoError; + let _guard = force_display_mode(DisplayMode::Staging); + let error = AppError::network("upstream down").with_source(IoError::other("timeout")); + let output = format!("{}", error); + assert!(output.starts_with(r#"{"kind":"Network""#), "{output}"); + assert!(output.contains(r#""source_chain":["timeout"]"#)); + assert!(!output.contains('\u{1b}')); + } + + #[test] + fn display_dispatches_local_layout() { + let _guard = force_display_mode(DisplayMode::Local); + let error = AppError::not_found("missing user"); + let output = format!("{}", error); + assert!(output.contains("Error:")); + assert!(output.contains("Code: NOT_FOUND")); + assert!(output.contains("missing user")); + } + #[test] fn fmt_prod_outputs_json() { let error = AppError::not_found("User not found"); @@ -450,6 +603,70 @@ mod tests { let error = AppError::internal("Error").with_field(field::str("password", "secret")); let output = format!("{}", error.fmt_prod_wrapper()); assert!(!output.contains("secret")); + assert!(output.contains(r#""password":"[REDACTED]""#)); + } + + #[test] + fn hash_redaction_renders_hex_digest_in_all_modes() { + let expected = sha256_hex(b"super-secret"); + let error = AppError::internal("Error").with_field( + field::str("fingerprint", "super-secret").with_redaction(FieldRedaction::Hash) + ); + let prod = format!("{}", error.fmt_prod_wrapper()); + let staging = format!("{}", error.fmt_staging_wrapper()); + let local = format!("{}", error.fmt_local_wrapper()); + for output in [&prod, &staging, &local] { + assert!(output.contains(&expected), "{output}"); + assert!(!output.contains("super-secret"), "{output}"); + } + assert!(prod.contains(&format!(r#""fingerprint":"{}""#, expected))); + assert!(staging.contains(&format!(r#""fingerprint":"{}""#, expected))); + assert!(local.contains(&format!("fingerprint: {}", expected))); + } + + #[test] + fn last4_redaction_masks_value_in_all_modes() { + let error = AppError::internal("Error").with_field( + field::str("card_number", "4111111111111111").with_redaction(FieldRedaction::Last4) + ); + let prod = format!("{}", error.fmt_prod_wrapper()); + let staging = format!("{}", error.fmt_staging_wrapper()); + let local = format!("{}", error.fmt_local_wrapper()); + for output in [&prod, &staging, &local] { + assert!(output.contains("************1111"), "{output}"); + assert!(!output.contains("4111111111111111"), "{output}"); + } + } + + #[test] + fn last4_redaction_omits_unmaskable_field() { + let error = AppError::internal("Error") + .with_field(field::bool("consent", true).with_redaction(FieldRedaction::Last4)); + let prod = format!("{}", error.fmt_prod_wrapper()); + let staging = format!("{}", error.fmt_staging_wrapper()); + let local = format!("{}", error.fmt_local_wrapper()); + assert!(!prod.contains("metadata"), "{prod}"); + assert!(!staging.contains("metadata"), "{staging}"); + assert!(!local.contains("Context:"), "{local}"); + for output in [&prod, &staging, &local] { + assert!(!output.contains("consent"), "{output}"); + } + } + + #[test] + fn redact_policy_uses_placeholder_in_local_layout() { + let error = AppError::internal("Error").with_field(field::str("password", "hunter2")); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(output.contains("password: [REDACTED]"), "{output}"); + assert!(!output.contains("hunter2")); + } + + #[test] + fn fmt_local_hides_redacted_message() { + let error = AppError::internal("sensitive data").redactable(); + let output = format!("{}", error.fmt_local_wrapper()); + assert!(!output.contains("sensitive data")); + assert!(!output.contains("Message:")); } #[test] @@ -570,11 +787,11 @@ mod tests { #[test] fn fmt_local_with_metadata() { let error = AppError::internal("Error") - .with_field(field::str("key", "value")) + .with_field(field::str("region", "value")) .with_field(field::i64("count", -42)); let output = format!("{}", error.fmt_local_wrapper()); assert!(output.contains("Context:")); - assert!(output.contains("key: value")); + assert!(output.contains("region: value")); assert!(output.contains("count: -42")); } @@ -619,7 +836,11 @@ mod tests { #[test] fn display_mode_current_caches_result() { + let _guard = force_display_mode(DisplayMode::Staging); + reset_display_mode(); + assert_eq!(CACHED_MODE.load(Ordering::Relaxed), MODE_CACHE_UNSET); let first = DisplayMode::current(); + assert_eq!(CACHED_MODE.load(Ordering::Relaxed), first as u8); let second = DisplayMode::current(); assert_eq!(first, second); } @@ -664,7 +885,7 @@ mod tests { assert!(output.contains(r#""name":"value""#)); } - #[cfg(feature = "colored")] + #[cfg(feature = "std")] #[test] fn fmt_local_with_deep_source_chain() { use std::io::{Error as IoError, ErrorKind}; @@ -706,6 +927,7 @@ mod tests { .with_field(field::str("password", "secret")); let output = format!("{}", error.fmt_staging_wrapper()); assert!(output.contains(r#""public":"visible""#)); + assert!(output.contains(r#""password":"[REDACTED]""#)); assert!(!output.contains("secret")); } diff --git a/src/app_error/core/error.rs b/src/app_error/core/error.rs index f322aee..871f563 100644 --- a/src/app_error/core/error.rs +++ b/src/app_error/core/error.rs @@ -19,7 +19,10 @@ use serde_json::Value as JsonValue; #[cfg(not(feature = "backtrace"))] use super::types::CapturedBacktrace; -use super::types::{MessageEditPolicy, StoredSource}; +use super::{ + display::DisplayMode, + types::{MessageEditPolicy, StoredSource} +}; use crate::{AppCode, AppErrorKind, RetryAdvice, app_error::metadata::Metadata}; /// Internal representation of error state. @@ -95,41 +98,10 @@ impl DerefMut for Error { impl Display for Error { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - #[cfg(not(feature = "colored"))] - { - Display::fmt(&self.kind, f) - } - #[cfg(feature = "colored")] - { - use crate::colored::style; - writeln!(f, "Error: {}", self.kind)?; - writeln!(f, "Code: {}", style::error_code(self.code.to_string()))?; - if let Some(msg) = &self.message { - writeln!(f, "Message: {}", style::error_message(msg))?; - } - if let Some(source) = &self.source { - writeln!(f)?; - let mut current: &dyn CoreError = source.as_dyn(); - let mut depth = 0; - while depth < 10 { - write!(f, " {}: ", style::source_context("Caused by"))?; - writeln!(f, "{}", style::source_context(current.to_string()))?; - if let Some(next) = current.source() { - current = next; - depth += 1; - } else { - break; - } - } - } - if !self.metadata.is_empty() { - writeln!(f)?; - writeln!(f, "Context:")?; - for (key, value) in self.metadata.iter() { - writeln!(f, " {}: {}", style::metadata_key(key), value)?; - } - } - Ok(()) + match DisplayMode::current() { + DisplayMode::Prod => self.fmt_prod(f), + DisplayMode::Staging => self.fmt_staging(f), + DisplayMode::Local => self.fmt_local(f) } } } diff --git a/src/app_error/redaction.rs b/src/app_error/redaction.rs new file mode 100644 index 0000000..cd283c2 --- /dev/null +++ b/src/app_error/redaction.rs @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Shared redaction helpers applied to metadata field values. +//! +//! Implements the value transformations backing +//! [`FieldRedaction`](crate::FieldRedaction) policies: placeholder +//! substitution, SHA-256 hashing and last-four masking. The same helpers power +//! the `Display` layouts of [`Error`](crate::Error) and the RFC 7807 +//! [`ProblemJson`](crate::ProblemJson) payloads so redaction behaves +//! identically everywhere. + +use alloc::string::{String, ToString}; +use core::{fmt::Write, iter::repeat_n, str::from_utf8}; + +use itoa::Buffer as IntegerBuffer; +use ryu::Buffer as FloatBuffer; +use sha2::{Digest, Sha256}; + +use super::{duration_to_string, metadata::FieldValue}; + +/// Placeholder rendered instead of values redacted with +/// [`FieldRedaction::Redact`](crate::FieldRedaction::Redact). +pub(crate) const REDACTED_PLACEHOLDER: &str = "[REDACTED]"; + +/// Fixed-capacity stack buffer used to format short values without +/// allocating. +struct StackBuffer { + buf: [u8; N], + len: usize +} + +impl StackBuffer { + const fn new() -> Self { + Self { + buf: [0; N], + len: 0 + } + } + + fn as_bytes(&self) -> &[u8] { + &self.buf[..self.len] + } + + fn as_str(&self) -> Option<&str> { + from_utf8(self.as_bytes()).ok() + } +} + +impl Write for StackBuffer { + fn write_str(&mut self, s: &str) -> core::fmt::Result { + let remaining = N.saturating_sub(self.len); + if s.len() > remaining { + return Err(core::fmt::Error); + } + self.buf[self.len..self.len + s.len()].copy_from_slice(s.as_bytes()); + self.len += s.len(); + Ok(()) + } +} + +/// Hashes a field value with SHA-256 and returns the lowercase hex digest. +pub(crate) fn hash_field_value(value: &FieldValue) -> String { + let mut hasher = Sha256::new(); + match value { + FieldValue::Str(value) => hasher.update(value.as_ref().as_bytes()), + FieldValue::I64(value) => { + let mut buffer = IntegerBuffer::new(); + hasher.update(buffer.format(*value).as_bytes()); + } + FieldValue::U64(value) => { + let mut buffer = IntegerBuffer::new(); + hasher.update(buffer.format(*value).as_bytes()); + } + FieldValue::F64(value) => hasher.update(value.to_le_bytes()), + FieldValue::Bool(value) => { + if *value { + hasher.update(b"true"); + } else { + hasher.update(b"false"); + } + } + FieldValue::Uuid(value) => { + let mut repr = [0u8; 36]; + let text = value.hyphenated().encode_lower(&mut repr); + hasher.update(text.as_bytes()); + } + FieldValue::Duration(value) => { + hasher.update(value.as_secs().to_le_bytes()); + hasher.update(value.subsec_nanos().to_le_bytes()); + } + FieldValue::Ip(value) => { + let mut buffer = StackBuffer::<46>::new(); + if write!(&mut buffer, "{value}").is_ok() { + hasher.update(buffer.as_bytes()); + } else { + let fallback = value.to_string(); + hasher.update(fallback.as_bytes()); + } + } + #[cfg(feature = "serde_json")] + FieldValue::Json(value) => { + if let Ok(serialized) = serde_json::to_vec(value) { + hasher.update(&serialized); + } + } + } + let digest = hasher.finalize(); + let mut hex = String::with_capacity(digest.len() * 2); + for byte in digest { + let _ = write!(&mut hex, "{:02x}", byte); + } + hex +} + +/// Masks a field value keeping only its last four characters. +/// +/// Returns `None` for values that have no meaningful textual form to mask +/// (currently booleans), in which case the field must be omitted entirely. +pub(crate) fn mask_last4_field_value(value: &FieldValue) -> Option { + match value { + FieldValue::Str(value) => Some(mask_last4(value.as_ref())), + FieldValue::I64(value) => { + let mut buffer = IntegerBuffer::new(); + Some(mask_last4(buffer.format(*value))) + } + FieldValue::U64(value) => { + let mut buffer = IntegerBuffer::new(); + Some(mask_last4(buffer.format(*value))) + } + FieldValue::F64(value) => { + let mut buffer = FloatBuffer::new(); + Some(mask_last4(buffer.format(*value))) + } + FieldValue::Uuid(value) => { + let mut repr = [0u8; 36]; + let text = value.hyphenated().encode_lower(&mut repr); + Some(mask_last4(text)) + } + FieldValue::Duration(value) => Some(mask_last4(&duration_to_string(*value))), + FieldValue::Ip(value) => { + let mut buffer = StackBuffer::<46>::new(); + if write!(&mut buffer, "{value}").is_err() { + return Some(mask_last4(&value.to_string())); + } + buffer.as_str().map(mask_last4) + } + #[cfg(feature = "serde_json")] + FieldValue::Json(value) => serde_json::to_string(value) + .ok() + .map(|text| mask_last4(&text)), + FieldValue::Bool(_) => None + } +} + +/// Replaces all but the trailing characters of `value` with `*`. +/// +/// Values of four characters or fewer keep only the last character. +pub(crate) fn mask_last4(value: &str) -> String { + let chars = value.chars(); + let total = chars.clone().count(); + if total == 0 { + return String::new(); + } + let keep = if total <= 4 { 1 } else { 4 }; + let mask_len = total.saturating_sub(keep); + let mut masked = String::with_capacity(value.len()); + masked.extend(repeat_n('*', mask_len)); + masked.extend(chars.skip(mask_len)); + masked +} diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index f42714c..cc706ad 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -33,6 +33,7 @@ impl StdError for AnyhowSource { } } +use super::core::{DisplayMode, display::force_display_mode}; #[cfg(feature = "backtrace")] use super::core::{reset_backtrace_preference, set_backtrace_preference_override}; @@ -731,36 +732,29 @@ fn app_error_fits_result_budget() { #[test] #[cfg(feature = "std")] fn error_chain_iterates_through_sources() { + let _guard = force_display_mode(DisplayMode::Local); let io_err = IoError::other("disk offline"); let app_err = AppError::internal("db down").with_context(io_err); let chain: Vec<_> = app_err.chain().collect(); assert_eq!(chain.len(), 2); let first_err = chain[0].to_string(); - assert!( - first_err.contains("Internal") - || first_err.contains("INTERNAL") - || first_err.contains("Error:") - ); - #[cfg(feature = "colored")] + assert!(first_err.contains("Error: Internal server error")); + assert!(first_err.contains("Code: INTERNAL")); assert!(first_err.contains("db down")); - #[cfg(not(feature = "colored"))] - assert!(first_err.contains("Internal")); assert_eq!(chain[1].to_string(), "disk offline"); } #[test] #[cfg(feature = "std")] fn error_chain_single_error() { + let _guard = force_display_mode(DisplayMode::Local); let err = AppError::bad_request("missing field"); let chain: Vec<_> = err.chain().collect(); assert_eq!(chain.len(), 1); let err_str = chain[0].to_string(); - assert!(err_str.contains("Bad") || err_str.contains("BAD")); - #[cfg(feature = "colored")] - { - assert!(chain[0].to_string().contains("Bad request")); - assert!(chain[0].to_string().contains("missing field")); - } + assert!(err_str.contains("Bad request")); + assert!(err_str.contains("Code: BAD_REQUEST")); + assert!(err_str.contains("missing field")); } #[test] @@ -785,18 +779,12 @@ fn root_cause_returns_deepest_error() { #[test] #[cfg(feature = "std")] fn root_cause_returns_self_when_no_source() { + let _guard = force_display_mode(DisplayMode::Local); let err = AppError::timeout("operation timed out"); let root = err.root_cause(); let root_str = root.to_string(); - assert!( - root_str.contains("timed out") - || root_str.contains("TIMEOUT") - || root_str.contains("Timeout") - ); - #[cfg(feature = "colored")] - assert!(root_str.contains("operation")); - #[cfg(not(feature = "colored"))] - assert!(root_str.contains("Timeout") || root_str.contains("timed out")); + assert!(root_str.contains("Code: TIMEOUT")); + assert!(root_str.contains("operation timed out")); } #[test] @@ -893,18 +881,19 @@ fn downcast_mut_returns_none_for_shared_arc_attached_via_with_context() { } #[test] -#[cfg(feature = "colored")] -fn colored_display_bare_error_without_message() { +fn local_display_bare_error_without_message() { + let _guard = force_display_mode(DisplayMode::Local); let err = AppError::bare(AppErrorKind::Internal); let output = format!("{}", err); assert!(output.contains("Internal server error")); assert!(output.contains("Code:")); assert!(output.contains("INTERNAL")); + assert!(!output.contains("Message:")); } #[test] -#[cfg(feature = "colored")] -fn colored_display_deep_error_chain() { +#[cfg(feature = "std")] +fn local_display_deep_error_chain() { use crate::field; #[derive(Debug)] struct CustomError { @@ -921,6 +910,7 @@ fn colored_display_deep_error_chain() { self.source.as_ref().map(|e| e as &(dyn StdError + 'static)) } } + let _guard = force_display_mode(DisplayMode::Local); let root = IoError::other("disk full"); let mid = CustomError { msg: "write failed".to_string(), diff --git a/src/lib.rs b/src/lib.rs index c7b7f31..12f934c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,7 +79,8 @@ //! but not this feature — enable it explicitly to get the JSON-gated APIs //! - `multipart` — `From` mapping to //! `BadRequest` (implies `axum`) -//! - `colored` — colored, multi-line `Display` output for [`struct@Error`] +//! - `colored` — ANSI styling for the `Local` `Display` layout of +//! [`struct@Error`] (see [`DisplayMode`]) //! - `tracing` — emit structured `tracing` events when errors are constructed //! - `metrics` — increment an `error_total{code,category}` counter per error //! - `backtrace` — capture backtraces (controlled by `RUST_BACKTRACE`) diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index b603d72..2044a9d 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -8,25 +8,19 @@ use alloc::{ string::{String, ToString} }; use core::{ - fmt::Write, - iter::repeat_n, mem::{replace, take}, - net::IpAddr, - str::from_utf8 + net::IpAddr }; use http::StatusCode; -use itoa::Buffer as IntegerBuffer; -use ryu::Buffer as FloatBuffer; use serde::Serialize; #[cfg(feature = "serde_json")] use serde_json::Value as JsonValue; -use sha2::{Digest, Sha256}; use super::core::ErrorResponse; use crate::{ AppCode, AppError, AppErrorKind, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, - app_error::duration_to_string + app_error::redaction::{REDACTED_PLACEHOLDER, hash_field_value, mask_last4_field_value} }; /// Canonical mapping for a public [`AppCode`]. @@ -509,8 +503,6 @@ fn sanitize_metadata_ref( } } -const REDACTED_PLACEHOLDER: &str = "[REDACTED]"; - fn sanitize_problem_metadata_value_owned( value: FieldValue, redaction: FieldRedaction @@ -545,143 +537,6 @@ fn sanitize_problem_metadata_value_ref( } } -struct StackBuffer { - buf: [u8; N], - len: usize -} - -impl StackBuffer { - const fn new() -> Self { - Self { - buf: [0; N], - len: 0 - } - } - - fn as_bytes(&self) -> &[u8] { - &self.buf[..self.len] - } - - fn as_str(&self) -> Option<&str> { - from_utf8(self.as_bytes()).ok() - } -} - -impl Write for StackBuffer { - fn write_str(&mut self, s: &str) -> core::fmt::Result { - let remaining = N.saturating_sub(self.len); - if s.len() > remaining { - return Err(core::fmt::Error); - } - self.buf[self.len..self.len + s.len()].copy_from_slice(s.as_bytes()); - self.len += s.len(); - Ok(()) - } -} - -fn hash_field_value(value: &FieldValue) -> String { - let mut hasher = Sha256::new(); - match value { - FieldValue::Str(value) => hasher.update(value.as_ref().as_bytes()), - FieldValue::I64(value) => { - let mut buffer = IntegerBuffer::new(); - hasher.update(buffer.format(*value).as_bytes()); - } - FieldValue::U64(value) => { - let mut buffer = IntegerBuffer::new(); - hasher.update(buffer.format(*value).as_bytes()); - } - FieldValue::F64(value) => hasher.update(value.to_le_bytes()), - FieldValue::Bool(value) => { - if *value { - hasher.update(b"true"); - } else { - hasher.update(b"false"); - } - } - FieldValue::Uuid(value) => { - let mut repr = [0u8; 36]; - let text = value.hyphenated().encode_lower(&mut repr); - hasher.update(text.as_bytes()); - } - FieldValue::Duration(value) => { - hasher.update(value.as_secs().to_le_bytes()); - hasher.update(value.subsec_nanos().to_le_bytes()); - } - FieldValue::Ip(value) => { - let mut buffer = StackBuffer::<46>::new(); - if write!(&mut buffer, "{value}").is_ok() { - hasher.update(buffer.as_bytes()); - } else { - let fallback = value.to_string(); - hasher.update(fallback.as_bytes()); - } - } - #[cfg(feature = "serde_json")] - FieldValue::Json(value) => { - if let Ok(serialized) = serde_json::to_vec(value) { - hasher.update(&serialized); - } - } - } - let digest = hasher.finalize(); - let mut hex = String::with_capacity(digest.len() * 2); - for byte in digest { - let _ = write!(&mut hex, "{:02x}", byte); - } - hex -} - -fn mask_last4_field_value(value: &FieldValue) -> Option { - match value { - FieldValue::Str(value) => Some(mask_last4(value.as_ref())), - FieldValue::I64(value) => { - let mut buffer = IntegerBuffer::new(); - Some(mask_last4(buffer.format(*value))) - } - FieldValue::U64(value) => { - let mut buffer = IntegerBuffer::new(); - Some(mask_last4(buffer.format(*value))) - } - FieldValue::F64(value) => { - let mut buffer = FloatBuffer::new(); - Some(mask_last4(buffer.format(*value))) - } - FieldValue::Uuid(value) => { - let mut repr = [0u8; 36]; - let text = value.hyphenated().encode_lower(&mut repr); - Some(mask_last4(text)) - } - FieldValue::Duration(value) => Some(mask_last4(&duration_to_string(*value))), - FieldValue::Ip(value) => { - let mut buffer = StackBuffer::<46>::new(); - if write!(&mut buffer, "{value}").is_err() { - return Some(mask_last4(&value.to_string())); - } - buffer.as_str().map(mask_last4) - } - #[cfg(feature = "serde_json")] - FieldValue::Json(value) => serde_json::to_string(value) - .ok() - .map(|text| mask_last4(&text)), - FieldValue::Bool(_) => None - } -} - -fn mask_last4(value: &str) -> String { - let chars = value.chars(); - let total = chars.clone().count(); - if total == 0 { - return String::new(); - } - let keep = if total <= 4 { 1 } else { 4 }; - let mask_len = total.saturating_sub(keep); - let mut masked = String::with_capacity(value.len()); - masked.extend(repeat_n('*', mask_len)); - masked.extend(chars.skip(mask_len)); - masked -} - /// Canonical mapping table covering every built-in [`AppCode`]. /// /// # Examples @@ -1037,6 +892,7 @@ mod tests { use crate::field::json; use crate::{ AppError, + app_error::redaction::mask_last4, field::{duration, f64, ip, str, u64, uuid} }; diff --git a/tests/display_mode_env.rs b/tests/display_mode_env.rs new file mode 100644 index 0000000..e63bb77 --- /dev/null +++ b/tests/display_mode_env.rs @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 RAprogramm +// +// SPDX-License-Identifier: MIT + +//! Integration test exercising real `MASTERROR_ENV` detection. +//! +//! Kept as a single test in its own binary so the environment variable is +//! set before the first `DisplayMode::current()` call in this process and +//! the cached detection cannot be influenced by other tests. + +use masterror::{AppError, DisplayMode}; + +#[test] +fn masterror_env_prod_selects_json_display_layout() { + unsafe { + std::env::set_var("MASTERROR_ENV", "prod"); + } + assert_eq!(DisplayMode::current(), DisplayMode::Prod); + let err = AppError::not_found("missing user"); + let rendered = err.to_string(); + assert!( + rendered.starts_with(r#"{"kind":"NotFound""#), + "unexpected layout: {rendered}" + ); + assert!(rendered.contains(r#""code":"NOT_FOUND""#)); + assert!(rendered.contains(r#""message":"missing user""#)); + assert!(!rendered.contains('\u{1b}')); +} From 93aa93987a444f06903d963d8ce2a766d63895ac Mon Sep 17 00:00:00 2001 From: RAprogramm Date: Sun, 5 Jul 2026 13:55:50 +0700 Subject: [PATCH 2/2] #450 test: cover redaction helpers for all field value variants --- src/app_error/redaction.rs | 111 +++++++++++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/src/app_error/redaction.rs b/src/app_error/redaction.rs index cd283c2..8191338 100644 --- a/src/app_error/redaction.rs +++ b/src/app_error/redaction.rs @@ -170,3 +170,114 @@ pub(crate) fn mask_last4(value: &str) -> String { masked.extend(chars.skip(mask_len)); masked } + +#[cfg(test)] +mod tests { + use core::{net::IpAddr, time::Duration}; + + use super::*; + + fn assert_hex_digest(digest: &str) { + assert_eq!(digest.len(), 64); + assert!(digest.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn hash_covers_all_scalar_variants() { + assert_hex_digest(&hash_field_value(&FieldValue::Str("secret".into()))); + assert_hex_digest(&hash_field_value(&FieldValue::I64(-42))); + assert_hex_digest(&hash_field_value(&FieldValue::U64(42))); + assert_hex_digest(&hash_field_value(&FieldValue::F64(1.5))); + assert_hex_digest(&hash_field_value(&FieldValue::Bool(true))); + assert_hex_digest(&hash_field_value(&FieldValue::Bool(false))); + assert_hex_digest(&hash_field_value(&FieldValue::Duration( + Duration::from_millis(1500) + ))); + } + + #[test] + fn hash_is_deterministic_per_value() { + let first = hash_field_value(&FieldValue::I64(7)); + let second = hash_field_value(&FieldValue::I64(7)); + let other = hash_field_value(&FieldValue::I64(8)); + assert_eq!(first, second); + assert_ne!(first, other); + } + + #[test] + fn hash_covers_uuid_and_ip() { + let uuid = uuid::Uuid::nil(); + assert_hex_digest(&hash_field_value(&FieldValue::Uuid(uuid))); + let ip: IpAddr = "192.168.0.1".parse().expect("ipv4"); + assert_hex_digest(&hash_field_value(&FieldValue::Ip(ip))); + let ip6: IpAddr = "::1".parse().expect("ipv6"); + assert_hex_digest(&hash_field_value(&FieldValue::Ip(ip6))); + } + + #[cfg(feature = "serde_json")] + #[test] + fn hash_covers_json() { + let value = serde_json::json!({"card": "4111"}); + assert_hex_digest(&hash_field_value(&FieldValue::Json(value))); + } + + #[test] + fn mask_covers_all_scalar_variants() { + assert_eq!( + mask_last4_field_value(&FieldValue::Str("4111111111111111".into())) + .expect("string masks"), + "************1111" + ); + assert_eq!( + mask_last4_field_value(&FieldValue::I64(-123_456)).expect("i64 masks"), + "***3456" + ); + assert_eq!( + mask_last4_field_value(&FieldValue::U64(123_456)).expect("u64 masks"), + "**3456" + ); + let masked_float = mask_last4_field_value(&FieldValue::F64(1234.5)).expect("f64 masks"); + assert!(masked_float.starts_with('*')); + let duration = Duration::from_secs(90); + assert_eq!( + mask_last4_field_value(&FieldValue::Duration(duration)).expect("duration masks"), + mask_last4(&duration_to_string(duration)) + ); + assert!(mask_last4_field_value(&FieldValue::Bool(true)).is_none()); + } + + #[test] + fn mask_covers_uuid_and_ip() { + let uuid = uuid::Uuid::nil(); + let masked = mask_last4_field_value(&FieldValue::Uuid(uuid)).expect("uuid masks"); + assert!(masked.ends_with("0000")); + assert!(masked.starts_with('*')); + let ip: IpAddr = "10.0.0.15".parse().expect("ipv4"); + let masked_ip = mask_last4_field_value(&FieldValue::Ip(ip)).expect("ip masks"); + assert!(masked_ip.ends_with("0.15")); + } + + #[cfg(feature = "serde_json")] + #[test] + fn mask_covers_json() { + let value = serde_json::json!("tok_123456"); + let masked = mask_last4_field_value(&FieldValue::Json(value)).expect("json masks"); + assert!(masked.ends_with("456\"")); + } + + #[test] + fn mask_last4_handles_empty_and_short_values() { + assert_eq!(mask_last4(""), ""); + assert_eq!(mask_last4("a"), "a"); + assert_eq!(mask_last4("abcd"), "***d"); + assert_eq!(mask_last4("abcde"), "*bcde"); + } + + #[test] + fn stack_buffer_rejects_overflow() { + let mut buffer = StackBuffer::<4>::new(); + assert!(write!(&mut buffer, "hello").is_err()); + assert!(write!(&mut buffer, "hi").is_ok()); + assert_eq!(buffer.as_str(), Some("hi")); + } +}