datafog-core 0.4.1__tar.gz → 0.4.2__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 (59) hide show
  1. {datafog_core-0.4.1 → datafog_core-0.4.2}/Cargo.lock +4 -4
  2. {datafog_core-0.4.1 → datafog_core-0.4.2}/PKG-INFO +10 -6
  3. {datafog_core-0.4.1/crates/core → datafog_core-0.4.2}/README.md +9 -5
  4. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/Cargo.toml +1 -1
  5. datafog_core-0.4.2/bindings/python/tests/email_context_conformance.py +49 -0
  6. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/test_installed.py +2 -0
  7. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/test_typing.py +2 -1
  8. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/Cargo.toml +1 -1
  9. {datafog_core-0.4.1 → datafog_core-0.4.2/crates/core}/README.md +9 -5
  10. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/capabilities.rs +5 -1
  11. datafog_core-0.4.2/crates/core/src/email_context.rs +172 -0
  12. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/lib.rs +45 -3
  13. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/structured.rs +5 -1
  14. datafog_core-0.4.2/crates/core/tests/email_context.rs +107 -0
  15. {datafog_core-0.4.1 → datafog_core-0.4.2}/pyproject.toml +1 -1
  16. {datafog_core-0.4.1 → datafog_core-0.4.2}/python/datafog_core/__init__.pyi +1 -0
  17. {datafog_core-0.4.1 → datafog_core-0.4.2}/Cargo.toml +0 -0
  18. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/src/lib.rs +0 -0
  19. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/api_key_conformance.py +0 -0
  20. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/bearer_token_conformance.py +0 -0
  21. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/capabilities_conformance.py +0 -0
  22. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/credential_overlap_conformance.py +0 -0
  23. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/credential_uri_conformance.py +0 -0
  24. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/german_conformance.py +0 -0
  25. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/jwt_conformance.py +0 -0
  26. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/npi_conformance.py +0 -0
  27. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/private_key_conformance.py +0 -0
  28. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/requirements-typing.txt +0 -0
  29. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/synthetic_fixtures.py +0 -0
  30. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/test_python49_integration.py +0 -0
  31. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/us_routing_number_conformance.py +0 -0
  32. {datafog_core-0.4.1 → datafog_core-0.4.2}/bindings/python/tests/uuid_conformance.py +0 -0
  33. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/examples/german_benchmark.rs +0 -0
  34. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/examples/scan_benchmark.rs +0 -0
  35. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/api_key.rs +0 -0
  36. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/bearer_token.rs +0 -0
  37. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/credential_uri.rs +0 -0
  38. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/german.rs +0 -0
  39. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/jwt.rs +0 -0
  40. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/npi.rs +0 -0
  41. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/offsets.rs +0 -0
  42. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/private_key.rs +0 -0
  43. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/selection_tests.rs +0 -0
  44. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/us_routing_number.rs +0 -0
  45. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/src/uuid.rs +0 -0
  46. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/api_key.rs +0 -0
  47. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/bearer_token.rs +0 -0
  48. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/capabilities.rs +0 -0
  49. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/credential_overlaps.rs +0 -0
  50. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/credential_uri.rs +0 -0
  51. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/german.rs +0 -0
  52. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/jwt.rs +0 -0
  53. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/npi.rs +0 -0
  54. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/private_key.rs +0 -0
  55. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/support/synthetic_fixtures.rs +0 -0
  56. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/us_routing_number.rs +0 -0
  57. {datafog_core-0.4.1 → datafog_core-0.4.2}/crates/core/tests/uuid.rs +0 -0
  58. {datafog_core-0.4.1 → datafog_core-0.4.2}/python/datafog_core/__init__.py +0 -0
  59. {datafog_core-0.4.1 → datafog_core-0.4.2}/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.1"
83
+ version = "0.4.2"
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.1"
97
+ version = "0.4.2"
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.1"
107
+ version = "0.4.2"
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.1"
118
+ version = "0.4.2"
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.1
3
+ Version: 0.4.2
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: License :: OSI Approved :: MIT License
6
6
  Classifier: Programming Language :: Python :: 3
