admina-core 0.13.2__tar.gz → 0.14.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,7 @@ version = 4
4
4
 
5
5
  [[package]]
6
6
  name = "admina_core"
7
- version = "0.13.2"
7
+ version = "0.14.1"
8
8
  dependencies = [
9
9
  "chrono",
10
10
  "hex",
@@ -15,7 +15,7 @@
15
15
 
16
16
  [package]
17
17
  name = "admina_core"
18
- version = "0.13.2"
18
+ version = "0.14.1"
19
19
  edition = "2021"
20
20
  description = "Admina governance engines — Rust core with Python bindings"
21
21
  license = "Apache-2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: admina-core
3
- Version: 0.13.2
3
+ Version: 0.14.1
4
4
  Classifier: Development Status :: 4 - Beta
5
5
  Classifier: Intended Audience :: Developers
6
6
  Classifier: Operating System :: POSIX :: Linux
@@ -46,7 +46,7 @@ pip install admina-core
46
46
 
47
47
  The Python framework auto-detects the Rust engine at runtime and falls
48
48
  back to a pure-Python implementation if `admina-core` is not installed,
49
- so `admina-framework` always works on its own.
49
+ so `admina` always works on its own.
50
50
 
51
51
  ## License
52
52
 
@@ -17,7 +17,7 @@ pip install admina-core
17
17
 
18
18
  The Python framework auto-detects the Rust engine at runtime and falls
19
19
  back to a pure-Python implementation if `admina-core` is not installed,
20
- so `admina-framework` always works on its own.
20
+ so `admina` always works on its own.
21
21
 
22
22
  ## License
23
23
 
@@ -19,7 +19,7 @@ build-backend = "maturin"
19
19
 
20
20
  [project]
21
21
  name = "admina-core"
22
- version = "0.13.2"
22
+ version = "0.14.1"
23
23
  description = "Admina governance engines — Rust core with Python bindings (firewall, PII, loop breaker, hash chain)"
24
24
  readme = "README.md"
25
25
  requires-python = ">=3.11"
@@ -13,13 +13,84 @@
13
13
  // limitations under the License.
14
14
 
15
15
  use pyo3::prelude::*;
16
- use regex::Regex;
16
+ use regex::{Captures, Regex};
17
17
  use std::sync::OnceLock;
18
18
 
19
19
  struct PiiPattern {
20
20
  name: &'static str,
21
21
  regex: Regex,
22
22
  mask: &'static str,
23
+ /// Whether a match is reported, given the scanned text: the checks the
24
+ /// regex cannot express (a checksum, the characters around the match).
25
+ accept: fn(&str, &Captures) -> bool,
26
+ }
27
+
28
+ fn any_match(_text: &str, _caps: &Captures) -> bool {
29
+ true
30
+ }
31
+
32
+ /// A card number is reported only when its digits pass the Luhn checksum,
33
+ /// as in the Python PII redactor.
34
+ fn luhn_valid(_text: &str, caps: &Captures) -> bool {
35
+ let digits: Vec<u32> = caps[0].chars().filter_map(|c| c.to_digit(10)).collect();
36
+ let sum: u32 = digits
37
+ .iter()
38
+ .rev()
39
+ .enumerate()
40
+ .map(|(i, &d)| {
41
+ if !i.is_multiple_of(2) {
42
+ let x = d * 2;
43
+ if x > 9 {
44
+ x - 9
45
+ } else {
46
+ x
47
+ }
48
+ } else {
49
+ d
50
+ }
51
+ })
52
+ .sum();
53
+ !digits.is_empty() && sum.is_multiple_of(10)
54
+ }
55
+
56
+ fn is_word(c: char) -> bool {
57
+ c.is_alphanumeric() || c == '_'
58
+ }
59
+
60
+ /// The boundaries of the phone patterns of the Python PII redactor, which
61
+ /// the regex crate cannot express as lookarounds: a North American number
62
+ /// is not preceded or followed by a digit; an Italian number is not
63
+ /// preceded by a word character or "+", nor followed by a word character.
64
+ fn phone_boundaries(text: &str, caps: &Captures) -> bool {
65
+ let whole = caps.get(0).unwrap();
66
+ let before = text[..whole.start()].chars().next_back();
67
+ let after = text[whole.end()..].chars().next();
68
+ if caps.name("us").is_some() {
69
+ !before.is_some_and(|c| c.is_ascii_digit()) && !after.is_some_and(|c| c.is_ascii_digit())
70
+ } else {
71
+ !before.is_some_and(|c| is_word(c) || c == '+') && !after.is_some_and(is_word)
72
+ }
73
+ }
74
+
75
+ // Phone numbers, as in the Python PII redactor: North American format
76
+ // (3-3-4 digits, optional country code), and Italian numbers, with +39 or
77
+ // 0039 or without: mobile numbers (3 and 8 or 9 more digits, compact or in
78
+ // groups) and landline numbers (an area code 02, 06 or 0 followed by 2 or 3
79
+ // digits, then 5 to 8 digits, or 2 or 3 groups of 2 to 4 digits separated
80
+ // by spaces; without +39 a space, "/" or "-" follows the area code).
81
+ const US_PHONE: &str = r"(?:\+\d{1,3}[\s.\-]?)?\(?\d{3}\)?[\s.\-]\d{3}[\s.\-]\d{4}";
82
+ const IT_MOBILE: &str = r"3[1-9]\d(?:[ \-]?\d{6,7}|[ \-]\d{3}[ \-]\d{3,4})";
83
+ const IT_AREA: &str = r"0(?:[26]|[1-9]\d{1,2})";
84
+ const IT_SUBSCRIBER: &str = r"(?:\d{5,8}|\d{2,4} \d{2,4}(?: \d{2,4})?)";
85
+
86
+ fn phone_regex() -> Regex {
87
+ let italian = format!(
88
+ "(?:(?:\\+|00)39[ .\\-]?(?:{m}|{a}[ ./\\-]?{s})|{m}|{a}[ /\\-]{s})",
89
+ m = IT_MOBILE,
90
+ a = IT_AREA,
91
+ s = IT_SUBSCRIBER
92
+ );
93
+ Regex::new(&format!("(?P<us>{US_PHONE})|(?P<it>{italian})")).unwrap()
23
94
  }
24
95
 
25
96
  static PII_PATTERNS: OnceLock<Vec<PiiPattern>> = OnceLock::new();
@@ -31,29 +102,31 @@ fn get_pii_patterns() -> &'static Vec<PiiPattern> {
31
102
  name: "email",
32
103
  regex: Regex::new(r"[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}").unwrap(),
33
104
  mask: "[EMAIL_REDACTED]",
105
+ accept: any_match,
34
106
  },
