powerio_cli/corpus/anonymize.rs
1//! The anonymization boundary.
2//!
3//! Findings leave the machine; corpus bytes do not. Everything the harness
4//! emits passes through this module, which states three things and only three:
5//! class ordinals in place of identifiers, ratios and orders of magnitude in
6//! place of values, and masked templates in place of any text that came from a
7//! file.
8//!
9//! The last line of defense is [`Sanitizer::audit`], which re-reads the emitted
10//! report and fails the run if any string the corpus taught the harness
11//! survived into it. The harness knows every name in every case it parsed and
12//! every path it walked, so this is a decidable check rather than a heuristic.
13
14use std::collections::{BTreeMap, BTreeSet};
15
16/// The shortest string treated as identifying.
17///
18/// Two characters would match a field name in nearly every path the report
19/// emits (`.p`, `.q`, `.id`), so masking them costs more than it buys. The
20/// consequence is stated rather than hidden: a corpus whose identifiers are one
21/// or two characters is not protected by this module, and a report from one
22/// should be read before it is shared.
23const MIN_IDENTIFYING: usize = 3;
24
25/// A string from the corpus that reached the report.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Leak {
28 pub secret: String,
29 pub line: usize,
30}
31
32impl std::fmt::Display for Leak {
33 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34 // Names the length and the line, never the string itself: a panic
35 // message is as public as the report is.
36 write!(
37 f,
38 "line {}: a {}-character string from the corpus reached the report",
39 self.line,
40 self.secret.chars().count()
41 )
42 }
43}
44
45/// Replaces corpus identifiers with ordinals and corpus values with
46/// magnitudes.
47#[derive(Debug, Default, Clone)]
48pub struct Sanitizer {
49 /// Bus id to dense ordinal, in sorted bus-id order.
50 bus_ordinal: BTreeMap<usize, usize>,
51 /// Every string the corpus taught us: element names, uids, extras values,
52 /// case names, and every path component walked.
53 secrets: BTreeSet<String>,
54 /// Tokens the harness itself emits that a corpus may also happen to use.
55 /// A corpus directory named `psse/` must not make the format token `psse`
56 /// unreportable.
57 allowed: BTreeSet<String>,
58}
59
60impl Sanitizer {
61 #[must_use]
62 pub fn new() -> Self {
63 Self::default()
64 }
65
66 /// Learn every string in a parsed network, and give its buses ordinals.
67 ///
68 /// Walks the serialized model rather than a hand-written field list: a new
69 /// name-bearing field then joins the secret set the day it is added, with
70 /// no second place to remember.
71 pub fn learn_network(&mut self, value: &serde_json::Value) {
72 collect_strings(value, &mut self.secrets);
73 }
74
75 /// Assign ordinals to a bus id set. Called once per bucket with the
76 /// reference member's buses, then again per sibling so a renumbering
77 /// format still lands on ordinals.
78 pub fn learn_buses(&mut self, ids: impl IntoIterator<Item = usize>) {
79 let mut ids: Vec<usize> = ids.into_iter().collect();
80 ids.sort_unstable();
81 for id in ids {
82 let next = self.bus_ordinal.len();
83 self.bus_ordinal.entry(id).or_insert(next);
84 }
85 }
86
87 /// Declare a token as the harness's own vocabulary. Format names are the
88 /// case that matters: a corpus laid out as `psse/`, `matpower/`, `dss/`
89 /// teaches those words as path components, and without this the report
90 /// could not name the format a finding belongs to.
91 pub fn allow(&mut self, token: &str) {
92 self.allowed.insert(token.to_string());
93 // `egret-json` also licenses the word `egret`, which is what a corpus
94 // laid out by format actually names its directories.
95 for part in token.split(['-', '_', '.']) {
96 if !part.is_empty() {
97 self.allowed.insert(part.to_string());
98 }
99 }
100 }
101
102 /// Learn a path the harness walked, component by component, so neither the
103 /// full path nor a directory or file name can reach the report.
104 pub fn learn_path(&mut self, path: &std::path::Path) {
105 self.secrets.insert(path.to_string_lossy().into_owned());
106 for part in path.components() {
107 let part = part.as_os_str().to_string_lossy();
108 self.secrets.insert(part.to_string());
109 if let Some((stem, _)) = part.rsplit_once('.') {
110 self.secrets.insert(stem.to_string());
111 }
112 }
113 }
114
115 /// A bus as a class ordinal. An unseen id gets the next ordinal rather
116 /// than passing through.
117 pub fn bus(&mut self, id: usize) -> String {
118 let next = self.bus_ordinal.len();
119 let ordinal = *self.bus_ordinal.entry(id).or_insert(next);
120 format!("bus#{ordinal}")
121 }
122
123 /// Free text reduced to a template: names become `<name>`, digit runs
124 /// become `#`, and quoted spans become `'…'`.
125 ///
126 /// Warning prose is format vocabulary and is worth keeping; the record
127 /// values a warning quotes are case data and are not.
128 #[must_use]
129 pub fn template(&self, text: &str) -> String {
130 let mut out = mask_quoted(text);
131 // Prefiltered on this text's own tokens, the same necessary condition
132 // `audit` uses, so a message pays for sorting only the handful of
133 // secrets that could appear in it rather than every one the corpus
134 // taught. Replacement only ever inserts `<name>` and `#`, neither of
135 // which can introduce a secret (`name` is powerio's own vocabulary and
136 // never identifying), so filtering once against the starting text is
137 // enough.
138 let tokens: std::collections::HashSet<&str> = word_tokens(&out).collect();
139 let mut names: Vec<&String> = self
140 .identifying()
141 .filter(|s| leading_run(s).is_none_or(|r| tokens.contains(r)))
142 .collect();
143 // Longest first, so a name that contains another is replaced whole.
144 names.sort_by_key(|n| std::cmp::Reverse(n.len()));
145 for name in names {
146 out = replace_tokens(&out, name, "<name>");
147 }
148 mask_digits(&out)
149 }
150
151 /// The secrets worth checking for: long enough to identify, carrying a
152 /// letter, and not part of powerio's own vocabulary.
153 ///
154 /// A digit only secret is deliberately outside this set. Auditing one
155 /// would match the report's own counts, ordinals and magnitudes, which are
156 /// numbers the harness computed rather than strings the corpus taught;
157 /// [`Sanitizer::template`] masks every digit run instead.
158 fn identifying(&self) -> impl Iterator<Item = &String> {
159 let vocabulary = vocabulary();
160 self.secrets.iter().filter(move |s| {
161 s.chars().count() >= MIN_IDENTIFYING
162 && s.chars().any(char::is_alphabetic)
163 && !vocabulary.contains(s.as_str())
164 && !self.allowed.contains(s.as_str())
165 })
166 }
167
168 /// Confirm nothing the corpus taught us survived into `emitted`.
169 ///
170 /// This is the backstop, and it is decidable rather than heuristic over
171 /// the set it covers: the harness parsed every case and walked every path,
172 /// so it knows the exact set of strings that must not appear. That set is
173 /// every secret long enough to identify and carrying a letter; masking is
174 /// what handles the rest.
175 ///
176 /// # Errors
177 ///
178 /// Returns every secret found, with the line it appeared on.
179 pub fn audit(&self, emitted: &str) -> Result<(), Vec<Leak>> {
180 // Scanning every secret against every line is the whole cost here, and
181 // a corpus teaches hundreds of thousands of them. `leading_run` is a
182 // necessary condition for a match, so most secrets are excluded by one
183 // hash lookup and only the survivors pay for a scan.
184 let secrets: Vec<(&String, Option<&str>)> =
185 self.identifying().map(|s| (s, leading_run(s))).collect();
186 let mut leaks = Vec::new();
187 let mut tokens: std::collections::HashSet<&str> = std::collections::HashSet::new();
188 for (i, line) in emitted.lines().enumerate() {
189 tokens.clear();
190 tokens.extend(word_tokens(line));
191 for (secret, run) in &secrets {
192 // A secret whose leading run is absent from this line's tokens
193 // cannot match it; one that does not begin with a word
194 // character has no such run and is always scanned.
195 if run.is_some_and(|r| !tokens.contains(r)) {
196 continue;
197 }
198 if contains_token(line, secret) {
199 leaks.push(Leak {
200 secret: (*secret).clone(),
201 line: i + 1,
202 });
203 }
204 }
205 }
206 if leaks.is_empty() { Ok(()) } else { Err(leaks) }
207 }
208}
209
210/// Every string powerio itself writes into a serialized network that is not
211/// case data: field names, enum spellings, format tokens, unit names.
212///
213/// Derived from the model itself rather than from a hand-kept list, twice
214/// over. The JSON schemas of both network types name every field and every
215/// enum spelling, populated or not, so a new field joins the vocabulary the
216/// day the model gains it; a reference network built here adds the spellings
217/// custom serializers write past the schema, such as the generator capability
218/// map's column names. A real case whose element is named after one of these
219/// spellings is exempt from the audit; the tradeoff runs the safe way, since a
220/// spurious failure would stop a run cold while this costs one unmasked token.
221///
222/// The completeness gate lives in `super::tests`: the union of every
223/// fixture's serde keys must sit inside this set.
224///
225/// Built once. `identifying()` runs per emitted message and per audited line,
226/// so rebuilding the schemas and the network there would dominate both.
227pub(crate) fn vocabulary() -> &'static BTreeSet<String> {
228 static VOCABULARY: std::sync::OnceLock<BTreeSet<String>> = std::sync::OnceLock::new();
229 VOCABULARY.get_or_init(build_vocabulary)
230}
231
232fn build_vocabulary() -> BTreeSet<String> {
233 use powerio_matrix::{BalancedNetwork, Branch, Bus, BusId, BusType, Generator, Load, Shunt};
234 let mut out = BTreeSet::new();
235 // Field names and enum spellings of both models, from the same derives
236 // that generate the published schema documents.
237 for value in [
238 serde_json::to_value(schemars::schema_for!(BalancedNetwork)),
239 serde_json::to_value(schemars::schema_for!(powerio_dist::MulticonductorNetwork)),
240 ]
241 .into_iter()
242 .flatten()
243 {
244 collect_schema_words(&value, &mut out);
245 }
246 let mut net = BalancedNetwork::new("", 100.0);
247 net.buses_mut().push(Bus::new(BusId(1), BusType::Ref, 1.0));
248 net.buses_mut().push(Bus::new(BusId(2), BusType::Pv, 1.0));
249 net.buses_mut().push(Bus::new(BusId(3), BusType::Pq, 1.0));
250 net.buses_mut()
251 .push(Bus::new(BusId(4), BusType::Isolated, 1.0));
252 net.branches_mut()
253 .push(Branch::new(BusId(1), BusId(2), 0.0, 1.0));
254 // Filled so `caps_serde` writes its column names, which the schema
255 // states as a plain string map.
256 let mut generator = Generator::new(BusId(1));
257 generator.caps.fill(Some(0.0));
258 net.generators_mut().push(generator);
259 net.loads_mut().push(Load::new(BusId(1), 0.0, 0.0));
260 net.shunts_mut().push(Shunt::new(BusId(1), 0.0, 0.0));
261 if let Ok(value) = serde_json::to_value(&net) {
262 collect_words(&value, &mut out);
263 }
264 // Format metadata property names are keys in a source neutral string map,
265 // so the generated schema cannot enumerate them. PowerIO writes these
266 // PSS/E generator keys when retaining fields for fresh RAW/RAWX emission.
267 out.extend(
268 [
269 "psse_baslod",
270 "psse_eqid",
271 "psse_f1",
272 "psse_f2",
273 "psse_f3",
274 "psse_f4",
275 "psse_gtap",
276 "psse_o1",
277 "psse_o2",
278 "psse_o3",
279 "psse_o4",
280 "psse_rmpct",
281 "psse_rt",
282 "psse_wmod",
283 "psse_wpf",
284 "psse_xt",
285 "psse_zr",
286 "psse_zx",
287 ]
288 .map(str::to_owned),
289 );
290 out
291}
292
293/// Keys and string values of a document powerio built itself, where every
294/// spelling is powerio's own. Never run on anything a corpus touched.
295fn collect_words(value: &serde_json::Value, out: &mut BTreeSet<String>) {
296 match value {
297 serde_json::Value::String(s) if !s.is_empty() => {
298 out.insert(s.clone());
299 }
300 serde_json::Value::Array(xs) => {
301 for x in xs {
302 collect_words(x, out);
303 }
304 }
305 serde_json::Value::Object(xs) => {
306 for (key, x) in xs {
307 out.insert(key.clone());
308 collect_words(x, out);
309 }
310 }
311 _ => {}
312 }
313}
314
315/// Property names and enum spellings of a JSON schema. Nothing else: a
316/// schema's description sentences are not spellings the report writes.
317fn collect_schema_words(value: &serde_json::Value, out: &mut BTreeSet<String>) {
318 match value {
319 serde_json::Value::Array(xs) => {
320 for x in xs {
321 collect_schema_words(x, out);
322 }
323 }
324 serde_json::Value::Object(map) => {
325 if let Some(serde_json::Value::Object(props)) = map.get("properties") {
326 out.extend(props.keys().cloned());
327 }
328 if let Some(serde_json::Value::Array(spellings)) = map.get("enum") {
329 out.extend(
330 spellings
331 .iter()
332 .filter_map(|s| s.as_str().map(str::to_string)),
333 );
334 }
335 if let Some(spelling) = map.get("const").and_then(serde_json::Value::as_str) {
336 out.insert(spelling.to_string());
337 }
338 for x in map.values() {
339 collect_schema_words(x, out);
340 }
341 }
342 _ => {}
343 }
344}
345
346/// `after / before`, to three significant digits, or `None` when `before` is
347/// zero and the ratio would state the new value outright.
348#[must_use]
349pub fn ratio(before: f64, after: f64) -> Option<f64> {
350 if before == 0.0 || !before.is_finite() || !after.is_finite() {
351 return None;
352 }
353 let r = after / before;
354 // A zero ratio has no decade to round against: `log10(0)` is `-inf`, and
355 // the `as i32` saturation would make the exponent overflow.
356 if r == 0.0 {
357 return Some(0.0);
358 }
359 let decade = r.abs().log10().floor();
360 let scale = 10f64.powi(2 - decade as i32);
361 if !scale.is_finite() || scale == 0.0 {
362 return Some(r);
363 }
364 Some((r * scale).round() / scale)
365}
366
367/// The decimal order of magnitude of a value: `1` for 42.0, `-3` for 0.004,
368/// `None` for zero. Enough to tell a per-unit impedance from a MW total,
369/// never enough to state the value.
370#[must_use]
371pub fn magnitude(x: f64) -> Option<i32> {
372 if x == 0.0 || !x.is_finite() {
373 return None;
374 }
375 Some(x.abs().log10().floor() as i32)
376}
377
378/// Collapse array indices in a serde path so `.loads[3].p` and `.loads[47].p`
379/// become one class, `.loads[#].p`. Both safer (the report stops counting a
380/// utility's loads) and more useful (one line per class of loss).
381#[must_use]
382pub fn collapse_path(path: &str) -> String {
383 let mut out = String::with_capacity(path.len());
384 let mut in_index = false;
385 for ch in path.chars() {
386 match ch {
387 '[' => {
388 in_index = true;
389 out.push_str("[#");
390 }
391 ']' => {
392 in_index = false;
393 out.push(']');
394 }
395 _ if in_index => {}
396 _ => out.push(ch),
397 }
398 }
399 out
400}
401
402/// Whether `needle` appears in `haystack` as a whole word.
403///
404/// Substring matching is wrong in both directions here. It fires on
405/// `egret` inside the format token `egret-json`, which is vocabulary rather
406/// than a leak; and a corpus name that happens to be two letters would
407/// otherwise match half the report. A word is what a reader means when they
408/// ask whether their filename appears.
409fn word_boundaries(haystack: &str, needle: &str) -> Vec<usize> {
410 if needle.is_empty() {
411 return Vec::new();
412 }
413 let bytes = haystack.as_bytes();
414 haystack
415 .match_indices(needle)
416 .filter(|(i, m)| {
417 let before = *i == 0 || !word_part(bytes[*i - 1]);
418 let end = *i + m.len();
419 let after = end >= bytes.len() || !word_part(bytes[end]);
420 before && after
421 })
422 .map(|(i, _)| i)
423 .collect()
424}
425
426/// Whether `b` is part of a word, matching [`word_boundaries`]'s rule.
427fn word_part(b: u8) -> bool {
428 b.is_ascii_alphanumeric() || b == b'_'
429}
430
431/// The maximal word run `needle` opens with, or `None` when it does not start
432/// with a word character.
433///
434/// This run is what [`Sanitizer::audit`] prefilters on, and the reason it is
435/// sound: a match of `needle` needs a non-word character (or the start of the
436/// line) before it, and the run is followed either by `needle`'s own next
437/// character — a non-word one, since the run is maximal — or by the match's
438/// own trailing boundary. So the run always lands in the line as a complete
439/// token, and a line whose tokens do not hold it cannot hold `needle`.
440fn leading_run(needle: &str) -> Option<&str> {
441 let end = needle
442 .bytes()
443 .position(|b| !word_part(b))
444 .unwrap_or(needle.len());
445 (end > 0).then(|| &needle[..end])
446}
447
448/// The maximal word runs of `line`, the tokens a secret's leading run must
449/// appear among.
450fn word_tokens(line: &str) -> impl Iterator<Item = &str> {
451 line.split(|c: char| !(c.is_ascii_alphanumeric() || c == '_'))
452 .filter(|t| !t.is_empty())
453}
454
455fn contains_token(haystack: &str, needle: &str) -> bool {
456 !word_boundaries(haystack, needle).is_empty()
457}
458
459fn replace_tokens(haystack: &str, needle: &str, with: &str) -> String {
460 let hits = word_boundaries(haystack, needle);
461 if hits.is_empty() {
462 return haystack.to_string();
463 }
464 let mut out = String::with_capacity(haystack.len());
465 let mut cursor = 0;
466 for start in hits {
467 if start < cursor {
468 continue;
469 }
470 out.push_str(&haystack[cursor..start]);
471 out.push_str(with);
472 cursor = start + needle.len();
473 }
474 out.push_str(&haystack[cursor..]);
475 out
476}
477
478fn mask_quoted(text: &str) -> String {
479 let mut out = String::with_capacity(text.len());
480 let mut quote: Option<char> = None;
481 for ch in text.chars() {
482 match quote {
483 Some(open) if ch == open => {
484 out.push('…');
485 out.push(ch);
486 quote = None;
487 }
488 Some(_) => {}
489 None => {
490 if ch == '\'' || ch == '"' || ch == '`' {
491 quote = Some(ch);
492 }
493 out.push(ch);
494 }
495 }
496 }
497 out
498}
499
500fn mask_digits(text: &str) -> String {
501 let mut out = String::with_capacity(text.len());
502 let mut in_run = false;
503 for ch in text.chars() {
504 if ch.is_ascii_digit() {
505 if !in_run {
506 out.push('#');
507 in_run = true;
508 }
509 } else {
510 in_run = false;
511 out.push(ch);
512 }
513 }
514 out
515}
516
517fn collect_strings(value: &serde_json::Value, out: &mut BTreeSet<String>) {
518 collect_strings_inner(value, false, out);
519}
520
521/// `in_extras` marks the subtree under an `extras` map, where the keys are
522/// whatever token the source stated — a deck naming a property after a feeder
523/// puts that name in a key and nowhere else. Everywhere else the keys are
524/// powerio's own field names, and learning those made `uid`, `route`,
525/// `charging` and every other optional field into a corpus secret: masked out
526/// of the serde paths the report is written in, and tripping [`audit`] on the
527/// harness's own vocabulary.
528fn collect_strings_inner(value: &serde_json::Value, in_extras: bool, out: &mut BTreeSet<String>) {
529 match value {
530 serde_json::Value::String(s) if !s.is_empty() => {
531 out.insert(s.clone());
532 }
533 serde_json::Value::Array(xs) => {
534 for x in xs {
535 collect_strings_inner(x, in_extras, out);
536 }
537 }
538 serde_json::Value::Object(xs) => {
539 for (key, x) in xs {
540 if in_extras {
541 out.insert(key.clone());
542 }
543 collect_strings_inner(x, in_extras || key == "extras", out);
544 }
545 }
546 _ => {}
547 }
548}