diff --git a/.dumplingconf.example b/.dumplingconf.example index 21ff505..9e20860 100644 --- a/.dumplingconf.example +++ b/.dumplingconf.example @@ -59,8 +59,10 @@ last_login = { strategy = "datetime_fuzz" } wake_time = { strategy = "time_fuzz", min_seconds = -3600, max_seconds = 3600 } [rules."public.orders"] -# credit card — redact entirely; force as quoted string -credit_card = { strategy = "redact", as_string = true } +# credit card — Luhn-valid synthetic PAN (length 13–19); use domain for stable FKs across dumps +credit_card = { strategy = "payment_card", length = 16, domain = "order_pan" } +# monetary / numeric — random decimal in range with fixed fractional digits +order_total = { strategy = "decimal", min = 0, max = 99999, scale = 2, domain = "order_amount" } # keep the same anonymized email as users table via shared domain customer_email = { strategy = "faker", faker = "internet::SafeEmail", domain = "customer_identity" } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2fd8895..1d39e15 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: components: rustfmt, clippy - name: Cache Cargo build artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.9.1 - name: Check formatting run: cargo fmt --all -- --check diff --git a/.github/workflows/platform-compat-latest.yml b/.github/workflows/platform-compat-latest.yml index ec84433..67e3f32 100644 --- a/.github/workflows/platform-compat-latest.yml +++ b/.github/workflows/platform-compat-latest.yml @@ -28,7 +28,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache Cargo build artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.9.1 - name: Build release binary run: cargo build --release --locked diff --git a/.github/workflows/platform-compat-matrix.yml b/.github/workflows/platform-compat-matrix.yml index 0ec5136..32c1810 100644 --- a/.github/workflows/platform-compat-matrix.yml +++ b/.github/workflows/platform-compat-matrix.yml @@ -26,7 +26,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache Cargo build artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.9.1 - name: Build release binary run: cargo build --release --locked diff --git a/.github/workflows/policy-lint.yml b/.github/workflows/policy-lint.yml index 48d3d9d..4ce4fa0 100644 --- a/.github/workflows/policy-lint.yml +++ b/.github/workflows/policy-lint.yml @@ -38,7 +38,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache Cargo build artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.9.1 - name: Build dumpling run: cargo build --release --locked diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index cd748ee..33b1d15 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache Cargo build artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.9.1 - name: Set up Python uses: actions/setup-python@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 06b3741..b53102b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -21,7 +21,7 @@ jobs: components: rustfmt, clippy - name: Cache Cargo build artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.9.1 - name: Validate formatting run: cargo fmt --all -- --check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 486df91..ffe05c2 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: uses: dtolnay/rust-toolchain@stable - name: Cache Cargo build artifacts - uses: Swatinem/rust-cache@v2 + uses: Swatinem/rust-cache@v2.9.1 - name: Run cargo tests run: cargo test --all-targets --all-features diff --git a/AGENTS.md b/AGENTS.md index 8224e76..7ecbd27 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -221,7 +221,7 @@ Follow these steps in order. Do not skip any step. 7. **Tests**: Add `#[test]` functions in `src/transform.rs` (unit-test strategy output values) and in `src/sql.rs` (end-to-end pipeline test). Use `set_random_seed(N)` for reproducibility. -8. **`README.md`**: Add a row to the "Anonymization strategies" table. +8. **`README.md`**: Document the strategy under *Configuration → Anonymization strategies* (per-strategy subsection with accepted options), and mention any new spec fields in `AnonymizerSpec`’s doc comment in `settings.rs`. **`faker` strategy:** Config only carries string identifiers; Dumpling never evaluates user Rust from config. To ship a new generator, add dispatch in `src/faker_dispatch.rs` and validation in `validate_anonymizer_spec` for the `faker` branch. Upstream reference: [`fake` on docs.rs](https://docs.rs/fake/latest/fake/), [`fake::faker` module index](https://docs.rs/fake/latest/fake/faker/index.html), [source on GitHub](https://github.com/cksac/fake-rs). diff --git a/README.md b/README.md index 4fe7ae5..f4e23e5 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,8 @@ ssn = { strategy = "hash", salt = "${env:DUMPLING_USERS_SSN_SALT}", as_string age = { strategy = "int_range", min = 18, max = 90 } [rules."orders"] -credit_card = { strategy = "redact", as_string = true } +credit_card = { strategy = "payment_card", length = 16, domain = "order_pan" } +amount = { strategy = "decimal", min = 0, max = 9999, scale = 2, domain = "order_amount" } # Optional explicit sensitive columns policy list (for strict coverage) [sensitive_columns] @@ -166,29 +167,99 @@ token = "high" ### Anonymization strategies -| Strategy | Description | -|---|---| -| `null` | Set field to SQL `NULL` | -| `redact` | Replace with `REDACTED` (string) | -| `uuid` | Random UUIDv4-like string | -| `hash` | SHA-256 hex of original value; supports per-column `salt` and global `salt` | -| `email` | Safe email address (same generator as `faker = "internet::SafeEmail"`); supports `locale` | -| `name` | Full name (same as `faker = "name::Name"`); supports `locale` | -| `first_name` | First name (same as `faker = "name::FirstName"`); supports `locale` | -| `last_name` | Last name (same as `faker = "name::LastName"`); supports `locale` | -| `phone` | Locale-aware fake phone number (configurable via `locale`); defaults to English format | -| `faker` | Values from the Rust [`fake`](https://crates.io/crates/fake) crate ([docs.rs](https://docs.rs/fake/latest/fake/), [`faker` modules](https://docs.rs/fake/latest/fake/faker/index.html)), chosen by a **string identifier** only (`faker = "module::Type"`, e.g. `internet::SafeEmail`). Config is **data only**: nothing from TOML is compiled or executed as Rust at runtime. Use `locale` for locale-aware generators; optional `min`/`max`, `length`, `format` as documented. Unsupported targets fail at config load. New generators require a **new Dumpling release** (or your own fork), not config-side code. | -| `int_range` | Random integer in `[min, max]` | -| `string` | Random alphanumeric string (`length = 12` by default) | -| `date_fuzz` | Shifts a date by a random number of days in `[min_days, max_days]` (defaults: `-30..30`) | -| `time_fuzz` | Shifts a time-of-day by a random number of seconds in `[min_seconds, max_seconds]` with 24h wraparound (defaults: `-300..300`) | -| `datetime_fuzz` | Shifts a timestamp/timestamptz by a random number of seconds in `[min_seconds, max_seconds]` (defaults: `-86400..86400`) | - -**`faker` reference (upstream `fake` crate):** Dumpling’s `faker = "module::Type"` strings mirror the Rust [`fake`](https://crates.io/crates/fake) crate’s [`faker`](https://docs.rs/fake/latest/fake/faker/index.html) module layout. Use these when picking or extending generators: - -- [docs.rs — `fake` crate root](https://docs.rs/fake/latest/fake/) (overview, `Fake` / `Dummy` traits, locales) -- [docs.rs — `fake::faker` module index](https://docs.rs/fake/latest/fake/faker/index.html) (per-domain submodules: `address`, `internet`, `name`, …) -- [GitHub — `cksac/fake-rs`](https://github.com/cksac/fake-rs) (source, README with the CLI’s generator name list) +Each column rule is a TOML inline table: `{ strategy = "", ... }`. **Strategy-specific keys** are documented next to the strategy that accepts them. A few keys apply across many strategies; see [Cross-cutting options](#cross-cutting-options) below. + +#### Choosing a strategy (cheaper vs more realistic) + +Prefer **lightweight** strategies when nothing downstream requires lifelike values: **`null`**, **`redact`**, **`blank`**, **`empty_array`**, **`empty_object`**, **`string`**, **`int_range`**, and **`decimal`** are cheap to generate (simple constants, random digits/alnum, or bounded numeric shapes). Use **`blank`** for NOT NULL text where you must clear content without SQL NULL; use **`empty_array`** / **`empty_object`** on JSON path rules (or text columns holding JSON) when the document must keep `[]` / `{}` instead of `null` or `""`. + +Reach for **richer** strategies when realism matters for restores, demos, or tests that exercise parsers and validators: **`email`**, **`name`**, **`first_name`**, **`last_name`**, **`phone`**, **`faker`**, **`uuid`**, **`hash`**, **`payment_card`**, and the **`date_fuzz` / `time_fuzz` / `datetime_fuzz`** family do more work (formatting, parsing, digest, or upstream generators). If a cheap strategy would break **CHECK constraints**, **NOT NULL**, **foreign-key shape**, or **import tooling** that validates formats, switch to a strategy that emits compatible values—or keep **`domain`** on the heavier strategy so referential consistency is preserved where you need it. + +#### `null` + +- **Behavior:** emit SQL `NULL` for the cell. +- **Options:** none. (`domain` is rejected.) + +#### `redact` + +- **Behavior:** replace with the literal `REDACTED`. +- **`as_string`:** if `true`, the replacement is always a single-quoted SQL string; if `false`, it is emitted without quotes (still valid as an identifier-like token in many dumps). When the **original** cell was already a quoted string, Dumpling quotes the output even when `as_string` is omitted—see [Cross-cutting options](#cross-cutting-options). + +#### `blank` + +- **Behavior:** replace with an **empty string** (`''` in SQL when quoted). If the source cell is SQL **`NULL`**, the cell stays **`NULL`** (same as `null` / `redact` semantics for missing values). +- **Options:** none. (`domain` is rejected.) **`as_string`** is ignored; output is always the empty string literal when non-NULL. + +#### `empty_array` / `empty_object` + +- **Behavior:** replace with the JSON tokens **`[]`** and **`{}`** as **unquoted** SQL/COPY tokens (so they parse as JSON when the column holds JSON). If the source cell is SQL **`NULL`**, the cell stays **`NULL`**. +- **JSON path rules:** use these on leaves that are JSON **arrays** or **objects** when you need a typed empty container instead of `null` or `""`. +- **Options:** none. (`domain` is rejected.) + +#### `uuid` + +- **Behavior:** random UUIDv4-like hyphenated hex string. +- **`as_string`:** same meaning as for `redact` / `hash` (force quoted literal vs. unquoted token). + +#### `hash` + +- **Behavior:** salted digest of the original cell value (SHA-256 in standard profile; HMAC-SHA-256 in `--security-profile hardened`). +- **`salt`:** optional per-column salt; otherwise the top-level `salt` or registry default applies. +- **`as_string`:** if `true`, force a quoted string literal; if `false`, unquoted hex. Quoted **source** cells are still written quoted when `as_string` is omitted. + +#### `email`, `name`, `first_name`, `last_name`, `phone` + +- **Behavior:** locale-aware fake values (same underlying generators as the matching `faker` targets). +- **`locale`:** optional; one of `en`, `fr_fr`, `de_de`, `it_it`, `pt_br`, `pt_pt`, `ar_sa`, `zh_cn`, `zh_tw`, `ja_jp`, `cy_gb` (default `en`). +- **Output:** always emitted as a quoted string replacement. + +#### `int_range` + +- **Behavior:** random integer in the inclusive range `[min, max]` (defaults `min = 0`, `max = 1_000_000`). +- **`min` / `max`:** inclusive bounds; `min` must be ≤ `max`. +- **Output:** always unquoted digits (suitable for integer / JSON number columns). + +#### `decimal` + +- **Behavior:** random decimal with integer part in `[min, max]` and fractional part of **`scale`** digits (defaults `min = 0`, `max = 1_000_000`, `scale = 2`). Use `scale = 0` for a plain integer string in the same range. +- **`min` / `max`:** inclusive integer-part bounds. +- **`scale`:** number of digits after `.` (0–38). +- **`as_string`:** same as `hash` / `redact` for quoting the full literal. + +#### `payment_card` + +- **Behavior:** random digit string of length **`length`** (default **16**) with a **valid Luhn check digit**, so `--scan-output` PAN detection treats synthetic values like test cards, not arbitrary digit runs. +- **`length`:** total digit count including check digit; must be 13–19 (PAN lengths). +- **Output:** always a quoted string of digits (no separators). + +#### `string` + +- **Behavior:** random alphanumeric string. +- **`length`:** character count (default 12); must be ≥ 1 when set. + +#### `faker` + +- **Behavior:** values from the Rust [`fake`](https://crates.io/crates/fake) crate ([`faker` modules](https://docs.rs/fake/latest/fake/faker/index.html)), selected only by the string **`faker = "module::Type"`** (e.g. `internet::SafeEmail`). Config is **data only**—nothing from TOML is compiled as Rust. Unsupported pairs fail at config load; new generators require a **new Dumpling release** (or a fork), not config-side code. +- **`faker`:** required; maps to a built-in allowlist in `src/faker_dispatch.rs`. +- **`locale`:** optional; same set as the built-in PII strategies when the upstream generator is locale-aware. +- **`min` / `max` / `length` / `format`:** only for/faker combinations that upstream supports (e.g. `number::NumberWithFormat` uses **`format`**: `#` = any digit, `^` = 1–9 per [`fake` docs](https://docs.rs/fake/latest/fake/)). + +**Upstream reference:** [docs.rs — `fake`](https://docs.rs/fake/latest/fake/), [docs.rs — `fake::faker`](https://docs.rs/fake/latest/fake/faker/index.html), [GitHub — cksac/fake-rs](https://github.com/cksac/fake-rs). + +#### `date_fuzz`, `time_fuzz`, `datetime_fuzz` + +- **Behavior:** parse the existing value when possible and shift by a random offset; on parse failure the original string is kept. +- **`date_fuzz`:** **`min_days` / `max_days`** (defaults `-30` … `30`). +- **`time_fuzz` / `datetime_fuzz`:** **`min_seconds` / `max_seconds`** (`time_fuzz` defaults `-300` … `300`; `datetime_fuzz` defaults `-86400` … `86400`). +- **`as_string`:** force quoted literal vs. unquoted token for the emitted date/time/timestamp string. + +### Cross-cutting options + +These keys are valid on **multiple** strategies (unless validation says otherwise): + +- **`domain`:** deterministic mapping bucket. The same non-NULL source value maps to the same pseudonym for that strategy inside the domain (across tables/columns). **SQL `NULL` is always preserved**—no fabricated FK targets. +- **`unique_within_domain`:** when `true` (requires `domain`), different source values are assigned distinct pseudonyms within the domain. +- **`as_string`:** when `true`, force the replacement to render as a **single-quoted SQL string literal**. When `false` or omitted, Dumpling still quotes the output if the **original** cell was quoted (`render_cell` uses `force_quoted || original.was_quoted`). Set `as_string = true` when the source may be unquoted (numeric-looking literals, some `COPY` shapes) but you need a string literal in the dump. ### Secret references @@ -230,17 +301,6 @@ dumpling --input dump.sql --check --strict-coverage --report coverage.json dumpling --security-profile hardened --input dump.sql --check ``` -### Common column options - -- `as_string`: if true, forces the anonymized value to be rendered as a quoted SQL string literal. By default Dumpling preserves the original quoting where possible. -- `domain`: deterministic mapping domain. When set, the same source value always maps to the same pseudonym inside that domain (across tables/columns). **SQL `NULL` inputs are always preserved as `NULL`** — a null FK reference has no source value to map, so no pseudonym is fabricated. -- `unique_within_domain`: when true, different source values are assigned unique pseudonyms within the configured `domain`. NULL values are unaffected and always remain NULL. -- `min_days` / `max_days`: used by `date_fuzz`. -- `min_seconds` / `max_seconds`: used by `time_fuzz` and `datetime_fuzz`. -- `locale`: selects the language/regional format for `email`, `name`, `first_name`, `last_name`, `faker`, and `phone`. Supported values: `en`, `fr_fr`, `de_de`, `it_it`, `pt_br`, `pt_pt`, `ar_sa`, `zh_cn`, `zh_tw`, `ja_jp`, `cy_gb`. Defaults to `en` when not specified. -- `faker`: required when `strategy = "faker"`. A plain string `"module::Type"` (case-insensitive) that maps to a **built-in** generator compiled into Dumpling—not arbitrary Rust or expressions. Names follow [`fake::faker`](https://docs.rs/fake/latest/fake/faker/index.html) (e.g. `internet::SafeEmail` → `faker::internet::SafeEmail` in the crate). -- `format`: used with `faker = "number::NumberWithFormat"`; pattern uses `#` (0–9) and `^` (1–9) per the [`fake` crate docs](https://docs.rs/fake/latest/fake/). - > **Note:** `table_options` are no longer supported; use explicit `rules` and optional `column_cases`. --- diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 970a430..26aeff9 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -63,6 +63,10 @@ When you use `strategy = "faker"` with `faker = "module::Type"`, those names ali Dumpling only exposes a **subset** wired in `src/faker_dispatch.rs`; unsupported `module::Type` pairs fail at config load. +## Anonymization strategies + +Strategy names and **per-strategy options** (`min`, `scale`, `as_string`, `faker`, …) are documented in the repository **README** under *Configuration → Anonymization strategies* (each strategy lists only the keys it accepts, plus **Choosing a strategy** for when to prefer cheap vs realistic transforms, and **Cross-cutting options** for `domain`, `unique_within_domain`, and `as_string`). + ## Baseline config template ```toml diff --git a/src/filter.rs b/src/filter.rs index dede70a..56e9df2 100644 --- a/src/filter.rs +++ b/src/filter.rs @@ -722,6 +722,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: Some(4), min_days: None, max_days: None, @@ -786,6 +787,7 @@ mod tests { salt: None, min: Some(0), max: Some(9), + scale: None, length: None, min_days: None, max_days: None, @@ -820,6 +822,7 @@ mod tests { salt: None, min: Some(0), max: Some(0), + scale: None, length: None, min_days: None, max_days: None, @@ -848,4 +851,82 @@ mod tests { ); assert_eq!(v2["b"], false); } + + #[test] + fn rewrite_json_paths_empty_array_and_empty_object_strategies() { + let mut rules: HashMap> = HashMap::new(); + rules.insert("public.t".to_string(), HashMap::new()); + let cfg = ResolvedConfig { + salt: None, + rules, + row_filters: HashMap::new(), + column_cases: HashMap::new(), + sensitive_columns: HashMap::new(), + output_scan: crate::settings::OutputScanConfig::default(), + source_path: None, + }; + let registry = AnonymizerRegistry::from_config(&cfg); + + let arr_spec = AnonymizerSpec { + strategy: "empty_array".to_string(), + salt: None, + min: None, + max: None, + scale: None, + length: None, + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: None, + unique_within_domain: None, + as_string: None, + locale: None, + faker: None, + format: None, + }; + let out = rewrite_json_paths_with_rules( + ®istry, + None, + &[(vec!["items".to_string()], arr_spec)], + r#"{"items":[1,2],"meta":{}}"#, + ) + .unwrap() + .unwrap(); + let v: serde_json::Value = serde_json::from_str(&out).unwrap(); + assert!(v["items"].is_array()); + assert_eq!(v["items"], serde_json::json!([])); + assert_eq!(v["meta"], serde_json::json!({})); + + let obj_spec = AnonymizerSpec { + strategy: "empty_object".to_string(), + salt: None, + min: None, + max: None, + scale: None, + length: None, + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: None, + unique_within_domain: None, + as_string: None, + locale: None, + faker: None, + format: None, + }; + let out2 = rewrite_json_paths_with_rules( + ®istry, + None, + &[(vec!["meta".to_string()], obj_spec)], + r#"{"items":[],"meta":{"k":1}}"#, + ) + .unwrap() + .unwrap(); + let v2: serde_json::Value = serde_json::from_str(&out2).unwrap(); + assert!(v2["meta"].is_object()); + assert_eq!(v2["meta"], serde_json::json!({})); + assert_eq!(v2["items"], serde_json::json!([])); + } } diff --git a/src/lint.rs b/src/lint.rs index d8d289a..8ec87db 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -271,6 +271,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -291,6 +292,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -311,6 +313,7 @@ mod tests { salt: Some(salt.to_string()), min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, diff --git a/src/scan.rs b/src/scan.rs index 83c74d9..fca7c23 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -284,8 +284,9 @@ fn ranges_overlap(a: (usize, usize), b: (usize, usize)) -> bool { a.0 < b.1 && b.0 < a.1 } +/// Luhn check over a digit-only string (used by output PAN scanning and `payment_card` generation). #[allow(unknown_lints, clippy::manual_is_multiple_of)] -fn luhn_valid(input: &str) -> bool { +pub(crate) fn luhn_valid(input: &str) -> bool { if input.is_empty() { return false; } diff --git a/src/seal.rs b/src/seal.rs index af3c3a3..d6e81fe 100644 --- a/src/seal.rs +++ b/src/seal.rs @@ -438,6 +438,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -460,6 +461,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, diff --git a/src/settings.rs b/src/settings.rs index 79ebf1f..60d5e7b 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -32,14 +32,16 @@ pub struct RawConfig { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct AnonymizerSpec { - /// Strategy name: redact|null|uuid|hash|email|name|first_name|last_name|phone|faker|int_range|string|date_fuzz|time_fuzz|datetime_fuzz + /// Strategy name: null|redact|blank|empty_array|empty_object|uuid|hash|… (see README) pub strategy: String, /// if strategy=hash: optional per-column salt override; otherwise ignored pub salt: Option, - /// if strategy=int_range: inclusive min/max + /// if strategy=int_range or decimal: inclusive min/max integer part (decimal) or value range (int_range) pub min: Option, pub max: Option, - /// if strategy=string: length to generate + /// if strategy=decimal: digits after the decimal point (default 2; use 0 for integers) + pub scale: Option, + /// if strategy=string: length to generate; if strategy=payment_card: digit count including check digit (13–19, default 16) pub length: Option, /// if strategy=date_fuzz: inclusive min/max day shift pub min_days: Option, @@ -545,6 +547,9 @@ fn resolve(raw: RawConfig, source_path: Option) -> ResolvedConfig { const KNOWN_STRATEGIES: &[&str] = &[ "null", "redact", + "blank", + "empty_array", + "empty_object", "uuid", "hash", "email", @@ -554,6 +559,8 @@ const KNOWN_STRATEGIES: &[&str] = &[ "phone", "faker", "int_range", + "decimal", + "payment_card", "string", "date_fuzz", "time_fuzz", @@ -673,7 +680,12 @@ fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result if spec.unique_within_domain.is_some() && domain.is_none() { unsupported.push("unique_within_domain"); } - if domain.is_some() && matches!(strategy, "null" | "redact") { + if domain.is_some() + && matches!( + strategy, + "null" | "redact" | "blank" | "empty_array" | "empty_object" + ) + { unsupported.push("domain"); if spec.unique_within_domain.is_some() { unsupported.push("unique_within_domain"); @@ -682,7 +694,8 @@ fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result if spec.salt.is_some() && strategy != "hash" { unsupported.push("salt"); } - if (spec.min.is_some() || spec.max.is_some()) && strategy != "int_range" && strategy != "faker" + if (spec.min.is_some() || spec.max.is_some()) + && !matches!(strategy, "int_range" | "faker" | "decimal") { if spec.min.is_some() { unsupported.push("min"); @@ -691,9 +704,12 @@ fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result unsupported.push("max"); } } - if spec.length.is_some() && strategy != "string" && strategy != "faker" { + if spec.length.is_some() && !matches!(strategy, "string" | "faker" | "payment_card") { unsupported.push("length"); } + if spec.scale.is_some() && strategy != "decimal" { + unsupported.push("scale"); + } if spec.format.is_some() && strategy != "faker" { unsupported.push("format"); } @@ -767,6 +783,37 @@ fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result ); } } + "decimal" => { + let min = spec.min.unwrap_or(0); + let max = spec.max.unwrap_or(1_000_000); + if min > max { + anyhow::bail!( + "{} has invalid bounds: min ({}) must be <= max ({})", + path, + min, + max + ); + } + if let Some(scale) = spec.scale { + if scale > 38 { + anyhow::bail!( + "{}.scale must be <= 38 for decimal strategy (got {})", + path, + scale + ); + } + } + } + "payment_card" => { + let len = spec.length.unwrap_or(16); + if !(13..=19).contains(&len) { + anyhow::bail!( + "{}.length must be between 13 and 19 for payment_card (got {})", + path, + len + ); + } + } "string" => { if let Some(0) = spec.length { anyhow::bail!("{}.length must be >= 1", path); @@ -1150,6 +1197,10 @@ mod tests { use std::time::{SystemTime, UNIX_EPOCH}; use toml::Value; + use std::sync::Mutex; + + static LOAD_CONFIG_CWD_MUTEX: Mutex<()> = Mutex::new(()); + struct CurrentDirGuard { original: PathBuf, } @@ -1206,6 +1257,9 @@ mod tests { #[test] fn load_config_fails_closed_when_nothing_found() { + let _cwd_test_lock = LOAD_CONFIG_CWD_MUTEX + .lock() + .expect("cwd test mutex poisoned"); let temp_dir = make_temp_dir("fail-closed"); { let _cwd_guard = CurrentDirGuard::change_to(&temp_dir); @@ -1221,6 +1275,9 @@ mod tests { #[test] fn load_config_allow_noop_returns_empty_config() { + let _cwd_test_lock = LOAD_CONFIG_CWD_MUTEX + .lock() + .expect("cwd test mutex poisoned"); let temp_dir = make_temp_dir("allow-noop"); { let _cwd_guard = CurrentDirGuard::change_to(&temp_dir); @@ -1235,6 +1292,9 @@ mod tests { #[test] fn load_config_reports_pyproject_without_tool_dumpling() { + let _cwd_test_lock = LOAD_CONFIG_CWD_MUTEX + .lock() + .expect("cwd test mutex poisoned"); let temp_dir = make_temp_dir("pyproject-missing-tool"); { let _cwd_guard = CurrentDirGuard::change_to(&temp_dir); @@ -1316,6 +1376,22 @@ ssn = { strategy = "redact", as_string = true, domain = "customer_identity" } let _ = fs::remove_file(path); } + #[test] + fn domain_is_rejected_for_blank_strategy() { + let path = write_temp_config( + r#" +[rules."public.users"] +notes = { strategy = "blank", domain = "x" } +"#, + ); + let err = + load_config(Some(&path), false).expect_err("expected semantic validation failure"); + let msg = format!("{:#}", err); + assert!(msg.contains("rules.\"public.users\".notes")); + assert!(msg.contains("domain")); + let _ = fs::remove_file(path); + } + #[test] fn malformed_strategy_parameters_fail_validation() { let path = write_temp_config( diff --git a/src/sql.rs b/src/sql.rs index 86e9632..1b79917 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -1533,6 +1533,7 @@ fn base_spec(strategy: &str, as_string: Option) -> AnonymizerSpec { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1549,6 +1550,7 @@ fn base_spec(strategy: &str, as_string: Option) -> AnonymizerSpec { #[cfg(test)] mod tests { use super::*; + use crate::scan::luhn_valid; use crate::settings::{AnonymizerSpec, ColumnCase, ResolvedConfig, RowFilterSet, When}; use crate::transform::{set_random_seed, AnonymizerRegistry}; use std::collections::{HashMap, HashSet}; @@ -1565,6 +1567,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: Some(-1), max_days: Some(1), @@ -1646,6 +1649,7 @@ COPY public.events (id, email, the_date) FROM stdin; salt: Some("base".into()), min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1679,6 +1683,7 @@ COPY public.events (id, email, the_date) FROM stdin; salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1712,6 +1717,7 @@ COPY public.events (id, email, the_date) FROM stdin; salt: Some("eu".into()), min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1770,6 +1776,7 @@ INSERT INTO public.users (id, email, country, is_admin) VALUES salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1859,6 +1866,7 @@ INSERT INTO public.orders (id, customer_email) VALUES salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1924,6 +1932,7 @@ INSERT INTO public.users (id, email) VALUES salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1985,6 +1994,7 @@ INSERT INTO public.users (id, email) VALUES (1, 'alice@myco.com'); salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -2071,6 +2081,7 @@ INSERT INTO public.users (id, email) VALUES salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -2158,6 +2169,7 @@ INSERT INTO public.users (id, email) VALUES salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -2362,6 +2374,7 @@ COPY public.events (id, payload) FROM stdin; salt: None, min: None, max: None, + scale: None, length: Some(8), min_days: None, max_days: None, @@ -2442,6 +2455,7 @@ COPY public.events (id, payload) FROM stdin; salt: None, min: None, max: None, + scale: None, length: Some(8), min_days: None, max_days: None, @@ -2512,6 +2526,7 @@ COPY public.events (id, payload) FROM stdin; salt: None, min: Some(0), max: Some(100), + scale: None, length: None, min_days: None, max_days: None, @@ -2581,6 +2596,80 @@ COPY public.events (id, payload) FROM stdin; assert_eq!(v_ins["label"], "x"); } + #[test] + fn pipeline_payment_card_column_rewrites_insert_and_copy() { + set_random_seed(77_007); + let mut rules: HashMap> = HashMap::new(); + let mut cols: HashMap = HashMap::new(); + cols.insert( + "pan".to_string(), + AnonymizerSpec { + strategy: "payment_card".to_string(), + salt: None, + min: None, + max: None, + scale: None, + length: Some(16), + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: None, + unique_within_domain: None, + as_string: None, + locale: None, + faker: None, + format: None, + }, + ); + rules.insert("public.payments".to_string(), cols); + let cfg = ResolvedConfig { + salt: None, + rules, + row_filters: HashMap::new(), + column_cases: HashMap::new(), + sensitive_columns: HashMap::new(), + output_scan: crate::settings::OutputScanConfig::default(), + source_path: None, + }; + let reg = AnonymizerRegistry::from_config(&cfg); + let mut proc = + SqlStreamProcessor::new(reg, cfg, Vec::new(), Vec::new(), None, DumpFormat::Postgres); + let input = r#" +CREATE TABLE public.payments (id int, pan text); +INSERT INTO public.payments (id, pan) VALUES (1, '4111111111111111'); + +COPY public.payments (id, pan) FROM stdin; +2 4111111111111111 +\. +"#; + let mut reader = std::io::BufReader::new(input.as_bytes()); + let mut out = Vec::new(); + proc.process(&mut reader, &mut out).unwrap(); + let s = String::from_utf8(out).unwrap(); + assert!( + !s.contains("4111111111111111"), + "original PAN should not appear, got:\n{s}" + ); + let insert_line = s + .lines() + .find(|l| l.contains("INSERT INTO public.payments")) + .unwrap(); + let pan_ins = insert_line + .split_once(", '") + .unwrap() + .1 + .split_once('\'') + .unwrap() + .0; + assert_eq!(pan_ins.len(), 16); + assert!(luhn_valid(pan_ins), "INSERT PAN must be Luhn-valid"); + let copy_line = s.lines().find(|l| l.starts_with("2\t")).unwrap(); + let pan_copy = copy_line.split_once('\t').unwrap().1; + assert_eq!(pan_copy.len(), 16); + assert!(luhn_valid(pan_copy)); + } + #[test] fn parse_values_rows_tracks_trailing_cast_for_quoted_literals() { let rows = @@ -2608,6 +2697,7 @@ COPY public.events (id, payload) FROM stdin; salt: None, min: None, max: None, + scale: None, length: Some(8), min_days: None, max_days: None, @@ -2676,6 +2766,7 @@ INSERT INTO public.events (id, payload) VALUES salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -2696,6 +2787,7 @@ INSERT INTO public.events (id, payload) VALUES salt: None, min: None, max: None, + scale: None, length: Some(24), min_days: None, max_days: None, @@ -2716,6 +2808,7 @@ INSERT INTO public.events (id, payload) VALUES salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -2798,6 +2891,7 @@ old@example.com verylongname (000) 000-0000 salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -2873,6 +2967,7 @@ CREATE TABLE public.users ( salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -2955,6 +3050,7 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -3045,6 +3141,7 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -3105,6 +3202,7 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -3161,6 +3259,7 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -3255,6 +3354,7 @@ INSERT INTO [dbo].[users] ([id], [email]) VALUES (1, N'alice@example.com'); salt: Some("test-salt".to_string()), min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -3317,6 +3417,7 @@ INSERT INTO [dbo].[users] ([id], [email]) VALUES (1, N'alice@example.com'); salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -3337,6 +3438,7 @@ INSERT INTO [dbo].[users] ([id], [email]) VALUES (1, N'alice@example.com'); salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, diff --git a/src/transform.rs b/src/transform.rs index 3dd8d7c..07b269c 100644 --- a/src/transform.rs +++ b/src/transform.rs @@ -2,6 +2,7 @@ use crate::faker_dispatch::{ faker_string_with_rng, pii_first_name, pii_full_name, pii_last_name, pii_phone_number, pii_safe_email, resolved_locale_key, }; +use crate::scan::luhn_valid; use crate::settings::{AnonymizerSpec, ResolvedConfig}; use chrono::Timelike; use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime}; @@ -267,6 +268,27 @@ fn apply_random_anonymizer( Replacement::unquoted("REDACTED") } } + "blank" => { + if original_unescaped.is_none() { + Replacement::null() + } else { + Replacement::quoted("") + } + } + "empty_array" => { + if original_unescaped.is_none() { + Replacement::null() + } else { + Replacement::unquoted("[]") + } + } + "empty_object" => { + if original_unescaped.is_none() { + Replacement::null() + } else { + Replacement::unquoted("{}") + } + } "uuid" => { let id = pseudo_uuid_v4(); if as_string { @@ -334,6 +356,17 @@ fn apply_random_anonymizer( let v = random_range_inclusive(min, max); Replacement::unquoted(v.to_string()) } + "decimal" => { + let min = spec.min.unwrap_or(0); + let max = spec.max.unwrap_or(1_000_000); + let scale = spec.scale.unwrap_or(2); + decimal_replacement(min, max, scale, as_string, None) + } + "payment_card" => { + let len = spec.length.unwrap_or(16); + let digits = random_payment_card_digits(len); + Replacement::quoted(digits) + } "string" => { let len = spec.length.unwrap_or(12); let s = random_alnum(len).to_lowercase(); @@ -436,6 +469,27 @@ fn apply_deterministic_anonymizer( Replacement::unquoted("REDACTED") } } + "blank" => { + if original_unescaped.is_none() { + Replacement::null() + } else { + Replacement::quoted("") + } + } + "empty_array" => { + if original_unescaped.is_none() { + Replacement::null() + } else { + Replacement::unquoted("[]") + } + } + "empty_object" => { + if original_unescaped.is_none() { + Replacement::null() + } else { + Replacement::unquoted("{}") + } + } "uuid" => { let id = deterministic_uuid_v4(&mut stream); if as_string { @@ -529,6 +583,17 @@ fn apply_deterministic_anonymizer( let v = deterministic_range_inclusive(min, max, &mut stream); Replacement::unquoted(v.to_string()) } + "decimal" => { + let min = spec.min.unwrap_or(0); + let max = spec.max.unwrap_or(1_000_000); + let scale = spec.scale.unwrap_or(2); + decimal_replacement(min, max, scale, as_string, Some(&mut stream)) + } + "payment_card" => { + let len = spec.length.unwrap_or(16); + let digits = deterministic_payment_card_digits(len, &mut stream); + Replacement::quoted(digits) + } "string" => { let len = spec.length.unwrap_or(12); let s = deterministic_alnum(len, &mut stream).to_lowercase(); @@ -732,6 +797,106 @@ fn deterministic_alnum(n: usize, stream: &mut DeterministicByteStream) -> String out } +fn random_frac_digits(scale: u32) -> String { + let mut s = String::with_capacity(scale as usize); + for _ in 0..scale { + s.push(char::from(b'0' + (random_u32() % 10) as u8)); + } + s +} + +fn deterministic_frac_digits(scale: u32, stream: &mut DeterministicByteStream) -> String { + let mut s = String::with_capacity(scale as usize); + for _ in 0..scale { + s.push(char::from(b'0' + (stream.next_u64() % 10) as u8)); + } + s +} + +/// `int_part` in `[min, max]`; `scale` fractional digits (0 = integer only). +fn decimal_replacement( + min: i64, + max: i64, + scale: u32, + as_string: bool, + mut stream: Option<&mut DeterministicByteStream>, +) -> Replacement { + let int_part = match &mut stream { + Some(s) => deterministic_range_inclusive(min, max, s), + None => random_range_inclusive(min, max), + }; + if scale == 0 { + let v = int_part.to_string(); + return if as_string { + Replacement::quoted(v) + } else { + Replacement::unquoted(v) + }; + } + let frac = match &mut stream { + Some(s) => deterministic_frac_digits(scale, s), + None => random_frac_digits(scale), + }; + let v = format!("{int_part}.{frac}"); + if as_string { + Replacement::quoted(v) + } else { + Replacement::unquoted(v) + } +} + +fn luhn_check_digit_for_prefix(prefix: &[u8]) -> u8 { + for check in 0u8..=9 { + let mut s = String::with_capacity(prefix.len() + 1); + for &d in prefix { + s.push(char::from(b'0' + d)); + } + s.push(char::from(b'0' + check)); + if luhn_valid(&s) { + return check; + } + } + 0 +} + +fn random_payment_card_digits(len: usize) -> String { + let mut prefix: Vec = Vec::with_capacity(len - 1); + for i in 0..len - 1 { + let d = if i == 0 { + 1 + (random_u32() % 9) + } else { + random_u32() % 10 + }; + prefix.push(d as u8); + } + let check = luhn_check_digit_for_prefix(&prefix); + let mut s = String::with_capacity(len); + for d in prefix { + s.push(char::from(b'0' + d)); + } + s.push(char::from(b'0' + check)); + s +} + +fn deterministic_payment_card_digits(len: usize, stream: &mut DeterministicByteStream) -> String { + let mut prefix: Vec = Vec::with_capacity(len - 1); + for i in 0..len - 1 { + let d = if i == 0 { + 1 + (stream.next_u64() % 9) + } else { + stream.next_u64() % 10 + }; + prefix.push(d as u8); + } + let check = luhn_check_digit_for_prefix(&prefix); + let mut s = String::with_capacity(len); + for d in prefix { + s.push(char::from(b'0' + d)); + } + s.push(char::from(b'0' + check)); + s +} + fn deterministic_uuid_v4(stream: &mut DeterministicByteStream) -> String { let mut bytes = [0u8; 16]; for byte in &mut bytes { @@ -759,7 +924,10 @@ fn deterministic_uuid_v4(stream: &mut DeterministicByteStream) -> String { } fn should_enforce_max_len(strategy: &str) -> bool { - !matches!(strategy, "null" | "int_range") + !matches!( + strategy, + "null" | "blank" | "empty_array" | "empty_object" | "int_range" + ) } fn truncate_arc_str(value: Arc, max_len: usize) -> Arc { @@ -953,6 +1121,7 @@ fn fuzz_datetime(input: &str, shift_seconds: i64) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::scan::luhn_valid; use crate::settings::AnonymizerSpec; use std::collections::HashMap; @@ -962,6 +1131,7 @@ mod tests { salt: salt.map(|s| s.to_string()), min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1144,6 +1314,29 @@ mod tests { ); } + #[test] + fn test_blank_empty_array_empty_object_strategies() { + let registry = make_registry(None); + let blank = make_spec("blank", None, None); + assert!(apply_anonymizer(®istry, &blank, None, None).is_null); + let b = apply_anonymizer(®istry, &blank, Some("x"), None); + assert!(!b.is_null); + assert!(b.force_quoted); + assert!(b.value.is_empty()); + + let ea = make_spec("empty_array", None, None); + assert!(apply_anonymizer(®istry, &ea, None, None).is_null); + let a = apply_anonymizer(®istry, &ea, Some("[1]"), None); + assert_eq!(a.value.as_ref(), "[]"); + assert!(!a.force_quoted); + + let eo = make_spec("empty_object", None, None); + assert!(apply_anonymizer(®istry, &eo, None, None).is_null); + let o = apply_anonymizer(®istry, &eo, Some(r#"{"a":1}"#), None); + assert_eq!(o.value.as_ref(), "{}"); + assert!(!o.force_quoted); + } + #[test] fn test_hardened_random_values_are_non_deterministic() { // Confirm CSPRNG produces varying values (extremely unlikely to collide). @@ -1160,6 +1353,119 @@ mod tests { ); } + #[test] + fn test_decimal_random_respects_min_max_and_scale() { + set_random_seed(99_001); + let registry = make_registry(None); + let spec = AnonymizerSpec { + strategy: "decimal".to_string(), + salt: None, + min: Some(10), + max: Some(20), + scale: Some(3), + length: None, + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: None, + unique_within_domain: None, + as_string: None, + locale: None, + faker: None, + format: None, + }; + let r = apply_anonymizer(®istry, &spec, Some("99.99"), None); + let (whole, frac) = r.value.split_once('.').expect("decimal must contain a dot"); + let int_part: i64 = whole.parse().expect("integer part must parse"); + assert!((10..=20).contains(&int_part)); + assert_eq!(frac.len(), 3); + assert!(frac.chars().all(|c| c.is_ascii_digit())); + assert!(!r.force_quoted); + } + + #[test] + fn test_decimal_domain_maps_consistently() { + let registry = make_registry(None); + let spec = AnonymizerSpec { + strategy: "decimal".to_string(), + salt: None, + min: Some(0), + max: Some(5), + scale: Some(2), + length: None, + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: Some("amounts".to_string()), + unique_within_domain: None, + as_string: None, + locale: None, + faker: None, + format: None, + }; + let a = apply_anonymizer(®istry, &spec, Some("100.50"), None); + let b = apply_anonymizer(®istry, &spec, Some("100.50"), None); + assert_eq!(a.value, b.value); + } + + #[test] + fn test_payment_card_random_is_luhn_valid() { + set_random_seed(42_424); + let registry = make_registry(None); + let spec = AnonymizerSpec { + strategy: "payment_card".to_string(), + salt: None, + min: None, + max: None, + scale: None, + length: Some(16), + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: None, + unique_within_domain: None, + as_string: None, + locale: None, + faker: None, + format: None, + }; + let r = apply_anonymizer(®istry, &spec, Some("4111111111111111"), None); + assert!(r.force_quoted); + assert_eq!(r.value.len(), 16); + assert!(r.value.chars().all(|c| c.is_ascii_digit())); + assert!(luhn_valid(&r.value), "PAN must pass Luhn: {}", r.value); + } + + #[test] + fn test_payment_card_domain_deterministic() { + let registry = make_registry(None); + let spec = AnonymizerSpec { + strategy: "payment_card".to_string(), + salt: None, + min: None, + max: None, + scale: None, + length: Some(16), + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: Some("cards".to_string()), + unique_within_domain: None, + as_string: None, + locale: None, + faker: None, + format: None, + }; + let r1 = apply_anonymizer(®istry, &spec, Some("4111111111111111"), None); + let r2 = apply_anonymizer(®istry, &spec, Some("4111111111111111"), None); + assert_eq!(r1.value, r2.value); + assert!(luhn_valid(&r1.value)); + } + // --- Localized name and phone strategies --- fn make_spec_with_locale(strategy: &str, locale: Option<&str>) -> AnonymizerSpec { @@ -1168,6 +1474,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1270,6 +1577,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1290,6 +1598,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1318,6 +1627,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1338,6 +1648,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1437,6 +1748,7 @@ mod tests { salt: None, min: None, max: None, + scale: None, length: None, min_days: None, max_days: None, @@ -1460,7 +1772,16 @@ mod tests { fn test_domain_mapping_null_preserved_for_multiple_strategies() { // Verify NULL preservation works across all strategies that support domain mapping. let registry = make_registry(None); - for strategy in &["email", "uuid", "name", "first_name", "last_name", "string"] { + for strategy in &[ + "email", + "uuid", + "name", + "first_name", + "last_name", + "string", + "decimal", + "payment_card", + ] { let spec = make_spec(strategy, None, Some("test_domain")); let result = apply_anonymizer(®istry, &spec, None, None); assert!(