35
107
  PiiPattern {
36
108
  name: "credit_card",
37
109
  regex: Regex::new(r"\b(?:\d{4}[\s\-]?){3}\d{4}\b").unwrap(),
38
110
  mask: "[CC_REDACTED]",
111
+ accept: luhn_valid,
39
112
  },
40
113
  PiiPattern {
41
114
  name: "ssn",
42
115
  regex: Regex::new(r"\b\d{3}-\d{2}-\d{4}\b").unwrap(),
43
116
  mask: "[SSN_REDACTED]",
117
+ accept: any_match,
44
118
  },
45
119
  PiiPattern {
46
120
  name: "phone",
47
- regex: Regex::new(
48
- r"(?:\+?\d{1,3}[\s\-.]?)?\(?\d{2,4}\)?[\s\-.]?\d{3,4}[\s\-.]?\d{3,4}\b",
49
- )
50
- .unwrap(),
121
+ regex: phone_regex(),
51
122
  mask: "[PHONE_REDACTED]",
123
+ accept: phone_boundaries,
52
124
  },
53
125
  PiiPattern {
54
126
  name: "iban",
55
127
  regex: Regex::new(r"\b[A-Z]{2}\d{2}[A-Z0-9]{4}\d{7}([A-Z0-9]?){0,16}\b").unwrap(),
56
128
  mask: "[IBAN_REDACTED]",
129
+ accept: any_match,
57
130
  },
58
131
  PiiPattern {
59
132
  name: "ip_address",
@@ -62,6 +135,7 @@ fn get_pii_patterns() -> &'static Vec<PiiPattern> {
62
135
  )
63
136
  .unwrap(),
64
137
  mask: "[IP_REDACTED]",
