Skip to main content

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}