@@ -79,8 +79,12 @@ full-match regex values:
79
79
  }
80
80
  ```
81
81
 
82
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
82
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
83
83
  detection settings remain separate from transformation policy.
84
+ For `.env` assignments or standard SQL quoted strings, select `format: "env"`
85
+ or `format: "sql"` to preserve email boundaries. See the
86
+ [email source-context policy](docs/guides/email-boundaries.mdx) for supported
87
+ syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
84
88
 
85
89
  ## Packages
86
90
 
@@ -321,8 +325,8 @@ boundaries, never newlines. Returned text and offsets preserve the source.
321
325
  Passport and residence-permit patterns are legacy heuristics with limited
322
326
  coverage. See the [German entity reference](docs/reference/german-entities.mdx)
323
327
  and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
324
- Core 0.4.0 is published. The higher-level Python adapter and its dependency
325
- update remain separate and unreleased in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179).
328
+ The higher-level Python adapter and its dependency update were merged in
329
+ [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
326
330
 
327
331
  ## UUID identifiers
328
332
 
@@ -332,7 +336,7 @@ structured scans, independently of locale. It emits `UUID` findings for versions
332
336
  sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
333
337
  excluded sentinel forms and transformation examples.
334
338
 
335
- **0.4.0 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter remains a separate unreleased integration in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179). See the [release notes](docs/releases/0-4-0.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
339
+ **0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
336
340
 
337
- The unreleased **0.4.1 candidate** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [candidate release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the required exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
341
+ **Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
338
342
 
@@ -56,8 +56,12 @@ full-match regex values:
56
56
  }
57
57
  ```
58
58
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
59
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
60
60
  detection settings remain separate from transformation policy.
61
+ For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
+ or `format: "sql"` to preserve email boundaries. See the
63
+ [email source-context policy](docs/guides/email-boundaries.mdx) for supported
64
+ syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
61
65
 
62
66
  ## Packages
63
67
 
@@ -298,8 +302,8 @@ boundaries, never newlines. Returned text and offsets preserve the source.
298
302
  Passport and residence-permit patterns are legacy heuristics with limited
299
303
  coverage. See the [German entity reference](docs/reference/german-entities.mdx)
300
304
  and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