138
+ accept: any_match,
65
139
  },
66
140
  ]
67
141
  })
@@ -123,10 +197,18 @@ impl RustPiiScanner {
123
197
  let mut details: Vec<(String, usize)> = Vec::new();
124
198
 
125
199
  for pattern in patterns.iter() {
126
- let matches: Vec<_> = pattern.regex.find_iter(&result).collect();
127
- let match_count = matches.len();
200
+ let current = result.clone();
201
+ let mut match_count: usize = 0;
202
+ let replaced = pattern.regex.replace_all(&current, |caps: &Captures| {
203
+ if (pattern.accept)(&current, caps) {
204
+ match_count += 1;
205
+ pattern.mask.to_string()
206
+ } else {
207
+ caps[0].to_string()
208
+ }
209
+ });
128
210
  if match_count > 0 {
129
- result = pattern.regex.replace_all(&result, pattern.mask).to_string();
211
+ result = replaced.into_owned();
130
212
  count += match_count;
131
213
  categories.push(pattern.name.to_string());
132
214
  details.push((pattern.name.to_string(), match_count));
@@ -151,7 +233,11 @@ impl RustPiiScanner {
151
233
  let mut details: Vec<(String, usize)> = Vec::new();
152
234
 
153
235
  for pattern in patterns.iter() {
154
- let match_count = pattern.regex.find_iter(text).count();
236
+ let match_count = pattern
237
+ .regex
238
+ .captures_iter(text)
239
+ .filter(|caps| (pattern.accept)(text, caps))
240
+ .count();
155
241
  if match_count > 0 {
156
242
  count += match_count;
157
243
  categories.push(pattern.name.to_string());
@@ -203,8 +289,50 @@ mod tests {
203
289
  #[test]
204
290
  fn test_credit_card_redaction() {
205
291
  let mut s = RustPiiScanner::new();
206
- let r = s.redact("Card: 4111-2222-3333-4444");
207
- assert!(!r.redacted_text.contains("4111-2222-3333-4444"));
292
+ let r = s.redact("Card: 4111-1111-1111-1111");
293
+ assert!(!r.redacted_text.contains("4111-1111-1111-1111"));
294
+ assert!(r.categories.contains(&"credit_card".to_string()));
295
+ }
296
+
297
+ #[test]
298
+ fn test_card_number_failing_luhn_is_not_a_card() {
299
+ let mut s = RustPiiScanner::new();
300
+ let r = s.redact("order 4111 1111 1111 1112 was cancelled");
301
+ assert!(r.redacted_text.contains("4111 1111 1111 1112"));
302
+ assert_eq!(r.count, 0);
303
+ assert_eq!(s.scan("order 4111 1111 1111 1112").count, 0);
304
+ }
305
+
306
+ #[test]
307
+ fn test_phone_numbers_are_redacted() {
308
+ let mut s = RustPiiScanner::new();
309
+ for text in [
310
+ "call 555-123-4567 today",
311
+ "call +1 555-123-4567 today",
312
+ "chiama il 333 1234567",
313
+ "chiama +39 06 1234 5678",
314
+ "ufficio 02/12345678",
315
+ ] {
316
+ let r = s.redact(text);
317
+ assert!(
318
+ r.redacted_text.contains("[PHONE_REDACTED]"),
319
+ "{text}: {}",
320
+ r.redacted_text
321
+ );
322
+ }
323
+ }
324
+
325
+ #[test]
326
+ fn test_codes_that_are_not_phone_numbers() {
327
+ let s = RustPiiScanner::new();
328
+ for text in [
329
+ "ISBN 978-3-16-148410-0 is out of print",
330
+ "build 20261008.1 passed all 4554 checks",
331
+ "upgrade to version 2.14.3",
332
+ "id1234567890",
333
+ ] {
334
+ assert_eq!(s.scan(text).count, 0, "{text}");
335
+ }
208
336
  }
209
337
 
210
338
  #[test]
@@ -4,5 +4,5 @@ requires-python = ">=3.11"
4
4
 
5
5
  [[package]]
6
6
  name = "admina-core"
7
- version = "0.13.2"
7
+ version = "0.14.1"
8
8
  source = { editable = "." }
File without changes