Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@ All notable changes to this project will be documented in this file.

## [Unreleased]

## [0.23.3] - 2025-10-15

### Changed
- Replaced temporary `String` allocations in RFC7807 metadata hashing and masking
with stack buffers to keep the textual representations and digests stable
while avoiding heap usage.

### Added
- Regression tests covering hashed and last-four redaction paths for numeric,
UUID, and IP metadata to guarantee the legacy formatting remains unchanged.

## [0.23.2] - 2025-10-14

### Fixed
Expand Down
4 changes: 3 additions & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "masterror"
version = "0.23.2"
version = "0.23.3"
rust-version = "1.90"
edition = "2024"
license = "MIT OR Apache-2.0"
Expand Down Expand Up @@ -99,6 +99,8 @@ serde_json = { version = "1", optional = true, default-features = false, feature
] }
http = "1"
sha2 = "0.10"
itoa = "1"
ryu = "1"

# optional integrations
axum = { version = "0.8", optional = true, default-features = false, features = [
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,9 +74,9 @@ The build script keeps the full feature snippet below in sync with

~~~toml
[dependencies]
masterror = { version = "0.23.1", default-features = false }
masterror = { version = "0.23.3", default-features = false }
# or with features:
# masterror = { version = "0.23.1", features = [
# masterror = { version = "0.23.3", features = [
# "std", "axum", "actix", "openapi",
# "serde_json", "tracing", "metrics", "backtrace",
# "sqlx", "sqlx-migrate", "reqwest", "redis",
Expand Down
211 changes: 196 additions & 15 deletions src/response/problem_json.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ use alloc::{
use core::{fmt::Write, 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;
Expand Down Expand Up @@ -532,17 +534,51 @@ fn sanitize_problem_metadata_value_ref(
}
}

struct StackBuffer<const N: usize> {
buf: [u8; N],
len: usize
}

impl<const N: usize> StackBuffer<N> {
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> {
core::str::from_utf8(self.as_bytes()).ok()
}
}

impl<const N: usize> Write for StackBuffer<N> {
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 string = value.to_string();
hasher.update(string.as_bytes());
let mut buffer = IntegerBuffer::new();
hasher.update(buffer.format(*value).as_bytes());
}
FieldValue::U64(value) => {
let string = value.to_string();
hasher.update(string.as_bytes());
let mut buffer = IntegerBuffer::new();
hasher.update(buffer.format(*value).as_bytes());
}
FieldValue::F64(value) => hasher.update(value.to_le_bytes()),
FieldValue::Bool(value) => {
Expand All @@ -553,17 +589,25 @@ fn hash_field_value(value: &FieldValue) -> String {
}
}
FieldValue::Uuid(value) => {
let string = value.to_string();
hasher.update(string.as_bytes());
// `Uuid::to_string()` produces a lowercase hyphenated representation; we
// keep the same bytes to preserve the hash output that clients rely on.
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) => match value {
IpAddr::V4(addr) => hasher.update(addr.octets()),
IpAddr::V6(addr) => hasher.update(addr.octets())
},
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());
Comment on lines +602 to +608

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Keep IP hash stable with previous releases

The new implementation feeds the textual representation of an IpAddr into the SHA-256 hasher (write!(&mut buffer, "{value}")), whereas the previous code hashed the raw octets. This changes the digest for every IPv4/IPv6 value, so clients that depended on the stable hash produced in prior versions will see different redacted metadata even though the crate only bumps from 0.23.2 to 0.23.3 and the changelog claims the output is unchanged. If compatibility matters, the hashing should continue to use the byte representation (or the version should be bumped/behavior documented).

Useful? React with 👍 / 👎.

}
}
#[cfg(feature = "serde_json")]
FieldValue::Json(value) => {
if let Ok(serialized) = serde_json::to_vec(value) {
Expand All @@ -582,12 +626,31 @@ fn hash_field_value(value: &FieldValue) -> String {
fn mask_last4_field_value(value: &FieldValue) -> Option<String> {
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::F64(value) => Some(mask_last4(&value.to_string())),
FieldValue::Uuid(value) => Some(mask_last4(&value.to_string())),
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) => Some(mask_last4(&value.to_string())),
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()
Expand Down Expand Up @@ -959,10 +1022,23 @@ mod tests {

use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;

use super::*;
use crate::AppError;

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 metadata_is_skipped_when_redacted() {
let err = AppError::internal("secret")
Expand Down Expand Up @@ -1059,6 +1135,58 @@ mod tests {
}
}

#[test]
fn hashed_numeric_metadata_uses_decimal_text() {
let err = AppError::internal("oops")
.with_field(crate::field::u64("attempt", 42).with_redaction(FieldRedaction::Hash));
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let value = metadata.0.get("attempt").expect("attempt field");
match value {
ProblemMetadataValue::String(text) => {
let expected = sha256_hex(b"42");
assert_eq!(text.as_ref(), expected);
}
other => panic!("unexpected metadata value: {other:?}")
}
}

#[test]
fn hashed_uuid_metadata_preserves_hyphenated_text() {
let uuid = Uuid::from_u128(0x1234_5678_9abc_def0_1234_5678_9abc_def0);
let err = AppError::internal("oops")
.with_field(crate::field::uuid("trace", uuid).with_redaction(FieldRedaction::Hash));
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let value = metadata.0.get("trace").expect("trace field");
match value {
ProblemMetadataValue::String(text) => {
let mut repr = [0u8; 36];
let expected_repr = uuid.hyphenated().encode_lower(&mut repr);
let expected = sha256_hex(expected_repr.as_bytes());
assert_eq!(text.as_ref(), expected);
}
other => panic!("unexpected metadata value: {other:?}")
}
}

#[test]
fn hashed_ip_metadata_preserves_display_text() {
let ip = IpAddr::from(Ipv4Addr::new(10, 10, 10, 10));
let err = AppError::internal("oops")
.with_field(crate::field::ip("peer", ip).with_redaction(FieldRedaction::Hash));
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let value = metadata.0.get("peer").expect("peer field");
match value {
ProblemMetadataValue::String(text) => {
let expected = sha256_hex(ip.to_string().as_bytes());
assert_eq!(text.as_ref(), expected);
}
other => panic!("unexpected metadata value: {other:?}")
}
}

#[test]
fn last4_metadata_preserves_suffix() {
let err = AppError::internal("oops")
Expand Down Expand Up @@ -1103,6 +1231,59 @@ mod tests {
}
}

#[test]
fn last4_numeric_metadata_matches_decimal_format() {
let number = 123456789u64;
let err = AppError::internal("oops").with_field(
crate::field::u64("invoice", number).with_redaction(FieldRedaction::Last4)
);
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let metadata_value = metadata.0.get("invoice").expect("invoice field");
let expected_suffix = mask_last4(&number.to_string());
match metadata_value {
ProblemMetadataValue::String(text) => {
assert_eq!(text.as_ref(), expected_suffix);
}
other => panic!("unexpected metadata value: {other:?}")
}
}

#[test]
fn last4_uuid_metadata_matches_previous_format() {
let uuid = Uuid::from_u128(0x4321_8765_cba9_0fed_cba9_8765_4321_0fed);
let err = AppError::internal("oops")
.with_field(crate::field::uuid("trace", uuid).with_redaction(FieldRedaction::Last4));
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let metadata_value = metadata.0.get("trace").expect("trace field");
let expected_repr = uuid.to_string();
let expected_suffix = mask_last4(&expected_repr);
match metadata_value {
ProblemMetadataValue::String(text) => {
assert_eq!(text.as_ref(), expected_suffix);
}
other => panic!("unexpected metadata value: {other:?}")
}
}

#[test]
fn last4_ip_metadata_matches_previous_format() {
let ip = IpAddr::from(Ipv4Addr::new(172, 16, 10, 1));
let err = AppError::internal("oops")
.with_field(crate::field::ip("peer", ip).with_redaction(FieldRedaction::Last4));
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let metadata_value = metadata.0.get("peer").expect("peer field");
let expected_suffix = mask_last4(&ip.to_string());
match metadata_value {
ProblemMetadataValue::String(text) => {
assert_eq!(text.as_ref(), expected_suffix);
}
other => panic!("unexpected metadata value: {other:?}")
}
}

#[test]
fn problem_json_serialization_masks_sensitive_metadata() {
let secret = "super-secret";
Expand Down
Loading