use std::collections::BTreeMap;
use std::fmt;
use thiserror::Error;
use crate::{PiiClass, SafetyNetPiiClass};
pub const NYM_SAFETY_NET_ID: &str = "nym-small-int8";
macro_rules! nym_labels {
($($variant:ident => $name:literal,)+) => {
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum NymLabel {
$(
#[doc = concat!("`", $name, "`.")]
$variant,
)+
}
impl NymLabel {
pub const ALL: &'static [NymLabel] = &[$(Self::$variant,)+];
pub fn as_str(self) -> &'static str {
match self {
$(Self::$variant => $name,)+
}
}
pub fn parse(value: &str) -> Option<Self> {
match value {
$($name => Some(Self::$variant),)+
_ => None,
}
}
}
};
}
nym_labels! {
AccountNumber => "ACCOUNT_NUMBER",
Age => "AGE",
ApiKey => "API_KEY",
BuildingNumber => "BUILDING_NUMBER",
City => "CITY",
CompanyName => "COMPANY_NAME",
Country => "COUNTRY",
CreditDebitCard => "CREDIT_DEBIT_CARD",
CustomerId => "CUSTOMER_ID",
Cvv => "CVV",
Date => "DATE",
DateOfBirth => "DATE_OF_BIRTH",
DriversLicense => "DRIVERS_LICENSE",
Email => "EMAIL",
EmployeeId => "EMPLOYEE_ID",
FaxNumber => "FAX_NUMBER",
Gender => "GENDER",
GivenName => "GIVEN_NAME",
GovernmentId => "GOVERNMENT_ID",
Iban => "IBAN",
LicensePlate => "LICENSE_PLATE",
MacAddress => "MAC_ADDRESS",
MedicalRecordNumber => "MEDICAL_RECORD_NUMBER",
Passport => "PASSPORT",
Password => "PASSWORD",
Phone => "PHONE",
Pin => "PIN",
RoutingNumber => "ROUTING_NUMBER",
SecondaryAddress => "SECONDARY_ADDRESS",
Ssn => "SSN",
State => "STATE",
StreetAddress => "STREET_ADDRESS",
StreetName => "STREET_NAME",
Surname => "SURNAME",
SwiftBic => "SWIFT_BIC",
TaxId => "TAX_ID",
Time => "TIME",
Url => "URL",
Username => "USERNAME",
ZipCode => "ZIP_CODE",
}
impl fmt::Display for NymLabel {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.as_str())
}
}
pub fn nym_label_to_safety_net_class(label: NymLabel) -> Result<SafetyNetPiiClass, NymConfigError> {
match label {
NymLabel::BuildingNumber => Ok(SafetyNetPiiClass::BuildingNumber),
NymLabel::LicensePlate => Ok(SafetyNetPiiClass::LicensePlate),
NymLabel::Username => Ok(SafetyNetPiiClass::Username),
NymLabel::DateOfBirth => Ok(SafetyNetPiiClass::Date),
NymLabel::ZipCode => Ok(SafetyNetPiiClass::PostalCode),
NymLabel::TaxId => Ok(SafetyNetPiiClass::TaxId),
NymLabel::GivenName | NymLabel::Surname => Ok(SafetyNetPiiClass::Name),
unsupported => Err(NymConfigError::UnsupportedLabel {
label: unsupported.as_str().to_string(),
}),
}
}
pub fn nym_label_to_pii_class(label: NymLabel) -> Result<PiiClass, NymConfigError> {
nym_label_to_safety_net_class(label).map(SafetyNetPiiClass::to_pii_class)
}
#[derive(Debug, Clone, PartialEq, Error)]
#[non_exhaustive]
pub enum NymConfigError {
#[error("unknown nym label `{label}`")]
UnknownLabel {
label: String,
},
#[error("nym label `{label}` has no gaze class and cannot be enabled")]
UnsupportedLabel {
label: String,
},
#[error("nym label `{label}` is listed more than once")]
DuplicateLabel {
label: String,
},
#[error("nym label `{label}` is enabled without a threshold")]
MissingThreshold {
label: String,
},
#[error("nym threshold for `{label}` names a label that is not in `labels`")]
ThresholdWithoutLabel {
label: String,
},
#[error("nym threshold for `{label}` must be in (0, 1], got {threshold}")]
ThresholdOutOfRange {
label: String,
threshold: f32,
},
#[error("nym allowlist is empty")]
EmptyAllowlist,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NymOperatingPoint {
thresholds: BTreeMap<NymLabel, f32>,
}
impl NymOperatingPoint {
pub fn op_b() -> Self {
Self {
thresholds: BTreeMap::from([
(NymLabel::BuildingNumber, 0.5),
(NymLabel::DateOfBirth, 0.9),
(NymLabel::GivenName, 0.5),
(NymLabel::LicensePlate, 0.5),
(NymLabel::Surname, 0.5),
(NymLabel::Username, 0.5),
]),
}
}
pub fn new(
thresholds: impl IntoIterator<Item = (NymLabel, f32)>,
) -> Result<Self, NymConfigError> {
let mut out = BTreeMap::new();
for (label, threshold) in thresholds {
nym_label_to_safety_net_class(label)?;
if !(threshold.is_finite() && threshold > 0.0 && threshold <= 1.0) {
return Err(NymConfigError::ThresholdOutOfRange {
label: label.as_str().to_string(),
threshold,
});
}
if out.insert(label, threshold).is_some() {
return Err(NymConfigError::DuplicateLabel {
label: label.as_str().to_string(),
});
}
}
if out.is_empty() {
return Err(NymConfigError::EmptyAllowlist);
}
Ok(Self { thresholds: out })
}
pub fn from_labels_and_thresholds(
labels: &[String],
thresholds: &BTreeMap<String, f32>,
) -> Result<Self, NymConfigError> {
let parse = |raw: &str| {
NymLabel::parse(raw).ok_or_else(|| NymConfigError::UnknownLabel {
label: raw.to_string(),
})
};
for raw in thresholds.keys() {
parse(raw)?;
if !labels.contains(raw) {
return Err(NymConfigError::ThresholdWithoutLabel { label: raw.clone() });
}
}
let mut pairs = Vec::with_capacity(labels.len());
for raw in labels {
let label = parse(raw)?;
nym_label_to_safety_net_class(label)?;
let threshold = *thresholds
.get(raw)
.ok_or_else(|| NymConfigError::MissingThreshold { label: raw.clone() })?;
pairs.push((label, threshold));
}
Self::new(pairs)
}
pub fn threshold(&self, label: NymLabel) -> Option<f32> {
self.thresholds.get(&label).copied()
}
pub fn iter(&self) -> impl Iterator<Item = (NymLabel, f32)> + '_ {
self.thresholds
.iter()
.map(|(label, threshold)| (*label, *threshold))
}
}
impl Default for NymOperatingPoint {
fn default() -> Self {
Self::op_b()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn personal_name_labels_can_be_configured_as_name() {
let op = NymOperatingPoint::new([(NymLabel::GivenName, 0.7), (NymLabel::Surname, 0.9)])
.expect("personal-name labels carry the Name class");
assert_eq!(
nym_label_to_pii_class(NymLabel::GivenName),
Ok(PiiClass::Name)
);
assert_eq!(
nym_label_to_pii_class(NymLabel::Surname),
Ok(PiiClass::Name)
);
assert_eq!(op.threshold(NymLabel::GivenName), Some(0.7));
assert_eq!(op.threshold(NymLabel::Surname), Some(0.9));
}
#[test]
fn label_order_matches_the_pinned_id2label() {
assert_eq!(NymLabel::ALL.len(), 40);
let mut sorted = NymLabel::ALL.iter().map(|l| l.as_str()).collect::<Vec<_>>();
sorted.sort_unstable();
let actual = NymLabel::ALL.iter().map(|l| l.as_str()).collect::<Vec<_>>();
assert_eq!(actual, sorted, "id2label lists the types alphabetically");
for label in NymLabel::ALL {
assert_eq!(NymLabel::parse(label.as_str()), Some(*label));
}
assert_eq!(NymLabel::parse("zip_code"), None);
}
#[test]
fn class_map_is_closed_with_explicit_person_name_labels() {
let mapped = NymLabel::ALL
.iter()
.filter_map(|label| {
nym_label_to_pii_class(*label)
.ok()
.map(|class| (label.as_str(), class.to_canonical_str()))
})
.collect::<Vec<_>>();
assert_eq!(
mapped,
vec![
("BUILDING_NUMBER", "custom:building_number".to_string()),
("DATE_OF_BIRTH", "custom:date".to_string()),
("GIVEN_NAME", "name".to_string()),
("LICENSE_PLATE", "custom:license_plate".to_string()),
("SURNAME", "name".to_string()),
("TAX_ID", "custom:tax_id".to_string()),
("USERNAME", "custom:username".to_string()),
("ZIP_CODE", "custom:postal_code".to_string()),
]
);
assert_eq!(
nym_label_to_safety_net_class(NymLabel::City),
Err(NymConfigError::UnsupportedLabel {
label: "CITY".to_string()
})
);
}
#[test]
fn op_b_is_the_default() {
let op_b = NymOperatingPoint::default();
assert_eq!(
op_b.iter().collect::<Vec<_>>(),
vec![
(NymLabel::BuildingNumber, 0.5),
(NymLabel::DateOfBirth, 0.9),
(NymLabel::GivenName, 0.5),
(NymLabel::LicensePlate, 0.5),
(NymLabel::Surname, 0.5),
(NymLabel::Username, 0.5),
]
);
assert_eq!(op_b.threshold(NymLabel::TaxId), None);
assert_eq!(op_b.threshold(NymLabel::ZipCode), None);
assert_eq!(op_b.threshold(NymLabel::GivenName), Some(0.5));
assert_eq!(op_b.threshold(NymLabel::Surname), Some(0.5));
}
fn policy(
labels: &[&str],
thresholds: &[(&str, f32)],
) -> Result<NymOperatingPoint, NymConfigError> {
NymOperatingPoint::from_labels_and_thresholds(
&labels.iter().map(|l| l.to_string()).collect::<Vec<_>>(),
&thresholds
.iter()
.map(|(l, t)| (l.to_string(), *t))
.collect(),
)
}
#[test]
fn policy_spelling_validates_every_rule() {
assert_eq!(
policy(
&["LICENSE_PLATE", "ZIP_CODE"],
&[("LICENSE_PLATE", 0.5), ("ZIP_CODE", 0.7)]
)
.unwrap()
.iter()
.collect::<Vec<_>>(),
vec![(NymLabel::LicensePlate, 0.5), (NymLabel::ZipCode, 0.7)]
);
assert!(matches!(
policy(&["PLATE"], &[("PLATE", 0.5)]),
Err(NymConfigError::UnknownLabel { .. })
));
assert!(matches!(
policy(&["CITY"], &[("CITY", 0.5)]),
Err(NymConfigError::UnsupportedLabel { .. })
));
assert!(matches!(
policy(&["USERNAME"], &[]),
Err(NymConfigError::MissingThreshold { .. })
));
assert!(matches!(
policy(&["USERNAME"], &[("USERNAME", 0.5), ("TAX_ID", 0.5)]),
Err(NymConfigError::ThresholdWithoutLabel { .. })
));
assert!(matches!(
policy(&["USERNAME", "USERNAME"], &[("USERNAME", 0.5)]),
Err(NymConfigError::DuplicateLabel { .. })
));
for bad in [0.0, -0.1, 1.01, f32::NAN] {
assert!(matches!(
policy(&["USERNAME"], &[("USERNAME", bad)]),
Err(NymConfigError::ThresholdOutOfRange { .. })
));
}
assert_eq!(policy(&[], &[]), Err(NymConfigError::EmptyAllowlist));
}
}