datafog-core 0.4.2__tar.gz → 0.4.4__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.4}/Cargo.lock +4 -4
  2. {datafog_core-0.4.2 → datafog_core-0.4.4}/PKG-INFO +42 -2
  3. {datafog_core-0.4.2/crates/core → datafog_core-0.4.4}/README.md +41 -1
  4. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/Cargo.toml +1 -1
  5. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/test_installed.py +37 -0
  6. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/test_typing.py +3 -1
  7. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/Cargo.toml +1 -1
  8. {datafog_core-0.4.2 → datafog_core-0.4.4/crates/core}/README.md +41 -1
  9. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/capabilities.rs +52 -4
  10. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/german.rs +9 -2
  11. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/lib.rs +269 -5
  12. datafog_core-0.4.4/crates/core/src/scan_selection_tests.rs +155 -0
  13. {datafog_core-0.4.2 → datafog_core-0.4.4}/pyproject.toml +1 -1
  14. {datafog_core-0.4.2 → datafog_core-0.4.4}/python/datafog_core/__init__.pyi +6 -2
  15. {datafog_core-0.4.2 → datafog_core-0.4.4}/Cargo.toml +0 -0
  16. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/src/lib.rs +0 -0
  17. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/api_key_conformance.py +0 -0
  18. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/bearer_token_conformance.py +0 -0
  19. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/capabilities_conformance.py +0 -0
  20. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/credential_overlap_conformance.py +0 -0
  21. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/credential_uri_conformance.py +0 -0
  22. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/email_context_conformance.py +0 -0
  23. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/german_conformance.py +0 -0
  24. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/jwt_conformance.py +0 -0
  25. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/npi_conformance.py +0 -0
  26. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/private_key_conformance.py +0 -0
  27. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/requirements-typing.txt +0 -0
  28. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/synthetic_fixtures.py +0 -0
  29. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/test_python49_integration.py +0 -0
  30. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/us_routing_number_conformance.py +0 -0
  31. {datafog_core-0.4.2 → datafog_core-0.4.4}/bindings/python/tests/uuid_conformance.py +0 -0
  32. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/examples/german_benchmark.rs +0 -0
  33. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/examples/scan_benchmark.rs +0 -0
  34. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/api_key.rs +0 -0
  35. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/bearer_token.rs +0 -0
  36. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/credential_uri.rs +0 -0
  37. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/email_context.rs +0 -0
  38. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/jwt.rs +0 -0
  39. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/npi.rs +0 -0
  40. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/offsets.rs +0 -0
  41. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/private_key.rs +0 -0
  42. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/selection_tests.rs +0 -0
  43. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/structured.rs +0 -0
  44. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/us_routing_number.rs +0 -0
  45. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/src/uuid.rs +0 -0
  46. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/api_key.rs +0 -0
  47. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/bearer_token.rs +0 -0
  48. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/capabilities.rs +0 -0
  49. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/credential_overlaps.rs +0 -0
  50. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/credential_uri.rs +0 -0
  51. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/email_context.rs +0 -0
  52. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/german.rs +0 -0
  53. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/jwt.rs +0 -0
  54. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/npi.rs +0 -0
  55. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/private_key.rs +0 -0
  56. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/support/synthetic_fixtures.rs +0 -0
  57. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/us_routing_number.rs +0 -0
  58. {datafog_core-0.4.2 → datafog_core-0.4.4}/crates/core/tests/uuid.rs +0 -0
  59. {datafog_core-0.4.2 → datafog_core-0.4.4}/python/datafog_core/__init__.py +0 -0
  60. {datafog_core-0.4.2 → datafog_core-0.4.4}/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.4"
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.4"
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.4"
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.4"
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.4
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: License :: OSI Approved :: MIT License
6
6
  Classifier: Programming Language :: Python :: 3
@@ -38,6 +38,13 @@ existing detectors and returns field paths plus string-local findings. No model
38
38
  or dictionary download is needed. See [person-field discovery](docs/guides/person-discovery.mdx)
39
39
  for `scan_structured` / `scanStructured` and structured transformation APIs.
40
40
 
