From a4586c435cdb547574ca24706ca2960dc55e161d Mon Sep 17 00:00:00 2001 From: Sid Mohan <61345237+sidmohan0@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:24:31 -0700 Subject: [PATCH 1/2] docs: canonize slice three contract --- docs/adr/001-privacy-core-contract.md | 33 +++++++++++++++++++++++++-- docs/privacy-capability-matrix.md | 2 +- docs/privacy-operations-roadmap.md | 12 ++++++---- 3 files changed, 40 insertions(+), 7 deletions(-) diff --git a/docs/adr/001-privacy-core-contract.md b/docs/adr/001-privacy-core-contract.md index 578196b..a2e7178 100644 --- a/docs/adr/001-privacy-core-contract.md +++ b/docs/adr/001-privacy-core-contract.md @@ -36,6 +36,23 @@ pseudonymize tokenize ``` +Strategies use a discriminated configuration. Rust represents the same shape +with typed enum variants; object-oriented bindings serialize it as: + +```text +{ strategy: "redact" } +{ strategy: "remove" } +{ strategy: "mask", character: "*" } +{ + strategy: "mask", + character: "*", + reveal: { direction: "first" | "last", count: non_negative_integer } +} +``` + +Fields that do not belong to the selected strategy are rejected rather than +silently ignored. + `allow`, `warn`, and `block` are governance decisions, not Core operations or transformation strategies. A separate governance layer may consume findings and transformation results to make those decisions. The calling application, @@ -147,7 +164,19 @@ original values are excluded from default debug and log output. - `redact` replaces every selected finding with the unnumbered placeholder `[ENTITY_TYPE]`. Repeated occurrences intentionally receive the same type-only placeholder; this makes no identity or equality claim. -- `mask` hides all or a configured part of a value. +- `mask` replaces every non-revealed Unicode code point, including punctuation, + with one masking code point. The default masking character is `*`. A custom + masking character must be exactly one non-whitespace, non-control Unicode + code point. Omitting `reveal`, or setting its count to zero, masks the entire + finding. `first` preserves the requested number of leading code points; + `last` preserves the requested number of trailing code points. A reveal count + equal to or greater than the finding length preserves the whole finding + without error. Output byte ranges still reflect the encoded byte length of + the chosen masking character. +- `remove` replaces the exact finding span with the empty string. It accepts no + configuration, does not consume surrounding whitespace, and does not + normalize the remaining text. Its transformation record uses an empty output + range at the deletion position. - `hash` is a compatibility fingerprint with explicitly documented leakage and must not be presented as secure pseudonymization. - `pseudonymize` is a new keyed, scoped, deterministic, one-way operation. It @@ -183,8 +212,8 @@ and documented separately. This ADR does not choose: -- mask length and direction options; - compatibility hash format; - HMAC token encoding, key provider, scope fields, or rotation procedure; - reversible-token storage or cryptographic construction; - production audit and mapping storage. +- custom literal replacement or whitespace-normalizing removal. diff --git a/docs/privacy-capability-matrix.md b/docs/privacy-capability-matrix.md index 648afa0..d07b5c7 100644 --- a/docs/privacy-capability-matrix.md +++ b/docs/privacy-capability-matrix.md @@ -8,7 +8,7 @@ for a PII detection and transformation engine. | --- | --- | --- | | PII scanning | Preserve | Return validated findings with explicit byte and code-point ranges, optional confidence, and detector provenance. | | Typed redaction | Preserve | Replace findings with typed, document-local placeholders after deterministic overlap resolution. | -| Character masking | Preserve | Support configurable masking with explicit direction and length semantics. | +| Character masking | Preserve | Mask Unicode code points with a validated character and explicit leading- or trailing-reveal semantics. | | Entity-type selection | Preserve | Select canonical entity types through transformation configuration. | | Exact allowlists | Preserve | Exempt exact values using documented, case-sensitive matching. | | Regex allowlists | Preserve | Use full-match semantics, validated patterns, and bounded execution. | diff --git a/docs/privacy-operations-roadmap.md b/docs/privacy-operations-roadmap.md index 9858c72..6ffb2e4 100644 --- a/docs/privacy-operations-roadmap.md +++ b/docs/privacy-operations-roadmap.md @@ -68,12 +68,16 @@ and Unicode cases satisfy ADR 001. ## Slice 3: Mask and remove -- Define full and partial masking direction and length semantics. -- Support a validated masking character. -- Add literal removal as a small transformation if a concrete use case remains. +- Use a discriminated strategy configuration across public bindings. +- Mask every non-revealed Unicode code point, including punctuation. +- Support `first` and `last` reveal modes with a non-negative code-point count. +- Default to `*` and accept exactly one non-whitespace, non-control Unicode + code point as a custom masking character. +- Add parameterless exact removal with no implicit whitespace normalization. **Proof:** transformation records identify the exact input and output spans for -full, partial, and zero-length replacements. +full, partial, multibyte-mask-character, unchanged, and zero-length +replacements. Invalid strategy fields and masking characters are rejected. ## Slice 4: Transformation selection From 74356bc118258294c2e3f63c469db435614a2149 Mon Sep 17 00:00:00 2001 From: Sid Mohan <61345237+sidmohan0@users.noreply.github.com> Date: Thu, 27 Aug 2026 19:35:42 -0700 Subject: [PATCH 2/2] feat: implement slice three masking and removal --- README.md | 37 +++-- bindings/node/dts-header.d.ts | 16 +- bindings/node/index.d.ts | 31 +++- bindings/node/index.js | 72 +++++++-- bindings/node/src/lib.rs | 93 ++++++++++- bindings/python/src/lib.rs | 109 ++++++++++++- bindings/python/tests/test_installed.py | 76 ++++++++- bindings/wasm/index.d.ts | 20 ++- bindings/wasm/index.js | 68 ++++++-- bindings/wasm/src/lib.rs | 73 ++++++++- crates/core/src/lib.rs | 201 +++++++++++++++++++++++- docs/privacy-operations-roadmap.md | 2 + scripts/test-node-package.mjs | 73 ++++++++- scripts/test-wasm-package.mjs | 84 +++++++++- 14 files changed, 881 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index 977cbf3..69a3edd 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,21 @@ Both ranges use zero-based, end-exclusive offsets. The byte range addresses the UTF-8 input; the code-point range addresses Unicode scalar values. Rule-based detectors currently report no confidence score. -The initial transformation strategy is `redact`, which replaces each selected -finding with an unnumbered `[ENTITY_TYPE]` placeholder. `transform` requires -explicit findings; `scan_and_transform` (or `scanAndTransform` in JavaScript) -is the explicit scan-then-transform convenience. Results include the transformed -text and an ordered record for every applied replacement, including its output -byte and code-point ranges. +The initial transformation strategies are `redact`, `mask`, and `remove`. +Redaction uses an unnumbered `[ENTITY_TYPE]` placeholder, masking supports full +or leading/trailing reveal modes, and removal deletes only the exact finding +span. `transform` requires explicit findings; `scan_and_transform` (or +`scanAndTransform` in JavaScript) is the explicit scan-then-transform +convenience. Results include the transformed text and an ordered record for +every applied replacement, including its output byte and code-point ranges. + +Object-oriented bindings use a discriminated configuration: + +```text +{ strategy: "redact" } +{ strategy: "remove" } +{ strategy: "mask", character: "*", reveal: { direction: "last", count: 4 } } +``` ## Packages @@ -66,8 +75,14 @@ print(findings[0].entity_type) # EMAIL print(findings[0].matched_text) # jane@example.com print(findings[0].byte_range.start) # 6 -result = scan_and_transform("Email jane@example.com", "redact") +result = scan_and_transform("Email jane@example.com", {"strategy": "redact"}) assert result.text == "Email [EMAIL]" + +masked = scan_and_transform( + "Email jane@example.com", + {"strategy": "mask", "reveal": {"direction": "last", "count": 4}}, +) +assert masked.text == "Email ************.com" ``` ### Node.js @@ -78,7 +93,9 @@ assert result.text == "Email [EMAIL]" import { scan, scanAndTransform } from "@datafog/node"; console.log(scan("Email jane@example.com")); -console.log(scanAndTransform("Email jane@example.com", "redact").text); +console.log( + scanAndTransform("Email jane@example.com", { strategy: "redact" }).text, +); ``` The release includes prebuilt binaries for macOS (Intel and Apple Silicon), Linux (x64 and ARM64), and Windows x64. @@ -92,7 +109,9 @@ import { init, scan, scanAndTransform } from "@datafog/wasm"; await init(); console.log(scan("Email jane@example.com")); -console.log(scanAndTransform("Email jane@example.com", "redact").text); +console.log( + scanAndTransform("Email jane@example.com", { strategy: "redact" }).text, +); ``` ## Development diff --git a/bindings/node/dts-header.d.ts b/bindings/node/dts-header.d.ts index 5f1d68a..b816875 100644 --- a/bindings/node/dts-header.d.ts +++ b/bindings/node/dts-header.d.ts @@ -1,4 +1,18 @@ /** Canonical built-in values are uppercase, but custom detectors may add values. */ export type EntityType = string; -export type TransformationStrategy = "redact"; +export type TransformationStrategy = "redact" | "mask" | "remove"; + +export interface MaskRevealConfig { + readonly direction: "first" | "last"; + readonly count: number; +} + +export type TransformationConfig = + | { readonly strategy: "redact" } + | { readonly strategy: "remove" } + | { + readonly strategy: "mask"; + readonly character?: string; + readonly reveal?: MaskRevealConfig; + }; diff --git a/bindings/node/index.d.ts b/bindings/node/index.d.ts index da8ba53..3e8d8f6 100644 --- a/bindings/node/index.d.ts +++ b/bindings/node/index.d.ts @@ -1,7 +1,21 @@ /** Canonical built-in values are uppercase, but custom detectors may add values. */ export type EntityType = string; -export type TransformationStrategy = "redact"; +export type TransformationStrategy = "redact" | "mask" | "remove"; + +export interface MaskRevealConfig { + readonly direction: "first" | "last"; + readonly count: number; +} + +export type TransformationConfig = + | { readonly strategy: "redact" } + | { readonly strategy: "remove" } + | { + readonly strategy: "mask"; + readonly character?: string; + readonly reveal?: MaskRevealConfig; + }; export interface Finding { readonly entityType: EntityType readonly matchedText: string @@ -12,11 +26,22 @@ export interface Finding { readonly detectorVersion?: string } +export interface NativeMaskRevealConfig { + direction: string + count: number +} + +export interface NativeTransformationConfig { + strategy: TransformationStrategy + character?: string + reveal?: NativeMaskRevealConfig +} + /** Scan text for supported PII findings. */ export declare function scan(text: string): Array /** Scan text and transform the detected findings. */ -export declare function scanAndTransform(text: string, strategy: TransformationStrategy): TransformResult +export declare function scanAndTransform(text: string, config: TransformationConfig): TransformResult export interface TextRange { readonly start: number @@ -24,7 +49,7 @@ export interface TextRange { } /** Transform explicit findings without scanning implicitly. */ -export declare function transform(text: string, findings: Array, strategy: TransformationStrategy): TransformResult +export declare function transform(text: string, findings: Array, config: TransformationConfig): TransformResult export interface Transformation { readonly finding: Finding diff --git a/bindings/node/index.js b/bindings/node/index.js index 40a3838..923b942 100644 --- a/bindings/node/index.js +++ b/bindings/node/index.js @@ -12,27 +12,79 @@ export function scan(text) { return nativeScan(text); } -export function transform(text, findings, strategy) { +function validateConfig(config) { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new TypeError("transformation configuration must be an object"); + } + if (!["redact", "mask", "remove"].includes(config.strategy)) { + throw new TypeError("strategy must be 'redact', 'mask', or 'remove'"); + } + + const allowed = + config.strategy === "mask" + ? new Set(["strategy", "character", "reveal"]) + : new Set(["strategy"]); + for (const key of Object.keys(config)) { + if (!allowed.has(key)) { + throw new TypeError(`unexpected configuration field: ${key}`); + } + } + + if (config.strategy === "mask") { + if (config.character !== undefined) { + if ( + typeof config.character !== "string" || + Array.from(config.character).length !== 1 || + /[\p{White_Space}\p{Cc}]/u.test(config.character) + ) { + throw new TypeError( + "mask character must be one non-whitespace, non-control code point", + ); + } + } + if (config.reveal !== undefined) { + if ( + typeof config.reveal !== "object" || + config.reveal === null || + Array.isArray(config.reveal) + ) { + throw new TypeError("mask reveal configuration must be an object"); + } + for (const key of Object.keys(config.reveal)) { + if (key !== "direction" && key !== "count") { + throw new TypeError(`unexpected reveal field: ${key}`); + } + } + if (!["first", "last"].includes(config.reveal.direction)) { + throw new TypeError("reveal direction must be 'first' or 'last'"); + } + if ( + !Number.isSafeInteger(config.reveal.count) || + config.reveal.count < 0 + ) { + throw new TypeError("reveal count must be a non-negative safe integer"); + } + } + } + + return config; +} + +export function transform(text, findings, config) { if (typeof text !== "string") { throw new TypeError("transform text must be a string"); } if (!Array.isArray(findings)) { throw new TypeError("transform findings must be an array"); } - if (typeof strategy !== "string") { - throw new TypeError("transform strategy must be a string"); - } - return nativeTransform(text, findings, strategy); + return nativeTransform(text, findings, validateConfig(config)); } -export function scanAndTransform(text, strategy) { +export function scanAndTransform(text, config) { if (typeof text !== "string") { throw new TypeError("scanAndTransform text must be a string"); } - if (typeof strategy !== "string") { - throw new TypeError("scanAndTransform strategy must be a string"); - } - return nativeScanAndTransform(text, strategy); + return nativeScanAndTransform(text, validateConfig(config)); } diff --git a/bindings/node/src/lib.rs b/bindings/node/src/lib.rs index 988782b..9bc1e89 100644 --- a/bindings/node/src/lib.rs +++ b/bindings/node/src/lib.rs @@ -36,6 +36,20 @@ pub struct Finding { pub detector_version: Option, } +#[napi(object)] +pub struct NativeMaskRevealConfig { + pub direction: String, + pub count: f64, +} + +#[napi(object)] +pub struct NativeTransformationConfig { + #[napi(ts_type = "TransformationStrategy")] + pub strategy: String, + pub character: Option, + pub reveal: Option, +} + #[napi(object, object_from_js = false)] pub struct Transformation { #[napi(readonly)] @@ -109,10 +123,71 @@ fn core_finding(finding: Finding) -> datafog_core::Finding { } } -fn core_strategy(strategy: &str) -> napi::Result { - match strategy { - "redact" => Ok(datafog_core::TransformationStrategy::Redact), - _ => Err(Error::new(Status::InvalidArg, "strategy must be 'redact'")), +fn core_strategy( + config: NativeTransformationConfig, +) -> napi::Result { + match config.strategy.as_str() { + "redact" if config.character.is_none() && config.reveal.is_none() => { + Ok(datafog_core::TransformationStrategy::Redact) + } + "remove" if config.character.is_none() && config.reveal.is_none() => { + Ok(datafog_core::TransformationStrategy::Remove) + } + "mask" => { + let character = config.character.unwrap_or_else(|| "*".to_owned()); + let mut characters = character.chars(); + let character = characters + .next() + .filter(|_| characters.next().is_none()) + .ok_or_else(|| { + Error::new( + Status::InvalidArg, + "mask character must contain exactly one code point", + ) + })?; + let reveal = match config.reveal { + None => datafog_core::MaskReveal::None, + Some(reveal) => { + if !reveal.count.is_finite() + || reveal.count < 0.0 + || reveal.count.fract() != 0.0 + || reveal.count > 9_007_199_254_740_991.0 + { + return Err(Error::new( + Status::InvalidArg, + "reveal count must be a non-negative safe integer", + )); + } + let count = reveal.count as usize; + match reveal.direction.as_str() { + "first" => datafog_core::MaskReveal::First(count), + "last" => datafog_core::MaskReveal::Last(count), + _ => { + return Err(Error::new( + Status::InvalidArg, + "reveal direction must be 'first' or 'last'", + )); + } + } + } + }; + datafog_core::MaskConfig::new(character, reveal) + .map(datafog_core::TransformationStrategy::Mask) + .map_err(|_| { + Error::new( + Status::InvalidArg, + "mask character must not be whitespace or a control character", + ) + }) + } + "redact" | "remove" => Err(Error::new( + Status::InvalidArg, + "redact and remove do not accept mask configuration", + )), + _ => Err(Error::new( + Status::InvalidArg, + "strategy must be 'redact', 'mask', or 'remove'", + )), } } @@ -127,6 +202,8 @@ fn js_transform_result(result: datafog_core::TransformResult) -> napi::Result "redact".to_owned(), + datafog_core::TransformationStrategy::Remove => "remove".to_owned(), + datafog_core::TransformationStrategy::Mask(_) => "mask".to_owned(), }, replacement: transformation.replacement, output_byte_range: js_range(transformation.output_byte_range)?, @@ -151,10 +228,10 @@ pub fn scan(text: String) -> napi::Result> { pub fn transform( text: String, findings: Vec, - #[napi(ts_arg_type = "TransformationStrategy")] strategy: String, + #[napi(ts_arg_type = "TransformationConfig")] config: NativeTransformationConfig, ) -> napi::Result { let findings = findings.into_iter().map(core_finding).collect::>(); - datafog_core::transform(&text, &findings, core_strategy(&strategy)?) + datafog_core::transform(&text, &findings, core_strategy(config)?) .map_err(|error| Error::new(Status::InvalidArg, error.to_string())) .and_then(js_transform_result) } @@ -163,9 +240,9 @@ pub fn transform( #[napi(strict, catch_unwind)] pub fn scan_and_transform( text: String, - #[napi(ts_arg_type = "TransformationStrategy")] strategy: String, + #[napi(ts_arg_type = "TransformationConfig")] config: NativeTransformationConfig, ) -> napi::Result { - datafog_core::scan_and_transform(&text, core_strategy(&strategy)?) + datafog_core::scan_and_transform(&text, core_strategy(config)?) .map_err(|error| Error::new(Status::GenericFailure, error.to_string())) .and_then(js_transform_result) } diff --git a/bindings/python/src/lib.rs b/bindings/python/src/lib.rs index 979aa9f..957dcf1 100644 --- a/bindings/python/src/lib.rs +++ b/bindings/python/src/lib.rs @@ -1,6 +1,7 @@ use ::datafog_core as core; use pyo3::exceptions::{PyRuntimeError, PyValueError}; use pyo3::prelude::*; +use pyo3::types::{PyAny, PyBool, PyDict}; /// A zero-based, end-exclusive text range. #[pyclass(frozen, skip_from_py_object)] @@ -182,6 +183,8 @@ impl From for Transformation { finding: transformation.finding.into(), strategy: match transformation.strategy { core::TransformationStrategy::Redact => "redact".to_owned(), + core::TransformationStrategy::Remove => "remove".to_owned(), + core::TransformationStrategy::Mask(_) => "mask".to_owned(), }, replacement: transformation.replacement, output_byte_range: transformation.output_byte_range.into(), @@ -238,10 +241,100 @@ impl TransformResult { } } -fn parse_strategy(strategy: &str) -> PyResult { - match strategy { - "redact" => Ok(core::TransformationStrategy::Redact), - _ => Err(PyValueError::new_err("strategy must be 'redact'")), +fn validate_config_keys(config: &Bound<'_, PyDict>, allowed: &[&str]) -> PyResult<()> { + for (key, _) in config.iter() { + let key = key + .extract::() + .map_err(|_| PyValueError::new_err("configuration keys must be strings"))?; + if !allowed.contains(&key.as_str()) { + return Err(PyValueError::new_err(format!( + "unexpected configuration field: {key}" + ))); + } + } + Ok(()) +} + +fn required_item<'py>(config: &Bound<'py, PyDict>, key: &str) -> PyResult> { + config + .get_item(key)? + .ok_or_else(|| PyValueError::new_err(format!("missing configuration field: {key}"))) +} + +fn parse_strategy(config: &Bound<'_, PyAny>) -> PyResult { + let config = config + .cast::() + .map_err(|_| PyValueError::new_err("strategy configuration must be a dict"))?; + let strategy = required_item(config, "strategy")? + .extract::() + .map_err(|_| PyValueError::new_err("strategy must be a string"))?; + + match strategy.as_str() { + "redact" => { + validate_config_keys(config, &["strategy"])?; + Ok(core::TransformationStrategy::Redact) + } + "remove" => { + validate_config_keys(config, &["strategy"])?; + Ok(core::TransformationStrategy::Remove) + } + "mask" => { + validate_config_keys(config, &["strategy", "character", "reveal"])?; + let character = config + .get_item("character")? + .map(|value| value.extract::()) + .transpose() + .map_err(|_| PyValueError::new_err("mask character must be a string"))? + .unwrap_or_else(|| "*".to_owned()); + let mut characters = character.chars(); + let character = characters + .next() + .filter(|_| characters.next().is_none()) + .ok_or_else(|| { + PyValueError::new_err("mask character must contain exactly one code point") + })?; + + let reveal = match config.get_item("reveal")? { + None => core::MaskReveal::None, + Some(reveal) => { + let reveal = reveal.cast::().map_err(|_| { + PyValueError::new_err("mask reveal configuration must be a dict") + })?; + validate_config_keys(reveal, &["direction", "count"])?; + let direction = required_item(reveal, "direction")? + .extract::() + .map_err(|_| PyValueError::new_err("reveal direction must be a string"))?; + let count = required_item(reveal, "count")?; + if count.is_instance_of::() { + return Err(PyValueError::new_err( + "reveal count must be a non-negative integer", + )); + } + let count = count.extract::().map_err(|_| { + PyValueError::new_err("reveal count must be a non-negative integer") + })?; + match direction.as_str() { + "first" => core::MaskReveal::First(count), + "last" => core::MaskReveal::Last(count), + _ => { + return Err(PyValueError::new_err( + "reveal direction must be 'first' or 'last'", + )); + } + } + } + }; + core::MaskConfig::new(character, reveal) + .map(core::TransformationStrategy::Mask) + .map_err(|_| { + PyValueError::new_err( + "mask character must not be whitespace or a control character", + ) + }) + } + _ => Err(PyValueError::new_err( + "strategy must be 'redact', 'mask', or 'remove'", + )), } } @@ -258,9 +351,9 @@ fn transform( py: Python<'_>, text: &str, findings: Vec>, - strategy: &str, + config: &Bound<'_, PyAny>, ) -> PyResult { - let strategy = parse_strategy(strategy)?; + let strategy = parse_strategy(config)?; let core_findings: Vec = findings .iter() .map(|finding| finding.bind(py).borrow().to_core()) @@ -272,8 +365,8 @@ fn transform( /// Scan text and transform the detected findings. #[pyfunction] -fn scan_and_transform(text: &str, strategy: &str) -> PyResult { - core::scan_and_transform(text, parse_strategy(strategy)?) +fn scan_and_transform(text: &str, config: &Bound<'_, PyAny>) -> PyResult { + core::scan_and_transform(text, parse_strategy(config)?) .map(TransformResult::from) .map_err(|error| PyRuntimeError::new_err(error.to_string())) } diff --git a/bindings/python/tests/test_installed.py b/bindings/python/tests/test_installed.py index a62c6fd..bba00a4 100644 --- a/bindings/python/tests/test_installed.py +++ b/bindings/python/tests/test_installed.py @@ -61,8 +61,8 @@ def main() -> None: assert (emoji_finding.codepoint_range.start, emoji_finding.codepoint_range.end) == (2, 18) text = "👋 jane@example.com and jane@example.com" - explicit = transform(text, scan(text), "redact") - convenience = scan_and_transform(text, "redact") + explicit = transform(text, scan(text), {"strategy": "redact"}) + convenience = scan_and_transform(text, {"strategy": "redact"}) assert explicit == convenience assert explicit.text == "👋 [EMAIL] and [EMAIL]" assert len(explicit.transformations) == 2 @@ -71,6 +71,76 @@ def main() -> None: assert (first.output_byte_range.start, first.output_byte_range.end) == (5, 12) assert (first.output_codepoint_range.start, first.output_codepoint_range.end) == (2, 9) + masked = scan_and_transform( + "Email jane@example.com", + {"strategy": "mask"}, + ) + assert masked.text == "Email ****************" + + partially_masked = scan_and_transform( + "Email jane@example.com", + { + "strategy": "mask", + "character": "•", + "reveal": {"direction": "last", "count": 4}, + }, + ) + assert partially_masked.text == "Email ••••••••••••.com" + assert partially_masked.transformations[0].strategy == "mask" + assert partially_masked.transformations[0].replacement == "••••••••••••.com" + assert ( + partially_masked.transformations[0].output_byte_range.start, + partially_masked.transformations[0].output_byte_range.end, + ) == (6, 46) + + unchanged = scan_and_transform( + "Email jane@example.com", + { + "strategy": "mask", + "reveal": {"direction": "first", "count": 99}, + }, + ) + assert unchanged.text == "Email jane@example.com" + + removed = scan_and_transform( + "Email jane@example.com today", + {"strategy": "remove"}, + ) + assert removed.text == "Email today" + assert removed.transformations[0].strategy == "remove" + assert removed.transformations[0].replacement == "" + assert ( + removed.transformations[0].output_codepoint_range.start, + removed.transformations[0].output_codepoint_range.end, + ) == (6, 6) + + invalid_configs = [ + {"strategy": "mask", "character": ""}, + {"strategy": "mask", "character": "**"}, + {"strategy": "mask", "character": " "}, + {"strategy": "mask", "unexpected": True}, + {"strategy": "remove", "character": "*"}, + { + "strategy": "mask", + "reveal": {"direction": "last", "count": -1}, + }, + { + "strategy": "mask", + "reveal": {"direction": "last", "count": True}, + }, + { + "strategy": "mask", + "reveal": {"direction": "middle", "count": 4}, + }, + ] + for invalid_config in invalid_configs: + try: + scan_and_transform("Email jane@example.com", invalid_config) + except ValueError: + pass + else: + raise AssertionError(f"invalid configuration was accepted: {invalid_config}") + invalid = Finding( "EMAIL", "jane@example.com", @@ -80,7 +150,7 @@ def main() -> None: confidence=2.0, ) try: - transform(text, [invalid], "redact") + transform(text, [invalid], {"strategy": "redact"}) except ValueError as error: assert "InvalidConfidence" in str(error) else: diff --git a/bindings/wasm/index.d.ts b/bindings/wasm/index.d.ts index dccad4a..0f55b2b 100644 --- a/bindings/wasm/index.d.ts +++ b/bindings/wasm/index.d.ts @@ -1,6 +1,20 @@ /** Canonical built-in values are uppercase, but custom detectors may add values. */ export type EntityType = string; -export type TransformationStrategy = "redact"; +export type TransformationStrategy = "redact" | "mask" | "remove"; + +export interface MaskRevealConfig { + readonly direction: "first" | "last"; + readonly count: number; +} + +export type TransformationConfig = + | { readonly strategy: "redact" } + | { readonly strategy: "remove" } + | { + readonly strategy: "mask"; + readonly character?: string; + readonly reveal?: MaskRevealConfig; + }; export interface TextRange { readonly start: number; @@ -35,9 +49,9 @@ export function scan(text: string): Finding[]; export function transform( text: string, findings: Finding[], - strategy: TransformationStrategy, + config: TransformationConfig, ): TransformResult; export function scanAndTransform( text: string, - strategy: TransformationStrategy, + config: TransformationConfig, ): TransformResult; diff --git a/bindings/wasm/index.js b/bindings/wasm/index.js index 3b3e72c..bc1292f 100644 --- a/bindings/wasm/index.js +++ b/bindings/wasm/index.js @@ -42,38 +42,88 @@ function assertInitialized(operation) { } } -function assertStrategy(strategy) { - if (strategy !== "redact") { - throw new TypeError("strategy must be 'redact'"); +function validateConfig(config) { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new TypeError("transformation configuration must be an object"); } + if (!["redact", "mask", "remove"].includes(config.strategy)) { + throw new TypeError("strategy must be 'redact', 'mask', or 'remove'"); + } + + const allowed = + config.strategy === "mask" + ? new Set(["strategy", "character", "reveal"]) + : new Set(["strategy"]); + for (const key of Object.keys(config)) { + if (!allowed.has(key)) { + throw new TypeError(`unexpected configuration field: ${key}`); + } + } + + if (config.strategy === "mask") { + if (config.character !== undefined) { + if ( + typeof config.character !== "string" || + Array.from(config.character).length !== 1 || + /[\p{White_Space}\p{Cc}]/u.test(config.character) + ) { + throw new TypeError( + "mask character must be one non-whitespace, non-control code point", + ); + } + } + if (config.reveal !== undefined) { + if ( + typeof config.reveal !== "object" || + config.reveal === null || + Array.isArray(config.reveal) + ) { + throw new TypeError("mask reveal configuration must be an object"); + } + for (const key of Object.keys(config.reveal)) { + if (key !== "direction" && key !== "count") { + throw new TypeError(`unexpected reveal field: ${key}`); + } + } + if (!["first", "last"].includes(config.reveal.direction)) { + throw new TypeError("reveal direction must be 'first' or 'last'"); + } + if ( + !Number.isSafeInteger(config.reveal.count) || + config.reveal.count < 0 + ) { + throw new TypeError("reveal count must be a non-negative safe integer"); + } + } + } + + return config; } -export function transform(text, findings, strategy) { +export function transform(text, findings, config) { if (typeof text !== "string") { throw new TypeError("transform text must be a string"); } if (!Array.isArray(findings)) { throw new TypeError("transform findings must be an array"); } - assertStrategy(strategy); assertInitialized("transform"); try { - return transformWasm(text, findings, strategy); + return transformWasm(text, findings, validateConfig(config)); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } } -export function scanAndTransform(text, strategy) { +export function scanAndTransform(text, config) { if (typeof text !== "string") { throw new TypeError("scanAndTransform text must be a string"); } - assertStrategy(strategy); assertInitialized("scanAndTransform"); try { - return scanAndTransformWasm(text, strategy); + return scanAndTransformWasm(text, validateConfig(config)); } catch (error) { throw error instanceof Error ? error : new Error(String(error)); } diff --git a/bindings/wasm/src/lib.rs b/bindings/wasm/src/lib.rs index f6c8f70..9b05e55 100644 --- a/bindings/wasm/src/lib.rs +++ b/bindings/wasm/src/lib.rs @@ -64,6 +64,31 @@ struct TransformResult { transformations: Vec, } +#[derive(Deserialize)] +#[serde(rename_all = "lowercase")] +enum RevealDirection { + First, + Last, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct MaskRevealConfig { + direction: RevealDirection, + count: usize, +} + +#[derive(Deserialize)] +#[serde(tag = "strategy", rename_all = "lowercase", deny_unknown_fields)] +enum TransformationConfig { + Redact, + Remove, + Mask { + character: Option, + reveal: Option, + }, +} + fn finding_from_core(finding: datafog_core::Finding) -> Finding { Finding { entity_type: finding.entity_type, @@ -76,10 +101,40 @@ fn finding_from_core(finding: datafog_core::Finding) -> Finding { } } -fn strategy_from_js(strategy: &str) -> Result { - match strategy { - "redact" => Ok(datafog_core::TransformationStrategy::Redact), - _ => Err(JsValue::from_str("strategy must be 'redact'")), +fn strategy_from_js(config: JsValue) -> Result { + let config: TransformationConfig = serde_wasm_bindgen::from_value(config) + .map_err(|error| JsValue::from_str(&error.to_string()))?; + match config { + TransformationConfig::Redact => Ok(datafog_core::TransformationStrategy::Redact), + TransformationConfig::Remove => Ok(datafog_core::TransformationStrategy::Remove), + TransformationConfig::Mask { character, reveal } => { + let character = character.unwrap_or_else(|| "*".to_owned()); + let mut characters = character.chars(); + let character = characters + .next() + .filter(|_| characters.next().is_none()) + .ok_or_else(|| { + JsValue::from_str("mask character must contain exactly one code point") + })?; + let reveal = match reveal { + None => datafog_core::MaskReveal::None, + Some(MaskRevealConfig { + direction: RevealDirection::First, + count, + }) => datafog_core::MaskReveal::First(count), + Some(MaskRevealConfig { + direction: RevealDirection::Last, + count, + }) => datafog_core::MaskReveal::Last(count), + }; + datafog_core::MaskConfig::new(character, reveal) + .map(datafog_core::TransformationStrategy::Mask) + .map_err(|_| { + JsValue::from_str( + "mask character must not be whitespace or a control character", + ) + }) + } } } @@ -93,6 +148,8 @@ fn result_to_js(result: datafog_core::TransformResult) -> Result "redact", + datafog_core::TransformationStrategy::Remove => "remove", + datafog_core::TransformationStrategy::Mask(_) => "mask", }, replacement: transformation.replacement, output_byte_range: transformation.output_byte_range.into(), @@ -115,21 +172,21 @@ pub fn scan(text: &str) -> Result { } #[wasm_bindgen] -pub fn transform(text: &str, findings: JsValue, strategy: &str) -> Result { +pub fn transform(text: &str, findings: JsValue, config: JsValue) -> Result { let findings: Vec = serde_wasm_bindgen::from_value(findings) .map_err(|error| JsValue::from_str(&error.to_string()))?; let findings = findings .into_iter() .map(datafog_core::Finding::from) .collect::>(); - let result = datafog_core::transform(text, &findings, strategy_from_js(strategy)?) + let result = datafog_core::transform(text, &findings, strategy_from_js(config)?) .map_err(|error| JsValue::from_str(&error.to_string()))?; result_to_js(result) } #[wasm_bindgen] -pub fn scan_and_transform(text: &str, strategy: &str) -> Result { - let result = datafog_core::scan_and_transform(text, strategy_from_js(strategy)?) +pub fn scan_and_transform(text: &str, config: JsValue) -> Result { + let result = datafog_core::scan_and_transform(text, strategy_from_js(config)?) .map_err(|error| JsValue::from_str(&error.to_string()))?; result_to_js(result) } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3aca1da..cdec031 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -37,6 +37,76 @@ pub struct Finding { pub enum TransformationStrategy { /// Replace the finding with its unnumbered entity-type placeholder. Redact, + /// Delete the exact finding span. + Remove, + /// Replace non-revealed code points with a configured character. + Mask(MaskConfig), +} + +/// Configuration for character masking. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MaskConfig { + character: char, + reveal: MaskReveal, +} + +/// Portion of a finding preserved by character masking. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaskReveal { + /// Reveal no source code points. + None, + /// Reveal the requested number of leading code points. + First(usize), + /// Reveal the requested number of trailing code points. + Last(usize), +} + +/// Reason a masking configuration is invalid. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MaskConfigError { + /// The masking character is whitespace or a control character. + InvalidCharacter, +} + +impl std::fmt::Display for MaskConfigError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidCharacter => { + formatter.write_str("mask character must not be whitespace or a control character") + } + } + } +} + +impl std::error::Error for MaskConfigError {} + +impl MaskConfig { + /// Create a validated masking configuration. + pub fn new(character: char, reveal: MaskReveal) -> Result { + if character.is_whitespace() || character.is_control() { + return Err(MaskConfigError::InvalidCharacter); + } + Ok(Self { character, reveal }) + } + + /// Character used to replace hidden source code points. + pub fn character(self) -> char { + self.character + } + + /// Portion of the source finding that remains visible. + pub fn reveal(self) -> MaskReveal { + self.reveal + } +} + +impl Default for MaskConfig { + fn default() -> Self { + Self { + character: '*', + reveal: MaskReveal::None, + } + } } /// One transformation applied to the source text. @@ -210,6 +280,25 @@ pub fn transform( let output_codepoint_start = output.chars().count(); let replacement = match strategy { TransformationStrategy::Redact => format!("[{}]", finding.entity_type), + TransformationStrategy::Remove => String::new(), + TransformationStrategy::Mask(config) => { + let codepoint_count = finding.matched_text.chars().count(); + finding + .matched_text + .chars() + .enumerate() + .map(|(index, source)| { + let revealed = match config.reveal { + MaskReveal::None => false, + MaskReveal::First(count) => index < count, + MaskReveal::Last(count) => { + index >= codepoint_count.saturating_sub(count) + } + }; + if revealed { source } else { config.character } + }) + .collect() + } }; output.push_str(&replacement); @@ -1022,8 +1111,8 @@ fn detect_ip_address(text: &str, candidates: &mut Vec) { #[cfg(test)] mod tests { use super::{ - Finding, FindingValidationError, TextRange, TransformError, TransformationStrategy, scan, - scan_and_transform, transform, + Finding, FindingValidationError, MaskConfig, MaskConfigError, MaskReveal, TextRange, + TransformError, TransformationStrategy, scan, scan_and_transform, transform, }; fn expected_finding( @@ -1323,6 +1412,114 @@ mod tests { ); } + #[test] + fn fully_masks_every_codepoint_including_punctuation() { + let text = "Email jane@example.com"; + let findings = scan(text); + let strategy = TransformationStrategy::Mask(MaskConfig::default()); + + let result = transform(text, &findings, strategy).unwrap(); + + assert_eq!(result.text, "Email ****************"); + assert_eq!(result.transformations[0].strategy, strategy); + assert_eq!(result.transformations[0].replacement, "****************"); + } + + #[test] + fn partial_masking_reveals_the_requested_edge() { + let text = "Email jane@example.com"; + let findings = scan(text); + + let reveal_first = + TransformationStrategy::Mask(MaskConfig::new('*', MaskReveal::First(4)).unwrap()); + let reveal_last = + TransformationStrategy::Mask(MaskConfig::new('*', MaskReveal::Last(4)).unwrap()); + + assert_eq!( + transform(text, &findings, reveal_first).unwrap().text, + "Email jane************" + ); + assert_eq!( + transform(text, &findings, reveal_last).unwrap().text, + "Email ************.com" + ); + } + + #[test] + fn reveal_counts_handle_zero_and_the_finding_length() { + let text = "Email jane@example.com"; + let findings = scan(text); + let reveal_none = + TransformationStrategy::Mask(MaskConfig::new('*', MaskReveal::First(0)).unwrap()); + let reveal_all = TransformationStrategy::Mask( + MaskConfig::new('*', MaskReveal::Last(usize::MAX)).unwrap(), + ); + + assert_eq!( + transform(text, &findings, reveal_none).unwrap().text, + "Email ****************" + ); + assert_eq!(transform(text, &findings, reveal_all).unwrap().text, text); + } + + #[test] + fn masking_rejects_whitespace_and_control_characters() { + for character in [' ', '\n', '\0'] { + assert_eq!( + MaskConfig::new(character, MaskReveal::None), + Err(MaskConfigError::InvalidCharacter) + ); + } + assert!(MaskConfig::new('•', MaskReveal::None).is_ok()); + } + + #[test] + fn multibyte_mask_character_reports_exact_output_ranges() { + let text = "A é👋 Z"; + let finding = Finding { + entity_type: "CUSTOM".to_owned(), + matched_text: "é👋".to_owned(), + byte_range: TextRange { start: 2, end: 8 }, + codepoint_range: TextRange { start: 2, end: 4 }, + confidence: None, + detector_name: "test".to_owned(), + detector_version: None, + }; + let strategy = + TransformationStrategy::Mask(MaskConfig::new('•', MaskReveal::None).unwrap()); + + let result = transform(text, &[finding], strategy).unwrap(); + + assert_eq!(result.text, "A •• Z"); + assert_eq!( + result.transformations[0].output_byte_range, + TextRange { start: 2, end: 8 } + ); + assert_eq!( + result.transformations[0].output_codepoint_range, + TextRange { start: 2, end: 4 } + ); + } + + #[test] + fn removal_deletes_only_the_finding_and_records_the_deletion_point() { + let text = "Email jane@example.com today"; + let findings = scan(text); + + let result = transform(text, &findings, TransformationStrategy::Remove).unwrap(); + + assert_eq!(result.text, "Email today"); + assert_eq!(result.transformations[0].replacement, ""); + assert_eq!( + result.transformations[0].output_byte_range, + TextRange { start: 6, end: 6 } + ); + assert_eq!( + result.transformations[0].output_codepoint_range, + TextRange { start: 6, end: 6 } + ); + } + #[test] fn rejects_a_finding_whose_matched_text_differs_from_the_source() { let text = "Contact jane@example.com"; diff --git a/docs/privacy-operations-roadmap.md b/docs/privacy-operations-roadmap.md index 6ffb2e4..60a866f 100644 --- a/docs/privacy-operations-roadmap.md +++ b/docs/privacy-operations-roadmap.md @@ -68,6 +68,8 @@ and Unicode cases satisfy ADR 001. ## Slice 3: Mask and remove +**Status: complete** + - Use a discriminated strategy configuration across public bindings. - Mask every non-revealed Unicode code point, including punctuation. - Support `first` and `last` reveal modes with a non-negative code-point count. diff --git a/scripts/test-node-package.mjs b/scripts/test-node-package.mjs index 6e4f596..ed20062 100644 --- a/scripts/test-node-package.mjs +++ b/scripts/test-node-package.mjs @@ -84,16 +84,62 @@ assert.deepEqual(emojiFinding.byteRange, { start: 5, end: 21 }); assert.deepEqual(emojiFinding.codepointRange, { start: 2, end: 18 }); const transformText = "👋 jane@example.com and jane@example.com"; -const explicit = transform(transformText, scan(transformText), "redact"); -const convenience = scanAndTransform(transformText, "redact"); +const explicit = transform(transformText, scan(transformText), { strategy: "redact" }); +const convenience = scanAndTransform(transformText, { strategy: "redact" }); assert.deepEqual(explicit, convenience); assert.equal(explicit.text, "👋 [EMAIL] and [EMAIL]"); assert.equal(explicit.transformations.length, 2); assert.equal(explicit.transformations[0].replacement, "[EMAIL]"); assert.deepEqual(explicit.transformations[0].outputByteRange, { start: 5, end: 12 }); assert.deepEqual(explicit.transformations[0].outputCodepointRange, { start: 2, end: 9 }); +assert.equal( + scanAndTransform("Email jane@example.com", { strategy: "mask" }).text, + "Email ****************", +); +const partialMask = scanAndTransform("Email jane@example.com", { + strategy: "mask", + character: "•", + reveal: { direction: "last", count: 4 }, +}); +assert.equal(partialMask.text, "Email ••••••••••••.com"); +assert.equal(partialMask.transformations[0].strategy, "mask"); +assert.equal(partialMask.transformations[0].replacement, "••••••••••••.com"); +assert.deepEqual(partialMask.transformations[0].outputByteRange, { start: 6, end: 46 }); + +assert.equal( + scanAndTransform("Email jane@example.com", { + strategy: "mask", + reveal: { direction: "first", count: 99 }, + }).text, + "Email jane@example.com", +); + +const removed = scanAndTransform("Email jane@example.com today", { strategy: "remove" }); +assert.equal(removed.text, "Email today"); +assert.equal(removed.transformations[0].strategy, "remove"); +assert.equal(removed.transformations[0].replacement, ""); +assert.deepEqual(removed.transformations[0].outputCodepointRange, { start: 6, end: 6 }); + +for (const invalidConfig of [ + { strategy: "mask", character: "" }, + { strategy: "mask", character: "**" }, + { strategy: "mask", character: " " }, + { strategy: "mask", unexpected: true }, + { strategy: "remove", character: "*" }, + { strategy: "mask", reveal: { direction: "last", count: -1 } }, + { strategy: "mask", reveal: { direction: "middle", count: 4 } }, +]) { + assert.throws( + () => scanAndTransform("Email jane@example.com", invalidConfig), + TypeError, + ); +} assert.throws( - () => transform(transformText, [{ ...scan(transformText)[0], confidence: 2 }], "redact"), + () => transform( + transformText, + [{ ...scan(transformText)[0], confidence: 2 }], + { strategy: "redact" }, + ), /InvalidConfidence/, ); @@ -110,20 +156,37 @@ import { transform, type EntityType, type Finding, + type MaskRevealConfig, type TextRange, + type TransformationConfig, type TransformResult, } from "@datafog/node"; const findings: Finding[] = scan("Email jane@example.com"); const entityType: EntityType = findings[0]?.entityType ?? "CUSTOM_ENTITY"; const range: TextRange = findings[0]?.byteRange ?? { start: 0, end: 0 }; -const explicit: TransformResult = transform("Email jane@example.com", findings, "redact"); -const convenience: TransformResult = scanAndTransform("Email jane@example.com", "redact"); +const explicit: TransformResult = transform( + "Email jane@example.com", + findings, + { strategy: "redact" }, +); +const convenience: TransformResult = scanAndTransform( + "Email jane@example.com", + { strategy: "redact" }, +); +const reveal: MaskRevealConfig = { direction: "last", count: 4 }; +const maskConfig: TransformationConfig = { + strategy: "mask", + character: "•", + reveal, +}; +const masked: TransformResult = scanAndTransform("Email jane@example.com", maskConfig); void entityType; void range; void explicit; void convenience; +void masked; `.trimStart(), ); diff --git a/scripts/test-wasm-package.mjs b/scripts/test-wasm-package.mjs index 59a63a8..9db6ec0 100644 --- a/scripts/test-wasm-package.mjs +++ b/scripts/test-wasm-package.mjs @@ -85,7 +85,9 @@ import { transform, type EntityType, type Finding, + type MaskRevealConfig, type TextRange, + type TransformationConfig, type TransformResult, } from "@datafog/wasm"; @@ -96,18 +98,26 @@ const range: TextRange = findings[0]?.byteRange ?? { start: 0, end: 0 }; const transformed: TransformResult = transform( "Email jane@example.com", findings, - "redact", + { strategy: "redact" }, ); const scannedAndTransformed: TransformResult = scanAndTransform( "Email jane@example.com", - "redact", + { strategy: "redact" }, ); +const reveal: MaskRevealConfig = { direction: "last", count: 4 }; +const maskConfig: TransformationConfig = { + strategy: "mask", + character: "•", + reveal, +}; +const masked: TransformResult = scanAndTransform("Email jane@example.com", maskConfig); void ready; void entityType; void range; void transformed; void scannedAndTransformed; +void masked; `.trimStart(), ); @@ -261,8 +271,8 @@ try { const text = "👋 jane@example.com and jane@example.com"; const findings = scan(text); - const explicit = transform(text, findings, "redact"); - const convenient = scanAndTransform(text, "redact"); + const explicit = transform(text, findings, { strategy: "redact" }); + const convenient = scanAndTransform(text, { strategy: "redact" }); if (JSON.stringify(explicit) !== JSON.stringify(convenient)) { throw new Error("explicit and convenience transforms differ"); } @@ -283,8 +293,72 @@ try { throw new Error("transformation records do not select their replacements"); } + if ( + scanAndTransform("Email jane@example.com", { strategy: "mask" }).text !== + "Email ****************" + ) { + throw new Error("full masking did not replace every code point"); + } + + const partialMask = scanAndTransform("Email jane@example.com", { + strategy: "mask", + character: "•", + reveal: { direction: "last", count: 4 }, + }); + if ( + partialMask.text !== "Email ••••••••••••.com" || + partialMask.transformations[0].strategy !== "mask" || + partialMask.transformations[0].replacement !== "••••••••••••.com" || + JSON.stringify(partialMask.transformations[0].outputByteRange) !== + JSON.stringify({ start: 6, end: 46 }) + ) { + throw new Error("partial multibyte masking contract failed"); + } + + if ( + scanAndTransform("Email jane@example.com", { + strategy: "mask", + reveal: { direction: "first", count: 99 }, + }).text !== "Email jane@example.com" + ) { + throw new Error("oversized reveal count should preserve the finding"); + } + + const removed = scanAndTransform("Email jane@example.com today", { + strategy: "remove", + }); + if ( + removed.text !== "Email today" || + removed.transformations[0].strategy !== "remove" || + removed.transformations[0].replacement !== "" || + JSON.stringify(removed.transformations[0].outputCodepointRange) !== + JSON.stringify({ start: 6, end: 6 }) + ) { + throw new Error("exact removal contract failed"); + } + + for (const invalidConfig of [ + { strategy: "mask", character: "" }, + { strategy: "mask", character: "**" }, + { strategy: "mask", character: " " }, + { strategy: "mask", unexpected: true }, + { strategy: "remove", character: "*" }, + { strategy: "mask", reveal: { direction: "last", count: -1 } }, + { strategy: "mask", reveal: { direction: "middle", count: 4 } }, + ]) { + expectThrows( + () => scanAndTransform("Email jane@example.com", invalidConfig), + "TypeError", + ); + } + expectThrows( - () => transform(text, [{ ...findings[0], confidence: 2 }], "redact"), + () => + transform( + text, + [{ ...findings[0], confidence: 2 }], + { strategy: "redact" }, + ), "Error", ); });