use std::collections::HashMap;
use regex::{Captures, Regex};
use serde::{Serialize, Serializer};
use tracing::{info, warn};
use super::jev_error::JevError;
use super::jev_matcher::{MatcherError, NameMatcher};
use super::jev_names::{name_key, NameSet};
use super::jev_patterns::{
classify_dotted, is_ipv6, is_path, is_placeholder, is_public_branch, known_ext, patterns,
split_trailing_punct, Dotted, Patterns, FILE_STEMS,
};
use super::jev_tickets::{is_hex_id, is_record_id, replace_ids, ticket_start};
use super::jev_trailers::{item_names, items, names_in, scan, Role, Trailer};
use super::llm_context::{CommitContext, Fact};
#[cfg(test)]
pub(crate) const NAME_MATCHER_SIZE_LIMIT: usize =
crate::core::config::JEV_DEFAULT_NAME_MATCHER_BYTES;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(crate) enum TokenKind {
Email,
Person,
Ticket,
Url,
Host,
Path,
Branch,
Repo,
Term,
Id,
}
impl TokenKind {
fn prefix(self) -> &'static str {
match self {
Self::Email => "EMAIL",
Self::Person => "PERSON",
Self::Ticket => "TICKET",
Self::Url => "URL",
Self::Host => "HOST",
Self::Path => "PATH",
Self::Branch => "BRANCH",
Self::Repo => "REPO",
Self::Term => "TERM",
Self::Id => "ID",
}
}
fn key(self, original: &str) -> String {
match self {
Self::Url | Self::Path | Self::Branch | Self::Id => original.to_string(),
Self::Person | Self::Repo | Self::Term => name_key(original),
Self::Email | Self::Host | Self::Ticket => original.to_lowercase(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ObfuscatedText(String);
impl ObfuscatedText {
pub(crate) fn fixed(text: &'static str) -> Self {
Self(text.to_string())
}
pub(crate) fn config(text: String) -> Self {
Self(text)
}
#[cfg(test)]
pub(crate) fn as_str(&self) -> &str {
&self.0
}
}
impl Serialize for ObfuscatedText {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.0)
}
}
#[derive(Clone, Default)]
pub(crate) struct KnownNames {
pub(crate) repos: Vec<String>,
pub(crate) people: Vec<String>,
pub(crate) terms: Vec<String>,
pub(crate) id_patterns: Vec<String>,
pub(crate) vocab: Vec<String>,
}
#[derive(Clone, Default)]
pub(crate) struct RunNames {
pub(crate) people: Vec<String>,
pub(crate) paths: Vec<String>,
pub(crate) repos: Vec<String>,
}
pub(crate) struct Obfuscator {
p: &'static Patterns,
tokens: HashMap<(TokenKind, String), String>,
originals: HashMap<String, String>,
counters: HashMap<TokenKind, usize>,
names: NameSet,
matcher: Option<NameMatcher>,
id_patterns: Vec<Regex>,
size_limit: usize,
poisoned: bool,
}
impl std::fmt::Debug for Obfuscator {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Obfuscator")
.field("pseudonyms", &self.tokens.len())
.field("names", &self.names.entries.len())
.field(
"matcher_bytes",
&self.matcher.as_ref().map_or(0, NameMatcher::memory_usage),
)
.field("poisoned", &self.poisoned)
.finish_non_exhaustive()
}
}
impl Obfuscator {
#[cfg(test)]
pub(crate) fn new(names: &KnownNames) -> Result<Self, JevError> {
Self::with_size_limit(names, NAME_MATCHER_SIZE_LIMIT)
}
pub(crate) fn with_size_limit(names: &KnownNames, size_limit: usize) -> Result<Self, JevError> {
let p = patterns()?;
let mut set = NameSet::default();
set.set_vocab(&names.vocab);
for t in &names.terms {
set.push(t, TokenKind::Term);
}
for r in names.repos.iter().filter(|r| r.trim().chars().count() >= 2) {
set.push(r, TokenKind::Repo);
}
for person in &names.people {
set.push_person(person);
}
for stem in FILE_STEMS.iter().chain(&["codeowners"]) {
set.push(stem, TokenKind::Path);
}
let id_patterns = names
.id_patterns
.iter()
.enumerate()
.map(|(i, pat)| Regex::new(pat).map_err(|_| JevError::IdPattern(i)))
.collect::<Result<Vec<_>, _>>()?;
let mut obf = Self {
p,
tokens: HashMap::new(),
originals: HashMap::new(),
counters: HashMap::new(),
names: set,
matcher: None,
id_patterns,
size_limit,
poisoned: false,
};
obf.rebuild()?;
Ok(obf)
}
fn check(&self) -> Result<(), JevError> {
if self.poisoned {
Err(JevError::Poisoned)
} else {
Ok(())
}
}
pub(crate) fn add_files(&mut self, paths: &[String]) -> Result<(), JevError> {
self.check()?;
let ident = |s: &str| {
s.chars().count() >= 4
&& (s.contains(['_', '-'])
|| s.bytes().any(|b| b.is_ascii_digit())
|| s.as_bytes()
.windows(2)
.any(|w| w[0].is_ascii_lowercase() && w[1].is_ascii_uppercase()))
};
let mut changed = false;
for path in paths {
let base = path.rsplit(['/', '\\']).next().unwrap_or("").trim();
if base.chars().count() < 3 || (!base.contains('.') && self.names.is_common(base)) {
continue;
}
changed |= self.names.push(base, TokenKind::Path);
if let Some((stem, _)) = base.split_once('.') {
if ident(stem) && !self.names.is_common(stem) {
changed |= self.names.push(stem, TokenKind::Path);
}
}
}
if changed {
self.rebuild()?;
}
Ok(())
}
pub(crate) fn add_repos(&mut self, repos: &[String]) -> Result<(), JevError> {
self.check()?;
let mut changed = false;
for repo in repos {
let parts = repo.split('/').filter(|_| repo.contains('/'));
for name in std::iter::once(repo.as_str()).chain(parts).map(str::trim) {
let skip = name.chars().count() < 2
|| name.eq_ignore_ascii_case("unknown")
|| self.names.is_common(name);
if !skip {
changed |= self.names.push(name, TokenKind::Repo);
}
}
}
if changed {
self.rebuild()?;
}
Ok(())
}
pub(crate) fn add_people(&mut self, people: &[String]) -> Result<(), JevError> {
self.check()?;
let mut changed = false;
for person in people {
changed |= self.names.push_person(person);
}
if changed {
self.rebuild()?;
}
Ok(())
}
pub(crate) fn learn_trailers(&mut self, text: &str) -> Result<(), JevError> {
let found = names_in(self.p, text);
self.add_people(&found)
}
fn rebuild(&mut self) -> Result<(), JevError> {
if self.names.skipped > 0 {
info!(
skipped = self.names.skipped,
"jev pseudonymizer: name entries not matched on their own (stop-list or too short)"
);
}
match NameMatcher::build(&self.names, self.size_limit) {
Ok(m) => {
self.matcher = m;
Ok(())
}
Err((count, e)) => {
self.poisoned = true;
let why = match e {
MatcherError::TooBig { limit } => format!("exceeds {limit} bytes"),
MatcherError::Build => "could not be built".to_string(),
};
warn!(
names = count,
"jev pseudonymizer disabled: matcher rebuild failed"
);
Err(JevError::Matcher { count, why })
}
}
}
fn token(&mut self, kind: TokenKind, original: &str) -> String {
let key = (kind, kind.key(original));
if let Some(t) = self.tokens.get(&key) {
return t.clone();
}
let n = self.counters.entry(kind).or_insert(0);
*n += 1;
let t = format!("{}_{n}", kind.prefix());
self.tokens.insert(key, t.clone());
self.originals.insert(t.clone(), original.to_string());
t
}
pub(crate) fn originals_in(
&self,
text: &ObfuscatedText,
) -> std::collections::BTreeMap<String, String> {
self.p
.placeholder
.find_iter(&text.0)
.filter_map(|m| {
let t = m.as_str();
self.originals.get(t).map(|o| (t.to_string(), o.clone()))
})
.collect()
}
pub(crate) fn obfuscate(&mut self, text: &str) -> Result<ObfuscatedText, JevError> {
self.check()?;
self.learn_trailers(text)?;
let p = self.p;
let lines: Vec<String> = scan(p, text)
.into_iter()
.zip(text.split('\n'))
.map(|(role, line)| match role {
Role::Trailer(t) => self.trailer(&t),
Role::Continuation {
indent,
value,
usernames,
..
} => format!("{indent}{}", self.value(value, usernames)),
Role::Text => self.line(line),
})
.collect();
Ok(ObfuscatedText(lines.join("\n")))
}
pub(crate) fn obfuscate_with_context(
&mut self,
message: &str,
context: Option<&CommitContext>,
) -> Result<ObfuscatedText, JevError> {
let mut text = self.obfuscate(message)?.0;
if let Some(ctx) = context {
text.push_str(&self.context_block(ctx)?);
}
Ok(ObfuscatedText(text))
}
pub(crate) fn context_block(&mut self, ctx: &CommitContext) -> Result<String, JevError> {
self.check()?;
ctx.render(|fact, s| match fact {
Fact::Path => Ok(self.file_token(s)),
Fact::PrTitle | Fact::IssueType => self.obfuscate(s).map(|t| t.0),
})
}
fn trailer(&mut self, t: &Trailer<'_>) -> String {
let value = self.value(t.value, t.usernames);
if value.is_empty() {
return format!("{}{}:", t.prefix, t.token);
}
format!("{}{}: {value}", t.prefix, t.token)
}
fn value(&mut self, value: &str, usernames: bool) -> String {
let p = self.p;
items(value, usernames)
.into_iter()
.map(|item| {
let tag = if item.trim_start().starts_with('#') {
"#"
} else {
""
};
let mut parts: Vec<String> = item_names(p, item)
.iter()
.map(|n| format!("{tag}{}", self.token(TokenKind::Person, n)))
.collect();
for e in p.email.find_iter(item) {
parts.push(format!("<{}>", self.token(TokenKind::Email, e.as_str())));
}
parts.join(" ")
})
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(", ")
}
fn line(&mut self, line: &str) -> String {
let p = self.p;
let mut s = self.merge_refs(line);
for i in 0..self.id_patterns.len() {
let re = self.id_patterns[i].clone();
s = self.regex_pass(&s, &re, TokenKind::Id, false);
}
let s = self.regex_pass(&s, &p.url, TokenKind::Url, true);
let s = self.regex_pass(&s, &p.email, TokenKind::Email, false);
let hex = |c: &Captures| c.get(0).is_some_and(|m| is_hex_id(&s, m.start(), m.end()));
let s = replace_ids(&s, &p.hex, hex, |m| self.token(TokenKind::Id, m));
let s = self.names(&s);
let s = self.ipv6(&s);
let s = self.paths(&s);
let s = self.dotted(&s);
let s = self.tickets(&s);
let s = replace_ids(
&s,
&p.id,
|c| is_record_id(&c[1]),
|m| self.token(TokenKind::Id, m),
);
self.mentions(&s)
}
fn regex_pass(&mut self, text: &str, re: &Regex, kind: TokenKind, trim: bool) -> String {
re.replace_all(text, |c: &Captures| {
let m = &c[0];
let (core, tail) = if trim {
split_trailing_punct(m)
} else {
(m, "")
};
if core.is_empty() || is_placeholder(core) {
return m.to_string();
}
format!("{}{tail}", self.token(kind, core))
})
.into_owned()
}
fn merge_refs(&mut self, line: &str) -> String {
let p = self.p;
let branch = |obf: &mut Self, name: &str| -> String {
let (core, tail) = split_trailing_punct(name);
if core.is_empty() || is_placeholder(core) || is_public_branch(core) {
return name.to_string();
}
format!("{}{tail}", obf.token(TokenKind::Branch, core))
};
let mut s = line.to_string();
if p.merge_branch.is_match(&s) {
s = p
.merge_of
.replace_all(&s, |c: &Captures| {
format!("{}{}", &c[1], self.token(TokenKind::Url, &c[2]))
})
.into_owned();
s = p
.quoted
.replace_all(&s, |c: &Captures| {
format!("{}{}{}", &c[1], branch(self, &c[2]), &c[3])
})
.into_owned();
}
for re in [&p.merge_pr, &p.merged_in] {
s = re
.replace_all(&s, |c: &Captures| {
format!("{}{}", &c[1], branch(self, &c[2]))
})
.into_owned();
}
p.merge_request
.replace_all(&s, |c: &Captures| {
format!("{}{}{}", &c[1], self.token(TokenKind::Repo, &c[2]), &c[3])
})
.into_owned()
}
fn ipv6(&mut self, text: &str) -> String {
self.p
.ipv6
.replace_all(text, |c: &Captures| {
let end = c.get(0).map_or(0, |m| m.end());
if is_ipv6(&c[2], text[end..].chars().next()) {
format!("{}{}", &c[1], self.token(TokenKind::Host, &c[2]))
} else {
c[0].to_string()
}
})
.into_owned()
}
fn file_token(&mut self, original: &str) -> String {
let t = self.token(TokenKind::Path, original);
match known_ext(original.rsplit(['/', '\\']).next().unwrap_or("")) {
Some(ext) => format!("{t}.{ext}"),
None => t,
}
}
fn paths(&mut self, text: &str) -> String {
self.p
.slash
.replace_all(text, |c: &Captures| {
let m = &c[0];
let (core, tail) = split_trailing_punct(m);
if core.is_empty() || is_placeholder(core) || !is_path(core) {
return m.to_string();
}
format!("{}{tail}", self.file_token(core))
})
.into_owned()
}
fn dotted(&mut self, text: &str) -> String {
self.p
.dotted
.replace_all(text, |c: &Captures| {
let m = &c[0];
let end = c.get(0).map_or(0, |x| x.end());
match classify_dotted(m, text[end..].chars().next()) {
Dotted::Keep => m.to_string(),
Dotted::Host => self.token(TokenKind::Host, m),
Dotted::File(ext) => {
format!("{}.{ext}", self.token(TokenKind::Path, m))
}
Dotted::FileName => self.token(TokenKind::Path, m),
}
})
.into_owned()
}
fn tickets(&mut self, text: &str) -> String {
let hits: Vec<(usize, usize)> = self
.p
.ticket
.captures_iter(text)
.filter_map(|c| {
let m = c.get(0)?;
let common = |w: &str| self.names.is_common(w);
ticket_start(text, m.start(), m.end(), &c[1], &common)
.map(|off| (m.start() + off, m.end()))
})
.collect();
let mut out = String::with_capacity(text.len());
let mut last = 0;
for (s, e) in hits {
out.push_str(&text[last..s]);
out.push_str(&self.token(TokenKind::Ticket, &text[s..e]));
last = e;
}
out.push_str(&text[last..]);
out
}
fn names(&mut self, text: &str) -> String {
let Some(m) = self.matcher.take() else {
return text.to_string();
};
let out = m.apply(text, |kind, original| self.token(kind, original));
self.matcher = Some(m);
out
}
fn mentions(&mut self, text: &str) -> String {
self.p
.mention
.replace_all(text, |c: &Captures| {
if is_placeholder(&c[2]) {
return c[0].to_string();
}
format!("{}@{}", &c[1], self.token(TokenKind::Person, &c[2]))
})
.into_owned()
}
}