41
+ Phone candidates without `+` are interpreted as North American numbers: ten
42
+ digits, optionally preceded by `1`, with area and exchange codes starting with
43
+ `2` through `9`. Candidates starting with `+` retain the existing permissive
44
+ 7–15 digit check, including shortened synthetic examples such as `+1-555-1002`.
45
+ These checks do not validate country calling codes or assigned phone numbers,
46
+ and numeric identifiers that fit the accepted patterns can still match `PHONE`.
47
+
41
48
  Both ranges use zero-based, end-exclusive offsets. The byte range addresses the
42
49
  UTF-8 input; the code-point range addresses Unicode scalar values. Rule-based
43
50
  detectors currently report no confidence score. Node.js and browser WASM also
@@ -79,13 +86,26 @@ full-match regex values:
79
86
  }
80
87
  ```
81
88
 
82
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
89
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
83
90
  detection settings remain separate from transformation policy.
84
91
  For `.env` assignments or standard SQL quoted strings, select `format: "env"`
85
92
  or `format: "sql"` to preserve email boundaries. See the
86
93
  [email source-context policy](docs/guides/email-boundaries.mdx) for supported
87
94
  syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
88
95
 
96
+ ## Credit-card detection and decimal numbers
97
+
98
+ To reduce decimal false positives, `CREDIT_CARD` detection suppresses contiguous
99
+ digit candidates immediately after a digit followed by `.` or `,`, such as
100
+ `3.4111111111111111` and `3,4111111111111111`. Cards containing spaces or hyphens,
101
+ such as `3.4111-1111-1111-1111`, still undergo the normal card checks.
102
+
103
+ In plain text, `3,4111111111111111` could also be two CSV fields: `3` and a card
104
+ number. The detector cannot distinguish that structure from a comma-decimal
105
+ number, so it suppresses the card candidate. For CSV input, parse the CSV and
106
+ scan each field separately so a card value is checked without the preceding
107
+ field and delimiter.
108
+
89
109
  ## Packages
90
110
 
91
111
  | Runtime | Distribution | Import | Status |
@@ -328,6 +348,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
328
348
  The higher-level Python adapter and its dependency update were merged in
329
349
  [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
330
350
 
351
+ ## Text detector selection
352
+
353
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
354
+ selected detectors, rather than detecting every type and filtering findings
355
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
356
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
357
+ `scan` configuration; transformation selection remains independent.
358
+
359
+ Omitting `entities` preserves existing detection. An explicit selection must be
360
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
361
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
362
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
363
+ `capabilities().entities` for types with the `text` scope.
364
+
365
+ Selection narrows enabled detectors: selecting `UUID` still requires
366
+ `detect_uuid=true`, and German types still require a German locale. Text format
367
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
368
+ scans do not accept this option. It avoids unselected detector work and findings,
369
+ but does not cap the selected findings or bound the size of a scan response.
370
+
331
371
  ## UUID identifiers
332
372
 
333
373
  Canonical UUID detection is opt-in: pass `{"detect_uuid":true}` to text or
@@ -15,6 +15,13 @@ existing detectors and returns field paths plus string-local findings. No model
15
15
  or dictionary download is needed. See [person-field discovery](docs/guides/person-discovery.mdx)
16
16
  for `scan_structured` / `scanStructured` and structured transformation APIs.
17
17
 
18
+ Phone candidates without `+` are interpreted as North American numbers: ten
19
+ digits, optionally preceded by `1`, with area and exchange codes starting with
20
+ `2` through `9`. Candidates starting with `+` retain the existing permissive
21
+ 7–15 digit check, including shortened synthetic examples such as `+1-555-1002`.
22
+ These checks do not validate country calling codes or assigned phone numbers,
23
+ and numeric identifiers that fit the accepted patterns can still match `PHONE`.
24
+
18
25
  Both ranges use zero-based, end-exclusive offsets. The byte range addresses the
19
26
  UTF-8 input; the code-point range addresses Unicode scalar values. Rule-based
20
27
  detectors currently report no confidence score. Node.js and browser WASM also
@@ -56,13 +63,26 @@ full-match regex values:
56
63
  }
57
64
  ```
58
65
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
66
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
60
67
  detection settings remain separate from transformation policy.
61
68
  For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
69
  or `format: "sql"` to preserve email boundaries. See the
