use std::collections::{BTreeMap, BTreeSet};
const MIN_IDENTIFYING: usize = 3;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Leak {
pub secret: String,
pub line: usize,
}
impl std::fmt::Display for Leak {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"line {}: a {}-character string from the corpus reached the report",
self.line,
self.secret.chars().count()
)
}
}
#[derive(Debug, Default, Clone)]
pub struct Sanitizer {
bus_ordinal: BTreeMap<usize, usize>,
secrets: BTreeSet<String>,
allowed: BTreeSet<String>,
}
impl Sanitizer {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn learn_network(&mut self, value: &serde_json::Value) {
collect_strings(value, &mut self.secrets);
}
pub fn learn_buses(&mut self, ids: impl IntoIterator<Item = usize>) {
let mut ids: Vec<usize> = ids.into_iter().collect();
ids.sort_unstable();
for id in ids {
let next = self.bus_ordinal.len();
self.bus_ordinal.entry(id).or_insert(next);
}
}
pub fn allow(&mut self, token: &str) {
self.allowed.insert(token.to_string());
for part in token.split(['-', '_', '.']) {
if !part.is_empty() {
self.allowed.insert(part.to_string());
}
}
}
pub fn learn_path(&mut self, path: &std::path::Path) {
self.secrets.insert(path.to_string_lossy().into_owned());
for part in path.components() {
let part = part.as_os_str().to_string_lossy();
self.secrets.insert(part.to_string());
if let Some((stem, _)) = part.rsplit_once('.') {
self.secrets.insert(stem.to_string());
}
}
}
pub fn bus(&mut self, id: usize) -> String {
let next = self.bus_ordinal.len();
let ordinal = *self.bus_ordinal.entry(id).or_insert(next);
format!("bus#{ordinal}")
}
#[must_use]
pub fn template(&self, text: &str) -> String {
let mut out = mask_quoted(text);
let tokens: std::collections::HashSet<&str> = word_tokens(&out).collect();
let mut names: Vec<&String> = self
.identifying()
.filter(|s| leading_run(s).is_none_or(|r| tokens.contains(r)))
.collect();
names.sort_by_key(|n| std::cmp::Reverse(n.len()));
for name in names {
out = replace_tokens(&out, name, "<name>");
}
mask_digits(&out)
}
fn identifying(&self) -> impl Iterator<Item = &String> {
let vocabulary = vocabulary();
self.secrets.iter().filter(move |s| {
s.chars().count() >= MIN_IDENTIFYING
&& s.chars().any(char::is_alphabetic)
&& !vocabulary.contains(s.as_str())
&& !self.allowed.contains(s.as_str())
})
}
pub fn audit(&self, emitted: &str) -> Result<(), Vec<Leak>> {
let secrets: Vec<(&String, Option<&str>)> =
self.identifying().map(|s| (s, leading_run(s))).collect();
let mut leaks = Vec::new();
let mut tokens: std::collections::HashSet<&str> = std::collections::HashSet::new();
for (i, line) in emitted.lines().enumerate() {
tokens.clear();
tokens.extend(word_tokens(line));
for (secret, run) in &secrets {
if run.is_some_and(|r| !tokens.contains(r)) {
continue;
}
if contains_token(line, secret) {
leaks.push(Leak {
secret: (*secret).clone(),
line: i + 1,
});
}
}
}
if leaks.is_empty() { Ok(()) } else { Err(leaks) }
}
}
pub(crate) fn vocabulary() -> &'static BTreeSet<String> {
static VOCABULARY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
VOCABULARY.get_or_init(build_vocabulary)
}
fn build_vocabulary() -> BTreeSet<String> {
use powerio_matrix::{BalancedNetwork, Branch, Bus, BusId, BusType, Generator, Load, Shunt};
let mut out = BTreeSet::new();
for value in [
serde_json::to_value(schemars::schema_for!(BalancedNetwork)),
serde_json::to_value(schemars::schema_for!(powerio_dist::MulticonductorNetwork)),
]
.into_iter()
.flatten()
{
collect_schema_words(&value, &mut out);
}
let mut net = BalancedNetwork::new("", 100.0);
net.buses.push(Bus::new(BusId(1), BusType::Ref, 1.0));
net.buses.push(Bus::new(BusId(2), BusType::Pv, 1.0));
net.buses.push(Bus::new(BusId(3), BusType::Pq, 1.0));
net.buses.push(Bus::new(BusId(4), BusType::Isolated, 1.0));
net.branches.push(Branch::new(BusId(1), BusId(2), 0.0, 1.0));
let mut generator = Generator::new(BusId(1));
generator.caps.fill(Some(0.0));
net.generators.push(generator);
net.loads.push(Load::new(BusId(1), 0.0, 0.0));
net.shunts.push(Shunt::new(BusId(1), 0.0, 0.0));
if let Ok(value) = serde_json::to_value(&net) {
collect_words(&value, &mut out);
}
out
}
fn collect_words(value: &serde_json::Value, out: &mut BTreeSet<String>) {
match value {
serde_json::Value::String(s) if !s.is_empty() => {
out.insert(s.clone());
}
serde_json::Value::Array(xs) => {
for x in xs {
collect_words(x, out);
}
}
serde_json::Value::Object(xs) => {
for (key, x) in xs {
out.insert(key.clone());
collect_words(x, out);
}
}
_ => {}
}
}
fn collect_schema_words(value: &serde_json::Value, out: &mut BTreeSet<String>) {
match value {
serde_json::Value::Array(xs) => {
for x in xs {
collect_schema_words(x, out);
}
}
serde_json::Value::Object(map) => {
if let Some(serde_json::Value::Object(props)) = map.get("properties") {
out.extend(props.keys().cloned());
}
if let Some(serde_json::Value::Array(spellings)) = map.get("enum") {
out.extend(
spellings
.iter()
.filter_map(|s| s.as_str().map(str::to_string)),
);
}
if let Some(spelling) = map.get("const").and_then(serde_json::Value::as_str) {
out.insert(spelling.to_string());
}
for x in map.values() {
collect_schema_words(x, out);
}
}
_ => {}
}
}
#[must_use]
pub fn ratio(before: f64, after: f64) -> Option<f64> {
if before == 0.0 || !before.is_finite() || !after.is_finite() {
return None;
}
let r = after / before;
if r == 0.0 {
return Some(0.0);
}
let decade = r.abs().log10().floor();
let scale = 10f64.powi(2 - decade as i32);
if !scale.is_finite() || scale == 0.0 {
return Some(r);
}
Some((r * scale).round() / scale)
}
#[must_use]
pub fn magnitude(x: f64) -> Option<i32> {
if x == 0.0 || !x.is_finite() {
return None;
}
Some(x.abs().log10().floor() as i32)
}
#[must_use]
pub fn collapse_path(path: &str) -> String {
let mut out = String::with_capacity(path.len());
let mut in_index = false;
for ch in path.chars() {
match ch {
'[' => {
in_index = true;
out.push_str("[#");
}
']' => {
in_index = false;
out.push(']');
}
_ if in_index => {}
_ => out.push(ch),
}
}
out
}
fn word_boundaries(haystack: &str, needle: &str) -> Vec<usize> {
if needle.is_empty() {
return Vec::new();
}
let bytes = haystack.as_bytes();
haystack
.match_indices(needle)
.filter(|(i, m)| {
let before = *i == 0 || !word_part(bytes[*i - 1]);
let end = *i + m.len();
let after = end >= bytes.len() || !word_part(bytes[end]);
before && after
})
.map(|(i, _)| i)
.collect()
}
fn word_part(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn leading_run(needle: &str) -> Option<&str> {
let end = needle
.bytes()
.position(|b| !word_part(b))
.unwrap_or(needle.len());
(end > 0).then(|| &needle[..end])
}
fn word_tokens(line: &str) -> impl Iterator<Item = &str> {
line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
.filter(|t| !t.is_empty())
}
fn contains_token(haystack: &str, needle: &str) -> bool {
!word_boundaries(haystack, needle).is_empty()
}
fn replace_tokens(haystack: &str, needle: &str, with: &str) -> String {
let hits = word_boundaries(haystack, needle);
if hits.is_empty() {
return haystack.to_string();
}
let mut out = String::with_capacity(haystack.len());
let mut cursor = 0;
for start in hits {
if start < cursor {
continue;
}
out.push_str(&haystack[cursor..start]);
out.push_str(with);
cursor = start + needle.len();
}
out.push_str(&haystack[cursor..]);
out
}
fn mask_quoted(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut quote: Option<char> = None;
for ch in text.chars() {
match quote {
Some(open) if ch == open => {
out.push('…');
out.push(ch);
quote = None;
}
Some(_) => {}
None => {
if ch == '\'' || ch == '"' || ch == '`' {
quote = Some(ch);
}
out.push(ch);
}
}
}
out
}
fn mask_digits(text: &str) -> String {
let mut out = String::with_capacity(text.len());
let mut in_run = false;
for ch in text.chars() {
if ch.is_ascii_digit() {
if !in_run {
out.push('#');
in_run = true;
}
} else {
in_run = false;
out.push(ch);
}
}
out
}
fn collect_strings(value: &serde_json::Value, out: &mut BTreeSet<String>) {
collect_strings_inner(value, false, out);
}
fn collect_strings_inner(value: &serde_json::Value, in_extras: bool, out: &mut BTreeSet<String>) {
match value {
serde_json::Value::String(s) if !s.is_empty() => {
out.insert(s.clone());
}
serde_json::Value::Array(xs) => {
for x in xs {
collect_strings_inner(x, in_extras, out);
}
}
serde_json::Value::Object(xs) => {
for (key, x) in xs {
if in_extras {
out.insert(key.clone());
}
collect_strings_inner(x, in_extras || key == "extras", out);
}
}
_ => {}
}
}