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.
- {datafog_core-0.4.2 → datafog_core-0.4.3}/Cargo.lock +4 -4
- {datafog_core-0.4.2 → datafog_core-0.4.3}/PKG-INFO +22 -2
- {datafog_core-0.4.2/crates/core → datafog_core-0.4.3}/README.md +21 -1
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/Cargo.toml +1 -1
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/test_installed.py +37 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/test_typing.py +3 -1
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/Cargo.toml +1 -1
- {datafog_core-0.4.2 → datafog_core-0.4.3/crates/core}/README.md +21 -1
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/capabilities.rs +52 -4
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/german.rs +9 -2
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/lib.rs +61 -1
- datafog_core-0.4.3/crates/core/src/scan_selection_tests.rs +155 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/pyproject.toml +1 -1
- {datafog_core-0.4.2 → datafog_core-0.4.3}/python/datafog_core/__init__.pyi +6 -2
- {datafog_core-0.4.2 → datafog_core-0.4.3}/Cargo.toml +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/src/lib.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/api_key_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/bearer_token_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/capabilities_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/credential_overlap_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/credential_uri_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/email_context_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/german_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/jwt_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/npi_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/private_key_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/requirements-typing.txt +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/synthetic_fixtures.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/test_python49_integration.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/us_routing_number_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/uuid_conformance.py +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/examples/german_benchmark.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/examples/scan_benchmark.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/api_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/bearer_token.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/credential_uri.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/email_context.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/jwt.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/npi.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/offsets.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/private_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/selection_tests.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/structured.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/us_routing_number.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/src/uuid.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/api_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/bearer_token.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/capabilities.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/credential_overlaps.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/credential_uri.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/email_context.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/german.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/jwt.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/npi.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/private_key.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/support/synthetic_fixtures.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/us_routing_number.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/crates/core/tests/uuid.rs +0 -0
- {datafog_core-0.4.2 → datafog_core-0.4.3}/python/datafog_core/__init__.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
@@ -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
|
|
@@ -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
|
|
151
|
-
|
|
158
|
+
pub(super) fn text_entity_supported(entity: &str) -> bool {
|
|
159
|
+
REGISTRY
|
|
152
160
|
.iter()
|
|
153
|
-
.
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -8,11 +8,15 @@ _JsonValue: TypeAlias = (
|
|
|
8
8
|
)
|
|
9
9
|
_JsonDocument: TypeAlias = list[_JsonValue] | dict[str, _JsonValue]
|
|
10
10
|
|
|
11
|
-
class
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/credential_overlap_conformance.py
RENAMED
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/credential_uri_conformance.py
RENAMED
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/email_context_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/test_python49_integration.py
RENAMED
|
File without changes
|
{datafog_core-0.4.2 → datafog_core-0.4.3}/bindings/python/tests/us_routing_number_conformance.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|