63
70
  [email source-context policy](docs/guides/email-boundaries.mdx) for supported
64
71
  syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
65
72
 
73
+ ## Credit-card detection and decimal numbers
74
+
75
+ To reduce decimal false positives, `CREDIT_CARD` detection suppresses contiguous
76
+ digit candidates immediately after a digit followed by `.` or `,`, such as
77
+ `3.4111111111111111` and `3,4111111111111111`. Cards containing spaces or hyphens,
78
+ such as `3.4111-1111-1111-1111`, still undergo the normal card checks.
79
+
80
+ In plain text, `3,4111111111111111` could also be two CSV fields: `3` and a card
81
+ number. The detector cannot distinguish that structure from a comma-decimal
82
+ number, so it suppresses the card candidate. For CSV input, parse the CSV and
83
+ scan each field separately so a card value is checked without the preceding
84
+ field and delimiter.
85
+
66
86
  ## Packages
67
87
 
68
88
  | Runtime | Distribution | Import | Status |
@@ -305,6 +325,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
305
325
  The higher-level Python adapter and its dependency update were merged in
306
326
  [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
307
327
 
328
+ ## Text detector selection
329
+
330
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
331
+ selected detectors, rather than detecting every type and filtering findings
332
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
333
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
334
+ `scan` configuration; transformation selection remains independent.
335
+
336
+ Omitting `entities` preserves existing detection. An explicit selection must be
337
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
338
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
339
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
340
+ `capabilities().entities` for types with the `text` scope.
341
+
342
+ Selection narrows enabled detectors: selecting `UUID` still requires
343
+ `detect_uuid=true`, and German types still require a German locale. Text format
344
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
345
+ scans do not accept this option. It avoids unselected detector work and findings,
346
+ but does not cap the selected findings or bound the size of a scan response.
347
+
308
348
  ## UUID identifiers
309
349
 
310
350
  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.4"
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.4"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  description = "Fast structured PII detection library"
@@ -15,6 +15,13 @@ existing detectors and returns field paths plus string-local findings. No model
15
15
  or dictionary download is needed. See [person-field discovery](docs/guides/person-discovery.mdx)
16
16
  for `scan_structured` / `scanStructured` and structured transformation APIs.
17
17
 
18
+ Phone candidates without `+` are interpreted as North American numbers: ten
19
+ digits, optionally preceded by `1`, with area and exchange codes starting with
20
+ `2` through `9`. Candidates starting with `+` retain the existing permissive
21
+ 7–15 digit check, including shortened synthetic examples such as `+1-555-1002`.
22
+ These checks do not validate country calling codes or assigned phone numbers,
23
+ and numeric identifiers that fit the accepted patterns can still match `PHONE`.
24
+
18
25
  Both ranges use zero-based, end-exclusive offsets. The byte range addresses the
19
26
  UTF-8 input; the code-point range addresses Unicode scalar values. Rule-based
20
27
  detectors currently report no confidence score. Node.js and browser WASM also
@@ -56,13 +63,26 @@ full-match regex values:
56
63
  }
57
64
  ```
58
65
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
66
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql", entities?: string[] }, transform: ... }` so
60
67
  detection settings remain separate from transformation policy.
61
68
  For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
69
  or `format: "sql"` to preserve email boundaries. See the
63
70
  [email source-context policy](docs/guides/email-boundaries.mdx) for supported
64
71
  syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
65
72
 
73
+ ## Credit-card detection and decimal numbers
74
+
75
+ To reduce decimal false positives, `CREDIT_CARD` detection suppresses contiguous
76
+ digit candidates immediately after a digit followed by `.` or `,`, such as
77
+ `3.4111111111111111` and `3,4111111111111111`. Cards containing spaces or hyphens,
78
+ such as `3.4111-1111-1111-1111`, still undergo the normal card checks.
79
+
80
+ In plain text, `3,4111111111111111` could also be two CSV fields: `3` and a card
81
+ number. The detector cannot distinguish that structure from a comma-decimal
82
+ number, so it suppresses the card candidate. For CSV input, parse the CSV and
83
+ scan each field separately so a card value is checked without the preceding
84
+ field and delimiter.
85
+
66
86
  ## Packages
67
87
 
68
88
  | Runtime | Distribution | Import | Status |