301
- Core 0.4.0 is published. The higher-level Python adapter and its dependency
302
- update remain separate and unreleased in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179).
305
+ The higher-level Python adapter and its dependency update were merged in
306
+ [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
303
307
 
304
308
  ## UUID identifiers
305
309
 
@@ -309,6 +313,6 @@ structured scans, independently of locale. It emits `UUID` findings for versions
309
313
  sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
310
314
  excluded sentinel forms and transformation examples.
311
315
 
312
- **0.4.0 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter remains a separate unreleased integration in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179). See the [release notes](docs/releases/0-4-0.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
316
+ **0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
313
317
 
314
- The unreleased **0.4.1 candidate** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [candidate release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the required exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
318
+ **Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "datafog-core-python"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  description = "Python bindings for datafog-core"
@@ -0,0 +1,49 @@
1
+ """Email source-context fixtures against the installed extension."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import datafog_core as api
7
+
8
+
9
+ def verify():
10
+ fixture = Path(__file__).resolve().parents[3] / "fixtures/email-context.jsonl"
11
+ for line in fixture.read_text().splitlines():
12
+ row = json.loads(line)
13
+ text = row["text"]
14
+ findings = api.scan(text, row["config"])
15
+ emails = [f for f in findings if f.entity_type == "EMAIL"]
16
+ assert [
17
+ dict(text=f.matched_text, start=f.codepoint_range.start, end=f.codepoint_range.end)
18
+ for f in emails
19
+ ] == row["matches"], row["id"]
20
+ for finding in emails:
21
+ assert text.encode()[finding.byte_range.start:finding.byte_range.end].decode() == finding.matched_text
22
+ assert text[finding.codepoint_range.start:finding.codepoint_range.end] == finding.matched_text
23
+ data = {"source": text}
24
+ located = api.scan_structured(data, row["config"]).findings
25
+ assert [f.finding for f in located] == findings
26
+ for strategy in ["redact", "mask", "remove"]:
27
+ policy = {"default": {"strategy": strategy}, "entities": ["EMAIL"]}
28
+ envelope = {"scan": row["config"], "transform": policy}
29
+ result = api.scan_and_transform(text, envelope)
30
+ assert result.text == row["outputs"][strategy], (row["id"], strategy)
31
+ assert api.transform(text, findings, policy) == result
32
+ for record, finding in zip(result.transformations, emails):
33
+ assert record.source_byte_range == finding.byte_range
34
+ assert record.source_codepoint_range == finding.codepoint_range
35
+ assert result.text.encode()[record.output_byte_range.start:record.output_byte_range.end].decode() == record.replacement
36
+ assert api.scan_and_transform_structured(data, envelope).data == {"source": result.text}
37
+ if row["config"]["format"] == "text":
38
+ assert api.scan(text) == findings
39
+ for invalid in [None, 1, True, {}, "yaml", "ENV"]:
40
+ for call in [
41
+ lambda: api.scan("", {"format": invalid}),
42
+ lambda: api.scan_structured({"source": ""}, {"format": invalid}),
43
+ ]:
44
+ try:
45
+ call()
46
+ except api.DataFogConfigurationError as error:
47
+ assert error.path == "/format"
48
+ else:
49
+ raise AssertionError("Invalid source format accepted")
@@ -12,6 +12,7 @@ import jwt_conformance
12
12
  import bearer_token_conformance
13
13
  import private_key_conformance
14
14
  import api_key_conformance
15
+ import email_context_conformance
15
16
  import uuid_conformance
16
17
  import credential_uri_conformance
17
18
  import us_routing_number_conformance
@@ -133,6 +134,7 @@ def main() -> None:
133
134
  bearer_token_conformance.verify()
134
135
  private_key_conformance.verify()
135
136
  api_key_conformance.verify()
137
+ email_context_conformance.verify()
136
138
  uuid_conformance.verify()
137
139
  credential_uri_conformance.verify()
138
140
  us_routing_number_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})
45
+ findings = scan("jane@example.com", {"locale": "en-US", "detect_uuid": True, "format": "env"})
46
46
  assert_type(findings, list[Finding])
47
47
  finding = findings[0]
48
48
  assert_type(finding.entity_type, str)
@@ -127,6 +127,7 @@ INVALID = """
127
127
  from datafog_core import capabilities, scan, transform, scan_structured, PrivacyManager, TransformResult
128
128
  capabilities(1) # error
129
129
  scan(123) # error
130
+ scan("text", {"format": "yaml"}) # error
130
131
  scan("text", {"locale": 123}) # error
131
132
  scan("text", {"detect_uuid": "true"}) # error
132
133
  scan("text", {"max_bytes": 100}) # error
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "datafog-core"
3
- version = "0.4.1"
3
+ version = "0.4.2"
4
4
  edition = "2024"
5
5
  rust-version = "1.88"
6
6
  description = "Fast structured PII detection library"
@@ -56,8 +56,12 @@ full-match regex values:
56
56
  }
57
57
  ```
58
58
 
59
- `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean }, transform: ... }` so
59
+ `scan_and_transform` uses `{ scan?: { locale?: string, detect_uuid?: boolean, format?: "text" | "env" | "sql" }, transform: ... }` so
60
60
  detection settings remain separate from transformation policy.
61
+ For `.env` assignments or standard SQL quoted strings, select `format: "env"`
62
+ or `format: "sql"` to preserve email boundaries. See the
63
+ [email source-context policy](docs/guides/email-boundaries.mdx) for supported
64
+ syntax, raw escaped spans, and limitations. Default text scanning is unchanged.
61
65
 
62
66
  ## Packages
63
67
 
@@ -298,8 +302,8 @@ boundaries, never newlines. Returned text and offsets preserve the source.
298
302
  Passport and residence-permit patterns are legacy heuristics with limited
299
303
  coverage. See the [German entity reference](docs/reference/german-entities.mdx)
300
304
  and [migration differences](docs/guides/migrating-from-datafog-python.mdx).
301
- Core 0.4.0 is published. The higher-level Python adapter and its dependency
302
- update remain separate and unreleased in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179).
305
+ The higher-level Python adapter and its dependency update were merged in
306
+ [PR #179](https://github.com/DataFog/datafog-python/pull/179). Its package release is separate from Core.
303
307
 
304
308
  ## UUID identifiers
305
309
 
@@ -309,6 +313,6 @@ structured scans, independently of locale. It emits `UUID` findings for versions
309
313
  sensitivity. See the [UUID reference](docs/reference/uuid.mdx) for boundaries,
310
314
  excluded sentinel forms and transformation examples.
311
315
 
312
- **0.4.0 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter remains a separate unreleased integration in [draft PR #179](https://github.com/DataFog/datafog-python/pull/179). See the [release notes](docs/releases/0-4-0.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
316
+ **0.4.1 is published** for Rust, Python, Node.js, and WASM. The higher-level Python adapter is integrated in [merged PR #179](https://github.com/DataFog/datafog-python/pull/179); its package release is separate. See the [release notes](docs/releases/0-4-1.mdx), [runtime capabilities](docs/reference/capabilities.mdx), and [0.4.x compatibility policy](docs/reference/compatibility.mdx).
313
317
 
314
- The unreleased **0.4.1 candidate** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [candidate release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the required exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
318
+ **Core 0.4.1** adds three default detectors: provider-prefixed [`API_KEY`](docs/reference/api-keys.mdx), context-required [`BEARER_TOKEN`](docs/reference/bearer-token.mdx), and whole PostgreSQL [`CREDENTIAL_URI`](docs/reference/credential-uri.mdx). The registry reports 23 supported entities and 14 defaults with capability contract 1. See the [release notes](docs/releases/0-4-1.mdx) for exact scopes, preserved legacy Python overlap behavior, and the passed local exact-wheel Python 4.9 integration gate. Python's default backend is unchanged.
@@ -152,7 +152,11 @@ pub(super) fn detect(text: &str, config: &ScanConfig, candidates: &mut Vec<Candi
152
152
  .iter()
153
153
  .filter(|detector| detector.activation.enabled(config))
154
154
  {
155
- (detector.detect)(text, candidates);
155
+ if detector.labels == [Label::Email] {
156
+ crate::email_context::detect(text, config.format(), candidates);
157
+ } else {
158
+ (detector.detect)(text, candidates);
159
+ }
156
160
  }
157
161
  }
158
162
 
@@ -0,0 +1,172 @@
1
+ //! Delimit original source slices before applying the shared email matcher.
2
+ use crate::{Candidate, ScanFormat, detect_email};
3
+
4
+ pub(super) fn detect(text: &str, format: ScanFormat, candidates: &mut Vec<Candidate>) {
5
+ match format {
6
+ ScanFormat::Text => detect_email(text, candidates),
7
+ ScanFormat::Env => detect_env(text, candidates),
8
+ ScanFormat::Sql => detect_sql(text, candidates),
9
+ }
10
+ }
11
+
12
+ fn detect_slice(text: &str, start: usize, end: usize, candidates: &mut Vec<Candidate>) {
13
+ let first = candidates.len();
14
+ detect_email(&text[start..end], candidates);
15
+ for candidate in &mut candidates[first..] {
16
+ candidate.start_byte += start;
17
+ candidate.end_byte += start;
18
+ }
19
+ }
20
+
21
+ fn line_end(text: &str, start: usize) -> usize {
22
+ text[start..]
23
+ .find('\n')
24
+ .map_or(text.len(), |end| start + end)
25
+ }
26
+
27
+ fn skip_horizontal_space(bytes: &[u8], mut cursor: usize) -> usize {
28
+ while matches!(bytes.get(cursor), Some(b' ' | b'\t' | b'\r')) {
29
+ cursor += 1;
30
+ }
31
+ cursor
32
+ }
33
+
34
+ fn env_value_start(line: &str) -> Option<usize> {
35
+ let bytes = line.as_bytes();
36
+ let mut cursor = skip_horizontal_space(bytes, 0);
37
+ if line[cursor..].starts_with("export") && matches!(bytes.get(cursor + 6), Some(b' ' | b'\t')) {
38
+ cursor = skip_horizontal_space(bytes, cursor + 6);
39
+ }
40
+ if !bytes
41
+ .get(cursor)
42
+ .is_some_and(|byte| byte.is_ascii_alphabetic() || *byte == b'_')
43
+ {
44
+ return None;
45
+ }
46
+ cursor += 1;
47
+ while bytes
48
+ .get(cursor)
49
+ .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
50
+ {
51
+ cursor += 1;
52
+ }
53
+ cursor = skip_horizontal_space(bytes, cursor);
54
+ if bytes.get(cursor) != Some(&b'=') {
55
+ return None;
56
+ }
57
+ Some(skip_horizontal_space(bytes, cursor + 1))
58
+ }
59
+
60
+ // Return the closing delimiter or EOF. No values are decoded, so offsets and
61
+ // escaped local parts remain contiguous spans of the caller's source text.
62
+ fn quote_end(bytes: &[u8], opening: usize, doubled: bool, backslash: bool) -> usize {
63
+ let quote = bytes[opening];
64
+ let mut cursor = opening + 1;
65
+ while cursor < bytes.len() {
66
+ if backslash && bytes[cursor] == b'\\' {
67
+ cursor += if cursor + 1 < bytes.len() { 2 } else { 1 };
68
+ } else if bytes[cursor] == quote {
69
+ if doubled && bytes.get(cursor + 1) == Some(&quote) {
70
+ cursor += 2;
71
+ } else {
72
+ return cursor;
73
+ }
74
+ } else {
75
+ cursor += 1;
76
+ }
77
+ }
78
+ bytes.len()
79
+ }
80
+
81
+ fn detect_env(text: &str, candidates: &mut Vec<Candidate>) {
82
+ let bytes = text.as_bytes();
83
+ let mut cursor = 0;
84
+ while cursor < text.len() {
85
+ let end = line_end(text, cursor);
86
+ if let Some(value) = env_value_start(&text[cursor..end]) {
87
+ let start = cursor + value;
88
+ if matches!(bytes.get(start), Some(b'\'' | b'"')) {
89
+ let close = quote_end(bytes, start, false, bytes[start] == b'"');
90
+ detect_slice(text, start + 1, close, candidates);
91
+ if close == text.len() {
92
+ break;
93
+ }
94
+ let tail_end = line_end(text, close + 1);
95
+ detect_env_unquoted(text, close + 1, tail_end, candidates);
96
+ cursor = tail_end;
97
+ } else {
98
+ detect_env_unquoted(text, start, end, candidates);
99
+ cursor = end;
100
+ }
101
+ } else {
102
+ detect_env_unquoted(text, cursor, end, candidates);
103
+ cursor = end;
104
+ }
105
+ if cursor < text.len() {
106
+ cursor += 1;
107
+ }
108
+ }
109
+ }
110
+
111
+ fn detect_env_unquoted(text: &str, start: usize, end: usize, candidates: &mut Vec<Candidate>) {
112
+ let bytes = text.as_bytes();
113
+ for cursor in start..end {
114
+ if bytes[cursor] == b'#' && (cursor == start || bytes[cursor - 1].is_ascii_whitespace()) {
115
+ detect_slice(text, start, cursor, candidates);
116
+ detect_slice(text, cursor + 1, end, candidates);
117
+ return;
118
+ }
119
+ }
120
+ detect_slice(text, start, end, candidates);
121
+ }
122
+
123
+ fn detect_sql(text: &str, candidates: &mut Vec<Candidate>) {
124
+ let bytes = text.as_bytes();
125
+ let mut cursor = 0;
126
+ let mut plain_start = 0;
127
+ while cursor < bytes.len() {
128
+ let end = if matches!(bytes[cursor], b'\'' | b'"') {
129
+ detect_slice(text, plain_start, cursor, candidates);
130
+ let close = quote_end(bytes, cursor, true, false);
131
+ detect_slice(text, cursor + 1, close, candidates);
132
+ if close < bytes.len() {
133
+ close + 1
134
+ } else {
135
+ close
136
+ }
137
+ } else if bytes[cursor..].starts_with(b"--") {
138
+ detect_slice(text, plain_start, cursor, candidates);
139
+ let end = line_end(text, cursor);
140
+ detect_slice(text, cursor + 2, end, candidates);
141
+ end
142
+ } else if bytes[cursor..].starts_with(b"/*") {
143
+ detect_slice(text, plain_start, cursor, candidates);
144
+ let mut end = cursor + 2;
145
+ let mut comment_start = end;
146
+ let mut depth = 1;
147
+ while end < bytes.len() && depth > 0 {
148
+ if bytes[end..].starts_with(b"/*") {
149
+ detect_slice(text, comment_start, end, candidates);
150
+ depth += 1;
151
+ end += 2;
152
+ comment_start = end;
153
+ } else if bytes[end..].starts_with(b"*/") {
154
+ detect_slice(text, comment_start, end, candidates);
155
+ depth -= 1;
156
+ end += 2;
157
+ comment_start = end;
158
+ } else {
159
+ end += 1;
160
+ }
161
+ }
162
+ detect_slice(text, comment_start, end, candidates);
163
+ end
164
+ } else {
165
+ cursor += 1;
166
+ continue;
167
+ };
168
+ cursor = end;
169
+ plain_start = end;
170
+ }
171
+ detect_slice(text, plain_start, text.len(), candidates);
172
+ }
@@ -3,6 +3,7 @@ mod api_key;
3
3
  mod bearer_token;
4
4
  mod capabilities;
5
5
  mod credential_uri;
6
+ mod email_context;
6
7
  pub use capabilities::{
7
8
  ActivationScanConfig, Capabilities, EntityActivation, EntityCapabilities, LocaleCapabilities,
8
9
  capabilities,
@@ -535,15 +536,39 @@ fn json_pointer_segment(segment: &str) -> String {
535
536
  segment.replace('~', "~0").replace('/', "~1")
536
537
  }
537
538
 
538
- /// Scanner configuration. Current built-in detectors share the same execution
539
- /// path; German locale aliases activate German detectors, and UUIDs are opt-in.
539
+ /// Source syntax used to delimit email candidates. Other detectors are unchanged.
540
+ #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
541
+ pub enum ScanFormat {
542
+ /// Unstructured text; retain the full supported email local-part alphabet.
543
+ #[default]
544
+ Text,
545
+ /// Environment assignments with optional `export` and quoted values.
546
+ Env,
547
+ /// SQL strings using standard doubled-quote escaping.
548
+ Sql,
549
+ }
550
+
551
+ /// Scanner configuration. German locale aliases activate German detectors,
552
+ /// UUIDs are opt-in, and source format controls email boundaries.
540
553
  #[derive(Debug, Clone, Default, PartialEq, Eq)]
541
554
  pub struct ScanConfig {
542
555
  locale: Option<String>,
543
556
  detect_uuid: bool,
557
+ format: ScanFormat,
544
558
  }
545
559
 
546
560
  impl ScanConfig {
561
+ /// Select source syntax for email boundaries; defaults to unstructured text.
562
+ pub fn with_format(mut self, format: ScanFormat) -> Self {
563
+ self.format = format;
564
+ self
565
+ }
566
+
567
+ /// Source syntax used for email boundaries.
568
+ pub fn format(&self) -> ScanFormat {
569
+ self.format
570
+ }
571
+
547
572
  /// Create scanner configuration using detector defaults.
548
573
  pub fn new() -> Self {
549
574
  Self::default()
@@ -755,8 +780,11 @@ pub fn parse_scan_and_transform_config(
755
780
  /// Parse canonical scanner configuration.
756
781
  pub fn parse_scan_config(value: &serde_json::Value) -> Result<ScanConfig, PrivacyError> {
757
782
  let object = require_object(value, "", "scan configuration must be an object")?;
758
- reject_unknown_fields(object, &["locale", "detect_uuid"], "")?;
783
+ reject_unknown_fields(object, &["locale", "detect_uuid", "format"], "")?;
759
784
  let mut config = ScanConfig::new();
785
+ if let Some(format) = object.get("format") {
786
+ config = config.with_format(parse_scan_format(format)?);
787
+ }
760
788
  if let Some(enabled) = object.get("detect_uuid") {
761
789
  config = config.with_uuid_detection(parse_uuid_detection(enabled)?);
762
790
  }
@@ -767,6 +795,20 @@ pub fn parse_scan_config(value: &serde_json::Value) -> Result<ScanConfig, Privac
767
795
  Ok(config)
768
796
  }
769
797
 
798
+ fn parse_scan_format(value: &serde_json::Value) -> Result<ScanFormat, PrivacyError> {
799
+ let format = require_string(value, "/format", "scan format must be a string")?;
800
+ match format.as_str() {
801
+ "text" => Ok(ScanFormat::Text),
802
+ "env" => Ok(ScanFormat::Env),
803
+ "sql" => Ok(ScanFormat::Sql),
804
+ _ => Err(PrivacyError::invalid_configuration(
805
+ PrivacyErrorReason::InvalidValue,
806
+ "/format",
807
+ "scan format must be text, env, or sql",
808
+ )),
809
+ }
810
+ }
811
+
770
812
  fn parse_uuid_detection(value: &serde_json::Value) -> Result<bool, PrivacyError> {
771
813
  value.as_bool().ok_or_else(|| {
772
814
  PrivacyError::invalid_configuration(
@@ -79,7 +79,7 @@ fn validate_pointer(pointer: &str, path: &str) -> Result<(), PrivacyError> {
79
79
  Ok(())
80
80
  }
81
81
 
82
- /// Parse `{ locale?, detect_uuid?, discover_person?, mappings?: {pointer: "PERSON"}, exclude?: [pointer] }`.
82
+ /// Parse `{ locale?, detect_uuid?, format?, discover_person?, mappings?: {pointer: "PERSON"}, exclude?: [pointer] }`.
83
83
  pub fn parse_scan_config(value: &Value) -> Result<StructuredScanConfig, PrivacyError> {
84
84
  let object = require_object(value, "", "structured scan configuration must be an object")?;
85
85
  reject_unknown_fields(
@@ -87,6 +87,7 @@ pub fn parse_scan_config(value: &Value) -> Result<StructuredScanConfig, PrivacyE
87
87
  &[
88
88
  "locale",
89
89
  "detect_uuid",
90
+ "format",
90
91
  "discover_person",
91
92
  "mappings",
92
93
  "exclude",
@@ -94,6 +95,9 @@ pub fn parse_scan_config(value: &Value) -> Result<StructuredScanConfig, PrivacyE
94
95
  "",
95
96
  )?;
96
97
  let mut config = StructuredScanConfig::default();
98
+ if let Some(format) = object.get("format") {
99
+ config.scan = config.scan.with_format(parse_scan_format(format)?);
100
+ }
97
101
  if let Some(enabled) = object.get("detect_uuid") {
98
102
  config.scan = config
99
103
  .scan
@@ -0,0 +1,107 @@
1
+ use datafog_core::{
2
+ parse_scan_and_transform_config, parse_scan_config, parse_transformation_config, scan,
3
+ scan_and_transform, scan_with_config, structured, transform, utf16_range,
4
+ };
5
+ use serde_json::{Value, json};
6
+
7
+ #[test]
8
+ fn email_context_preserves_source_spans_and_syntax() {
9
+ for line in include_str!("../../../fixtures/email-context.jsonl").lines() {
10
+ let row: Value = serde_json::from_str(line).unwrap();
11
+ let text = row["text"].as_str().unwrap();
12
+ let findings = scan_with_config(text, &parse_scan_config(&row["config"]).unwrap());
13
+ let emails: Vec<_> = findings
14
+ .iter()
15
+ .filter(|f| f.entity_type == "EMAIL")
16
+ .collect();
17
+ let matches: Vec<_> = emails.iter().map(|f| {
18
+ assert_eq!(&text[f.byte_range.start..f.byte_range.end], f.matched_text);
19
+ let range = utf16_range(text, f.byte_range).unwrap();
20
+ let units: Vec<_> = text.encode_utf16().collect();
21
+ assert_eq!(String::from_utf16(&units[range.start..range.end]).unwrap(), f.matched_text);
22
+ json!({"text":f.matched_text,"start":f.codepoint_range.start,"end":f.codepoint_range.end})
23
+ }).collect();
24
+ assert_eq!(json!(matches), row["matches"], "{}", row["id"]);
25
+ let data = json!({"source":text});
26
+ let located = structured::scan(
27
+ &data,
28
+ &structured::parse_scan_config(&row["config"]).unwrap(),
29
+ )
30
+ .unwrap();
31
+ assert_eq!(
32
+ located
33
+ .findings
34
+ .iter()
35
+ .map(|f| f.finding.clone())
36
+ .collect::<Vec<_>>(),
37
+ findings
38
+ );
39
+ for strategy in ["redact", "mask", "remove"] {
40
+ let policy = json!({"default":{"strategy":strategy},"entities":["EMAIL"]});
41
+ let envelope = json!({"scan":row["config"],"transform":policy});
42
+ let result =
43
+ scan_and_transform(text, &parse_scan_and_transform_config(&envelope).unwrap())
44
+ .unwrap();
45
+ assert_eq!(
46
+ result.text, row["outputs"][strategy],
47
+ "{} {strategy}",
48
+ row["id"]
49
+ );
50
+ assert_eq!(
51
+ transform(
52
+ text,
53
+ &findings,
54
+ &parse_transformation_config(&policy).unwrap()
55
+ )
56
+ .unwrap(),
57
+ result
58
+ );
59
+ for (record, finding) in result.transformations.iter().zip(&emails) {
60
+ assert_eq!(record.source_byte_range, finding.byte_range);
61
+ assert_eq!(record.source_codepoint_range, finding.codepoint_range);
62
+ assert_eq!(
63
+ &result.text[record.output_byte_range.start..record.output_byte_range.end],
64
+ record.replacement
65
+ );
66
+ }
67
+ let result = structured::scan_and_transform(
68
+ &data,
69
+ &structured::parse_scan_and_transform_config(&envelope).unwrap(),
70
+ )
71
+ .unwrap();
72
+ assert_eq!(result.data, json!({"source":row["outputs"][strategy]}));
73
+ }
74
+ if row["config"]["format"] == "text" {
75
+ assert_eq!(scan(text), findings);
76
+ }
77
+ }
78
+ }
79
+
80
+ #[test]
81
+ fn invalid_format_is_reported_at_the_configuration_path() {
82
+ for format in [
83
+ json!(null),
84
+ json!(1),
85
+ json!(true),
86
+ json!({}),
87
+ json!("yaml"),
88
+ json!("ENV"),
89
+ ] {
90
+ let config = json!({"format":format});
91
+ assert_eq!(
92
+ parse_scan_config(&config).unwrap_err().path(),
93
+ Some("/format")
94
+ );
95
+ assert_eq!(
96
+ structured::parse_scan_config(&config).unwrap_err().path(),
97
+ Some("/format")
98
+ );
99
+ let envelope = json!({"scan":config,"transform":{"default":{"strategy":"redact"}}});
100
+ assert_eq!(
101
+ parse_scan_and_transform_config(&envelope)
102
+ .unwrap_err()
103
+ .path(),
104
+ Some("/scan/format")
105
+ );
106
+ }
107
+ }
@@ -4,7 +4,7 @@ build-backend = "maturin"
4
4
 
5
5
  [project]
6
6
  name = "datafog-core"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Fast structured PII detection implemented in Rust"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -9,6 +9,7 @@ _JsonValue: TypeAlias = (
9
9
  _JsonDocument: TypeAlias = list[_JsonValue] | dict[str, _JsonValue]
10
10
 
11
11
  class _ScanConfig(TypedDict, total=False):
12
+ format: Literal["text", "env", "sql"]
12
13
  locale: str
13
14
  detect_uuid: bool
14
15
 
File without changes