type EncodeFn = std::sync::Arc<dyn Fn(&str) -> String + Send + Sync>;
#[derive(Clone)]
pub struct RestoreEncoder {
encode: Option<EncodeFn>,
}
impl RestoreEncoder {
#[must_use]
pub fn identity() -> Self {
Self { encode: None }
}
#[must_use]
pub fn new(encode: impl Fn(&str) -> String + Send + Sync + 'static) -> Self {
Self {
encode: Some(std::sync::Arc::new(encode)),
}
}
fn encode_into(&self, original: &[u8], out: &mut Vec<u8>) {
match &self.encode {
None => out.extend_from_slice(original),
Some(f) => {
let original = String::from_utf8_lossy(original);
out.extend_from_slice(f(&original).as_bytes());
}
}
}
}
impl Default for RestoreEncoder {
fn default() -> Self {
Self::identity()
}
}
impl std::fmt::Debug for RestoreEncoder {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let kind = if self.encode.is_some() {
"custom"
} else {
"identity"
};
f.debug_struct("RestoreEncoder")
.field("kind", &kind)
.finish()
}
}
#[derive(Debug, Clone)]
pub struct Substituter {
subs: Vec<Sub>,
longest: usize,
}
#[derive(Debug, Clone)]
struct Sub {
sentinel: Vec<u8>,
sentinel_lower: Vec<u8>,
original: Vec<u8>,
}
impl Substituter {
#[must_use]
pub fn from_pairs<'a>(pairs: impl Iterator<Item = (&'a str, &'a str)>) -> Self {
let mut subs: Vec<Sub> = pairs
.map(|(sentinel, original)| Sub {
sentinel: sentinel.as_bytes().to_vec(),
sentinel_lower: sentinel.to_ascii_lowercase().into_bytes(),
original: original.as_bytes().to_vec(),
})
.collect();
subs.sort_by_key(|s| std::cmp::Reverse(s.sentinel.len()));
let longest = subs.first().map_or(0, |s| s.sentinel.len());
Self { subs, longest }
}
#[must_use]
pub fn longest_sentinel(&self) -> usize {
self.longest
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.subs.is_empty()
}
#[must_use]
pub fn substitute(&self, text: &str) -> String {
self.substitute_with_counting(text, &RestoreEncoder::identity())
.0
}
#[must_use]
pub fn substitute_with_counting(
&self,
text: &str,
encoder: &RestoreEncoder,
) -> (String, Vec<(String, u32)>) {
let bytes = text.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut counts: Vec<u32> = vec![0; self.subs.len()];
let mut i = 0;
'scan: while i < bytes.len() {
if bytes[i] == b'[' {
for (idx, sub) in self.subs.iter().enumerate() {
if matches_at(bytes, i, sub) {
encoder.encode_into(&sub.original, &mut out);
i += sub.sentinel.len();
counts[idx] += 1;
continue 'scan;
}
}
}
out.push(bytes[i]);
i += 1;
}
let text = String::from_utf8(out)
.unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
let restored = self
.subs
.iter()
.zip(counts)
.filter(|(_, n)| *n > 0)
.map(|(sub, n)| (String::from_utf8_lossy(&sub.sentinel).into_owned(), n))
.collect();
(text, restored)
}
#[must_use]
pub fn substitute_with(&self, text: &str, encoder: &RestoreEncoder) -> String {
self.substitute_with_counting(text, encoder).0
}
#[must_use]
pub fn substitute_counting(&self, text: &str) -> (String, Vec<(String, u32)>) {
self.substitute_with_counting(text, &RestoreEncoder::identity())
}
#[must_use]
pub fn is_sentinel_prefix(&self, tail: &[u8]) -> bool {
self.subs
.iter()
.any(|sub| tail.len() < sub.sentinel.len() && ci_starts_with(&sub.sentinel_lower, tail))
}
}
fn matches_at(bytes: &[u8], i: usize, sub: &Sub) -> bool {
let end = i + sub.sentinel.len();
end <= bytes.len() && ci_eq(&bytes[i..end], &sub.sentinel_lower)
}
fn ci_eq(bytes: &[u8], lowered: &[u8]) -> bool {
bytes.len() == lowered.len()
&& bytes
.iter()
.zip(lowered)
.all(|(b, l)| b.to_ascii_lowercase() == *l)
}
fn ci_starts_with(lowered: &[u8], prefix: &[u8]) -> bool {
prefix.len() <= lowered.len()
&& prefix
.iter()
.zip(lowered)
.all(|(p, l)| p.to_ascii_lowercase() == *l)
}
#[cfg(test)]
mod tests {
#![allow(
clippy::unwrap_used,
clippy::expect_used,
reason = "tests assert on known-good values"
)]
use super::*;
fn subber(pairs: &[(&str, &str)]) -> Substituter {
Substituter::from_pairs(pairs.iter().map(|(s, o)| (*s, *o)))
}
#[test]
fn exact_substitution() {
let s = subber(&[("[REDACTED_EMAIL_1_abcd1234]", "alice@x.com")]);
assert_eq!(
s.substitute("see [REDACTED_EMAIL_1_abcd1234] now"),
"see alice@x.com now"
);
}
#[test]
fn case_insensitive_substitution() {
let s = subber(&[("[REDACTED_EMAIL_1_ABCD1234]", "alice@x.com")]);
assert_eq!(
s.substitute("see [redacted_email_1_abcd1234] now"),
"see alice@x.com now"
);
}
#[test]
fn longest_first_avoids_shadowing() {
let s = subber(&[
("[REDACTED_EMAIL_1_aa]", "short"),
("[REDACTED_EMAIL_1_aa_X]", "long"),
]);
assert_eq!(s.substitute("[REDACTED_EMAIL_1_aa_X]"), "long");
}
#[test]
fn mangled_sentinel_is_left_alone() {
let s = subber(&[("[REDACTED_EMAIL_1_abcd1234]", "alice@x.com")]);
let mangled = "[REDACTED_EMAL_1_abcd1234]";
assert_eq!(s.substitute(mangled), mangled);
}
#[test]
fn prefix_detection_for_holdback() {
let s = subber(&[("[REDACTED_EMAIL_1_abcd1234]", "alice@x.com")]);
assert!(s.is_sentinel_prefix(b"[REDACTED_EM"));
assert!(s.is_sentinel_prefix(b"[redacted_em"));
assert!(!s.is_sentinel_prefix(b"[REDACTED_EMAIL_1_abcd1234]"));
assert!(!s.is_sentinel_prefix(b"[50]"));
}
#[test]
fn substitute_counting_reports_per_sentinel_counts() {
let s = subber(&[
("[REDACTED_EMAIL_1_abcd1234]", "alice@x.com"),
("[REDACTED_PHONE_1_abcd1234]", "555-1234"),
]);
let (text, counts) = s.substitute_counting(
"mail [REDACTED_EMAIL_1_abcd1234] twice [REDACTED_EMAIL_1_abcd1234] phone [REDACTED_PHONE_1_abcd1234]",
);
assert!(text.contains("alice@x.com"));
assert!(text.contains("555-1234"));
let map: std::collections::BTreeMap<_, _> = counts.into_iter().collect();
assert_eq!(map.get("[REDACTED_EMAIL_1_abcd1234]"), Some(&2));
assert_eq!(map.get("[REDACTED_PHONE_1_abcd1234]"), Some(&1));
}
#[test]
fn substitute_counting_omits_unmatched_sentinels() {
let s = subber(&[("[REDACTED_EMAIL_1_abcd1234]", "alice@x.com")]);
let (_text, counts) = s.substitute_counting("no sentinel here");
assert!(counts.is_empty());
}
#[test]
fn unrelated_bracket_run_untouched() {
let s = subber(&[("[REDACTED_EMAIL_1_abcd1234]", "alice@x.com")]);
assert_eq!(s.substitute("price [50] then text"), "price [50] then text");
}
#[test]
fn identity_encoder_matches_plain_substitute() {
let s = subber(&[("[REDACTED_EMAIL_1_abcd1234]", "a\"b\nc")]);
let text = "x [REDACTED_EMAIL_1_abcd1234] y";
assert_eq!(
s.substitute(text),
s.substitute_with(text, &RestoreEncoder::identity()),
);
assert_eq!(s.substitute(text), "x a\"b\nc y");
}
#[test]
fn encoder_transforms_only_the_restored_original() {
let s = subber(&[("[REDACTED_EMAIL_1_abcd1234]", "a\"b")]);
let enc = RestoreEncoder::new(|o| o.replace('"', "\\\""));
assert_eq!(
s.substitute_with("say \"[REDACTED_EMAIL_1_abcd1234]\"", &enc),
"say \"a\\\"b\"",
);
}
}