use crate::ScanReport;
use super::{TransformError, ensure_non_overlapping, validated_spans};
const DEFAULT_PREFIX: &str = "cribra_pseudo_";
const DEFAULT_DIGEST_BYTES: usize = 16;
const MIN_DIGEST_BYTES: usize = 8;
const MAX_DIGEST_BYTES: usize = 32;
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct PseudonymizationOptions {
key: [u8; 32],
prefix: String,
digest_bytes: usize,
}
impl PseudonymizationOptions {
#[must_use]
pub fn new(key: [u8; 32]) -> Self {
Self {
key,
prefix: DEFAULT_PREFIX.to_owned(),
digest_bytes: DEFAULT_DIGEST_BYTES,
}
}
#[must_use]
pub fn prefix(mut self, prefix: impl Into<String>) -> Self {
self.prefix = prefix.into();
self
}
#[must_use]
pub fn digest_bytes(mut self, digest_bytes: usize) -> Self {
self.digest_bytes = digest_bytes.clamp(MIN_DIGEST_BYTES, MAX_DIGEST_BYTES);
self
}
#[must_use]
pub fn prefix_str(&self) -> &str {
&self.prefix
}
#[must_use]
pub const fn digest_len_bytes(&self) -> usize {
self.digest_bytes
}
}
pub fn pseudonymize(
source: &str,
report: &ScanReport,
options: &PseudonymizationOptions,
) -> Result<String, TransformError> {
let spans = validated_spans(source, report)?;
ensure_non_overlapping(&spans)?;
if spans.is_empty() {
return Ok(source.to_owned());
}
let estimated_replacement_len = options
.prefix
.len()
.saturating_add(options.digest_bytes.saturating_mul(2));
let mut output = String::with_capacity(
source
.len()
.saturating_add(estimated_replacement_len.saturating_mul(spans.len())),
);
let mut cursor = 0;
for span in spans {
output.push_str(&source[cursor..span.start]);
let matched = &source[span.start..span.end];
push_pseudonym(&mut output, matched, options);
cursor = span.end;
}
output.push_str(&source[cursor..]);
Ok(output)
}
fn push_pseudonym(output: &mut String, matched: &str, options: &PseudonymizationOptions) {
let digest = blake3::keyed_hash(&options.key, matched.as_bytes());
let digest = &digest.as_bytes()[..options.digest_bytes];
output.push_str(&options.prefix);
for byte in digest {
use std::fmt::Write as _;
write!(output, "{byte:02x}").expect("writing to String cannot fail");
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Confidence, Finding, Location, RuleId, Severity};
fn finding(id: &str, start: usize, end: usize) -> Finding {
Finding::new(
RuleId::from(id),
Location::from_span(start, end),
Severity::High,
Confidence::High,
None,
)
}
#[test]
fn same_value_and_key_produce_same_pseudonym() {
let source = "SECRET x SECRET";
let report = ScanReport::new_with_candidates(
vec![finding("one", 0, 6), finding("two", 9, 15)],
Vec::new(),
);
let options = PseudonymizationOptions::new([1; 32]);
let output = pseudonymize(source, &report, &options).unwrap();
let mut parts = output.split(" x ");
assert_eq!(parts.next(), parts.next());
}
#[test]
fn different_values_produce_different_pseudonyms() {
let source = "SECRET x OTHER!";
let report = ScanReport::new_with_candidates(
vec![finding("one", 0, 6), finding("two", 9, 15)],
Vec::new(),
);
let options = PseudonymizationOptions::new([2; 32]);
let output = pseudonymize(source, &report, &options).unwrap();
let parts = output.split(" x ").collect::<Vec<_>>();
assert_ne!(parts[0], parts[1]);
}
#[test]
fn different_keys_produce_different_pseudonyms() {
let source = "SECRET";
let report = ScanReport::new_with_candidates(vec![finding("secret", 0, 6)], Vec::new());
let first = pseudonymize(source, &report, &PseudonymizationOptions::new([3; 32])).unwrap();
let second = pseudonymize(source, &report, &PseudonymizationOptions::new([4; 32])).unwrap();
assert_ne!(first, second);
}
#[test]
fn rule_identity_does_not_change_pseudonym_identity() {
let source = "SECRET x SECRET";
let report = ScanReport::new_with_candidates(
vec![
finding("provider-specific", 0, 6),
finding("generic", 9, 15),
],
Vec::new(),
);
let options = PseudonymizationOptions::new([5; 32]);
let output = pseudonymize(source, &report, &options).unwrap();
let parts = output.split(" x ").collect::<Vec<_>>();
assert_eq!(parts[0], parts[1]);
}
#[test]
fn prefix_and_digest_length_are_configurable() {
let source = "SECRET";
let report = ScanReport::new_with_candidates(vec![finding("secret", 0, 6)], Vec::new());
let options = PseudonymizationOptions::new([6; 32])
.prefix("pseudo:")
.digest_bytes(8);
let output = pseudonymize(source, &report, &options).unwrap();
assert!(output.starts_with("pseudo:"));
assert_eq!(output.len(), "pseudo:".len() + 16);
assert_eq!(options.digest_len_bytes(), 8);
}
#[test]
fn digest_length_is_clamped_to_safe_bounds() {
assert_eq!(
PseudonymizationOptions::new([0; 32])
.digest_bytes(1)
.digest_len_bytes(),
8,
);
assert_eq!(
PseudonymizationOptions::new([0; 32])
.digest_bytes(128)
.digest_len_bytes(),
32,
);
}
#[test]
fn empty_report_preserves_source_exactly() {
let source = "ordinary 😀 UTF-8";
let options = PseudonymizationOptions::new([7; 32]);
assert_eq!(
pseudonymize(source, &ScanReport::default(), &options).unwrap(),
source,
);
}
#[test]
fn overlap_is_rejected() {
let report = ScanReport::new_with_candidates(
vec![finding("short", 0, 6), finding("long", 0, 10)],
Vec::new(),
);
let options = PseudonymizationOptions::new([8; 32]);
assert!(matches!(
pseudonymize("0123456789", &report, &options),
Err(TransformError::OverlappingSpans { .. }),
));
}
#[test]
fn original_value_does_not_survive_transformation() {
let source = "TOKEN=SUPER_SECRET_VALUE";
let start = "TOKEN=".len();
let report = ScanReport::new_with_candidates(
vec![finding("credential", start, source.len())],
Vec::new(),
);
let options = PseudonymizationOptions::new([9; 32]);
let output = pseudonymize(source, &report, &options).unwrap();
assert!(!output.contains("SUPER_SECRET_VALUE"));
assert!(output.starts_with("TOKEN=cribra_pseudo_"));
}
#[test]
fn utf8_values_are_pseudonymized_by_exact_bytes() {
let source = "PASSWORD=pässwörd😀";
let start = "PASSWORD=".len();
let report = ScanReport::new_with_candidates(
vec![finding("password", start, source.len())],
Vec::new(),
);
let options = PseudonymizationOptions::new([10; 32]);
let output = pseudonymize(source, &report, &options).unwrap();
assert!(!output.contains("pässwörd😀"));
assert!(output.starts_with("PASSWORD=cribra_pseudo_"));
}
}