@@ -305,6 +325,26 @@ and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
305
325
  The higher-level Python adapter and its dependency update were merged in
306
326
  [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
307
327
 
328
+ ## Text detector selection
329
+
330
+ Pass `{"entities":["EMAIL","SSN"]}` to a text scan to run only those
331
+ selected detectors, rather than detecting every type and filtering findings
332
+ later. In Rust, use `ScanConfig::new().with_entities(vec!["EMAIL".into(),
333
+ "SSN".into()])?`. The same selection works inside `scan_and_transform`'s
334
+ `scan` configuration; transformation selection remains independent.
335
+
336
+ Omitting `entities` preserves existing detection. An explicit selection must be
337
+ a nonempty array of unique, exact canonical text entity names. Unknown names,
338
+ lowercase names, duplicates, and structured-only `PERSON` are rejected using
339
+ configuration errors with `/entities` or `/entities/<index>` paths. Consult
340
+ `capabilities().entities` for types with the `text` scope.
341
+
342
+ Selection narrows enabled detectors: selecting `UUID` still requires
343
+ `detect_uuid=true`, and German types still require a German locale. Text format
344
+ handling, finding order, and byte/codepoint ranges are unchanged. Structured
345
+ scans do not accept this option. It avoids unselected detector work and findings,
346
+ but does not cap the selected findings or bound the size of a scan response.
347
+
308
348
  ## UUID identifiers
309
349
 
310
350
  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
  }
@@ -3100,10 +3157,30 @@ fn is_valid_phone(candidate: &str) -> bool {
3100
3157
  .collect();
3101
3158
 
3102
3159
  if candidate.starts_with('+') {
3103
- (7..=15).contains(&digits.len())
3104
- } else {
3105
- digits.len() == 10 || (digits.len() == 11 && digits.starts_with('1'))
3160
+ return (7..=15).contains(&digits.len());
3106
3161
  }
3162
+
3163
+ let national_number = match digits.len() {
3164
+ 10 => digits.as_str(),
3165
+ 11 if digits.starts_with('1') => &digits[1..],
3166
+ _ => return false,
3167
+ };
3168
+
3169
+ follows_nanp_rule(national_number.as_bytes())
3170
+ }
3171
+
3172
+ /// Checks that the 1st and 4th digits of a 10-digit North American number are
3173
+ /// between 2 and 9. These are the first digits of the area code and the
3174
+ /// exchange code, and the North American Numbering Plan does not allow either
3175
+ /// to start with 0 or 1.
3176
+ ///
3177
+ /// This is a special case to stop Unix timestamps, such as `1800005580`, being
3178
+ /// detected as phone numbers. It only works while timestamps start with 1,
3179
+ /// which is true until 2033. It is not full phone number validation. If better
3180
+ /// vetting is needed, Google's libphonenumber is a good alternative.
3181
+ fn follows_nanp_rule(national_number: &[u8]) -> bool {
3182
+ let is_valid_code_start = |digit: u8| (b'2'..=b'9').contains(&digit);
3183
+ is_valid_code_start(national_number[0]) && is_valid_code_start(national_number[3])
3107
3184
  }
3108
3185
 
