From 1f313f4a86a0ce7470b8824b056824b7026a9a9c Mon Sep 17 00:00:00 2001 From: RA <70325462+RAprogramm@users.noreply.github.com> Date: Tue, 23 Sep 2025 17:04:52 +0700 Subject: [PATCH] Add metadata redaction policies and internal formatters --- CHANGELOG.md | 18 ++ Cargo.lock | 5 +- Cargo.toml | 7 +- README.md | 19 +- README.ru.md | 5 +- README.template.md | 5 +- masterror-derive/Cargo.toml | 2 +- masterror-derive/src/input.rs | 108 ++++++++-- masterror-derive/src/masterror_impl.rs | 34 +++- src/app_error.rs | 2 +- src/app_error/context.rs | 41 +++- src/app_error/core.rs | 10 +- src/app_error/metadata.rs | 185 +++++++++++++++--- src/app_error/tests.rs | 33 +++- src/convert/tonic.rs | 8 +- src/lib.rs | 8 +- src/response.rs | 1 + src/response/core.rs | 6 + src/response/internal.rs | 74 +++++++ src/response/mapping.rs | 11 +- src/response/problem_json.rs | 169 +++++++++++++++- src/response/tests.rs | 25 ++- tests/masterror_macro.rs | 10 +- .../fail/enum_missing_variant.stderr | 7 +- tests/ui/app_error/fail/missing_code.stderr | 4 +- tests/ui/app_error/fail/missing_kind.stderr | 2 +- tests/ui/formatter/fail/duplicate_fmt.stderr | 2 +- .../fail/implicit_after_named.stderr | 1 + .../ui/formatter/fail/unsupported_flag.stderr | 4 +- .../fail/unsupported_formatter.stderr | 4 +- .../ui/formatter/fail/uppercase_binary.stderr | 4 +- .../formatter/fail/uppercase_pointer.stderr | 4 +- tests/ui/masterror/fail/duplicate_attr.stderr | 2 +- .../masterror/fail/duplicate_telemetry.stderr | 2 +- tests/ui/masterror/fail/empty_redact.stderr | 2 +- .../fail/enum_missing_variant.stderr | 7 +- .../ui/masterror/fail/missing_category.stderr | 4 +- tests/ui/masterror/fail/missing_code.stderr | 4 +- tests/ui/masterror/fail/unknown_option.stderr | 2 +- 39 files changed, 732 insertions(+), 109 deletions(-) create mode 100644 src/response/internal.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b8d5d48..c341449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,24 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [0.17.0] - 2025-09-27 + +### Added +- Per-field redaction metadata via a new [`FieldRedaction`] enum, default + heuristics for common secret keys (passwords, tokens, card numbers) and the + `Context::redact_field` / `AppError::redact_field` helpers. +- `#[masterror(redact(fields(...)))]` support in the derive macro to configure + metadata policies alongside message redaction. +- Opt-in internal formatters for [`ErrorResponse`] and [`ProblemJson`] that are + safe to use in diagnostic logs without additional serialization boilerplate. + +### Changed +- Problem JSON and legacy `ErrorResponse` serialization now hash, mask or drop + metadata according to per-field policies while honoring the global redaction + flag. +- Redaction-aware conversions ensure redactable messages fall back to the error + kind across HTTP and gRPC mappings. + ## [0.16.0] - 2025-09-26 ### Changed diff --git a/Cargo.lock b/Cargo.lock index 6460182..dc61df7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1727,7 +1727,7 @@ dependencies = [ [[package]] name = "masterror" -version = "0.16.0" +version = "0.17.0" dependencies = [ "actix-web", "axum 0.8.4", @@ -1744,6 +1744,7 @@ dependencies = [ "serde", "serde-wasm-bindgen", "serde_json", + "sha2", "sqlx", "sqlx-core", "telegram-webapp-sdk", @@ -1763,7 +1764,7 @@ dependencies = [ [[package]] name = "masterror-derive" -version = "0.7.1" +version = "0.8.0" dependencies = [ "masterror-template", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index 5de95af..3a8f8a7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "masterror" -version = "0.16.0" +version = "0.17.0" rust-version = "1.90" edition = "2024" license = "MIT OR Apache-2.0" @@ -75,11 +75,11 @@ tonic = ["dep:tonic"] openapi = ["dep:utoipa"] [workspace.dependencies] -masterror-derive = { version = "0.7.1" } +masterror-derive = { version = "0.8.0" } masterror-template = { version = "0.3.6" } [dependencies] -masterror-derive = { version = "0.7" } +masterror-derive = { version = "0.8" } masterror-template = { workspace = true } tracing = { version = "0.1", optional = true } log = { version = "0.4", optional = true } @@ -89,6 +89,7 @@ metrics = { version = "0.24", optional = true } serde = { version = "1", features = ["derive"] } serde_json = { version = "1", optional = true } http = "1" +sha2 = "0.10" # optional integrations axum = { version = "0.8", optional = true, default-features = false, features = [ diff --git a/README.md b/README.md index 0a8970a..278be85 100644 --- a/README.md +++ b/README.md @@ -38,9 +38,9 @@ guides, comparisons with `thiserror`/`anyhow`, and troubleshooting recipes. ~~~toml [dependencies] -masterror = { version = "0.16.0", default-features = false } +masterror = { version = "0.17.0", default-features = false } # or with features: -# masterror = { version = "0.16.0", features = [ +# masterror = { version = "0.17.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -78,10 +78,10 @@ masterror = { version = "0.16.0", default-features = false } ~~~toml [dependencies] # lean core -masterror = { version = "0.16.0", default-features = false } +masterror = { version = "0.17.0", default-features = false } # with Axum/Actix + JSON + integrations -# masterror = { version = "0.16.0", features = [ +# masterror = { version = "0.17.0", features = [ # "axum", "actix", "openapi", "serde_json", # "tracing", "metrics", "backtrace", "sqlx", # "sqlx-migrate", "reqwest", "redis", "validator", @@ -201,7 +201,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(message), + redact(message, fields("user_id" = hash)), telemetry( Some(masterror::field::str("user_id", user_id.clone())), attempt.map(|value| masterror::field::u64("attempt", value)) @@ -240,7 +240,8 @@ assert_eq!( - `message` forwards the formatted [`Display`] output as the safe public message. Omit it to keep the message private. - `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport - boundary. + boundary, `fields("name" = hash, "card" = last4)` overrides metadata + policies (`hash`, `last4`, `redact`, `none`). - `telemetry(...)` accepts expressions that evaluate to `Option`. Each populated field is inserted into the resulting [`Metadata`]; use `telemetry()` when no fields are attached. @@ -719,13 +720,13 @@ assert_eq!(problem.grpc.expect("grpc").name, "UNAUTHENTICATED"); Minimal core: ~~~toml -masterror = { version = "0.16.0", default-features = false } +masterror = { version = "0.17.0", default-features = false } ~~~ API (Axum + JSON + deps): ~~~toml -masterror = { version = "0.16.0", features = [ +masterror = { version = "0.17.0", features = [ "axum", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } @@ -734,7 +735,7 @@ masterror = { version = "0.16.0", features = [ API (Actix + JSON + deps): ~~~toml -masterror = { version = "0.16.0", features = [ +masterror = { version = "0.17.0", features = [ "actix", "serde_json", "openapi", "sqlx", "reqwest", "redis", "validator", "config", "tokio" ] } diff --git a/README.ru.md b/README.ru.md index 1354483..d87a385 100644 --- a/README.ru.md +++ b/README.ru.md @@ -212,7 +212,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(message), + redact(message, fields("user_id" = hash)), telemetry( Some(masterror::field::str("user_id", user_id.clone())), attempt.map(|value| masterror::field::u64("attempt", value)) @@ -250,7 +250,8 @@ assert_eq!( [`AppErrorKind`]. - `message` включает текст, возвращаемый [`Display`], в публичное сообщение. - `redact(message)` выставляет [`MessageEditPolicy`] в режим редактирования на - транспортной границе. + транспортной границе, `fields("name" = hash, "card" = last4)` переопределяет + обработку метаданных (`hash`, `last4`, `redact`, `none`). - `telemetry(...)` принимает выражения, возвращающие `Option`. Каждое присутствующее поле добавляется в [`Metadata`]; пустые выражения пропускаются. diff --git a/README.template.md b/README.template.md index 886ef7b..1095ee8 100644 --- a/README.template.md +++ b/README.template.md @@ -193,7 +193,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(message), + redact(message, fields("user_id" = hash)), telemetry( Some(masterror::field::str("user_id", user_id.clone())), attempt.map(|value| masterror::field::u64("attempt", value)) @@ -232,7 +232,8 @@ assert_eq!( - `message` forwards the formatted [`Display`] output as the safe public message. Omit it to keep the message private. - `redact(message)` flips [`MessageEditPolicy`] to redactable at the transport - boundary. + boundary, `fields("name" = hash, "card" = last4)` overrides metadata + policies (`hash`, `last4`, `redact`, `none`). - `telemetry(...)` accepts expressions that evaluate to `Option`. Each populated field is inserted into the resulting [`Metadata`]; use `telemetry()` when no fields are attached. diff --git a/masterror-derive/Cargo.toml b/masterror-derive/Cargo.toml index 797d219..76edd2a 100644 --- a/masterror-derive/Cargo.toml +++ b/masterror-derive/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "masterror-derive" rust-version = "1.90" -version = "0.7.1" +version = "0.8.0" edition = "2024" license = "MIT OR Apache-2.0" repository = "https://github.com/RAprogramm/masterror" diff --git a/masterror-derive/src/input.rs b/masterror-derive/src/input.rs index adedc94..382c0e4 100644 --- a/masterror-derive/src/input.rs +++ b/masterror-derive/src/input.rs @@ -6,7 +6,7 @@ use syn::{ Expr, ExprPath, Field as SynField, Fields as SynFields, GenericArgument, Ident, LitBool, LitInt, LitStr, Token, TypePath, ext::IdentExt, - parse::{Parse, ParseStream}, + parse::{Parse, ParseBuffer, ParseStream}, punctuated::Punctuated, spanned::Spanned, token::Paren @@ -62,7 +62,7 @@ pub struct MasterrorSpec { pub code: Expr, pub category: ExprPath, pub expose_message: bool, - pub redact_message: bool, + pub redact: RedactSpec, pub telemetry: Vec, pub map_grpc: Option, pub map_problem: Option, @@ -70,6 +70,26 @@ pub struct MasterrorSpec { pub attribute_span: Span } +#[derive(Clone, Debug, Default)] +pub struct RedactSpec { + pub message: bool, + pub fields: Vec +} + +#[derive(Clone, Debug)] +pub struct FieldRedactionSpec { + pub name: LitStr, + pub policy: FieldRedactionKind +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FieldRedactionKind { + None, + Redact, + Hash, + Last4 +} + #[derive(Debug)] pub enum Fields { Unit, @@ -791,7 +811,8 @@ fn parse_masterror_attribute(attr: &Attribute) -> Result { let mut code = None; let mut category = None; let mut expose_message = false; - let mut redact_message = false; + let mut redact = RedactSpec::default(); + let mut seen_redact = false; let mut telemetry = None; let mut map_grpc = None; let mut map_problem = None; @@ -822,10 +843,11 @@ fn parse_masterror_attribute(attr: &Attribute) -> Result { expose_message = parse_flag_value(input)?; } "redact" => { - if redact_message { + if seen_redact { return Err(Error::new(ident.span(), "duplicate redact(...) block")); } - redact_message = parse_redact_block(input, ident.span())?; + redact = parse_redact_block(input, ident.span())?; + seen_redact = true; } "telemetry" => { if telemetry.is_some() { @@ -909,7 +931,7 @@ fn parse_masterror_attribute(attr: &Attribute) -> Result { code, category, expose_message, - redact_message, + redact, telemetry: telemetry.unwrap_or_default(), map_grpc, map_problem, @@ -928,7 +950,7 @@ fn parse_flag_value(input: ParseStream) -> Result { } } -fn parse_redact_block(input: ParseStream, span: Span) -> Result { +fn parse_redact_block(input: ParseStream, span: Span) -> Result { let content; syn::parenthesized!(content in input); @@ -936,22 +958,31 @@ fn parse_redact_block(input: ParseStream, span: Span) -> Result { return Err(Error::new(span, "redact(...) requires at least one option")); } - let mut redact_message = false; + let mut spec = RedactSpec::default(); while !content.is_empty() { let ident: Ident = content.call(Ident::parse_any)?; match ident.to_string().as_str() { "message" => { - if redact_message { + if spec.message { return Err(Error::new(ident.span(), "duplicate redact(message) option")); } if content.peek(Token![=]) { content.parse::()?; let value: LitBool = content.parse()?; - redact_message = value.value; + spec.message = value.value; } else { - redact_message = true; + spec.message = true; + } + } + "fields" => { + if !spec.fields.is_empty() { + return Err(Error::new( + ident.span(), + "duplicate redact(fields(...)) option" + )); } + spec.fields = parse_redact_fields(&content, ident.span())?; } other => { return Err(Error::new( @@ -971,7 +1002,60 @@ fn parse_redact_block(input: ParseStream, span: Span) -> Result { } } - Ok(redact_message) + Ok(spec) +} + +fn parse_redact_fields( + content: &ParseBuffer<'_>, + span: Span +) -> Result, Error> { + let inner; + syn::parenthesized!(inner in *content); + + if inner.is_empty() { + return Err(Error::new( + span, + "redact(fields(...)) requires at least one field" + )); + } + + let mut fields = Vec::new(); + while !inner.is_empty() { + let name: LitStr = inner.parse()?; + let policy = if inner.peek(Token![=]) { + inner.parse::()?; + let ident: Ident = inner.call(Ident::parse_any)?; + match ident.to_string().to_ascii_lowercase().as_str() { + "none" => FieldRedactionKind::None, + "redact" => FieldRedactionKind::Redact, + "hash" => FieldRedactionKind::Hash, + "last4" | "last_four" => FieldRedactionKind::Last4, + other => { + return Err(Error::new( + ident.span(), + format!("unknown redact policy `{other}` in fields(...)") + )); + } + } + } else { + FieldRedactionKind::Redact + }; + fields.push(FieldRedactionSpec { + name, + policy + }); + + if inner.peek(Token![,]) { + inner.parse::()?; + } else if !inner.is_empty() { + return Err(Error::new( + inner.span(), + "expected `,` or end of input in redact(fields(...))" + )); + } + } + + Ok(fields) } fn parse_telemetry_block(input: ParseStream, span: Span) -> Result, Error> { diff --git a/masterror-derive/src/masterror_impl.rs b/masterror-derive/src/masterror_impl.rs index e16145e..1a91dc0 100644 --- a/masterror-derive/src/masterror_impl.rs +++ b/masterror-derive/src/masterror_impl.rs @@ -3,8 +3,8 @@ use quote::{format_ident, quote}; use syn::{Error, Expr, ExprPath, Index}; use crate::input::{ - ErrorData, ErrorInput, Field, Fields, MasterrorSpec, StructData, VariantData, is_arc_type, - is_option_type, option_inner_type + ErrorData, ErrorInput, Field, FieldRedactionKind, Fields, MasterrorSpec, RedactSpec, + StructData, VariantData, is_arc_type, is_option_type, option_inner_type }; pub fn expand(input: &ErrorInput) -> Result { @@ -75,7 +75,7 @@ fn struct_conversion_impl( let field_usage = field_usage_tokens(&bound_fields); let telemetry_init = telemetry_initialization(&spec.telemetry); let metadata_attach = metadata_attach_tokens(); - let redact_tokens = redact_tokens(spec.redact_message); + let redact_tokens = redact_tokens(&spec.redact); let source_tokens = source_attachment_tokens(&bound_fields); let backtrace_tokens = backtrace_attachment_tokens(&data.fields, &bound_fields); @@ -117,7 +117,7 @@ fn enum_conversion_impl(input: &ErrorInput, variants: &[VariantData]) -> TokenSt let field_usage = field_usage_tokens(&bound_fields); let telemetry_init = telemetry_initialization(&spec.telemetry); let metadata_attach = metadata_attach_tokens(); - let redact_tokens = redact_tokens(spec.redact_message); + let redact_tokens = redact_tokens(&spec.redact); let source_tokens = source_attachment_tokens(&bound_fields); let backtrace_tokens = backtrace_attachment_tokens(&variant.fields, &bound_fields); message_arms.push(enum_message_arm(ident, variant, spec.expose_message)); @@ -420,13 +420,35 @@ fn metadata_attach_tokens() -> TokenStream { } } -fn redact_tokens(enabled: bool) -> TokenStream { - if enabled { +fn redact_tokens(spec: &RedactSpec) -> TokenStream { + let message = if spec.message { quote!( __masterror_error = __masterror_error.redactable(); ) } else { TokenStream::new() + }; + + let field_updates = spec.fields.iter().map(|field| { + let name = &field.name; + let policy = field_redaction_tokens(field.policy); + quote!( + __masterror_error = __masterror_error.redact_field(#name, #policy); + ) + }); + + quote! { + #message + #( #field_updates )* + } +} + +fn field_redaction_tokens(kind: FieldRedactionKind) -> TokenStream { + match kind { + FieldRedactionKind::None => quote!(masterror::FieldRedaction::None), + FieldRedactionKind::Redact => quote!(masterror::FieldRedaction::Redact), + FieldRedactionKind::Hash => quote!(masterror::FieldRedaction::Hash), + FieldRedactionKind::Last4 => quote!(masterror::FieldRedaction::Last4) } } diff --git a/src/app_error.rs b/src/app_error.rs index c206df3..aaf3fe6 100644 --- a/src/app_error.rs +++ b/src/app_error.rs @@ -70,7 +70,7 @@ mod metadata; pub use core::{AppError, AppResult, Error, MessageEditPolicy}; pub use context::Context; -pub use metadata::{Field, FieldValue, Metadata, field}; +pub use metadata::{Field, FieldRedaction, FieldValue, Metadata, field}; #[cfg(test)] mod tests; diff --git a/src/app_error/context.rs b/src/app_error/context.rs index 57cb5cc..3539194 100644 --- a/src/app_error/context.rs +++ b/src/app_error/context.rs @@ -2,7 +2,7 @@ use std::{error::Error as StdError, panic::Location}; use super::{ core::{AppError, Error, MessageEditPolicy}, - metadata::{Field, FieldValue} + metadata::{Field, FieldRedaction, FieldValue} }; use crate::{AppCode, AppErrorKind}; @@ -41,6 +41,7 @@ pub struct Context { code: AppCode, category: AppErrorKind, fields: Vec, + field_policies: Vec<(&'static str, FieldRedaction)>, edit_policy: MessageEditPolicy, caller_location: Option<&'static Location<'static>>, code_overridden: bool @@ -57,6 +58,7 @@ impl Context { code: AppCode::from(category), category, fields: Vec::new(), + field_policies: Vec::new(), edit_policy: MessageEditPolicy::Preserve, caller_location: None, code_overridden: false @@ -88,6 +90,21 @@ impl Context { #[must_use] pub fn with(mut self, field: Field) -> Self { self.fields.push(field); + self.apply_field_redactions(); + self + } + + /// 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 } @@ -132,8 +149,12 @@ impl Context { let mut error = AppError::new_raw(self.category, None); error.code = self.code; if !self.fields.is_empty() { + self.apply_field_redactions(); error.metadata.extend(self.fields); } + for &(name, redaction) in &self.field_policies { + error = error.redact_field(name, redaction); + } if matches!(self.edit_policy, MessageEditPolicy::Redact) { error.edit_policy = MessageEditPolicy::Redact; } @@ -142,3 +163,21 @@ impl Context { error } } + +impl Context { + fn apply_field_redactions(&mut self) { + if self.field_policies.is_empty() { + return; + } + for field in &mut self.fields { + if let Some((_, policy)) = self + .field_policies + .iter() + .rev() + .find(|(name, _)| *name == field.name()) + { + field.set_redaction(*policy); + } + } + } +} diff --git a/src/app_error/core.rs b/src/app_error/core.rs index 7284192..e9384ce 100644 --- a/src/app_error/core.rs +++ b/src/app_error/core.rs @@ -21,7 +21,7 @@ use std::{ #[cfg(feature = "tracing")] use tracing::{Level, event}; -use super::metadata::{Field, Metadata}; +use super::metadata::{Field, FieldRedaction, Metadata}; use crate::{AppCode, AppErrorKind, RetryAdvice}; /// Controls whether the public message may be redacted before exposure. @@ -384,6 +384,14 @@ impl Error { self } + /// Override the redaction policy for a stored metadata field. + #[must_use] + pub fn redact_field(mut self, name: &'static str, redaction: FieldRedaction) -> Self { + self.metadata.set_redaction(name, redaction); + self.mark_dirty(); + self + } + /// Replace metadata entirely. #[must_use] pub fn with_metadata(mut self, metadata: Metadata) -> Self { diff --git a/src/app_error/metadata.rs b/src/app_error/metadata.rs index 754793b..0aa8267 100644 --- a/src/app_error/metadata.rs +++ b/src/app_error/metadata.rs @@ -4,6 +4,20 @@ use std::{ fmt::{Display, Formatter, Result as FmtResult} }; +/// Redaction policy associated with a metadata [`Field`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)] +pub enum FieldRedaction { + /// Preserve the value as-is. + #[default] + None, + /// Remove the value from public payloads. + Redact, + /// Hash the value with a cryptographic digest before exposure. + Hash, + /// Preserve only the last four characters (mask the rest). + Last4 +} + use uuid::Uuid; /// Value stored inside [`Metadata`]. @@ -41,17 +55,20 @@ impl Display for FieldValue { /// Single metadata field – name plus value. #[derive(Clone, Debug, PartialEq)] pub struct Field { - name: &'static str, - value: FieldValue + name: &'static str, + value: FieldValue, + redaction: FieldRedaction } impl Field { /// Create a new [`Field`]. #[must_use] - pub const fn new(name: &'static str, value: FieldValue) -> Self { + pub fn new(name: &'static str, value: FieldValue) -> Self { + let redaction = infer_default_redaction(name); Self { name, - value + value, + redaction } } @@ -67,10 +84,93 @@ impl Field { &self.value } + /// Field redaction policy. + #[must_use] + pub const fn redaction(&self) -> FieldRedaction { + self.redaction + } + + /// Override the redaction policy while consuming the field. + #[must_use] + pub fn with_redaction(mut self, redaction: FieldRedaction) -> Self { + self.redaction = redaction; + self + } + + /// Update the redaction policy in place. + pub fn set_redaction(&mut self, redaction: FieldRedaction) { + self.redaction = redaction; + } + /// Consume the field and return owned components. #[must_use] - pub fn into_parts(self) -> (&'static str, FieldValue) { - (self.name, self.value) + pub fn into_parts(self) -> (&'static str, FieldValue, FieldRedaction) { + (self.name, self.value, self.redaction) + } + + /// Consume the field and return only the value. + #[must_use] + pub fn into_value(self) -> FieldValue { + self.value + } +} + +fn infer_default_redaction(name: &str) -> FieldRedaction { + let lowered = name.to_ascii_lowercase(); + + if lowered.contains("password") + || lowered.contains("passphrase") + || lowered.contains("secret") + || lowered.contains("authorization") + || lowered.contains("cookie") + || lowered.contains("session") + || lowered.contains("jwt") + || lowered.contains("bearer") + || lowered.contains("otp") + || lowered.contains("pin") + { + return FieldRedaction::Redact; + } + + let mut card_like = false; + let mut number_like = false; + + for segment in lowered.split(['.', '_', '-', ':', '/']) { + if segment.is_empty() { + continue; + } + if segment.eq_ignore_ascii_case("token") + || segment.eq_ignore_ascii_case("apikey") + || segment.eq_ignore_ascii_case("api") && lowered.contains("key") + || segment.ends_with("token") + || segment.eq_ignore_ascii_case("key") + || segment.eq_ignore_ascii_case("access") && lowered.contains("token") + || segment.eq_ignore_ascii_case("refresh") && lowered.contains("token") + { + return FieldRedaction::Hash; + } + + if segment.eq_ignore_ascii_case("card") + || segment.eq_ignore_ascii_case("iban") + || segment.eq_ignore_ascii_case("pan") + || segment.eq_ignore_ascii_case("account") + || segment.eq_ignore_ascii_case("acct") + { + card_like = true; + } + + if segment.eq_ignore_ascii_case("number") + || segment.eq_ignore_ascii_case("no") + || segment.eq_ignore_ascii_case("id") + { + number_like = true; + } + } + + if card_like && number_like { + FieldRedaction::Last4 + } else { + FieldRedaction::None } } @@ -81,7 +181,7 @@ impl Field { /// enum construction. #[derive(Clone, Debug, Default, PartialEq)] pub struct Metadata { - fields: BTreeMap<&'static str, FieldValue> + fields: BTreeMap<&'static str, Field> } impl Metadata { @@ -95,12 +195,8 @@ impl Metadata { #[must_use] pub fn from_fields(fields: impl IntoIterator) -> Self { let mut map = BTreeMap::new(); - for Field { - name, - value - } in fields - { - map.insert(name, value); + for field in fields { + map.insert(field.name, field); } Self { fields: map @@ -121,8 +217,9 @@ impl Metadata { /// Insert or replace a field and return the previous value. pub fn insert(&mut self, field: Field) -> Option { - let (name, value) = field.into_parts(); - self.fields.insert(name, value) + self.fields + .insert(field.name, field) + .map(|previous| previous.into_value()) } /// Extend metadata with additional fields. @@ -135,29 +232,57 @@ impl Metadata { /// Borrow a field value by name. #[must_use] pub fn get(&self, name: &'static str) -> Option<&FieldValue> { + self.fields.get(name).map(|field| field.value()) + } + + /// Borrow the full field entry by name. + #[must_use] + pub fn get_field(&self, name: &'static str) -> Option<&Field> { self.fields.get(name) } + /// Override the redaction policy for a specific field. + pub fn set_redaction(&mut self, name: &'static str, redaction: FieldRedaction) { + if let Some(field) = self.fields.get_mut(name) { + field.set_redaction(redaction); + } + } + + /// Retrieve the redaction policy for a field if present. + #[must_use] + pub fn redaction(&self, name: &'static str) -> Option { + self.fields.get(name).map(|field| field.redaction()) + } + /// Iterator over metadata fields in sorted order. pub fn iter(&self) -> impl Iterator { - self.fields.iter().map(|(k, v)| (*k, v)) + self.fields.iter().map(|(k, v)| (*k, v.value())) + } + + /// Iterator over metadata entries including the redaction policy. + pub fn iter_with_redaction( + &self + ) -> impl Iterator { + self.fields + .iter() + .map(|(name, field)| (*name, field.value(), field.redaction())) } } impl IntoIterator for Metadata { type Item = Field; type IntoIter = std::iter::Map< - std::collections::btree_map::IntoIter<&'static str, FieldValue>, - fn((&'static str, FieldValue)) -> Field + std::collections::btree_map::IntoIter<&'static str, Field>, + fn((&'static str, Field)) -> Field >; fn into_iter(self) -> Self::IntoIter { - fn into_field(entry: (&'static str, FieldValue)) -> Field { - Field::new(entry.0, entry.1) + fn into_field(entry: (&'static str, Field)) -> Field { + entry.1 } self.fields .into_iter() - .map(into_field as fn((&'static str, FieldValue)) -> Field) + .map(into_field as fn((&'static str, Field)) -> Field) } } @@ -206,7 +331,7 @@ mod tests { use uuid::Uuid; - use super::{FieldValue, Metadata, field}; + use super::{FieldRedaction, FieldValue, Metadata, field}; #[test] fn metadata_roundtrip() { @@ -219,6 +344,7 @@ mod tests { Some(&FieldValue::Str(Cow::Borrowed("abc"))) ); assert_eq!(meta.get("count"), Some(&FieldValue::I64(42))); + assert_eq!(meta.redaction("request_id"), Some(FieldRedaction::None)); } #[test] @@ -240,14 +366,27 @@ mod tests { assert_eq!(meta.get("count"), Some(&FieldValue::I64(2))); } + #[test] + fn default_redaction_applies_to_common_keys() { + let password = field::str("password", Cow::Borrowed("secret")); + assert!(matches!(password.redaction(), FieldRedaction::Redact)); + + let token = field::str("api_token", Cow::Borrowed("abcdef")); + assert!(matches!(token.redaction(), FieldRedaction::Hash)); + + let card = field::str("card_number", Cow::Borrowed("4111111111111111")); + assert!(matches!(card.redaction(), FieldRedaction::Last4)); + } + #[test] fn field_into_parts_returns_components() { let field = field::u64("elapsed_ms", 30); let clone = field.clone(); assert_eq!(clone.name(), field.name()); assert_eq!(clone.value(), field.value()); - let (owned_name, owned_value) = clone.into_parts(); + let (owned_name, owned_value, redaction) = clone.into_parts(); assert_eq!(owned_name, field.name()); assert_eq!(owned_value, field.value().clone()); + assert_eq!(redaction, field.redaction()); } } diff --git a/src/app_error/tests.rs b/src/app_error/tests.rs index 0ca0ec6..3d9812c 100644 --- a/src/app_error/tests.rs +++ b/src/app_error/tests.rs @@ -8,7 +8,7 @@ use super::core::reset_backtrace_preference; #[cfg(feature = "backtrace")] static BACKTRACE_ENV_GUARD: Mutex<()> = Mutex::new(()); -use super::{AppError, FieldValue, MessageEditPolicy, field}; +use super::{AppError, FieldRedaction, FieldValue, MessageEditPolicy, field}; use crate::{AppCode, AppErrorKind}; // --- Helpers ------------------------------------------------------------- @@ -170,6 +170,37 @@ fn metadata_and_code_are_preserved() { assert_eq!(metadata.get("attempt"), Some(&FieldValue::I64(2))); } +#[test] +fn context_redact_field_overrides_policy() { + let err = super::Context::new(AppErrorKind::Service) + .with(field::str("token", "super-secret")) + .redact_field("token", FieldRedaction::Redact) + .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::Redact)); +} + +#[test] +fn app_error_redact_field_updates_metadata() { + let err = AppError::internal("boom") + .with_field(field::str("api_key", "key")) + .redact_field("api_key", FieldRedaction::Hash); + + assert_eq!( + err.metadata().redaction("api_key"), + Some(FieldRedaction::Hash) + ); + assert_eq!( + err.metadata().get("api_key"), + Some(&FieldValue::Str(Cow::Borrowed("key"))) + ); +} + #[derive(Debug)] struct DummyError; diff --git a/src/convert/tonic.rs b/src/convert/tonic.rs index 4126503..e56c68e 100644 --- a/src/convert/tonic.rs +++ b/src/convert/tonic.rs @@ -31,7 +31,8 @@ use tonic::{ #[cfg(test)] use crate::CODE_MAPPINGS; use crate::{ - AppErrorKind, Error, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, mapping_for_code + AppErrorKind, Error, FieldRedaction, FieldValue, MessageEditPolicy, Metadata, RetryAdvice, + mapping_for_code }; impl TryFrom for Status { @@ -102,7 +103,10 @@ fn insert_retry(meta: &mut MetadataMap, retry: RetryAdvice) { fn attach_metadata(meta: &mut MetadataMap, metadata: Metadata) { for field in metadata { - let (name, value) = field.into_parts(); + let (name, value, redaction) = field.into_parts(); + if !matches!(redaction, FieldRedaction::None) { + continue; + } if !is_safe_metadata_key(name) { continue; } diff --git a/src/lib.rs b/src/lib.rs index 825b779..0f912bd 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -106,7 +106,7 @@ //! code = AppCode::NotFound, //! category = AppErrorKind::NotFound, //! message, -//! redact(message), +//! redact(message, fields("user_id" = hash)), //! telemetry( //! Some(masterror::field::str("user_id", user_id.clone())), //! attempt.map(|value| masterror::field::u64("attempt", value)) @@ -144,7 +144,8 @@ //! - `message` — expose the formatted [`core::fmt::Display`] output as the //! public message. //! - `redact(message)` — mark the message as redactable at the transport -//! boundary. +//! boundary, `fields("name" = hash, "card" = last4)` override metadata +//! policies (`hash`, `last4`, `redact`, `none`). //! - `telemetry(...)` — list of expressions producing //! `Option` to be inserted into [`Metadata`]. //! - `map.grpc` / `map.problem` — optional gRPC status (as `i32`) and @@ -322,7 +323,8 @@ pub mod prelude; pub mod mapping; pub use app_error::{ - AppError, AppResult, Context, Error, Field, FieldValue, MessageEditPolicy, Metadata, field + AppError, AppResult, Context, Error, Field, FieldRedaction, FieldValue, MessageEditPolicy, + Metadata, field }; pub use code::AppCode; pub use kind::AppErrorKind; diff --git a/src/response.rs b/src/response.rs index 760ea53..5f7d926 100644 --- a/src/response.rs +++ b/src/response.rs @@ -58,6 +58,7 @@ mod core; mod details; +pub mod internal; mod legacy; mod mapping; mod metadata; diff --git a/src/response/core.rs b/src/response/core.rs index ccd4768..6b429f4 100644 --- a/src/response/core.rs +++ b/src/response/core.rs @@ -92,4 +92,10 @@ impl ErrorResponse { pub fn status_code(&self) -> StatusCode { StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) } + + /// Formatter exposing internals for diagnostic logs. + #[must_use] + pub fn internal(&self) -> crate::response::internal::ErrorResponseFormatter<'_> { + crate::response::internal::ErrorResponseFormatter::new(self) + } } diff --git a/src/response/internal.rs b/src/response/internal.rs new file mode 100644 index 0000000..1cd90bd --- /dev/null +++ b/src/response/internal.rs @@ -0,0 +1,74 @@ +use std::fmt::{self, Debug, Display, Formatter}; + +use super::{core::ErrorResponse, problem_json::ProblemJson}; + +/// Formatter exposing response internals for opt-in diagnostics. +#[derive(Clone, Copy)] +pub struct ErrorResponseFormatter<'a> { + inner: &'a ErrorResponse +} + +impl<'a> ErrorResponseFormatter<'a> { + pub(crate) fn new(inner: &'a ErrorResponse) -> Self { + Self { + inner + } + } +} + +impl Debug for ErrorResponseFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ErrorResponse") + .field("status", &self.inner.status) + .field("code", &self.inner.code) + .field("message", &self.inner.message) + .field("details", &self.inner.details) + .field("retry", &self.inner.retry) + .field("www_authenticate", &self.inner.www_authenticate) + .finish() + } +} + +impl Display for ErrorResponseFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + Display::fmt(self.inner, f) + } +} + +/// Formatter exposing problem-json internals for opt-in diagnostics. +#[derive(Clone, Copy)] +pub struct ProblemJsonFormatter<'a> { + inner: &'a ProblemJson +} + +impl<'a> ProblemJsonFormatter<'a> { + pub(crate) fn new(inner: &'a ProblemJson) -> Self { + Self { + inner + } + } +} + +impl Debug for ProblemJsonFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.debug_struct("ProblemJson") + .field("type", &self.inner.type_uri) + .field("title", &self.inner.title) + .field("status", &self.inner.status) + .field("detail", &self.inner.detail) + .field("code", &self.inner.code) + .field("grpc", &self.inner.grpc) + .field("metadata", &self.inner.metadata) + .finish() + } +} + +impl Display for ProblemJsonFormatter<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + write!( + f, + "{} {}: {:?}", + self.inner.status, self.inner.code, self.inner.detail + ) + } +} diff --git a/src/response/mapping.rs b/src/response/mapping.rs index cc522c5..c0d2302 100644 --- a/src/response/mapping.rs +++ b/src/response/mapping.rs @@ -16,11 +16,12 @@ impl From for ErrorResponse { let code = err.code; let retry = err.retry.take(); let www_authenticate = err.www_authenticate.take(); + let policy = err.edit_policy; let status = kind.http_status(); let message = match err.message.take() { - Some(msg) => msg.into_owned(), - None => kind.to_string() + Some(msg) if !matches!(policy, crate::MessageEditPolicy::Redact) => msg.into_owned(), + _ => kind.to_string() }; Self { @@ -37,7 +38,11 @@ impl From for ErrorResponse { impl From<&AppError> for ErrorResponse { fn from(err: &AppError) -> Self { let status = err.kind.http_status(); - let message = err.render_message().into_owned(); + let message = if matches!(err.edit_policy, crate::MessageEditPolicy::Redact) { + err.kind.to_string() + } else { + err.render_message().into_owned() + }; Self { status, diff --git a/src/response/problem_json.rs b/src/response/problem_json.rs index 28af7e7..440dd2e 100644 --- a/src/response/problem_json.rs +++ b/src/response/problem_json.rs @@ -1,10 +1,13 @@ -use std::{borrow::Cow, collections::BTreeMap}; +use std::{borrow::Cow, collections::BTreeMap, fmt::Write}; use http::StatusCode; use serde::Serialize; +use sha2::{Digest, Sha256}; use super::core::ErrorResponse; -use crate::{AppCode, AppError, AppErrorKind, FieldValue, MessageEditPolicy, Metadata}; +use crate::{ + AppCode, AppError, AppErrorKind, FieldRedaction, FieldValue, MessageEditPolicy, Metadata +}; /// Canonical mapping for a public [`AppCode`]. /// @@ -260,6 +263,12 @@ impl ProblemJson { Err(_) => StatusCode::INTERNAL_SERVER_ERROR } } + + /// Formatter exposing internals for diagnostic logging. + #[must_use] + pub fn internal(&self) -> crate::response::internal::ProblemJsonFormatter<'_> { + crate::response::internal::ProblemJsonFormatter::new(self) + } } /// Metadata section of a [`ProblemJson`] payload. @@ -291,7 +300,7 @@ impl ProblemMetadata { /// ```rust /// use masterror::{ProblemMetadataValue, field}; /// -/// let (_name, field_value) = field::u64("attempt", 2).into_parts(); +/// let (_name, field_value, _redaction) = field::u64("attempt", 2).into_parts(); /// let value = ProblemMetadataValue::from(field_value); /// assert!(matches!(value, ProblemMetadataValue::U64(2))); /// ``` @@ -362,8 +371,10 @@ fn sanitize_metadata_owned( let mut public = BTreeMap::new(); for field in metadata { - let (name, value) = field.into_parts(); - public.insert(name, ProblemMetadataValue::from(value)); + let (name, value, redaction) = field.into_parts(); + if let Some(sanitized) = sanitize_problem_metadata_value_owned(value, redaction) { + public.insert(name, sanitized); + } } if public.is_empty() { @@ -382,8 +393,10 @@ fn sanitize_metadata_ref( } let mut public = BTreeMap::new(); - for (name, value) in metadata.iter() { - public.insert(name, ProblemMetadataValue::from(value)); + for (name, value, redaction) in metadata.iter_with_redaction() { + if let Some(sanitized) = sanitize_problem_metadata_value_ref(value, redaction) { + public.insert(name, sanitized); + } } if public.is_empty() { @@ -393,6 +406,103 @@ fn sanitize_metadata_ref( } } +const REDACTED_PLACEHOLDER: &str = "[REDACTED]"; + +fn sanitize_problem_metadata_value_owned( + value: FieldValue, + redaction: FieldRedaction +) -> Option { + match redaction { + FieldRedaction::None => Some(ProblemMetadataValue::from(value)), + FieldRedaction::Redact => Some(ProblemMetadataValue::String(Cow::Borrowed( + REDACTED_PLACEHOLDER + ))), + FieldRedaction::Hash => Some(ProblemMetadataValue::String(Cow::Owned(hash_field_value( + &value + )))), + FieldRedaction::Last4 => mask_last4_field_value(&value) + .map(|masked| ProblemMetadataValue::String(Cow::Owned(masked))) + } +} + +fn sanitize_problem_metadata_value_ref( + value: &FieldValue, + redaction: FieldRedaction +) -> Option { + match redaction { + FieldRedaction::None => Some(ProblemMetadataValue::from(value)), + FieldRedaction::Redact => Some(ProblemMetadataValue::String(Cow::Borrowed( + REDACTED_PLACEHOLDER + ))), + FieldRedaction::Hash => Some(ProblemMetadataValue::String(Cow::Owned(hash_field_value( + value + )))), + FieldRedaction::Last4 => mask_last4_field_value(value) + .map(|masked| ProblemMetadataValue::String(Cow::Owned(masked))) + } +} + +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 string = value.to_string(); + hasher.update(string.as_bytes()); + } + FieldValue::U64(value) => { + let string = value.to_string(); + hasher.update(string.as_bytes()); + } + FieldValue::Bool(value) => { + if *value { + hasher.update(b"true"); + } else { + hasher.update(b"false"); + } + } + FieldValue::Uuid(value) => { + let string = value.to_string(); + hasher.update(string.as_bytes()); + } + } + 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) => Some(mask_last4(&value.to_string())), + FieldValue::U64(value) => Some(mask_last4(&value.to_string())), + FieldValue::Uuid(value) => Some(mask_last4(&value.to_string())), + FieldValue::Bool(_) => None + } +} + +fn mask_last4(value: &str) -> String { + let chars: Vec = value.chars().collect(); + let total = chars.len(); + 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()); + for _ in 0..mask_len { + masked.push('*'); + } + for ch in chars.iter().skip(mask_len) { + masked.push(*ch); + } + masked +} + /// Canonical mapping table covering every built-in [`AppCode`]. /// /// # Examples @@ -742,6 +852,51 @@ mod tests { assert!(!metadata.is_empty()); } + #[test] + fn redacted_metadata_uses_placeholder() { + let err = AppError::internal("oops").with_field(crate::field::str("password", "secret")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("password").expect("password field"); + match value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.as_ref(), super::REDACTED_PLACEHOLDER); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn hashed_metadata_masks_original_value() { + let err = AppError::internal("oops").with_field(crate::field::str("token", "super")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("token").expect("token field"); + match value { + ProblemMetadataValue::String(text) => { + assert_eq!(text.len(), 64); + assert_ne!(text.as_ref(), "super"); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + + #[test] + fn last4_metadata_preserves_suffix() { + let err = AppError::internal("oops") + .with_field(crate::field::str("card_number", "4111111111111111")); + let problem = ProblemJson::from_ref(&err); + let metadata = problem.metadata.expect("metadata"); + let value = metadata.0.get("card_number").expect("card number"); + match value { + ProblemMetadataValue::String(text) => { + assert!(text.ends_with("1111")); + assert!(text.starts_with("************")); + } + other => panic!("unexpected metadata value: {other:?}") + } + } + #[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 43fa5f0..89777e5 100644 --- a/src/response/tests.rs +++ b/src/response/tests.rs @@ -1,5 +1,5 @@ use super::ErrorResponse; -use crate::{AppCode, AppError, AppErrorKind}; +use crate::{AppCode, AppError, AppErrorKind, ProblemJson}; // --- Basic constructors and fields -------------------------------------- @@ -181,6 +181,18 @@ fn from_app_error_bare_uses_kind_display_as_message() { assert_eq!(resp.message, AppErrorKind::Timeout.to_string()); } +#[test] +fn from_app_error_redacts_message_when_policy_allows() { + let app = AppError::internal("sensitive").redactable(); + let resp: ErrorResponse = app.into(); + + assert_eq!(resp.message, AppErrorKind::Internal.to_string()); + + let borrowed = AppError::internal("private").redactable(); + let resp_ref: ErrorResponse = (&borrowed).into(); + assert_eq!(resp_ref.message, AppErrorKind::Internal.to_string()); +} + // --- Display formatting -------------------------------------------------- #[test] @@ -301,6 +313,17 @@ fn serialized_json_contains_core_fields() { assert!(s.contains("\"after_seconds\":1")); } +#[test] +fn internal_formatters_are_opt_in() { + let resp = ErrorResponse::new(404, AppCode::NotFound, "missing").expect("status"); + let formatted = format!("{:?}", resp.internal()); + assert!(formatted.contains("ErrorResponse")); + + let problem = ProblemJson::from_ref(&AppError::not_found("missing")); + let formatted_problem = format!("{:?}", problem.internal()); + assert!(formatted_problem.contains("ProblemJson")); +} + #[cfg(feature = "axum")] #[test] fn app_error_into_response_maps_status() { diff --git a/tests/masterror_macro.rs b/tests/masterror_macro.rs index 668b340..38b162f 100644 --- a/tests/masterror_macro.rs +++ b/tests/masterror_macro.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use masterror::{ - AppCode, AppErrorKind, Error as MasterrorError, Masterror, MessageEditPolicy, + AppCode, AppErrorKind, Error as MasterrorError, FieldRedaction, Masterror, MessageEditPolicy, mapping::{GrpcMapping, HttpMapping, ProblemMapping} }; @@ -13,7 +13,7 @@ use masterror::{ code = AppCode::NotFound, category = AppErrorKind::NotFound, message, - redact(message), + redact(message, fields("user_id" = hash)), telemetry( Some(masterror::field::str("user_id", user_id.clone())), attempt.map(|value| masterror::field::u64("attempt", value)) @@ -57,7 +57,7 @@ enum ApiError { #[test] fn struct_masterror_conversion_populates_metadata_and_source() { - let source = std::io::Error::new(std::io::ErrorKind::Other, "backend down"); + let source = std::io::Error::other("backend down"); let err = MissingFlag { user_id: "alice".into(), flag: "beta", @@ -101,6 +101,10 @@ fn struct_masterror_conversion_populates_metadata_and_source() { MissingFlag::HTTP_MAPPING, HttpMapping::new(AppCode::NotFound, AppErrorKind::NotFound) ); + assert_eq!( + converted.metadata().redaction("user_id"), + Some(FieldRedaction::Hash) + ); assert_eq!(MissingFlag::HTTP_MAPPING.status(), 404); let grpc = MissingFlag::GRPC_MAPPING.expect("grpc mapping"); diff --git a/tests/ui/app_error/fail/enum_missing_variant.stderr b/tests/ui/app_error/fail/enum_missing_variant.stderr index d000de1..bbc297c 100644 --- a/tests/ui/app_error/fail/enum_missing_variant.stderr +++ b/tests/ui/app_error/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[app_error(...)] to derive AppError conversion --> tests/ui/app_error/fail/enum_missing_variant.rs:8:5 | -8 | #[error("without")] - | ^ +8 | / #[error("without")] +9 | | Without, + | |___________^ warning: unused import: `AppErrorKind` --> tests/ui/app_error/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Error}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_code.stderr b/tests/ui/app_error/fail/missing_code.stderr index 70ccade..4f02301 100644 --- a/tests/ui/app_error/fail/missing_code.stderr +++ b/tests/ui/app_error/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: AppCode conversion requires `code = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_code.rs:9:5 | 9 | #[app_error(kind = AppErrorKind::Service)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/app_error/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Error}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/app_error/fail/missing_kind.stderr b/tests/ui/app_error/fail/missing_kind.stderr index c615e98..021c135 100644 --- a/tests/ui/app_error/fail/missing_kind.stderr +++ b/tests/ui/app_error/fail/missing_kind.stderr @@ -2,4 +2,4 @@ error: missing `kind = ...` in #[app_error(...)] --> tests/ui/app_error/fail/missing_kind.rs:5:1 | 5 | #[app_error(message)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/duplicate_fmt.stderr b/tests/ui/formatter/fail/duplicate_fmt.stderr index 5b08225..5b8f363 100644 --- a/tests/ui/formatter/fail/duplicate_fmt.stderr +++ b/tests/ui/formatter/fail/duplicate_fmt.stderr @@ -2,4 +2,4 @@ error: duplicate `fmt` handler specified --> tests/ui/formatter/fail/duplicate_fmt.rs:4:36 | 4 | #[error(fmt = crate::format_error, fmt = crate::format_error)] - | ^^^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/implicit_after_named.stderr b/tests/ui/formatter/fail/implicit_after_named.stderr index d416399..be76742 100644 --- a/tests/ui/formatter/fail/implicit_after_named.stderr +++ b/tests/ui/formatter/fail/implicit_after_named.stderr @@ -8,4 +8,5 @@ error: multiple unused formatting arguments | argument never used | argument never used | + = note: consider adding 2 format specifiers = note: this error originates in the derive macro `Error` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/tests/ui/formatter/fail/unsupported_flag.stderr b/tests/ui/formatter/fail/unsupported_flag.stderr index d7acdb1..b8bf229 100644 --- a/tests/ui/formatter/fail/unsupported_flag.stderr +++ b/tests/ui/formatter/fail/unsupported_flag.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..11 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_flag.rs:4:9 + --> tests/ui/formatter/fail/unsupported_flag.rs:4:10 | 4 | #[error("{value:##x}")] - | ^^^^^^^^^^^^^ + | ^^^^^^^^^^^ diff --git a/tests/ui/formatter/fail/unsupported_formatter.stderr b/tests/ui/formatter/fail/unsupported_formatter.stderr index 5869420..a6a40c2 100644 --- a/tests/ui/formatter/fail/unsupported_formatter.stderr +++ b/tests/ui/formatter/fail/unsupported_formatter.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/unsupported_formatter.rs:4:9 + --> tests/ui/formatter/fail/unsupported_formatter.rs:4:10 | 4 | #[error("{value:y}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_binary.stderr b/tests/ui/formatter/fail/uppercase_binary.stderr index bbe04b4..3d332c7 100644 --- a/tests/ui/formatter/fail/uppercase_binary.stderr +++ b/tests/ui/formatter/fail/uppercase_binary.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_binary.rs:4:9 + --> tests/ui/formatter/fail/uppercase_binary.rs:4:10 | 4 | #[error("{value:B}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/formatter/fail/uppercase_pointer.stderr b/tests/ui/formatter/fail/uppercase_pointer.stderr index 2c30e71..0bd10fa 100644 --- a/tests/ui/formatter/fail/uppercase_pointer.stderr +++ b/tests/ui/formatter/fail/uppercase_pointer.stderr @@ -1,5 +1,5 @@ error: placeholder spanning bytes 0..9 uses an unsupported formatter - --> tests/ui/formatter/fail/uppercase_pointer.rs:4:9 + --> tests/ui/formatter/fail/uppercase_pointer.rs:4:10 | 4 | #[error("{value:P}")] - | ^^^^^^^^^^^ + | ^^^^^^^^^ diff --git a/tests/ui/masterror/fail/duplicate_attr.stderr b/tests/ui/masterror/fail/duplicate_attr.stderr index c3fb86b..113a10d 100644 --- a/tests/ui/masterror/fail/duplicate_attr.stderr +++ b/tests/ui/masterror/fail/duplicate_attr.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/duplicate_telemetry.stderr b/tests/ui/masterror/fail/duplicate_telemetry.stderr index b331baa..9ada290 100644 --- a/tests/ui/masterror/fail/duplicate_telemetry.stderr +++ b/tests/ui/masterror/fail/duplicate_telemetry.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/empty_redact.stderr b/tests/ui/masterror/fail/empty_redact.stderr index b2658a1..fd151cc 100644 --- a/tests/ui/masterror/fail/empty_redact.stderr +++ b/tests/ui/masterror/fail/empty_redact.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/enum_missing_variant.stderr b/tests/ui/masterror/fail/enum_missing_variant.stderr index 83d517f..5a25e12 100644 --- a/tests/ui/masterror/fail/enum_missing_variant.stderr +++ b/tests/ui/masterror/fail/enum_missing_variant.stderr @@ -1,8 +1,9 @@ error: all variants must use #[masterror(...)] to derive masterror::Error conversion --> tests/ui/masterror/fail/enum_missing_variant.rs:8:5 | -8 | #[error("missing")] - | ^ +8 | / #[error("missing")] +9 | | Missing + | |___________^ warning: unused imports: `AppCode` and `AppErrorKind` --> tests/ui/masterror/fail/enum_missing_variant.rs:1:17 @@ -10,4 +11,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_category.stderr b/tests/ui/masterror/fail/missing_category.stderr index f929951..bdadf45 100644 --- a/tests/ui/masterror/fail/missing_category.stderr +++ b/tests/ui/masterror/fail/missing_category.stderr @@ -2,7 +2,7 @@ error: missing `category = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_category.rs:5:1 | 5 | #[masterror(code = AppCode::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppCode` --> tests/ui/masterror/fail/missing_category.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppCode` 1 | use masterror::{AppCode, Masterror}; | ^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/missing_code.stderr b/tests/ui/masterror/fail/missing_code.stderr index 34abc91..037fac8 100644 --- a/tests/ui/masterror/fail/missing_code.stderr +++ b/tests/ui/masterror/fail/missing_code.stderr @@ -2,7 +2,7 @@ error: missing `code = ...` in #[masterror(...)] --> tests/ui/masterror/fail/missing_code.rs:5:1 | 5 | #[masterror(category = AppErrorKind::Internal)] - | ^ + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ warning: unused import: `AppErrorKind` --> tests/ui/masterror/fail/missing_code.rs:1:17 @@ -10,4 +10,4 @@ warning: unused import: `AppErrorKind` 1 | use masterror::{AppErrorKind, Masterror}; | ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default diff --git a/tests/ui/masterror/fail/unknown_option.stderr b/tests/ui/masterror/fail/unknown_option.stderr index d579838..1822edf 100644 --- a/tests/ui/masterror/fail/unknown_option.stderr +++ b/tests/ui/masterror/fail/unknown_option.stderr @@ -10,4 +10,4 @@ warning: unused imports: `AppCode` and `AppErrorKind` 1 | use masterror::{AppCode, AppErrorKind, Masterror}; | ^^^^^^^ ^^^^^^^^^^^^ | - = note: `#[warn(unused_imports)]` on by default + = note: `#[warn(unused_imports)]` (part of `#[warn(unused)]`) on by default