use alloc::{
borrow::Cow,
collections::BTreeMap,
string::{String, ToString}
};
use core::{
fmt::Write,
iter::repeat_n,
mem::{replace, take},
net::IpAddr,
str::from_utf8
};
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
};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct CodeMapping {
http_status: u16,
grpc: GrpcCode,
problem_type: &'static str,
kind: AppErrorKind
}
impl CodeMapping {
#[cfg_attr(not(any(test, feature = "tonic")), allow(dead_code))]
#[must_use]
pub const fn http_status(&self) -> u16 {
self.http_status
}
#[must_use]
pub const fn grpc(&self) -> GrpcCode {
self.grpc
}
#[must_use]
pub const fn problem_type(&self) -> &'static str {
self.problem_type
}
#[must_use]
pub const fn kind(&self) -> AppErrorKind {
self.kind
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub struct GrpcCode {
pub name: &'static str,
pub value: i32
}
#[derive(Clone, Debug, Serialize)]
pub struct ProblemJson {
#[serde(rename = "type", skip_serializing_if = "Option::is_none")]
pub type_uri: Option<Cow<'static, str>>,
pub title: Cow<'static, str>,
pub status: u16,
#[serde(skip_serializing_if = "Option::is_none")]
pub detail: Option<Cow<'static, str>>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg(feature = "serde_json")]
pub details: Option<JsonValue>,
#[serde(skip_serializing_if = "Option::is_none")]
#[cfg(not(feature = "serde_json"))]
pub details: Option<String>,
pub code: AppCode,
#[serde(skip_serializing_if = "Option::is_none")]
pub grpc: Option<GrpcCode>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<ProblemMetadata>,
#[serde(skip)]
pub retry_after: Option<u64>,
#[serde(skip)]
pub www_authenticate: Option<String>
}
impl ProblemJson {
#[must_use]
pub fn from_app_error(mut error: AppError) -> Self {
error.emit_telemetry();
let kind = error.kind;
let code = replace(&mut error.code, AppCode::from(kind));
let message = error.message.take();
let metadata = take(&mut error.metadata);
let edit_policy = error.edit_policy;
let details = sanitize_details_owned(error.details.take(), edit_policy);
let retry = error.retry.take();
let www_authenticate = error.www_authenticate.take();
let mapping = mapping_for_code(&code);
let status = kind.http_status();
let title = Cow::Borrowed(kind.label());
let detail = sanitize_detail(message, kind, edit_policy);
let metadata = sanitize_metadata_owned(metadata, edit_policy);
Self {
type_uri: Some(Cow::Borrowed(mapping.problem_type())),
title,
status,
detail,
details,
code,
grpc: Some(mapping.grpc()),
metadata,
retry_after: retry.map(|value| value.after_seconds),
www_authenticate
}
}
#[must_use]
pub fn from_ref(error: &AppError) -> Self {
let mapping = mapping_for_code(&error.code);
let status = error.kind.http_status();
let title = Cow::Borrowed(error.kind.label());
let detail = sanitize_detail_ref(error);
let details = sanitize_details_ref(error);
let metadata = sanitize_metadata_ref(error.metadata(), error.edit_policy);
Self {
type_uri: Some(Cow::Borrowed(mapping.problem_type())),
title,
status,
detail,
details,
code: error.code.clone(),
grpc: Some(mapping.grpc()),
metadata,
retry_after: error.retry.map(|value| value.after_seconds),
www_authenticate: error.www_authenticate.clone()
}
}
#[must_use]
pub fn from_error_response(response: ErrorResponse) -> Self {
let ErrorResponse {
status,
code,
message,
details,
retry,
www_authenticate
} = response;
let mapping = mapping_for_code(&code);
let detail = if message.is_empty() {
None
} else {
Some(Cow::Owned(message))
};
Self {
type_uri: Some(Cow::Borrowed(mapping.problem_type())),
title: Cow::Borrowed(mapping.kind().label()),
status,
detail,
details,
code,
grpc: Some(mapping.grpc()),
metadata: None,
retry_after: retry.map(|value| value.after_seconds),
www_authenticate
}
}
#[must_use]
pub fn status_code(&self) -> StatusCode {
match StatusCode::from_u16(self.status) {
Ok(status) => status,
Err(_) => StatusCode::INTERNAL_SERVER_ERROR
}
}
#[must_use]
pub fn internal(&self) -> crate::response::internal::ProblemJsonFormatter<'_> {
crate::response::internal::ProblemJsonFormatter::new(self)
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(transparent)]
pub struct ProblemMetadata(BTreeMap<Cow<'static, str>, ProblemMetadataValue>);
impl ProblemMetadata {
#[cfg(test)]
fn is_empty(&self) -> bool {
self.0.is_empty()
}
}
#[derive(Clone, Debug, Serialize)]
#[serde(untagged)]
pub enum ProblemMetadataValue {
String(Cow<'static, str>),
I64(i64),
U64(u64),
F64(f64),
Bool(bool),
Duration {
secs: u64,
nanos: u32
},
Ip(IpAddr),
#[cfg(feature = "serde_json")]
Json(JsonValue)
}
impl From<FieldValue> for ProblemMetadataValue {
fn from(value: FieldValue) -> Self {
match value {
FieldValue::Str(value) => Self::String(value),
FieldValue::I64(value) => Self::I64(value),
FieldValue::U64(value) => Self::U64(value),
FieldValue::F64(value) => Self::F64(value),
FieldValue::Bool(value) => Self::Bool(value),
FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())),
FieldValue::Duration(value) => Self::Duration {
secs: value.as_secs(),
nanos: value.subsec_nanos()
},
FieldValue::Ip(value) => Self::Ip(value),
#[cfg(feature = "serde_json")]
FieldValue::Json(value) => Self::Json(value)
}
}
}
impl From<&FieldValue> for ProblemMetadataValue {
fn from(value: &FieldValue) -> Self {
match value {
FieldValue::Str(value) => Self::String(value.clone()),
FieldValue::I64(value) => Self::I64(*value),
FieldValue::U64(value) => Self::U64(*value),
FieldValue::F64(value) => Self::F64(*value),
FieldValue::Bool(value) => Self::Bool(*value),
FieldValue::Uuid(value) => Self::String(Cow::Owned(value.to_string())),
FieldValue::Duration(value) => Self::Duration {
secs: value.as_secs(),
nanos: value.subsec_nanos()
},
FieldValue::Ip(value) => Self::Ip(*value),
#[cfg(feature = "serde_json")]
FieldValue::Json(value) => Self::Json(value.clone())
}
}
}
fn sanitize_detail(
message: Option<Cow<'static, str>>,
kind: AppErrorKind,
policy: MessageEditPolicy
) -> Option<Cow<'static, str>> {
if matches!(policy, MessageEditPolicy::Redact) {
return None;
}
Some(message.unwrap_or_else(|| Cow::Borrowed(kind.label())))
}
fn sanitize_detail_ref(error: &AppError) -> Option<Cow<'static, str>> {
if matches!(error.edit_policy, MessageEditPolicy::Redact) {
return None;
}
match error.message.as_ref() {
Some(Cow::Borrowed(msg)) => Some(Cow::Borrowed(*msg)),
Some(Cow::Owned(msg)) => Some(Cow::Owned(msg.clone())),
None => Some(Cow::Borrowed(error.kind.label()))
}
}
#[cfg(feature = "serde_json")]
fn sanitize_details_owned(
details: Option<JsonValue>,
policy: MessageEditPolicy
) -> Option<JsonValue> {
if matches!(policy, MessageEditPolicy::Redact) {
None
} else {
details
}
}
#[cfg(not(feature = "serde_json"))]
fn sanitize_details_owned(details: Option<String>, policy: MessageEditPolicy) -> Option<String> {
if matches!(policy, MessageEditPolicy::Redact) {
None
} else {
details
}
}
#[cfg(feature = "serde_json")]
fn sanitize_details_ref(error: &AppError) -> Option<JsonValue> {
if matches!(error.edit_policy, MessageEditPolicy::Redact) {
None
} else {
error.details.clone()
}
}
#[cfg(not(feature = "serde_json"))]
fn sanitize_details_ref(error: &AppError) -> Option<String> {
if matches!(error.edit_policy, MessageEditPolicy::Redact) {
None
} else {
error.details.clone()
}
}
fn sanitize_metadata_owned(
metadata: Metadata,
policy: MessageEditPolicy
) -> Option<ProblemMetadata> {
if matches!(policy, MessageEditPolicy::Redact) || metadata.is_empty() {
return None;
}
let mut public = BTreeMap::new();
for field in metadata {
let (name, value, redaction) = field.into_parts();
if let Some(sanitized) = sanitize_problem_metadata_value_owned(value, redaction) {
public.insert(Cow::Borrowed(name), sanitized);
}
}
if public.is_empty() {
None
} else {
Some(ProblemMetadata(public))
}
}
fn sanitize_metadata_ref(
metadata: &Metadata,
policy: MessageEditPolicy
) -> Option<ProblemMetadata> {
if matches!(policy, MessageEditPolicy::Redact) || metadata.is_empty() {
return None;
}
let mut public = BTreeMap::new();
for (name, value, redaction) in metadata.iter_with_redaction() {
if let Some(sanitized) = sanitize_problem_metadata_value_ref(value, redaction) {
public.insert(Cow::Borrowed(name), sanitized);
}
}
if public.is_empty() {
None
} else {
Some(ProblemMetadata(public))
}
}
const REDACTED_PLACEHOLDER: &str = "[REDACTED]";
fn sanitize_problem_metadata_value_owned(
value: FieldValue,
redaction: FieldRedaction
) -> Option<ProblemMetadataValue> {
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<ProblemMetadataValue> {
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)))
}
}
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> {
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 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<String> {
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
}
pub const CODE_MAPPINGS: &[(AppCode, CodeMapping)] = &[
(
AppCode::NotFound,
CodeMapping {
http_status: 404,
grpc: GrpcCode {
name: "NOT_FOUND",
value: 5
},
problem_type: "https://errors.masterror.rs/not-found",
kind: AppErrorKind::NotFound
}
),
(
AppCode::Validation,
CodeMapping {
http_status: 422,
grpc: GrpcCode {
name: "INVALID_ARGUMENT",
value: 3
},
problem_type: "https://errors.masterror.rs/validation",
kind: AppErrorKind::Validation
}
),
(
AppCode::Conflict,
CodeMapping {
http_status: 409,
grpc: GrpcCode {
name: "ALREADY_EXISTS",
value: 6
},
problem_type: "https://errors.masterror.rs/conflict",
kind: AppErrorKind::Conflict
}
),
(
AppCode::UserAlreadyExists,
CodeMapping {
http_status: 409,
grpc: GrpcCode {
name: "ALREADY_EXISTS",
value: 6
},
problem_type: "https://errors.masterror.rs/user-already-exists",
kind: AppErrorKind::Conflict
}
),
(
AppCode::Unauthorized,
CodeMapping {
http_status: 401,
grpc: GrpcCode {
name: "UNAUTHENTICATED",
value: 16
},
problem_type: "https://errors.masterror.rs/unauthorized",
kind: AppErrorKind::Unauthorized
}
),
(
AppCode::Forbidden,
CodeMapping {
http_status: 403,
grpc: GrpcCode {
name: "PERMISSION_DENIED",
value: 7
},
problem_type: "https://errors.masterror.rs/forbidden",
kind: AppErrorKind::Forbidden
}
),
(
AppCode::NotImplemented,
CodeMapping {
http_status: 501,
grpc: GrpcCode {
name: "UNIMPLEMENTED",
value: 12
},
problem_type: "https://errors.masterror.rs/not-implemented",
kind: AppErrorKind::NotImplemented
}
),
(
AppCode::BadRequest,
CodeMapping {
http_status: 400,
grpc: GrpcCode {
name: "INVALID_ARGUMENT",
value: 3
},
problem_type: "https://errors.masterror.rs/bad-request",
kind: AppErrorKind::BadRequest
}
),
(
AppCode::RateLimited,
CodeMapping {
http_status: 429,
grpc: GrpcCode {
name: "RESOURCE_EXHAUSTED",
value: 8
},
problem_type: "https://errors.masterror.rs/rate-limited",
kind: AppErrorKind::RateLimited
}
),
(
AppCode::TelegramAuth,
CodeMapping {
http_status: 401,
grpc: GrpcCode {
name: "UNAUTHENTICATED",
value: 16
},
problem_type: "https://errors.masterror.rs/telegram-auth",
kind: AppErrorKind::TelegramAuth
}
),
(
AppCode::InvalidJwt,
CodeMapping {
http_status: 401,
grpc: GrpcCode {
name: "UNAUTHENTICATED",
value: 16
},
problem_type: "https://errors.masterror.rs/invalid-jwt",
kind: AppErrorKind::InvalidJwt
}
),
(
AppCode::Internal,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/internal",
kind: AppErrorKind::Internal
}
),
(
AppCode::Database,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/database",
kind: AppErrorKind::Database
}
),
(
AppCode::Service,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/service",
kind: AppErrorKind::Service
}
),
(
AppCode::Config,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/config",
kind: AppErrorKind::Config
}
),
(
AppCode::Turnkey,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/turnkey",
kind: AppErrorKind::Turnkey
}
),
(
AppCode::Timeout,
CodeMapping {
http_status: 504,
grpc: GrpcCode {
name: "DEADLINE_EXCEEDED",
value: 4
},
problem_type: "https://errors.masterror.rs/timeout",
kind: AppErrorKind::Timeout
}
),
(
AppCode::Network,
CodeMapping {
http_status: 503,
grpc: GrpcCode {
name: "UNAVAILABLE",
value: 14
},
problem_type: "https://errors.masterror.rs/network",
kind: AppErrorKind::Network
}
),
(
AppCode::DependencyUnavailable,
CodeMapping {
http_status: 503,
grpc: GrpcCode {
name: "UNAVAILABLE",
value: 14
},
problem_type: "https://errors.masterror.rs/dependency-unavailable",
kind: AppErrorKind::DependencyUnavailable
}
),
(
AppCode::Serialization,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/serialization",
kind: AppErrorKind::Serialization
}
),
(
AppCode::Deserialization,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/deserialization",
kind: AppErrorKind::Deserialization
}
),
(
AppCode::ExternalApi,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "UNAVAILABLE",
value: 14
},
problem_type: "https://errors.masterror.rs/external-api",
kind: AppErrorKind::ExternalApi
}
),
(
AppCode::Queue,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "UNAVAILABLE",
value: 14
},
problem_type: "https://errors.masterror.rs/queue",
kind: AppErrorKind::Queue
}
),
(
AppCode::Cache,
CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "UNAVAILABLE",
value: 14
},
problem_type: "https://errors.masterror.rs/cache",
kind: AppErrorKind::Cache
}
)
];
const DEFAULT_MAPPING: CodeMapping = CodeMapping {
http_status: 500,
grpc: GrpcCode {
name: "INTERNAL",
value: 13
},
problem_type: "https://errors.masterror.rs/internal",
kind: AppErrorKind::Internal
};
#[must_use]
pub fn mapping_for_code(code: &AppCode) -> CodeMapping {
CODE_MAPPINGS
.iter()
.find_map(|(candidate, mapping)| {
if candidate == code {
Some(*mapping)
} else {
None
}
})
.unwrap_or(DEFAULT_MAPPING)
}
#[cfg(test)]
mod tests {
use std::{
fmt::Write,
net::{IpAddr, Ipv4Addr},
time::Duration
};
use serde_json::Value;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use super::*;
#[cfg(feature = "serde_json")]
use crate::field::json;
use crate::{
AppError,
field::{duration, f64, ip, str, u64, uuid}
};
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")
.redactable()
.with_field(str("token", "super-secret"));
let problem = ProblemJson::from_ref(&err);
assert!(problem.detail.is_none());
assert!(problem.metadata.is_none());
}
#[test]
fn metadata_is_serialized_when_allowed() {
let err = AppError::internal("oops").with_field(u64("attempt", 2));
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
assert!(!metadata.is_empty());
}
#[test]
fn metadata_preserves_extended_field_types() {
let mut err = AppError::internal("oops");
err = err.with_field(f64("ratio", 0.25));
err = err.with_field(duration("elapsed", Duration::from_millis(1500)));
err = err.with_field(ip("peer", IpAddr::from(Ipv4Addr::new(10, 0, 0, 42))));
#[cfg(feature = "serde_json")]
{
err = err.with_field(json("payload", serde_json::json!({ "status": "ok" })));
}
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let ratio = metadata.0.get("ratio").expect("ratio metadata");
assert!(matches!(
ratio,
ProblemMetadataValue::F64(value) if (*value - 0.25).abs() < f64::EPSILON
));
let duration = metadata.0.get("elapsed").expect("elapsed metadata");
assert!(matches!(
duration,
ProblemMetadataValue::Duration { secs, nanos }
if *secs == 1 && *nanos == 500_000_000
));
let ip = metadata.0.get("peer").expect("peer metadata");
assert!(matches!(ip, ProblemMetadataValue::Ip(addr) if addr.is_ipv4()));
#[cfg(feature = "serde_json")]
{
let payload = metadata.0.get("payload").expect("payload metadata");
assert!(matches!(
payload,
ProblemMetadataValue::Json(value) if value["status"] == "ok"
));
}
}
#[test]
fn redacted_metadata_uses_placeholder() {
let err = AppError::internal("oops").with_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(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 hashed_numeric_metadata_uses_decimal_text() {
let err = AppError::internal("oops")
.with_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 trace_id = Uuid::from_u128(0x1234_5678_9abc_def0_1234_5678_9abc_def0);
let err = AppError::internal("oops")
.with_field(uuid("trace", trace_id).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 = trace_id.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 peer_addr = IpAddr::from(Ipv4Addr::new(10, 10, 10, 10));
let err = AppError::internal("oops")
.with_field(ip("peer", peer_addr).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(peer_addr.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").with_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 last4_metadata_handles_multibyte_suffix() {
let multibyte = "💳💳💳💳💳💳";
let err = AppError::internal("oops")
.with_field(str("emoji", multibyte).with_redaction(FieldRedaction::Last4));
let problem = ProblemJson::from_ref(&err);
let metadata = problem.metadata.expect("metadata");
let value = metadata.0.get("emoji").expect("emoji field");
match value {
ProblemMetadataValue::String(text) => {
let total = multibyte.chars().count();
let keep = if total <= 4 { 1 } else { 4 };
let expected_mask_len = total.saturating_sub(keep);
let expected_suffix: String = multibyte.chars().skip(expected_mask_len).collect();
assert!(text.ends_with(&expected_suffix));
assert!(text.chars().take(expected_mask_len).all(|c| c == '*'));
assert_eq!(
text.chars().filter(|c| *c == '*').count(),
expected_mask_len
);
assert_eq!(text.chars().count(), multibyte.chars().count());
}
other => panic!("unexpected metadata value: {other:?}")
}
}
#[test]
fn last4_numeric_metadata_matches_decimal_format() {
let number = 123456789u64;
let err = AppError::internal("oops")
.with_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 trace_id = Uuid::from_u128(0x4321_8765_cba9_0fed_cba9_8765_4321_0fed);
let err = AppError::internal("oops")
.with_field(uuid("trace", trace_id).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 = trace_id.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 peer_addr = IpAddr::from(Ipv4Addr::new(172, 16, 10, 1));
let err = AppError::internal("oops")
.with_field(ip("peer", peer_addr).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(&peer_addr.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";
let err = AppError::internal("oops").with_field(str("token", secret));
let problem = ProblemJson::from_ref(&err);
let json = serde_json::to_value(&problem).expect("serialize problem");
let metadata = json
.get("metadata")
.and_then(Value::as_object)
.expect("metadata present");
let hashed = metadata
.get("token")
.and_then(Value::as_str)
.expect("hashed token");
let mut hasher = Sha256::new();
hasher.update(secret.as_bytes());
let digest = hasher.finalize();
let expected = digest
.iter()
.fold(String::with_capacity(64), |mut acc, byte| {
let _ = write!(&mut acc, "{:02x}", byte);
acc
});
assert_eq!(hashed, expected);
assert!(!json.to_string().contains(secret));
let debug_repr = format!("{:?}", problem.internal());
assert!(debug_repr.contains("metadata"));
assert!(!debug_repr.contains(secret));
}
#[test]
fn problem_json_serialization_omits_metadata_when_redacted() {
let secret_value = "sensitive-value";
let err = AppError::internal("secret")
.redactable()
.with_field(str("token", secret_value));
let problem = ProblemJson::from_ref(&err);
let json = serde_json::to_value(&problem).expect("serialize problem");
assert!(json.get("metadata").is_none());
assert!(!json.to_string().contains(secret_value));
let debug_repr = format!("{:?}", problem.internal());
assert!(debug_repr.contains("ProblemJson"));
}
#[test]
fn mapping_for_every_code_matches_http_status() {
for (code, mapping) in CODE_MAPPINGS {
let status = mapping.http_status();
let expected = mapping.kind().http_status();
assert_eq!(status, expected, "status mismatch for {:?}", code);
}
}
}