3109
3186
  fn detect_phone(text: &str, candidates: &mut Vec<Candidate>) {
@@ -3291,6 +3368,12 @@ fn card_parts_at(bytes: &[u8], start: usize) -> (usize, Vec<u8>) {
3291
3368
  (last_digit_end, digits)
3292
3369
  }
3293
3370
 
3371
+ /// A contiguous digit run after `3.` or `3,` may be a decimal fraction.
3372
+ /// Comma-separated fields are ambiguous without structured input context.
3373
+ fn follows_decimal_separator(bytes: &[u8], start: usize) -> bool {
3374
+ start >= 2 && matches!(bytes[start - 1], b'.' | b',') && bytes[start - 2].is_ascii_digit()
3375
+ }
3376
+
3294
3377
  fn detect_credit_card(text: &str, candidates: &mut Vec<Candidate>) {
3295
3378
  let bytes = text.as_bytes();
3296
3379
  let mut start = 0;
@@ -3304,7 +3387,10 @@ fn detect_credit_card(text: &str, candidates: &mut Vec<Candidate>) {
3304
3387
 
3305
3388
  let (end, digits) = card_parts_at(bytes, start);
3306
3389
 
3307
- if end == start || (end < bytes.len() && bytes[end].is_ascii_alphanumeric()) {
3390
+ if end == start
3391
+ || (end < bytes.len() && bytes[end].is_ascii_alphanumeric())
3392
+ || (follows_decimal_separator(bytes, start) && end - start == digits.len())
3393
+ {
3308
3394
  start += 1;
3309
3395
  continue;
3310
3396
  }
@@ -3661,6 +3747,9 @@ fn detect_ip_address(text: &str, candidates: &mut Vec<Candidate>) {
3661
3747
  #[cfg(test)]
3662
3748
  mod selection_tests;
3663
3749
 
3750
+ #[cfg(test)]
3751
+ mod scan_selection_tests;
3752
+
3664
3753
  #[cfg(test)]
3665
3754
  mod tests {
3666
3755
  mod synthetic_fixtures {
@@ -4305,6 +4394,122 @@ mod tests {
4305
4394
  assert!(scan("order212-555-0100x").is_empty());
4306
4395
  }
4307
4396
 
4397
+ #[test]
4398
+ fn detects_phone_numbers_following_nanp_rule() {
4399
+ for phone in [
4400
+ "2125550100",
4401
+ "212-555-0100",
4402
+ "212.555.0100",
4403
+ "1-212-555-0100",
4404
+ "12125550100",
4405
+ "9995550100",
4406
+ "2122220100",
4407
+ ] {
4408
+ let text = format!("Call {phone}.");
4409
+ assert_eq!(
4410
+ scan(&text),
4411
+ vec![expected_ascii_finding(
4412
+ "PHONE",
4413
+ phone,
4414
+ 5,
4415
+ 5 + phone.len(),
4416
+ "datafog-core/phone",
4417
+ )],
4418
+ "{text}"
4419
+ );
4420
+ }
4421
+ }
4422
+
4423
+ #[test]
4424
+ fn rejects_phone_candidates_breaking_nanp_rule() {
4425
+ for text in [
4426
+ "Call 0125550100.", // area code starts with 0
4427
+ "Call (112) 555-0100.", // area code starts with 1
4428
+ "Call 212-055-0100.", // exchange code starts with 0
4429
+ "Call 1-212-155-0100.", // exchange code starts with 1
4430
+ "\"start\": 1800005580,", // Unix timestamp
4431
+ "\"end\": 1800021600", // Unix timestamp
4432
+ ] {
4433
+ assert!(scan(text).is_empty(), "{text}");
4434
+ }
4435
+ }
4436
+
4437
+ #[test]
4438
+ fn preserves_permissive_plus_phone_detection() {
4439
+ for phone in [
4440
+ "+1234567",
4441
+ "+1-555-1002",
4442
+ "+1-112-555-0100",
4443
+ "+1-212-155-0100",
4444
+ "+44 20 7946 0958",
4445
+ "+999222333444555",
4446
+ ] {
4447
+ let text = format!("Call {phone}.");
4448
+ assert_eq!(
4449
+ scan(&text),
4450
+ vec![expected_ascii_finding(
4451
+ "PHONE",
4452
+ phone,
4453
+ 5,
4454
+ 5 + phone.len(),
4455
+ "datafog-core/phone",
4456
+ )],
4457
+ "{text}"
4458
+ );
4459
+ }
4460
+ }
4461
+
4462
+ #[test]
4463
+ fn rejects_plus_phone_candidates_outside_length_or_boundary_rules() {
4464
+ for text in [
4465
+ "Call +123456.",
4466
+ "Call +9992223334445556.",
4467
+ "order+44 20 7946 0958",
4468
+ "Call +44 20 7946 0958x",
4469
+ ] {
4470
+ assert!(scan(text).is_empty(), "{text}");
4471
+ }
4472
+ }
4473
+
4474
+ #[test]
4475
+ fn reports_phone_ranges_in_unicode_text() {
4476
+ for phone in ["212-555-0100", "1-212-555-0100", "+1-555-1002"] {
4477
+ let text = format!("☎👋 {phone}。");
4478
+ let findings = scan(&text);
4479
+ assert_eq!(
4480
+ findings,
4481
+ vec![expected_finding(
4482
+ "PHONE",
4483
+ phone,
4484
+ (8, 8 + phone.len()),
4485
+ (3, 3 + phone.len()),
4486
+ "datafog-core/phone",
4487
+ )],
4488
+ "{text}"
4489
+ );
4490
+ let finding = &findings[0];
4491
+ assert_eq!(
4492
+ &text[finding.byte_range.start..finding.byte_range.end],
4493
+ phone
4494
+ );
4495
+ assert_eq!(
4496
+ text.chars()
4497
+ .skip(finding.codepoint_range.start)
4498
+ .take(finding.codepoint_range.end - finding.codepoint_range.start)
4499
+ .collect::<String>(),
4500
+ phone
4501
+ );
4502
+ assert_eq!(
4503
+ utf16_range(&text, finding.byte_range).unwrap(),
4504
+ TextRange {
4505
+ start: 4,
4506
+ end: 4 + phone.len(),
4507
+ }
4508
+ );
4509
+ }
4510
+ assert!(scan("☎👋 1800005580。").is_empty());
4511
+ }
4512
+
4308
4513
  #[test]
4309
4514
  fn detects_dashed_ssn() {
4310
4515
  assert_eq!(
@@ -4355,11 +4560,70 @@ mod tests {
4355
4560
  assert_eq!(scan("6011111111111117")[0].entity_type, "CREDIT_CARD");
4356
4561
  }
4357
4562
 
4563
+ #[test]
4564
+ fn detects_credit_card_after_separator_without_leading_digit() {
4565
+ for prefix in [".", ",", "no.", "no,"] {
4566
+ for digits in ["4222222222222", "4111111111111111", "4111111111111111110"] {
4567
+ let text = format!("{prefix}{digits}");
4568
+ assert_eq!(
4569
+ scan(&text),
4570
+ vec![expected_ascii_finding(
4571
+ "CREDIT_CARD",
4572
+ digits,
4573
+ prefix.len(),
4574
+ prefix.len() + digits.len(),
4575
+ "datafog-core/credit-card",
4576
+ )],
4577
+ "{text}"
4578
+ );
4579
+ }
4580
+ }
4581
+ }
4582
+
4583
+ #[test]
4584
+ fn detects_formatted_credit_card_after_digit_and_separator() {
4585
+ for prefix in ["3.", "3,"] {
4586
+ for card in [
4587
+ "4222-2222-22222",
4588
+ "4111-1111-1111-1111",
4589
+ "4111 1111 1111 1111",
4590
+ "4111-1111-1111-1111110",
4591
+ ] {
4592
+ let text = format!("{prefix}{card}");
4593
+ assert_eq!(
4594
+ scan(&text)
4595
+ .into_iter()
4596
+ .filter(|finding| finding.entity_type == "CREDIT_CARD")
4597
+ .collect::<Vec<_>>(),
4598
+ vec![expected_ascii_finding(
4599
+ "CREDIT_CARD",
4600
+ card,
4601
+ prefix.len(),
4602
+ prefix.len() + card.len(),
4603
+ "datafog-core/credit-card",
4604
+ )],
4605
+ "{text}"
4606
+ );
4607
+ }
4608
+ }
4609
+ }
4610
+
4358
4611
  #[test]
4359
4612
  fn rejects_invalid_credit_card_candidates() {
4360
4613
  assert!(scan("4111-1111-1111-1112").is_empty()); // bad Luhn checksum
4361
4614
  assert!(scan("1234-5678-9012-3452").is_empty()); // unsupported prefix
4362
4615
  assert!(scan("x4111-1111-1111-1111y").is_empty()); // embedded
4616
+ for fraction in [
4617
+ "4222222222222",
4618
+ "4111111111111111",
4619
+ "4111111111111111110",
4620
+ "41111111111111111100",
4621
+ ] {
4622
+ for separator in [".", ","] {
4623
+ let text = format!("3{separator}{fraction}");
4624
+ assert!(scan(&text).is_empty(), "{text}"); // decimal fraction
4625
+ }
4626
+ }
4363
4627
  }
4364
4628
 
4365
4629
  #[test]
@@ -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.4"
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