use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct RedactionReport {
pub findings: Vec<RedactionFinding>,
pub total_redacted: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
pub struct RedactionFinding {
pub start: u32,
pub end: u32,
pub category: PiiCategory,
pub strategy: RedactionStrategy,
pub replacement_token: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum RedactionStrategy {
#[default]
Mask,
Hash,
TokenReplace,
Drop,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[cfg_attr(feature = "api", derive(utoipa::ToSchema))]
#[serde(rename_all = "snake_case")]
pub enum PiiCategory {
Email,
Phone,
Ssn,
CreditCard,
PostalCode,
IpAddress,
Iban,
SwiftBic,
DateOfBirth,
Person,
Organization,
Location,
Custom(String),
}
impl Default for PiiCategory {
fn default() -> Self {
Self::Custom(String::new())
}
}
impl From<String> for PiiCategory {
fn from(s: String) -> Self {
match s.as_str() {
"email" => Self::Email,
"phone" => Self::Phone,
"ssn" => Self::Ssn,
"credit_card" => Self::CreditCard,
"postal_code" => Self::PostalCode,
"ip_address" => Self::IpAddress,
"iban" => Self::Iban,
"swift_bic" => Self::SwiftBic,
"date_of_birth" => Self::DateOfBirth,
"person" => Self::Person,
"organization" => Self::Organization,
"location" => Self::Location,
other => Self::Custom(other.to_string()),
}
}
}
impl std::str::FromStr for PiiCategory {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from(s.to_string()))
}
}
impl TryFrom<&str> for RedactionStrategy {
type Error = crate::XbergError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value {
"mask" => Ok(Self::Mask),
"hash" => Ok(Self::Hash),
"token_replace" => Ok(Self::TokenReplace),
"drop" => Ok(Self::Drop),
_ => Err(crate::XbergError::validation(format!(
"invalid RedactionStrategy value `{value}`; expected one of: mask, hash, token_replace, drop"
))),
}
}
}
#[doc = "Compatibility-only infallible conversion retained through Xberg 1.x."]
#[doc = "Unknown values default to `Mask`; input boundaries must use `TryFrom<&str>`. This conversion is scheduled for removal in 2.0."]
impl From<String> for RedactionStrategy {
fn from(s: String) -> Self {
Self::try_from(s.as_str()).unwrap_or_default()
}
}
#[doc = "Compatibility-only infallible parser retained through Xberg 1.x."]
#[doc = "Unknown values default to `Mask`; input boundaries must use `TryFrom<&str>`. This parser is scheduled for removal in 2.0."]
impl std::str::FromStr for RedactionStrategy {
type Err = std::convert::Infallible;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self::from(s.to_string()))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strict_redaction_strategy_parsing_accepts_every_wire_value() {
for (value, expected) in [
("mask", RedactionStrategy::Mask),
("hash", RedactionStrategy::Hash),
("token_replace", RedactionStrategy::TokenReplace),
("drop", RedactionStrategy::Drop),
] {
assert_eq!(
RedactionStrategy::try_from(value).expect("known strategies must parse"),
expected
);
}
}
#[test]
fn strict_redaction_strategy_parsing_rejects_unknown_values() {
let error = RedactionStrategy::try_from("erase").expect_err("unknown strategies must be rejected");
assert_eq!(
error.to_string(),
"Validation error: invalid RedactionStrategy value `erase`; expected one of: mask, hash, token_replace, drop"
);
}
#[test]
fn redaction_config_deserialization_rejects_unknown_strategy() {
let error = serde_json::from_str::<crate::RedactionConfig>(r#"{"strategy":"erase"}"#)
.expect_err("unknown strategies must not cross the JSON configuration boundary");
assert!(error.to_string().contains("unknown variant `erase`"));
}
}