datafog-core 0.4.2__tar.gz → 0.4.3__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.
Files changed (60) hide show
  1. {datafog_core-0.4.2 → datafog_core-0.4.3}/Cargo.lock +4 -4
  2. {datafog_core-0.4.2 → datafog_core-0.4.3}/PKG-INFO +22 -2
  3. {datafog_core-0.4.2/crates/core → datafog_core-0.4.3}/README.md +21 -1
  4. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/Cargo.toml +1 -1
  5. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/test_installed.py +37 -0
  6. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/test_typing.py +3 -1
  7. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/Cargo.toml +1 -1
  8. {datafog_core-0.4.2 → datafog_core-0.4.3/crates/core}/README.md +21 -1
  9. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/capabilities.rs +52 -4
  10. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/german.rs +9 -2
  11. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/lib.rs +61 -1
  12. datafog_core-0.4.3/crates/core/src/scan_selection_tests.rs +155 -0
  13. {datafog_core-0.4.2 → datafog_core-0.4.3}/pyproject.toml +1 -1
  14. {datafog_core-0.4.2 → datafog_core-0.4.3}/python/datafog_core/__init__.pyi +6 -2
  15. {datafog_core-0.4.2 → datafog_core-0.4.3}/Cargo.toml +0 -0
  16. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/src/lib.rs +0 -0
  17. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/api_key_conformance.py +0 -0
  18. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/bearer_token_conformance.py +0 -0
  19. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/capabilities_conformance.py +0 -0
  20. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/credential_overlap_conformance.py +0 -0
  21. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/credential_uri_conformance.py +0 -0
  22. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/email_context_conformance.py +0 -0
  23. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/german_conformance.py +0 -0
  24. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/jwt_conformance.py +0 -0
  25. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/npi_conformance.py +0 -0
  26. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/private_key_conformance.py +0 -0
  27. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/requirements-typing.txt +0 -0
  28. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/synthetic_fixtures.py +0 -0
  29. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/test_python49_integration.py +0 -0
  30. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/us_routing_number_conformance.py +0 -0
  31. {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/uuid_conformance.py +0 -0
  32. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/examples/german_benchmark.rs +0 -0
  33. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/examples/scan_benchmark.rs +0 -0
  34. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/api_key.rs +0 -0
  35. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/bearer_token.rs +0 -0
  36. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/credential_uri.rs +0 -0
  37. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/email_context.rs +0 -0
  38. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/jwt.rs +0 -0
  39. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/npi.rs +0 -0
  40. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/offsets.rs +0 -0
  41. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/private_key.rs +0 -0
  42. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/selection_tests.rs +0 -0
  43. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/structured.rs +0 -0
  44. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/us_routing_number.rs +0 -0
  45. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/uuid.rs +0 -0
  46. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/api_key.rs +0 -0
  47. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/bearer_token.rs +0 -0
  48. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/capabilities.rs +0 -0
  49. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/credential_overlaps.rs +0 -0
  50. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/credential_uri.rs +0 -0
  51. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/email_context.rs +0 -0
  52. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/german.rs +0 -0
  53. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/jwt.rs +0 -0
  54. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/npi.rs +0 -0
  55. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/private_key.rs +0 -0
  56. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/support/synthetic_fixtures.rs +0 -0
  57. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/us_routing_number.rs +0 -0
  58. {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/uuid.rs +0 -0
  59. {datafog_core-0.4.2 → datafog_core-0.4.3}/python/datafog_core/__init__.py +0 -0
  60. {datafog_core-0.4.2 → datafog_core-0.4.3}/python/datafog_core/py.typed +0 -0
@@ -80,7 +80,7 @@ checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d"
80
80
 
81
81
  [[package]]
82
82
  name = "datafog-core"
83
- version = "0.4.2"
83
+ version = "0.4.3"
84
84
  dependencies = [
85
85
  "base64",
86
86
  "futures",
@@ -94,7 +94,7 @@ dependencies = [
94
94
 
95
95
  [[package]]
96
96
  name = "datafog-core-python"
97
- version = "0.4.2"
97
+ version = "0.4.3"
98
98
  dependencies = [
99
99
  "datafog-core",
100
100
  "pyo3",
@@ -104,7 +104,7 @@ dependencies = [
104
104
 
105
105
  [[package]]
106
106
  name = "datafog-node"
107
- version = "0.4.2"
107
+ version = "0.4.3"
108
108
  dependencies = [
109
109
  "datafog-core",
110
110
  "napi",
@@ -115,7 +115,7 @@ dependencies = [
115
115
 
116
116
  [[package]]
117
117
  name = "datafog-wasm"
118
- version = "0.4.2"
118
+ version = "0.4.3"
119
119
  dependencies = [
120
120
  "datafog-core",
121
121
  "serde",
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: datafog-core
3
- Version: 0.4.2
3
+ Version: 0.4.3
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: License :: OSI Approved :: MIT License
6
6
  Classifier: Programming Language :: Python :: 3
@@ -79,7 +79,7 @@ full-match regex values:
79
79
  }
80
80
  ```
81
81
 
82
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
82
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
83
83
  detection settings remain separate from transformation policy.
84
84
  For `.env` assignments or standard SQL quoted strings, select `format: "env"`
85
85
  or `format: "sql"` to preserve email boundaries. See the
@@ -328,6 +328,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
328
328
  The higher-level Python adapter and its dependency update were merged in
329
329
  [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
330
330
 
331
+ ## Text detector selection
332
+
333
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
334
+ selected detectors, rather than detecting every type and filtering findings
335
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
336
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
337
+ `scan` configuration; transformation selection remains independent.
338
+
339
+ Omitting `entities` preserves existing detection. An explicit selection must be
340
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
341
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
342
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
343
+ `capabilities().entities` for types with the `text` scope.
344
+
345
+ Selection narrows enabled detectors: selecting `UUID` still requires
346
+ `detect_uuid=true`, and German types still require a German locale. Text format
347
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
348
+ scans do not accept this option. It avoids unselected detector work and findings,
349
+ but does not cap the selected findings or bound the size of a scan response.
350
+
331
351
  ## UUID identifiers
332
352
 
333
353
  Canonical UUID detection is opt-in: pass `{"detect_uuid":true}` to text or
@@ -56,7 +56,7 @@ full-match regex values:
56
56
  }
57
57
  ```
58
58
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
59
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
60
60
  detection settings remain separate from transformation policy.
61
61
  For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
62
  or `format: "sql"` to preserve email boundaries. See the
@@ -305,6 +305,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
305
305
  The higher-level Python adapter and its dependency update were merged in
306
306
  [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
307
307
 
308
+ ## Text detector selection
309
+
310
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
311
+ selected detectors, rather than detecting every type and filtering findings
312
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
313
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
314
+ `scan` configuration; transformation selection remains independent.
315
+
316
+ Omitting `entities` preserves existing detection. An explicit selection must be
317
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
318
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
319
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
320
+ `capabilities().entities` for types with the `text` scope.
321
+
322
+ Selection narrows enabled detectors: selecting `UUID` still requires
323
+ `detect_uuid=true`, and German types still require a German locale. Text format
324
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
325
+ scans do not accept this option. It avoids unselected detector work and findings,
326
+ but does not cap the selected findings or bound the size of a scan response.
327
+
308
328
  ## UUID identifiers
309
329
 
310
330
  Canonical UUID detection is opt-in: pass `{"detect_uuid":true}` to text or
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "datafog-core-python"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  description = "Python bindings for datafog-core"
@@ -126,7 +126,44 @@ def verify_structured() -> None:
126
126
  raise AssertionError("invalid structured input accepted")
127
127
 
128
128
 
129
+ def verify_text_selection() -> None:
130
+ text = "👋 jane@example.com SSN 123-45-6789"
131
+ complete = scan(text)
132
+ selected = scan(text, {"entities": ["EMAIL"]})
133
+ expected = [finding for finding in complete if finding.entity_type == "EMAIL"]
134
+ assert len(selected) == len(expected) == 1
135
+ assert selected[0].codepoint_range.start == expected[0].codepoint_range.start
136
+ assert selected[0].byte_range.start == expected[0].byte_range.start
137
+ assert selected[0].matched_text == "jane@example.com"
138
+ transformed = scan_and_transform(text, {
139
+ "scan": {"entities": ["EMAIL"]},
140
+ "transform": {"default": {"strategy": "redact"}},
141
+ })
142
+ assert "123-45-6789" in transformed.text
143
+ assert "jane@example.com" not in transformed.text
144
+ for entities in ([], ["EMAIL", "EMAIL"], ["PERSON"], ["email"], [1], "EMAIL"):
145
+ try:
146
+ scan(text, {"entities": entities})
147
+ except DataFogConfigurationError as error:
148
+ assert error.code == "invalid_configuration"
149
+ assert error.path.startswith("/entities")
150
+ else:
151
+ raise AssertionError(f"invalid selection accepted: {entities!r}")
152
+ uuid = "550e8400-e29b-41d4-a716-446655440000"
153
+ assert not scan(uuid, {"entities": ["UUID"]})
154
+ assert [item.entity_type for item in scan(uuid, {"entities": ["UUID"], "detect_uuid": True})] == ["UUID"]
155
+ assert not scan(uuid, {"entities": ["EMAIL"], "detect_uuid": True})
156
+ assert scan("contact=jane@example.com", {"format": "env", "entities": ["EMAIL"]})[0].matched_text == "jane@example.com"
157
+ try:
158
+ scan_structured({"email": "jane@example.com"}, {"entities": ["EMAIL"]})
159
+ except DataFogConfigurationError as error:
160
+ assert error.reason == "unknown_field"
161
+ else:
162
+ raise AssertionError("text selection was accepted in structured scan")
163
+
164
+
129
165
  def main() -> None:
166
+ verify_text_selection()
130
167
  capabilities_conformance.verify()
131
168
  german_conformance.verify()
132
169
  credential_overlap_conformance.verify()
@@ -42,7 +42,7 @@ config: _TransformationConfig = {
42
42
  },
43
43
  "allow": {"exact": {"EMAIL": ["safe@example.com"]}, "regex": {"EMAIL": [{"pattern": "safe", "case_sensitive": False}]}},
44
44
  }
45
- findings = scan("jane@example.com", {"locale": "en-US", "detect_uuid": True, "format": "env"})
45
+ findings = scan("jane@example.com", {"locale": "en-US", "detect_uuid": True, "format": "env", "entities": ["EMAIL"]})
46
46
  assert_type(findings, list[Finding])
47
47
  finding = findings[0]
48
48
  assert_type(finding.entity_type, str)
@@ -131,6 +131,8 @@ scan("text", {"format": "yaml"}) # error
131
131
  scan("text", {"locale": 123}) # error
132
132
  scan("text", {"detect_uuid": "true"}) # error
133
133
  scan("text", {"max_bytes": 100}) # error
134
+ scan("text", {"entities": "EMAIL"}) # error
135
+ scan_structured({"email": "jane@example.com"}, {"entities": ["EMAIL"]}) # error
134
136
  scan("text")[0].entity_type = "OTHER" # error
135
137
  scan("text")[0].byte_range.start = 1 # error
136
138
  transform("text", ["not a finding"], {"default": {"strategy": "redact"}}) # error
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "datafog-core"
3
- version = "0.4.2"
3
+ version = "0.4.3"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  description = "Fast structured PII detection library"
@@ -56,7 +56,7 @@ full-match regex values:
56
56
  }
57
57
  ```
58
58
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
59
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
60
60
  detection settings remain separate from transformation policy.
61
61
  For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
62
  or `format: "sql"` to preserve email boundaries. See the
@@ -305,6 +305,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
305
305
  The higher-level Python adapter and its dependency update were merged in
306
306
  [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
307
307
 
308
+ ## Text detector selection
309
+
310
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
311
+ selected detectors, rather than detecting every type and filtering findings
312
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
313
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
314
+ `scan` configuration; transformation selection remains independent.
315
+
316
+ Omitting `entities` preserves existing detection. An explicit selection must be
317
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
318
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
319
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
320
+ `capabilities().entities` for types with the `text` scope.
321
+
322
+ Selection narrows enabled detectors: selecting `UUID` still requires
323
+ `detect_uuid=true`, and German types still require a German locale. Text format
324
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
325
+ scans do not accept this option. It avoids unselected detector work and findings,
326
+ but does not cap the selected findings or bound the size of a scan response.
327
+
308
328
  ## UUID identifiers
309
329
 
310
330
  Canonical UUID detection is opt-in: pass `{"detect_uuid":true}` to text or
@@ -88,6 +88,14 @@ struct Detector {
88
88
  }
89
89
 
90
90
  impl Detector {
91
+ fn enabled(&self, config: &ScanConfig) -> bool {
92
+ self.activation.enabled(config)
93
+ && self
94
+ .labels
95
+ .iter()
96
+ .any(|label| config.includes_entity(label.as_str()))
97
+ }
98
+
91
99
  fn single(label: Label, detect: fn(&str, &mut Vec<Candidate>), activation: Activation) -> Self {
92
100
  Self {
93
101
  labels: vec![label],
@@ -147,13 +155,18 @@ pub(super) fn valid_locale(locale: &str) -> bool {
147
155
  locale_activation(locale).is_some()
148
156
  }
149
157
 
150
- pub(super) fn detect(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
151
- for detector in REGISTRY
158
+ pub(super) fn text_entity_supported(entity: &str) -> bool {
159
+ REGISTRY
152
160
  .iter()
153
- .filter(|detector| detector.activation.enabled(config))
154
- {
161
+ .any(|detector| detector.labels.iter().any(|label| label.as_str() == entity))
162
+ }
163
+
164
+ pub(super) fn detect(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
165
+ for detector in REGISTRY.iter().filter(|detector| detector.enabled(config)) {
155
166
  if detector.labels == [Label::Email] {
156
167
  crate::email_context::detect(text, config.format(), candidates);
168
+ } else if detector.activation == Activation::German {
169
+ german::detect_selected(text, config, candidates);
157
170
  } else {
158
171
  (detector.detect)(text, candidates);
159
172
  }
@@ -218,3 +231,38 @@ pub fn capabilities() -> Capabilities {
218
231
  entities,
219
232
  }
220
233
  }
234
+
235
+ #[cfg(test)]
236
+ mod selection_dispatch_tests {
237
+ use super::*;
238
+
239
+ #[test]
240
+ fn dispatch_skips_every_registry_detector_without_a_selected_label() {
241
+ let activated = ScanConfig::new()
242
+ .with_locale("de")
243
+ .unwrap()
244
+ .with_uuid_detection(true);
245
+ for entity in capabilities()
246
+ .supported_entities
247
+ .iter()
248
+ .filter(|name| name.as_str() != "PERSON")
249
+ {
250
+ let selected = activated
251
+ .clone()
252
+ .with_entities(vec![entity.clone()])
253
+ .unwrap();
254
+ let enabled: Vec<_> = REGISTRY
255
+ .iter()
256
+ .filter(|detector| detector.enabled(&selected))
257
+ .collect();
258
+ assert_eq!(enabled.len(), 1, "{entity}");
259
+ assert!(
260
+ enabled[0]
261
+ .labels
262
+ .iter()
263
+ .any(|label| label.as_str() == entity)
264
+ );
265
+ }
266
+ assert!(REGISTRY.iter().all(|detector| detector.enabled(&activated)));
267
+ }
268
+ }
@@ -1,5 +1,5 @@
1
1
  //! Locale-gated lexical detection, without official identifier validation.
2
- use crate::{Candidate, Label};
2
+ use crate::{Candidate, Label, ScanConfig};
3
3
  use regex::Regex;
4
4
  use std::sync::LazyLock;
5
5
 
@@ -71,7 +71,14 @@ fn ascii_boundary(text: &str, start: usize, end: usize) -> bool {
71
71
  }
72
72
 
73
73
  pub(super) fn detect(text: &str, candidates: &mut Vec<Candidate>) {
74
- for (label, regex) in DETECTORS.iter() {
74
+ detect_selected(text, &ScanConfig::default(), candidates);
75
+ }
76
+
77
+ pub(super) fn detect_selected(text: &str, config: &ScanConfig, candidates: &mut Vec<Candidate>) {
78
+ for (label, regex) in DETECTORS
79
+ .iter()
80
+ .filter(|(label, _)| config.includes_entity(label.as_str()))
81
+ {
75
82
  for captures in regex.captures_iter(text) {
76
83
  // Both captures are unconditional in the statically compiled expressions.
77
84
  let whole = captures.get(0).expect("whole regex match");
@@ -555,9 +555,54 @@ pub struct ScanConfig {
555
555
  locale: Option<String>,
556
556
  detect_uuid: bool,
557
557
  format: ScanFormat,
558
+ entities: Option<BTreeSet<String>>,
558
559
  }
559
560
 
560
561
  impl ScanConfig {
562
+ /// Restrict text scanning to exact canonical entity types. Selection only
563
+ /// narrows enabled detectors; locale and UUID activation remain independent.
564
+ pub fn with_entities(mut self, entities: Vec<String>) -> Result<Self, PrivacyError> {
565
+ if entities.is_empty() {
566
+ return Err(PrivacyError::invalid_configuration(
567
+ PrivacyErrorReason::EmptyValue,
568
+ "/entities",
569
+ "entities must contain at least one entity type",
570
+ ));
571
+ }
572
+ let mut selected = BTreeSet::new();
573
+ for (index, entity) in entities.into_iter().enumerate() {
574
+ let path = format!("/entities/{index}");
575
+ validate_entity_name(&entity, &path)?;
576
+ if !capabilities::text_entity_supported(&entity) {
577
+ return Err(PrivacyError::invalid_configuration(
578
+ PrivacyErrorReason::InvalidValue,
579
+ &path,
580
+ "entity type is not supported by text scanning",
581
+ ));
582
+ }
583
+ if !selected.insert(entity) {
584
+ return Err(PrivacyError::invalid_configuration(
585
+ PrivacyErrorReason::DuplicateValue,
586
+ path,
587
+ "entity selection contains a duplicate entity type",
588
+ ));
589
+ }
590
+ }
591
+ self.entities = Some(selected);
592
+ Ok(self)
593
+ }
594
+
595
+ /// Explicit text entity selection, or None for the existing detector defaults.
596
+ pub fn entities(&self) -> Option<&BTreeSet<String>> {
597
+ self.entities.as_ref()
598
+ }
599
+
600
+ pub(crate) fn includes_entity(&self, entity: &str) -> bool {
601
+ self.entities
602
+ .as_ref()
603
+ .is_none_or(|entities| entities.contains(entity))
604
+ }
605
+
561
606
  /// Select source syntax for email boundaries; defaults to unstructured text.
562
607
  pub fn with_format(mut self, format: ScanFormat) -> Self {
563
608
  self.format = format;
@@ -780,8 +825,20 @@ pub fn parse_scan_and_transform_config(
780
825
  /// Parse canonical scanner configuration.
781
826
  pub fn parse_scan_config(value: &serde_json::Value) -> Result<ScanConfig, PrivacyError> {
782
827
  let object = require_object(value, "", "scan configuration must be an object")?;
783
- reject_unknown_fields(object, &["locale", "detect_uuid", "format"], "")?;
828
+ reject_unknown_fields(object, &["locale", "detect_uuid", "format", "entities"], "")?;
784
829
  let mut config = ScanConfig::new();
830
+ if let Some(entities) = object.get("entities") {
831
+ let entities = require_array(entities, "/entities", "entities must be an array")?;
832
+ let mut parsed = Vec::with_capacity(entities.len());
833
+ for (index, entity) in entities.iter().enumerate() {
834
+ parsed.push(require_string(
835
+ entity,
836
+ &format!("/entities/{index}"),
837
+ "entity type must be a string",
838
+ )?);
839
+ }
840
+ config = config.with_entities(parsed)?;
841
+ }
785
842
  if let Some(format) = object.get("format") {
786
843
  config = config.with_format(parse_scan_format(format)?);
787
844
  }
@@ -3661,6 +3718,9 @@ fn detect_ip_address(text: &str, candidates: &mut Vec<Candidate>) {
3661
3718
  #[cfg(test)]
3662
3719
  mod selection_tests;
3663
3720
 
3721
+ #[cfg(test)]
3722
+ mod scan_selection_tests;
3723
+
3664
3724
  #[cfg(test)]
3665
3725
  mod tests {
3666
3726
  mod synthetic_fixtures {
@@ -0,0 +1,155 @@
1
+ use super::*;
2
+ use serde_json::json;
3
+
4
+ #[test]
5
+ fn selected_scans_match_filtered_defaults_for_every_text_detector() {
6
+ let fixtures = [
7
+ include_str!("../../../fixtures/development.jsonl"),
8
+ include_str!("../../../fixtures/german.jsonl"),
9
+ include_str!("../../../fixtures/uuid.jsonl"),
10
+ include_str!("../../../fixtures/credential-overlaps.jsonl"),
11
+ include_str!("../../../fixtures/credential-uri.jsonl"),
12
+ include_str!("../../../fixtures/api-key.jsonl"),
13
+ include_str!("../../../fixtures/bearer-token.jsonl"),
14
+ include_str!("../../../fixtures/jwt.jsonl"),
15
+ include_str!("../../../fixtures/npi.jsonl"),
16
+ include_str!("../../../fixtures/us-routing-number.jsonl"),
17
+ include_str!("../../../fixtures/private-key.jsonl"),
18
+ include_str!("../../../fixtures/email-context.jsonl"),
19
+ ];
20
+ let mut covered = BTreeSet::new();
21
+ for fixture in fixtures {
22
+ for line in fixture.lines() {
23
+ let row: serde_json::Value = serde_json::from_str(line).unwrap();
24
+ let text = row["text"].as_str().unwrap();
25
+ let config = parse_scan_config(row.get("config").unwrap_or(&json!({}))).unwrap();
26
+ let baseline = scan_with_config(text, &config);
27
+ for name in capabilities()
28
+ .supported_entities
29
+ .into_iter()
30
+ .filter(|name| name != "PERSON")
31
+ {
32
+ let selected = config.clone().with_entities(vec![name.clone()]).unwrap();
33
+ let expected: Vec<_> = baseline
34
+ .iter()
35
+ .filter(|finding| finding.entity_type == name)
36
+ .cloned()
37
+ .collect();
38
+ if !expected.is_empty() {
39
+ covered.insert(name.clone());
40
+ }
41
+ assert_eq!(
42
+ scan_with_config(text, &selected),
43
+ expected,
44
+ "{}: {name}",
45
+ row["id"]
46
+ );
47
+ }
48
+ }
49
+ }
50
+ let expected: BTreeSet<_> = capabilities()
51
+ .supported_entities
52
+ .into_iter()
53
+ .filter(|name| name != "PERSON")
54
+ .collect();
55
+ assert_eq!(covered, expected);
56
+ }
57
+
58
+ #[test]
59
+ fn invalid_text_selections_have_precise_safe_errors() {
60
+ for (value, path, reason) in [
61
+ (
62
+ json!({"entities": []}),
63
+ "/entities",
64
+ PrivacyErrorReason::EmptyValue,
65
+ ),
66
+ (
67
+ json!({"entities": null}),
68
+ "/entities",
69
+ PrivacyErrorReason::InvalidType,
70
+ ),
71
+ (
72
+ json!({"entities": "EMAIL"}),
73
+ "/entities",
74
+ PrivacyErrorReason::InvalidType,
75
+ ),
76
+ (
77
+ json!({"entities": [2]}),
78
+ "/entities/0",
79
+ PrivacyErrorReason::InvalidType,
80
+ ),
81
+ (
82
+ json!({"entities": ["EMAIL", "EMAIL"]}),
83
+ "/entities/1",
84
+ PrivacyErrorReason::DuplicateValue,
85
+ ),
86
+ (
87
+ json!({"entities": ["PERSON"]}),
88
+ "/entities/0",
89
+ PrivacyErrorReason::InvalidValue,
90
+ ),
91
+ (
92
+ json!({"entities": ["NOT_SUPPORTED"]}),
93
+ "/entities/0",
94
+ PrivacyErrorReason::InvalidValue,
95
+ ),
96
+ (
97
+ json!({"entities": ["email"]}),
98
+ "/entities/0",
99
+ PrivacyErrorReason::InvalidValue,
100
+ ),
101
+ ] {
102
+ let error = parse_scan_config(&value).unwrap_err();
103
+ assert_eq!(error.code(), PrivacyErrorCode::InvalidConfiguration);
104
+ assert_eq!(error.path(), Some(path));
105
+ assert_eq!(error.reason(), Some(reason));
106
+ }
107
+ let error = parse_scan_and_transform_config(
108
+ &json!({"scan": {"entities": []}, "transform": {"default": {"strategy": "redact"}}}),
109
+ )
110
+ .unwrap_err();
111
+ assert_eq!(error.path(), Some("/scan/entities"));
112
+ assert!(structured::parse_scan_config(&json!({"entities": ["EMAIL"]})).is_err());
113
+ }
114
+
115
+ #[test]
116
+ fn selection_does_not_activate_uuid_or_locale_detectors() {
117
+ for (entity, text, active) in [
118
+ (
119
+ "UUID",
120
+ "550e8400-e29b-41d4-a716-446655440000",
121
+ json!({"detect_uuid": true}),
122
+ ),
123
+ (
124
+ "DE_IBAN",
125
+ "DE44 5001 0517 5407 3249 31",
126
+ json!({"locale": "de"}),
127
+ ),
128
+ ] {
129
+ let selected = ScanConfig::new()
130
+ .with_entities(vec![entity.into()])
131
+ .unwrap();
132
+ assert!(scan_with_config(text, &selected).is_empty());
133
+ let active = parse_scan_config(&active)
134
+ .unwrap()
135
+ .with_entities(vec![entity.into()])
136
+ .unwrap();
137
+ assert_eq!(scan_with_config(text, &active).len(), 1);
138
+ let excluded = active.with_entities(vec!["EMAIL".into()]).unwrap();
139
+ assert!(scan_with_config(text, &excluded).is_empty());
140
+ }
141
+ }
142
+
143
+ #[test]
144
+ fn scan_and_transform_selection_preserves_unselected_content_and_unicode_offsets() {
145
+ let text = "👋 person@example.com / 123-45-6789";
146
+ let config = parse_scan_and_transform_config(
147
+ &json!({"scan": {"entities": ["EMAIL"]}, "transform": {"default": {"strategy": "redact"}}}),
148
+ )
149
+ .unwrap();
150
+ let result = scan_and_transform(text, &config).unwrap();
151
+ assert_eq!(result.text, "👋 [EMAIL] / 123-45-6789");
152
+ let finding = &scan_with_config(text, config.scan_config())[0];
153
+ assert_eq!(finding.codepoint_range.start, 2);
154
+ assert_eq!(finding.byte_range.start, 5);
155
+ }
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "datafog-core"
7
- version = "0.4.2"
7
+ version = "0.4.3"
8
8
  description = "Fast structured PII detection implemented in Rust"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -8,11 +8,15 @@ _JsonValue: TypeAlias = (
8
8
  )
9
9
  _JsonDocument: TypeAlias = list[_JsonValue] | dict[str, _JsonValue]
10
10
 
11
- class _ScanConfig(TypedDict, total=False):
11
+ class _CommonScanConfig(TypedDict, total=False):
12
12
  format: Literal["text", "env", "sql"]
13
13
  locale: str
14
14
  detect_uuid: bool
15
15
 
16
+ class _ScanConfig(_CommonScanConfig, total=False):
17
+ # Select activated text detectors; omission preserves the default scan.
18
+ entities: list[str] | tuple[str, ...]
19
+
16
20
  class _CapabilityActivationRequired(TypedDict):
17
21
  kind: Literal["default", "locale", "config", "structured"]
18
22
 
@@ -33,7 +37,7 @@ class _Capabilities(TypedDict):
33
37
  locales: dict[str, _LocaleCapability]
34
38
  entities: dict[str, _EntityCapability]
35
39
 
36
- class _StructuredScanConfig(_ScanConfig, total=False):
40
+ class _StructuredScanConfig(_CommonScanConfig, total=False):
37
41
  discover_person: bool
38
42
  mappings: dict[str, Literal["PERSON"]]
39
43
  exclude: list[str] | tuple[str, ...]
File without changes