diff --git a/.dumplingconf.example b/.dumplingconf.example index 055271f..4894f90 100644 --- a/.dumplingconf.example +++ b/.dumplingconf.example @@ -26,13 +26,17 @@ salt = "${DUMPLING_GLOBAL_SALT}" # # Each column maps to an anonymizer spec: { strategy = "…", } # --------------------------------------------------------------------------- +# Faker strategy: `faker = "module::Type"` matches the Rust `fake` crate layout. +# Crate docs: https://docs.rs/fake/latest/fake/ +# Faker modules: https://docs.rs/fake/latest/fake/faker/index.html +# Upstream repo: https://github.com/cksac/fake-rs [rules."public.users"] -# email — random-looking email at example.com; force quoted string output -email = { strategy = "email", domain = "customer_identity", unique_within_domain = true } -# name — random placeholder full name -full_name = { strategy = "name" } -first_name = { strategy = "first_name" } -last_name = { strategy = "last_name" } +# email — fake email via Rust `fake` crate; force quoted string output +email = { strategy = "faker", faker = "internet::SafeEmail", domain = "customer_identity", unique_within_domain = true } +# name — locale-aware full name (see `locale`); other generators use `faker = "module::Type"` +full_name = { strategy = "faker", faker = "name::Name" } +first_name = { strategy = "faker", faker = "name::FirstName" } +last_name = { strategy = "faker", faker = "name::LastName" } # phone — US-style (xxx) xxx-xxxx phone = { strategy = "phone" } # ssn — SHA-256 hex of original; use per-column salt for extra protection @@ -58,7 +62,7 @@ wake_time = { strategy = "time_fuzz", min_seconds = -3600, max_seconds = 360 # credit card — redact entirely; force as quoted string credit_card = { strategy = "redact", as_string = true } # keep the same anonymized email as users table via shared domain -customer_email = { strategy = "email", domain = "customer_identity" } +customer_email = { strategy = "faker", faker = "internet::SafeEmail", domain = "customer_identity" } [rules."public.audit_log"] # unqualified table name also works (matches any schema) diff --git a/.github/workflows/docs-pr.yml b/.github/workflows/docs-pr.yml new file mode 100644 index 0000000..0bedd91 --- /dev/null +++ b/.github/workflows/docs-pr.yml @@ -0,0 +1,35 @@ +# mdBook verification on pull requests only (no GitHub Pages upload or deploy). +# Pages build + deploy live in docs.yml and run on pushes to main. +name: Docs (PR) + +on: + pull_request: + paths: + - "README.md" + - "book.toml" + - "docs/**" + - ".github/workflows/docs.yml" + - ".github/workflows/docs-pr.yml" + +permissions: + contents: read + +concurrency: + group: docs-pr-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Build mdBook (verify) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install mdBook + uses: peaceiris/actions-mdbook@v2 + with: + mdbook-version: "0.4.52" + + - name: Build documentation site + run: mdbook build diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 23774a9..394622c 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,12 +1,8 @@ +# Build and deploy the mdBook site to GitHub Pages (main branch only). +# Pull-request verification runs in docs-pr.yml — this workflow does not run on PRs. name: Docs on: - pull_request: - paths: - - "README.md" - - "book.toml" - - "docs/**" - - ".github/workflows/docs.yml" push: branches: - main @@ -18,16 +14,16 @@ on: permissions: contents: read - pages: write - id-token: write concurrency: - group: docs-${{ github.ref }} + group: docs-pages-${{ github.ref }} cancel-in-progress: true jobs: build: runs-on: ubuntu-latest + permissions: + contents: read steps: - name: Checkout uses: actions/checkout@v4 @@ -40,15 +36,17 @@ jobs: - name: Build documentation site run: mdbook build - - name: Upload docs artifact + - name: Upload Pages deployment artifact uses: actions/upload-pages-artifact@v3 with: path: docs/book deploy: - if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: build runs-on: ubuntu-latest + permissions: + pages: write + id-token: write environment: name: github-pages url: ${{ steps.deployment.outputs.page_url }} diff --git a/.gitignore b/.gitignore index 14b90a7..ce8ac8f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target/ /docs/book/ +/.tools/ diff --git a/AGENTS.md b/AGENTS.md index 3bfa455..8224e76 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -223,6 +223,8 @@ Follow these steps in order. Do not skip any step. 8. **`README.md`**: Add a row to the "Anonymization strategies" table. +**`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). + --- ## How to Add a New Row Filter Predicate Operator @@ -274,15 +276,29 @@ Follow these steps in order. Do not skip any step. This is a pure Rust CLI project with **no external services** (no database, Docker, or network dependencies). The Rust stable toolchain (rustc + cargo) is the only prerequisite. +### One-shot environment (agents and humans) + +From the repository root: + +```bash +./scripts/setup-dev.sh +``` + +This installs the **stable** toolchain with **rustfmt** and **clippy** (via `rustup` when available), runs **`cargo fetch`**, and installs a pinned **mdBook** binary under `.tools/` (same version as the Docs CI workflow) so you can run `mdbook build` without a global install. Add `.tools` to `PATH` for convenience, or invoke `.tools/mdbook build` directly. + +The repo root **`rust-toolchain.toml`** pins **stable** and the **components** CI uses, so `cargo` automatically selects the right toolchain in fresh checkouts. + ### Quick reference | Task | Command | |------|---------| +| Setup (toolchain + fetch + mdbook) | `./scripts/setup-dev.sh` | | Build | `cargo build` | | Test | `cargo test --all-targets --all-features` | | Lint | `cargo clippy --all-targets --all-features` | | Format check | `cargo fmt --all -- --check` | | Auto-format | `cargo fmt` | +| Docs site (mdBook) | `mdbook build` or `.tools/mdbook build` after setup | | Run CLI | `./target/debug/dumpling --help` | ### Running the CLI @@ -295,6 +311,6 @@ Dumpling is fail-closed by default — it exits non-zero without a config file. ### Notes -- All 94 tests are inline `#[cfg(test)]` modules; there are no separate test files or fixtures to manage. +- All tests are inline `#[cfg(test)]` modules; there are no separate test files or fixtures to manage. - The update script uses `cargo fetch` to pre-download crate dependencies. A full `cargo build` or `cargo test` will then compile from the local cache without network access. - No environment variables or secrets are required for building, testing, or running the CLI locally. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e4db6a8..17ceeaa 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,14 @@ For AI coding agents: also read `AGENTS.md`, which contains more detailed techni - **Rust stable toolchain** — install via [rustup.rs](https://rustup.rs/). - No database, Docker, or external services are required. Dumpling is a pure CLI tool. +### One-shot setup (recommended) + +```bash +./scripts/setup-dev.sh +``` + +Installs stable + `rustfmt` + `clippy`, prefetches crates, and downloads a pinned **mdBook** under `.tools/` (for `mdbook build`, same version as CI). Optional: `export PATH="$PWD/.tools:$PATH"`. + ### Build and run ```bash diff --git a/README.md b/README.md index 1bd1707..1a4fff1 100644 --- a/README.md +++ b/README.md @@ -91,8 +91,8 @@ salt = "${DUMPLING_GLOBAL_SALT}" # Rules are keyed by either "table" or "schema.table" [rules."public.users"] -email = { strategy = "email", domain = "customer_identity", unique_within_domain = true } -name = { strategy = "name", locale = "de_de" } # German-locale name +email = { strategy = "faker", faker = "internet::SafeEmail", domain = "customer_identity", unique_within_domain = true } +name = { strategy = "faker", faker = "name::Name", locale = "de_de" } # German-locale name ssn = { strategy = "hash", salt = "${env:DUMPLING_USERS_SSN_SALT}", as_string = true } # SHA-256 of original (salted) age = { strategy = "int_range", min = 18, max = 90 } @@ -132,8 +132,7 @@ token = "high" | `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` | Random-looking email at `example.com` | -| `name` / `first_name` / `last_name` | Locale-aware fake name (configurable via `locale`); defaults to English | +| `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. | | `phone` | Locale-aware fake phone number (configurable via `locale`); defaults to English format | | `int_range` | Random integer in `[min, max]` | | `string` | Random alphanumeric string (`length = 12` by default) | @@ -141,6 +140,12 @@ token = "high" | `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) + ### Secret references Dumpling resolves secret references in string config fields so plaintext salts/keys @@ -188,7 +193,9 @@ dumpling --security-profile hardened --input dump.sql --check - `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 the `name`, `first_name`, `last_name`, and `phone` strategies. 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. +- `locale`: selects the language/regional format for the `faker` and `phone` strategies. 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`. @@ -332,7 +339,7 @@ Define default strategies in `rules.""` and add ordered per-column cases ```toml [rules."public.users"] email = { strategy = "hash", as_string = true } # default -name = { strategy = "name" } +name = { strategy = "faker", faker = "name::Name" } [[column_cases."public.users".email]] when.any = [{ column = "is_admin", op = "eq", value = "true" }] @@ -383,7 +390,7 @@ salt = "${DUMPLING_HMAC_KEY}" [rules."public.users"] ssn = { strategy = "hash", as_string = true } -email = { strategy = "email", domain = "users" } +email = { strategy = "faker", faker = "internet::SafeEmail", domain = "users" } ``` ```bash diff --git a/docs/src/ci-guardrails.md b/docs/src/ci-guardrails.md index 7d1018f..c978311 100644 --- a/docs/src/ci-guardrails.md +++ b/docs/src/ci-guardrails.md @@ -29,7 +29,7 @@ violations to stderr, and exits: | `empty-rules-table` | warning | A `[rules]` entry has no column rules. Likely a stale or incomplete config section. | | `empty-column-cases-table` | warning | A `[column_cases]` entry has no column cases. | | `unsalted-hash` | warning | A `hash` strategy is used with no salt (neither per-column `salt` nor global `salt`). Unsalted hashes are reversible via precomputed lookup tables for low-entropy inputs (names, emails, common IDs). | -| `inconsistent-domain-strategy` | error | The same domain name is used with two or more different strategies. This breaks referential integrity: a domain shared between `email` and `name` would try to maintain a bidirectional map between incompatible pseudonym types. | +| `inconsistent-domain-strategy` | error | The same domain name is used with two or more different strategies. This breaks referential integrity: a domain shared between incompatible generators (for example `faker` with different `faker` targets, or `faker` vs `hash`) cannot maintain a single stable mapping. | | `uncovered-sensitive-column` | error | A column listed in `[sensitive_columns]` has no matching anonymization rule or case. The column will pass through unmodified, making the sensitive declaration misleading. | --- diff --git a/docs/src/configuration.md b/docs/src/configuration.md index 6c1cc44..89be90f 100644 --- a/docs/src/configuration.md +++ b/docs/src/configuration.md @@ -31,6 +31,16 @@ If no configuration is found, Dumpling fails closed by default and exits non-zer Error output includes every checked location. If you intentionally want a no-op run, pass `--allow-noop`. +## Faker strategy and the `fake` crate + +When you use `strategy = "faker"` with `faker = "module::Type"`, those names align with the Rust [**`fake`**](https://crates.io/crates/fake) crate’s [`faker`](https://docs.rs/fake/latest/fake/faker/index.html) modules (for example `name::FirstName` ↔ `fake::faker::name::raw::FirstName`). Use the upstream docs to discover available generators and options: + +- [docs.rs — `fake` (crate overview)](https://docs.rs/fake/latest/fake/) +- [docs.rs — `fake::faker` (all faker submodules)](https://docs.rs/fake/latest/fake/faker/index.html) +- [GitHub — `cksac/fake-rs` (source + README)](https://github.com/cksac/fake-rs) + +Dumpling only exposes a **subset** wired in `src/faker_dispatch.rs`; unsupported `module::Type` pairs fail at config load. + ## Baseline config template ```toml @@ -38,7 +48,7 @@ salt = "${DUMPLING_GLOBAL_SALT}" [rules."public.users"] email = { strategy = "hash", salt = "${env:DUMPLING_USERS_EMAIL_SALT}", as_string = true } -name = { strategy = "name" } +full_name = { strategy = "faker", faker = "name::Name" } [sensitive_columns] "public.users" = ["employee_number", "tax_id"] diff --git a/docs/src/getting-started.md b/docs/src/getting-started.md index c472740..0afd888 100644 --- a/docs/src/getting-started.md +++ b/docs/src/getting-started.md @@ -2,9 +2,11 @@ ## Prerequisites -- Rust stable toolchain (edition 2021 compatible) +- Rust **stable** toolchain (`rustup` recommended). The repo includes `rust-toolchain.toml` (stable + `rustfmt` + `clippy`) so CI and local `cargo` stay aligned. - `cargo` on your `PATH` +Optional: run **`./scripts/setup-dev.sh`** once from the repo root — it installs toolchain components, **`cargo fetch`**, and a pinned **mdBook** under `.tools/` for the same docs build CI uses. + ## Build ```bash diff --git a/docs/src/index.md b/docs/src/index.md index b329fa5..97f19e5 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -11,9 +11,9 @@ This documentation covers the operating model for day-to-day use: ## Documentation quality gate -All documentation is built with `mdBook` in CI: +The mdBook site is built in CI as follows: -- pull requests must pass the docs build job, -- pushes to `main` automatically publish docs to GitHub Pages. +- **Pull requests:** the **Docs (PR)** workflow runs `mdbook build` when docs-related paths change (no deploy). +- **`main`:** the **Docs** workflow builds and deploys to GitHub Pages when docs-related paths change. This keeps the docs in a continuously deployable state instead of drifting from the codebase. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..73cb934 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "stable" +components = ["rustfmt", "clippy"] diff --git a/scripts/setup-dev.sh b/scripts/setup-dev.sh new file mode 100755 index 0000000..c786f92 --- /dev/null +++ b/scripts/setup-dev.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# One-shot dev environment for Dumpling (Rust CLI + optional mdBook for docs). +# Safe to re-run; skips work that is already done. +# +# Usage: from repo root — ./scripts/setup-dev.sh +# +# Environment: +# MDBOOK_VERSION — mdBook release tag (default: 0.4.52, matches CI docs workflow) + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT" + +MDBOOK_VERSION="${MDBOOK_VERSION:-0.4.52}" +TOOLS_DIR="${ROOT}/.tools" +MDBOOK_BIN="${TOOLS_DIR}/mdbook" + +require_rust() { + if ! command -v rustc >/dev/null 2>&1 || ! command -v cargo >/dev/null 2>&1; then + echo "error: rustc/cargo not found. Install Rust: https://rustup.rs/" >&2 + exit 1 + fi +} + +mdbook_download_url() { + local arch + case "$(uname -sm)" in + Linux\ x86_64) arch="x86_64-unknown-linux-gnu" ;; + Darwin\ x86_64) arch="x86_64-apple-darwin" ;; + Darwin\ arm64) arch="aarch64-apple-darwin" ;; + *) + echo "error: unsupported OS/arch for prebuilt mdbook: $(uname -sm)" >&2 + echo "Install mdbook yourself: https://github.com/rust-lang/mdBook/releases" >&2 + exit 1 + ;; + esac + echo "https://github.com/rust-lang/mdBook/releases/download/v${MDBOOK_VERSION}/mdbook-v${MDBOOK_VERSION}-${arch}.tar.gz" +} + +ensure_mdbook() { + if [[ -x "${MDBOOK_BIN}" ]]; then + installed="$("${MDBOOK_BIN}" --version 2>/dev/null | awk '{print $2}' || true)" + if [[ "${installed}" == "${MDBOOK_VERSION}" ]]; then + return 0 + fi + fi + + mkdir -p "${TOOLS_DIR}" + local url tmp + url="$(mdbook_download_url)" + tmp="$(mktemp -d)" + trap 'rm -rf "${tmp}"' EXIT + echo "Downloading mdbook v${MDBOOK_VERSION}…" + curl -fsSL "${url}" | tar xz -C "${tmp}" + mv "${tmp}/mdbook" "${MDBOOK_BIN}" + chmod +x "${MDBOOK_BIN}" + trap - EXIT + rm -rf "${tmp}" +} + +main() { + require_rust + + if command -v rustup >/dev/null 2>&1; then + echo "Installing stable toolchain + rustfmt + clippy (rustup)…" + rustup toolchain install stable + rustup component add rustfmt clippy --toolchain stable + else + echo "warning: rustup not found; ensure rustfmt and clippy are installed for stable CI parity." >&2 + fi + + echo "Prefetching crates (cargo fetch)…" + cargo fetch + + ensure_mdbook + echo "mdbook: ${MDBOOK_BIN} ($("${MDBOOK_BIN}" --version))" + + echo + echo "Done. Typical checks:" + echo " cargo fmt --all -- --check" + echo " cargo clippy --all-targets --all-features" + echo " cargo test --all-targets --all-features" + echo " ${MDBOOK_BIN} build # same as Docs CI (book.toml → docs/book)" + echo + echo "Tip: add ${TOOLS_DIR} to PATH for this shell: export PATH=\"${TOOLS_DIR}:\${PATH}\"" +} + +main "$@" diff --git a/src/faker_dispatch.rs b/src/faker_dispatch.rs new file mode 100644 index 0000000..cd78c36 --- /dev/null +++ b/src/faker_dispatch.rs @@ -0,0 +1,521 @@ +//! Dispatch from config `faker = "module::Type"` to the [`fake`](https://crates.io/crates/fake) crate. +//! +//! **Upstream documentation** (for humans/agents choosing or adding generators): +//! - [docs.rs — `fake` crate](https://docs.rs/fake/latest/fake/) +//! - [docs.rs — `fake::faker` modules](https://docs.rs/fake/latest/fake/faker/index.html) +//! - [GitHub — cksac/fake-rs](https://github.com/cksac/fake-rs) +//! +//! Rust cannot load arbitrary Faker types by string at runtime; this module is the **only** +//! allowlist of generators compiled into Dumpling. Config never supplies executable code—only +//! string keys that map to these arms. + +use crate::settings::AnonymizerSpec; +use fake::faker::address::raw::{ + BuildingNumber, CityName, CityPrefix, CitySuffix, CountryCode, CountryName, Geohash, Latitude, + Longitude, PostCode, SecondaryAddress, SecondaryAddressType, StateAbbr, StateName, StreetName, + StreetSuffix, TimeZone, ZipCode, +}; +use fake::faker::barcode::raw::{Isbn, Isbn10, Isbn13}; +use fake::faker::boolean::raw::Boolean; +use fake::faker::company::raw::{ + Bs, BsAdj, BsNoun, BsVerb, Buzzword, BuzzwordMiddle, BuzzwordTail, CatchPhrase, CompanyName, + CompanySuffix, Industry, Profession, +}; +use fake::faker::creditcard::raw::CreditCardNumber; +use fake::faker::currency::raw::{CurrencyCode, CurrencyName, CurrencySymbol}; +use fake::faker::filesystem::raw::{ + DirPath, FileExtension, FileName, FilePath, MimeType, Semver, SemverStable, SemverUnstable, +}; +use fake::faker::finance::raw::{Bic, Isin}; +use fake::faker::internet::raw::{ + DomainSuffix, FreeEmail, FreeEmailProvider, IPv4, IPv6, MACAddress, Password, SafeEmail, + UserAgent, Username, IP, +}; +use fake::faker::job::raw::{Field, Position, Seniority, Title as JobTitle}; +use fake::faker::lorem::raw::{Paragraph, Paragraphs, Sentence, Sentences, Word, Words}; +use fake::faker::markdown::raw::{ + BlockQuoteMultiLine, BlockQuoteSingleLine, BoldWord, BulletPoints, Code, ItalicWord, Link, + ListItems, +}; +use fake::faker::name::raw::{FirstName, LastName, Name, NameWithTitle, Suffix, Title}; +use fake::faker::number::raw::{Digit, NumberWithFormat}; +use fake::faker::phone_number::raw::{CellNumber, PhoneNumber}; +use fake::locales::{AR_SA, CY_GB, DE_DE, EN, FR_FR, IT_IT, JA_JP, PT_BR, PT_PT, ZH_CN, ZH_TW}; +use fake::Fake; +use rand::rngs::StdRng; +use rand::SeedableRng; +use std::ops::Range; + +macro_rules! fl { + ($locale:expr, $rng:expr, $ctor:ident) => { + match $locale { + "fr_fr" => $ctor(FR_FR).fake_with_rng($rng), + "de_de" => $ctor(DE_DE).fake_with_rng($rng), + "it_it" => $ctor(IT_IT).fake_with_rng($rng), + "pt_br" => $ctor(PT_BR).fake_with_rng($rng), + "pt_pt" => $ctor(PT_PT).fake_with_rng($rng), + "ar_sa" => $ctor(AR_SA).fake_with_rng($rng), + "zh_cn" => $ctor(ZH_CN).fake_with_rng($rng), + "zh_tw" => $ctor(ZH_TW).fake_with_rng($rng), + "ja_jp" => $ctor(JA_JP).fake_with_rng($rng), + "cy_gb" => $ctor(CY_GB).fake_with_rng($rng), + _ => $ctor(EN).fake_with_rng($rng), + } + }; +} + +/// Inclusive `min`/`max` from config → half-open `Range` for `fake` tuple fakers. +fn count_range(spec: &AnonymizerSpec, default_low: i64, default_high: i64) -> Range { + let low = spec.min.unwrap_or(default_low).max(0) as usize; + let high = spec.max.unwrap_or(default_high).max(0) as usize; + let (a, b) = if low <= high { + (low, high) + } else { + (high, low) + }; + let end = b.saturating_add(1).max(a.saturating_add(1)); + a..end +} + +/// Parse `faker` value: `"module::Type"` (case-insensitive module and type names). +pub fn parse_faker_path(faker: &str) -> Option<(&str, &str)> { + let trimmed = faker.trim(); + let (module, typ) = trimmed.rsplit_once("::")?; + let module = module.trim(); + let typ = typ.trim(); + if module.is_empty() || typ.is_empty() { + return None; + } + Some((module, typ)) +} + +pub fn faker_string_with_rng(spec: &AnonymizerSpec, rng: &mut StdRng) -> Option { + let faker = spec.faker.as_deref()?.trim(); + if faker.is_empty() { + return None; + } + let (module, typ) = parse_faker_path(faker)?; + let module_lc = module.to_ascii_lowercase(); + let typ_lc = typ.to_ascii_lowercase(); + let locale = spec + .locale + .as_deref() + .map(|l| l.trim().to_ascii_lowercase()) + .unwrap_or_else(|| "en".to_string()); + let loc = locale.as_str(); + + let s: String = match (module_lc.as_str(), typ_lc.as_str()) { + ("name", "firstname") => fl!(loc, rng, FirstName), + ("name", "lastname") => fl!(loc, rng, LastName), + ("name", "name") => fl!(loc, rng, Name), + ("name", "namewithtitle") => fl!(loc, rng, NameWithTitle), + ("name", "title") => fl!(loc, rng, Title), + ("name", "suffix") => fl!(loc, rng, Suffix), + + ("internet", "freeemail") => fl!(loc, rng, FreeEmail), + ("internet", "safeemail") => fl!(loc, rng, SafeEmail), + ("internet", "username") => fl!(loc, rng, Username), + ("internet", "freeemailprovider") => fl!(loc, rng, FreeEmailProvider), + ("internet", "domainsuffix") => fl!(loc, rng, DomainSuffix), + ("internet", "ipv4") => fl!(loc, rng, IPv4), + ("internet", "ipv6") => fl!(loc, rng, IPv6), + ("internet", "ip") => fl!(loc, rng, IP), + ("internet", "macaddress") => fl!(loc, rng, MACAddress), + ("internet", "useragent") => fl!(loc, rng, UserAgent), + ("internet", "password") => { + let len = spec.length.unwrap_or(12).max(1); + let r = len..len.saturating_add(1); + match loc { + "fr_fr" => Password(FR_FR, r).fake_with_rng(rng), + "de_de" => Password(DE_DE, r).fake_with_rng(rng), + "it_it" => Password(IT_IT, r).fake_with_rng(rng), + "pt_br" => Password(PT_BR, r).fake_with_rng(rng), + "pt_pt" => Password(PT_PT, r).fake_with_rng(rng), + "ar_sa" => Password(AR_SA, r).fake_with_rng(rng), + "zh_cn" => Password(ZH_CN, r).fake_with_rng(rng), + "zh_tw" => Password(ZH_TW, r).fake_with_rng(rng), + "ja_jp" => Password(JA_JP, r).fake_with_rng(rng), + "cy_gb" => Password(CY_GB, r).fake_with_rng(rng), + _ => Password(EN, r).fake_with_rng(rng), + } + } + + ("phone_number", "phonenumber") | ("phone_number", "phone") => fl!(loc, rng, PhoneNumber), + ("phone_number", "cellnumber") | ("phone_number", "cell") => fl!(loc, rng, CellNumber), + + ("address", "cityprefix") => fl!(loc, rng, CityPrefix), + ("address", "citysuffix") => fl!(loc, rng, CitySuffix), + ("address", "cityname") => fl!(loc, rng, CityName), + ("address", "countryname") => fl!(loc, rng, CountryName), + ("address", "countrycode") => fl!(loc, rng, CountryCode), + ("address", "streetsuffix") => fl!(loc, rng, StreetSuffix), + ("address", "streetname") => fl!(loc, rng, StreetName), + ("address", "timezone") => fl!(loc, rng, TimeZone), + ("address", "statename") => fl!(loc, rng, StateName), + ("address", "stateabbr") => fl!(loc, rng, StateAbbr), + ("address", "secondaryaddresstype") => fl!(loc, rng, SecondaryAddressType), + ("address", "secondaryaddress") => fl!(loc, rng, SecondaryAddress), + ("address", "zipcode") => fl!(loc, rng, ZipCode), + ("address", "postcode") => fl!(loc, rng, PostCode), + ("address", "buildingnumber") => fl!(loc, rng, BuildingNumber), + ("address", "latitude") => fl!(loc, rng, Latitude), + ("address", "longitude") => fl!(loc, rng, Longitude), + ("address", "geohash") => { + let p = spec.min.unwrap_or(8).clamp(1, 20) as u8; + match loc { + "fr_fr" => Geohash(FR_FR, p).fake_with_rng(rng), + "de_de" => Geohash(DE_DE, p).fake_with_rng(rng), + "it_it" => Geohash(IT_IT, p).fake_with_rng(rng), + "pt_br" => Geohash(PT_BR, p).fake_with_rng(rng), + "pt_pt" => Geohash(PT_PT, p).fake_with_rng(rng), + "ar_sa" => Geohash(AR_SA, p).fake_with_rng(rng), + "zh_cn" => Geohash(ZH_CN, p).fake_with_rng(rng), + "zh_tw" => Geohash(ZH_TW, p).fake_with_rng(rng), + "ja_jp" => Geohash(JA_JP, p).fake_with_rng(rng), + "cy_gb" => Geohash(CY_GB, p).fake_with_rng(rng), + _ => Geohash(EN, p).fake_with_rng(rng), + } + } + + ("company", "companysuffix") => fl!(loc, rng, CompanySuffix), + ("company", "companyname") => fl!(loc, rng, CompanyName), + ("company", "buzzword") => fl!(loc, rng, Buzzword), + ("company", "buzzwordmiddle") => fl!(loc, rng, BuzzwordMiddle), + ("company", "buzzwordtail") => fl!(loc, rng, BuzzwordTail), + ("company", "catchphrase") => fl!(loc, rng, CatchPhrase), + ("company", "bsverb") => fl!(loc, rng, BsVerb), + ("company", "bsadj") => fl!(loc, rng, BsAdj), + ("company", "bsnoun") => fl!(loc, rng, BsNoun), + ("company", "bs") => fl!(loc, rng, Bs), + ("company", "profession") => fl!(loc, rng, Profession), + ("company", "industry") => fl!(loc, rng, Industry), + + ("job", "seniority") => fl!(loc, rng, Seniority), + ("job", "field") => fl!(loc, rng, Field), + ("job", "position") => fl!(loc, rng, Position), + ("job", "title") => fl!(loc, rng, JobTitle), + + ("lorem", "word") => fl!(loc, rng, Word), + ("lorem", "words") => { + let r = count_range(spec, 3, 5); + match loc { + "fr_fr" => Words(FR_FR, r) + .fake_with_rng::, _>(rng) + .join(" "), + "de_de" => Words(DE_DE, r) + .fake_with_rng::, _>(rng) + .join(" "), + "it_it" => Words(IT_IT, r) + .fake_with_rng::, _>(rng) + .join(" "), + "pt_br" => Words(PT_BR, r) + .fake_with_rng::, _>(rng) + .join(" "), + "pt_pt" => Words(PT_PT, r) + .fake_with_rng::, _>(rng) + .join(" "), + "ar_sa" => Words(AR_SA, r) + .fake_with_rng::, _>(rng) + .join(" "), + "zh_cn" => Words(ZH_CN, r) + .fake_with_rng::, _>(rng) + .join(" "), + "zh_tw" => Words(ZH_TW, r) + .fake_with_rng::, _>(rng) + .join(" "), + "ja_jp" => Words(JA_JP, r) + .fake_with_rng::, _>(rng) + .join(" "), + "cy_gb" => Words(CY_GB, r) + .fake_with_rng::, _>(rng) + .join(" "), + _ => Words(EN, r).fake_with_rng::, _>(rng).join(" "), + } + } + ("lorem", "sentence") => { + let r = count_range(spec, 4, 10); + match loc { + "fr_fr" => Sentence(FR_FR, r).fake_with_rng(rng), + "de_de" => Sentence(DE_DE, r).fake_with_rng(rng), + "it_it" => Sentence(IT_IT, r).fake_with_rng(rng), + "pt_br" => Sentence(PT_BR, r).fake_with_rng(rng), + "pt_pt" => Sentence(PT_PT, r).fake_with_rng(rng), + "ar_sa" => Sentence(AR_SA, r).fake_with_rng(rng), + "zh_cn" => Sentence(ZH_CN, r).fake_with_rng(rng), + "zh_tw" => Sentence(ZH_TW, r).fake_with_rng(rng), + "ja_jp" => Sentence(JA_JP, r).fake_with_rng(rng), + "cy_gb" => Sentence(CY_GB, r).fake_with_rng(rng), + _ => Sentence(EN, r).fake_with_rng(rng), + } + } + ("lorem", "sentences") => { + let outer = count_range(spec, 2, 4); + let v: Vec = match loc { + "fr_fr" => Sentences(FR_FR, outer).fake_with_rng(rng), + "de_de" => Sentences(DE_DE, outer).fake_with_rng(rng), + "it_it" => Sentences(IT_IT, outer).fake_with_rng(rng), + "pt_br" => Sentences(PT_BR, outer).fake_with_rng(rng), + "pt_pt" => Sentences(PT_PT, outer).fake_with_rng(rng), + "ar_sa" => Sentences(AR_SA, outer).fake_with_rng(rng), + "zh_cn" => Sentences(ZH_CN, outer).fake_with_rng(rng), + "zh_tw" => Sentences(ZH_TW, outer).fake_with_rng(rng), + "ja_jp" => Sentences(JA_JP, outer).fake_with_rng(rng), + "cy_gb" => Sentences(CY_GB, outer).fake_with_rng(rng), + _ => Sentences(EN, outer).fake_with_rng(rng), + }; + v.join(" ") + } + ("lorem", "paragraph") => { + let r = count_range(spec, 4, 7); + match loc { + "fr_fr" => Paragraph(FR_FR, r).fake_with_rng(rng), + "de_de" => Paragraph(DE_DE, r).fake_with_rng(rng), + "it_it" => Paragraph(IT_IT, r).fake_with_rng(rng), + "pt_br" => Paragraph(PT_BR, r).fake_with_rng(rng), + "pt_pt" => Paragraph(PT_PT, r).fake_with_rng(rng), + "ar_sa" => Paragraph(AR_SA, r).fake_with_rng(rng), + "zh_cn" => Paragraph(ZH_CN, r).fake_with_rng(rng), + "zh_tw" => Paragraph(ZH_TW, r).fake_with_rng(rng), + "ja_jp" => Paragraph(JA_JP, r).fake_with_rng(rng), + "cy_gb" => Paragraph(CY_GB, r).fake_with_rng(rng), + _ => Paragraph(EN, r).fake_with_rng(rng), + } + } + ("lorem", "paragraphs") => { + let outer = count_range(spec, 2, 4); + let v: Vec = match loc { + "fr_fr" => Paragraphs(FR_FR, outer).fake_with_rng(rng), + "de_de" => Paragraphs(DE_DE, outer).fake_with_rng(rng), + "it_it" => Paragraphs(IT_IT, outer).fake_with_rng(rng), + "pt_br" => Paragraphs(PT_BR, outer).fake_with_rng(rng), + "pt_pt" => Paragraphs(PT_PT, outer).fake_with_rng(rng), + "ar_sa" => Paragraphs(AR_SA, outer).fake_with_rng(rng), + "zh_cn" => Paragraphs(ZH_CN, outer).fake_with_rng(rng), + "zh_tw" => Paragraphs(ZH_TW, outer).fake_with_rng(rng), + "ja_jp" => Paragraphs(JA_JP, outer).fake_with_rng(rng), + "cy_gb" => Paragraphs(CY_GB, outer).fake_with_rng(rng), + _ => Paragraphs(EN, outer).fake_with_rng(rng), + }; + v.join("\n\n") + } + + ("markdown", "italicword") => fl!(loc, rng, ItalicWord), + ("markdown", "boldword") => fl!(loc, rng, BoldWord), + ("markdown", "link") => fl!(loc, rng, Link), + ("markdown", "bulletpoints") => { + let r = count_range(spec, 2, 5); + match loc { + "fr_fr" => BulletPoints(FR_FR, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "de_de" => BulletPoints(DE_DE, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "it_it" => BulletPoints(IT_IT, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "pt_br" => BulletPoints(PT_BR, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "pt_pt" => BulletPoints(PT_PT, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "ar_sa" => BulletPoints(AR_SA, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "zh_cn" => BulletPoints(ZH_CN, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "zh_tw" => BulletPoints(ZH_TW, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "ja_jp" => BulletPoints(JA_JP, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "cy_gb" => BulletPoints(CY_GB, r) + .fake_with_rng::, _>(rng) + .join("\n"), + _ => BulletPoints(EN, r) + .fake_with_rng::, _>(rng) + .join("\n"), + } + } + ("markdown", "listitems") => { + let r = count_range(spec, 2, 5); + match loc { + "fr_fr" => ListItems(FR_FR, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "de_de" => ListItems(DE_DE, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "it_it" => ListItems(IT_IT, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "pt_br" => ListItems(PT_BR, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "pt_pt" => ListItems(PT_PT, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "ar_sa" => ListItems(AR_SA, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "zh_cn" => ListItems(ZH_CN, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "zh_tw" => ListItems(ZH_TW, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "ja_jp" => ListItems(JA_JP, r) + .fake_with_rng::, _>(rng) + .join("\n"), + "cy_gb" => ListItems(CY_GB, r) + .fake_with_rng::, _>(rng) + .join("\n"), + _ => ListItems(EN, r) + .fake_with_rng::, _>(rng) + .join("\n"), + } + } + ("markdown", "blockquotesingleline") => { + let r = count_range(spec, 1, 3); + match loc { + "fr_fr" => BlockQuoteSingleLine(FR_FR, r).fake_with_rng(rng), + "de_de" => BlockQuoteSingleLine(DE_DE, r).fake_with_rng(rng), + "it_it" => BlockQuoteSingleLine(IT_IT, r).fake_with_rng(rng), + "pt_br" => BlockQuoteSingleLine(PT_BR, r).fake_with_rng(rng), + "pt_pt" => BlockQuoteSingleLine(PT_PT, r).fake_with_rng(rng), + "ar_sa" => BlockQuoteSingleLine(AR_SA, r).fake_with_rng(rng), + "zh_cn" => BlockQuoteSingleLine(ZH_CN, r).fake_with_rng(rng), + "zh_tw" => BlockQuoteSingleLine(ZH_TW, r).fake_with_rng(rng), + "ja_jp" => BlockQuoteSingleLine(JA_JP, r).fake_with_rng(rng), + "cy_gb" => BlockQuoteSingleLine(CY_GB, r).fake_with_rng(rng), + _ => BlockQuoteSingleLine(EN, r).fake_with_rng(rng), + } + } + ("markdown", "blockquotemultiline") => { + let r = count_range(spec, 2, 4); + match loc { + "fr_fr" => BlockQuoteMultiLine(FR_FR, r).fake_with_rng::, _>(rng), + "de_de" => BlockQuoteMultiLine(DE_DE, r).fake_with_rng::, _>(rng), + "it_it" => BlockQuoteMultiLine(IT_IT, r).fake_with_rng::, _>(rng), + "pt_br" => BlockQuoteMultiLine(PT_BR, r).fake_with_rng::, _>(rng), + "pt_pt" => BlockQuoteMultiLine(PT_PT, r).fake_with_rng::, _>(rng), + "ar_sa" => BlockQuoteMultiLine(AR_SA, r).fake_with_rng::, _>(rng), + "zh_cn" => BlockQuoteMultiLine(ZH_CN, r).fake_with_rng::, _>(rng), + "zh_tw" => BlockQuoteMultiLine(ZH_TW, r).fake_with_rng::, _>(rng), + "ja_jp" => BlockQuoteMultiLine(JA_JP, r).fake_with_rng::, _>(rng), + "cy_gb" => BlockQuoteMultiLine(CY_GB, r).fake_with_rng::, _>(rng), + _ => BlockQuoteMultiLine(EN, r).fake_with_rng::, _>(rng), + } + .join("\n") + } + ("markdown", "code") => { + let r = count_range(spec, 3, 8); + match loc { + "fr_fr" => Code(FR_FR, r).fake_with_rng(rng), + "de_de" => Code(DE_DE, r).fake_with_rng(rng), + "it_it" => Code(IT_IT, r).fake_with_rng(rng), + "pt_br" => Code(PT_BR, r).fake_with_rng(rng), + "pt_pt" => Code(PT_PT, r).fake_with_rng(rng), + "ar_sa" => Code(AR_SA, r).fake_with_rng(rng), + "zh_cn" => Code(ZH_CN, r).fake_with_rng(rng), + "zh_tw" => Code(ZH_TW, r).fake_with_rng(rng), + "ja_jp" => Code(JA_JP, r).fake_with_rng(rng), + "cy_gb" => Code(CY_GB, r).fake_with_rng(rng), + _ => Code(EN, r).fake_with_rng(rng), + } + } + + ("number", "digit") => fl!(loc, rng, Digit), + ("number", "numberwithformat") => { + let fmt = spec.format.as_deref().unwrap_or("###-####").trim(); + if fmt.is_empty() { + return None; + } + match loc { + "fr_fr" => NumberWithFormat(FR_FR, fmt).fake_with_rng(rng), + "de_de" => NumberWithFormat(DE_DE, fmt).fake_with_rng(rng), + "it_it" => NumberWithFormat(IT_IT, fmt).fake_with_rng(rng), + "pt_br" => NumberWithFormat(PT_BR, fmt).fake_with_rng(rng), + "pt_pt" => NumberWithFormat(PT_PT, fmt).fake_with_rng(rng), + "ar_sa" => NumberWithFormat(AR_SA, fmt).fake_with_rng(rng), + "zh_cn" => NumberWithFormat(ZH_CN, fmt).fake_with_rng(rng), + "zh_tw" => NumberWithFormat(ZH_TW, fmt).fake_with_rng(rng), + "ja_jp" => NumberWithFormat(JA_JP, fmt).fake_with_rng(rng), + "cy_gb" => NumberWithFormat(CY_GB, fmt).fake_with_rng(rng), + _ => NumberWithFormat(EN, fmt).fake_with_rng(rng), + } + } + + ("boolean", "boolean") => { + let ratio = spec.min.unwrap_or(50).clamp(0, 100) as u8; + match loc { + "fr_fr" => Boolean(FR_FR, ratio) + .fake_with_rng::(rng) + .to_string(), + "de_de" => Boolean(DE_DE, ratio) + .fake_with_rng::(rng) + .to_string(), + "it_it" => Boolean(IT_IT, ratio) + .fake_with_rng::(rng) + .to_string(), + "pt_br" => Boolean(PT_BR, ratio) + .fake_with_rng::(rng) + .to_string(), + "pt_pt" => Boolean(PT_PT, ratio) + .fake_with_rng::(rng) + .to_string(), + "ar_sa" => Boolean(AR_SA, ratio) + .fake_with_rng::(rng) + .to_string(), + "zh_cn" => Boolean(ZH_CN, ratio) + .fake_with_rng::(rng) + .to_string(), + "zh_tw" => Boolean(ZH_TW, ratio) + .fake_with_rng::(rng) + .to_string(), + "ja_jp" => Boolean(JA_JP, ratio) + .fake_with_rng::(rng) + .to_string(), + "cy_gb" => Boolean(CY_GB, ratio) + .fake_with_rng::(rng) + .to_string(), + _ => Boolean(EN, ratio).fake_with_rng::(rng).to_string(), + } + } + + ("barcode", "isbn") => fl!(loc, rng, Isbn), + ("barcode", "isbn10") => fl!(loc, rng, Isbn10), + ("barcode", "isbn13") => fl!(loc, rng, Isbn13), + + ("creditcard", "creditcardnumber") => fl!(loc, rng, CreditCardNumber), + + ("currency", "currencycode") => fl!(loc, rng, CurrencyCode), + ("currency", "currencyname") => fl!(loc, rng, CurrencyName), + ("currency", "currencysymbol") => fl!(loc, rng, CurrencySymbol), + + ("finance", "bic") => fl!(loc, rng, Bic), + ("finance", "isin") => fl!(loc, rng, Isin), + + ("filesystem", "filepath") => fl!(loc, rng, FilePath), + ("filesystem", "filename") => fl!(loc, rng, FileName), + ("filesystem", "fileextension") => fl!(loc, rng, FileExtension), + ("filesystem", "dirpath") => fl!(loc, rng, DirPath), + ("filesystem", "mimetype") => fl!(loc, rng, MimeType), + ("filesystem", "semver") => fl!(loc, rng, Semver), + ("filesystem", "semverstable") => fl!(loc, rng, SemverStable), + ("filesystem", "semverunstable") => fl!(loc, rng, SemverUnstable), + + _ => return None, + }; + + Some(s) +} + +/// Returns true if `spec` selects a built-in `fake` generator Dumpling knows how to call. +pub fn faker_path_supported(spec: &AnonymizerSpec) -> bool { + let mut rng = StdRng::from_seed([0u8; 32]); + faker_string_with_rng(spec, &mut rng).is_some() +} diff --git a/src/lint.rs b/src/lint.rs index 6de9015..d8d289a 100644 --- a/src/lint.rs +++ b/src/lint.rs @@ -280,6 +280,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: None, + faker: None, + format: None, } } @@ -298,6 +300,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: None, + faker: None, + format: None, } } @@ -316,6 +320,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: None, + faker: None, + format: None, } } diff --git a/src/main.rs b/src/main.rs index 9a5a377..bb16942 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use std::path::{Path, PathBuf}; use clap::{ArgAction, Parser, Subcommand}; +mod faker_dispatch; mod filter; mod lint; mod report; diff --git a/src/scan.rs b/src/scan.rs index d316941..83c74d9 100644 --- a/src/scan.rs +++ b/src/scan.rs @@ -284,6 +284,7 @@ fn ranges_overlap(a: (usize, usize), b: (usize, usize)) -> bool { a.0 < b.1 && b.0 < a.1 } +#[allow(unknown_lints, clippy::manual_is_multiple_of)] fn luhn_valid(input: &str) -> bool { if input.is_empty() { return false; diff --git a/src/settings.rs b/src/settings.rs index 4ca6564..3b06ca8 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -32,7 +32,7 @@ pub struct RawConfig { #[derive(Debug, Clone, Deserialize)] pub struct AnonymizerSpec { - /// Strategy name: redact|null|uuid|hash|email|name|first_name|last_name|phone|int_range|string|date_fuzz|time_fuzz|datetime_fuzz + /// Strategy name: redact|null|uuid|hash|faker|phone|int_range|string|date_fuzz|time_fuzz|datetime_fuzz pub strategy: String, /// if strategy=hash: optional per-column salt override; otherwise ignored pub salt: Option, @@ -56,11 +56,17 @@ pub struct AnonymizerSpec { /// Force the replacement to be rendered as a SQL string literal /// If unset, we attempt to preserve the original quoting style. pub as_string: Option, - /// Locale for locale-aware strategies: name, first_name, last_name, phone. - /// Supported values: en, fr_fr, de_de, it_it, nl_nl, pt_br, pt_pt, tr_tr, - /// ar_sa, zh_cn, zh_tw, ja_jp, fa_ir, cy_gb. + /// Locale for locale-aware strategies: `faker` (passed to the `fake` crate) 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. pub locale: Option, + /// When `strategy = "faker"`, selects the `fake` generator as `"module::Type"` (e.g. `internet::SafeEmail`, `name::FirstName`). + /// See [`fake::faker`](https://docs.rs/fake/latest/fake/faker/index.html) and the [crate docs](https://docs.rs/fake/latest/fake/). + #[serde(default)] + pub faker: Option, + /// When `strategy = "faker"` with `number::NumberWithFormat`, the pattern string (`#` and `^` digit placeholders). + #[serde(default)] + pub format: Option, } #[derive(Debug, Clone)] @@ -442,6 +448,31 @@ fn is_simple_key(key: &str) -> bool { .all(|ch| ch.is_ascii_alphanumeric() || ch == '_') } +/// Map removed built-in strategies to `faker` + `faker` path for backwards compatibility. +fn normalize_anonymizer_spec(mut spec: AnonymizerSpec) -> AnonymizerSpec { + let s = spec.strategy.to_ascii_lowercase(); + match s.as_str() { + "email" => { + spec.strategy = "faker".to_string(); + spec.faker = Some("internet::SafeEmail".to_string()); + } + "name" => { + spec.strategy = "faker".to_string(); + spec.faker = Some("name::Name".to_string()); + } + "first_name" => { + spec.strategy = "faker".to_string(); + spec.faker = Some("name::FirstName".to_string()); + } + "last_name" => { + spec.strategy = "faker".to_string(); + spec.faker = Some("name::LastName".to_string()); + } + _ => {} + } + spec +} + fn resolve(raw: RawConfig, source_path: Option) -> ResolvedConfig { let RawConfig { salt, @@ -467,7 +498,7 @@ fn resolve(raw: RawConfig, source_path: Option) -> ResolvedConfig { let table_key_norm = table_key.to_lowercase(); let mut col_map: HashMap = HashMap::new(); for (col, spec) in cols.into_iter() { - col_map.insert(col.to_lowercase(), spec); + col_map.insert(col.to_lowercase(), normalize_anonymizer_spec(spec)); } normalized_rules.insert(table_key_norm, col_map); } @@ -480,6 +511,13 @@ fn resolve(raw: RawConfig, source_path: Option) -> ResolvedConfig { let table_key_norm = table_key.to_lowercase(); let mut inner: HashMap> = HashMap::new(); for (col, cases) in cols.into_iter() { + let cases: Vec = cases + .into_iter() + .map(|mut c| { + c.strategy = normalize_anonymizer_spec(c.strategy.clone()); + c + }) + .collect(); inner.insert(col.to_lowercase(), cases); } normalized_cases.insert(table_key_norm, inner); @@ -532,10 +570,7 @@ const KNOWN_STRATEGIES: &[&str] = &[ "redact", "uuid", "hash", - "email", - "name", - "first_name", - "last_name", + "faker", "phone", "int_range", "string", @@ -638,6 +673,9 @@ fn is_valid_severity(value: &str) -> bool { } fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result<()> { + // Legacy strategy names (`email`, `name`, …) normalize to `faker` during `resolve()`; apply the + // same mapping here so file validation matches runtime behavior. + let spec = normalize_anonymizer_spec(spec.clone()); let strategy = spec.strategy.as_str(); if !KNOWN_STRATEGIES.contains(&strategy) { anyhow::bail!( @@ -665,7 +703,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" { + if (spec.min.is_some() || spec.max.is_some()) && strategy != "int_range" && strategy != "faker" + { if spec.min.is_some() { unsupported.push("min"); } @@ -673,9 +712,15 @@ fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result unsupported.push("max"); } } - if spec.length.is_some() && strategy != "string" { + if spec.length.is_some() && strategy != "string" && strategy != "faker" { unsupported.push("length"); } + if spec.format.is_some() && strategy != "faker" { + unsupported.push("format"); + } + if spec.faker.is_some() && strategy != "faker" { + unsupported.push("faker"); + } if (spec.min_days.is_some() || spec.max_days.is_some()) && strategy != "date_fuzz" { if spec.min_days.is_some() { unsupported.push("min_days"); @@ -694,7 +739,7 @@ fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result unsupported.push("max_seconds"); } } - if spec.locale.is_some() && !matches!(strategy, "name" | "first_name" | "last_name" | "phone") { + if spec.locale.is_some() && !matches!(strategy, "faker" | "phone") { unsupported.push("locale"); } @@ -779,6 +824,35 @@ fn validate_anonymizer_spec(spec: &AnonymizerSpec, path: &str) -> anyhow::Result ); } } + "faker" => { + let Some(faker) = spec + .faker + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + else { + anyhow::bail!( + "{}.strategy = \"faker\" requires a non-empty `faker` key, e.g. faker = \"internet::SafeEmail\"", + path + ); + }; + if crate::faker_dispatch::parse_faker_path(faker).is_none() { + anyhow::bail!( + "{}.faker must look like \"module::Type\" (e.g. \"name::FirstName\"); got {:?}", + path, + faker + ); + } + if !crate::faker_dispatch::faker_path_supported(&spec) { + anyhow::bail!( + "{}.faker {:?} is not a supported generator; see README and \ + https://docs.rs/fake/latest/fake/faker/index.html for upstream module names. \ + Dumpling never runs user-supplied Rust from config—only built-in targets ship with the binary.", + path, + faker + ); + } + } _ => {} } @@ -885,7 +959,7 @@ pub fn lookup_json_path_rules_for_column<'a>( } let mut out: Vec<(Vec, &'a AnonymizerSpec)> = by_path.into_iter().collect(); - out.sort_by(|a, b| b.0.len().cmp(&a.0.len())); + out.sort_by_key(|(path, _)| std::cmp::Reverse(path.len())); out } @@ -1213,7 +1287,7 @@ email = { strategy = "has" } let path = write_temp_config( r#" [rules."public.users"] -email = { strategy = "email", min = 1 } +email = { strategy = "hash", min = 1 } "#, ); let err = @@ -1230,7 +1304,7 @@ email = { strategy = "email", min = 1 } let path = write_temp_config( r#" [rules."public.users"] -email = { strategy = "email", unique_within_domain = true } +email = { strategy = "faker", faker = "internet::SafeEmail", unique_within_domain = true } "#, ); let err = @@ -1559,7 +1633,7 @@ salt = "${vault:secret/dumpling#key}" let path = write_temp_config( r#" [rules."public.users"] -full_name = { strategy = "name", locale = "de_de" } +full_name = { strategy = "faker", faker = "name::Name", locale = "de_de" } "#, ); let cfg = load_config(Some(&path), false).expect("locale=de_de should be valid"); @@ -1569,6 +1643,8 @@ full_name = { strategy = "name", locale = "de_de" } .and_then(|c| c.get("full_name")) .expect("expected full_name rule"); assert_eq!(spec.locale.as_deref(), Some("de_de")); + assert_eq!(spec.strategy, "faker"); + assert_eq!(spec.faker.as_deref(), Some("name::Name")); let _ = fs::remove_file(path); } @@ -1595,7 +1671,7 @@ phone = { strategy = "phone", locale = "ja_jp" } let path = write_temp_config( r#" [rules."public.users"] -full_name = { strategy = "name", locale = "klingon" } +full_name = { strategy = "faker", faker = "name::Name", locale = "klingon" } "#, ); let err = load_config(Some(&path), false).expect_err("unknown locale should fail"); @@ -1610,14 +1686,13 @@ full_name = { strategy = "name", locale = "klingon" } let path = write_temp_config( r#" [rules."public.users"] -email = { strategy = "email", locale = "fr_fr" } +email = { strategy = "hash", locale = "fr_fr" } "#, ); - let err = - load_config(Some(&path), false).expect_err("locale on email strategy should fail"); + let err = load_config(Some(&path), false).expect_err("locale on hash strategy should fail"); let msg = format!("{:#}", err); assert!(msg.contains("locale")); - assert!(msg.contains("email")); + assert!(msg.contains("hash")); let _ = fs::remove_file(path); } } diff --git a/src/sql.rs b/src/sql.rs index 46a1442..67e1eee 100644 --- a/src/sql.rs +++ b/src/sql.rs @@ -1402,20 +1402,20 @@ fn qualified_column_name(schema: Option<&str>, table: &str, column: &str) -> Str fn infer_auto_strategy(column: &str) -> Option { let normalized = column.to_ascii_lowercase().replace('-', "_"); let spec = if normalized.contains("email") { - base_spec("email", Some(true)) + faker_spec("internet::SafeEmail", Some(true)) } else if normalized.contains("first_name") || normalized == "fname" || normalized.contains("given_name") { - base_spec("first_name", Some(true)) + faker_spec("name::FirstName", Some(true)) } else if normalized.contains("last_name") || normalized.contains("surname") || normalized == "lname" || normalized.contains("family_name") { - base_spec("last_name", Some(true)) + faker_spec("name::LastName", Some(true)) } else if normalized.contains("name") { - base_spec("name", Some(true)) + faker_spec("name::Name", Some(true)) } else if normalized.contains("phone") || normalized.contains("mobile") || normalized.contains("cell") @@ -1455,6 +1455,26 @@ fn infer_auto_strategy(column: &str) -> Option { Some(spec) } +fn faker_spec(faker_path: &str, as_string: Option) -> AnonymizerSpec { + AnonymizerSpec { + strategy: "faker".to_string(), + salt: None, + min: None, + max: None, + length: None, + min_days: None, + max_days: None, + min_seconds: None, + max_seconds: None, + domain: None, + unique_within_domain: None, + as_string, + locale: None, + faker: Some(faker_path.to_string()), + format: None, + } +} + fn base_spec(strategy: &str, as_string: Option) -> AnonymizerSpec { AnonymizerSpec { strategy: strategy.to_string(), @@ -1470,6 +1490,8 @@ fn base_spec(strategy: &str, as_string: Option) -> AnonymizerSpec { unique_within_domain: None, as_string, locale: None, + faker: None, + format: None, } } #[cfg(test)] @@ -1500,6 +1522,8 @@ mod tests { unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, ); rules.insert("public.events".to_string(), users_cols); @@ -1579,6 +1603,8 @@ COPY public.events (id, email, the_date) FROM stdin; unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, ); rules.insert("public.users".to_string(), base_cols); @@ -1610,6 +1636,8 @@ COPY public.events (id, email, the_date) FROM stdin; unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, }, ColumnCase { @@ -1641,6 +1669,8 @@ COPY public.events (id, email, the_date) FROM stdin; unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, }, ]; @@ -1684,7 +1714,7 @@ INSERT INTO public.users (id, email, country, is_admin) VALUES fn deterministic_domain_mapping_is_consistent_across_tables() { let mut rules: HashMap> = HashMap::new(); let email_spec = AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -1697,6 +1727,8 @@ INSERT INTO public.users (id, email, country, is_admin) VALUES unique_within_domain: Some(false), as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }; rules.insert( "public.customers".to_string(), @@ -1771,8 +1803,8 @@ INSERT INTO public.orders (id, customer_email) VALUES HashMap::from([( "email".to_string(), AnonymizerSpec { - strategy: "email".to_string(), - salt: Some("users-email-domain".to_string()), + strategy: "faker".to_string(), + salt: None, min: None, max: None, length: None, @@ -1784,6 +1816,8 @@ INSERT INTO public.orders (id, customer_email) VALUES unique_within_domain: Some(true), as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }, )]), ); @@ -1834,7 +1868,7 @@ INSERT INTO public.users (id, email) VALUES HashMap::from([( "email".to_string(), AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -1847,6 +1881,8 @@ INSERT INTO public.users (id, email) VALUES unique_within_domain: Some(true), as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }, )]), ); @@ -1893,7 +1929,7 @@ INSERT INTO public.users (id, email) VALUES (1, 'alice@myco.com'); // not be replaced by a fabricated pseudonym. let mut rules: HashMap> = HashMap::new(); let email_spec = AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -1906,6 +1942,8 @@ INSERT INTO public.users (id, email) VALUES (1, 'alice@myco.com'); unique_within_domain: Some(false), as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }; rules.insert( "public.users".to_string(), @@ -1977,7 +2015,7 @@ INSERT INTO public.users (id, email) VALUES // output \N (not a fabricated pseudonym). let mut rules: HashMap> = HashMap::new(); let email_spec = AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -1990,6 +2028,8 @@ INSERT INTO public.users (id, email) VALUES unique_within_domain: Some(false), as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }; rules.insert( "public.users".to_string(), @@ -2053,7 +2093,7 @@ INSERT INTO public.users (id, email) VALUES // non-NULL source values map to the same pseudonym across both tables. let mut rules: HashMap> = HashMap::new(); let email_spec = AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -2066,6 +2106,8 @@ INSERT INTO public.users (id, email) VALUES unique_within_domain: Some(false), as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }; rules.insert( "public.customers".to_string(), @@ -2268,6 +2310,8 @@ COPY public.events (id, payload) FROM stdin; unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, ); rules.insert("public.events".to_string(), cols); @@ -2333,7 +2377,7 @@ COPY public.events (id, payload) FROM stdin; cols.insert( "email".to_string(), AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -2346,6 +2390,8 @@ COPY public.events (id, payload) FROM stdin; unique_within_domain: None, as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }, ); cols.insert( @@ -2364,6 +2410,8 @@ COPY public.events (id, payload) FROM stdin; unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, ); cols.insert( @@ -2382,6 +2430,8 @@ COPY public.events (id, payload) FROM stdin; unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, ); rules.insert("public.users".to_string(), cols); @@ -2449,7 +2499,7 @@ old@example.com verylongname (000) 000-0000 users_cols.insert( "email".to_string(), AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -2462,6 +2512,8 @@ old@example.com verylongname (000) 000-0000 unique_within_domain: None, as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }, ); rules.insert("public.users".to_string(), users_cols); @@ -2522,7 +2574,7 @@ CREATE TABLE public.users ( HashMap::from([( "email".to_string(), AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -2535,6 +2587,8 @@ CREATE TABLE public.users ( unique_within_domain: None, as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }, )]), ); @@ -2615,6 +2669,8 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, )]), ); @@ -2703,6 +2759,8 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, )]), ); @@ -2761,6 +2819,8 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, )]), ); @@ -2802,7 +2862,7 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); HashMap::from([( "email".to_string(), AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -2815,6 +2875,8 @@ INSERT INTO users (id, email) VALUES (3, 'carol@example.com'); unique_within_domain: None, as_string: Some(true), locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }, )]), ); @@ -2907,6 +2969,8 @@ INSERT INTO [dbo].[users] ([id], [email]) VALUES (1, N'alice@example.com'); unique_within_domain: None, as_string: Some(true), locale: None, + faker: None, + format: None, }, )]), ); @@ -2954,7 +3018,7 @@ INSERT INTO [dbo].[users] ([id], [email]) VALUES (1, N'alice@example.com'); cols.insert( "full_name".to_string(), AnonymizerSpec { - strategy: "name".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -2967,6 +3031,8 @@ INSERT INTO [dbo].[users] ([id], [email]) VALUES (1, N'alice@example.com'); unique_within_domain: None, as_string: None, locale: Some("de_de".to_string()), + faker: Some("name::Name".to_string()), + format: None, }, ); cols.insert( @@ -2985,6 +3051,8 @@ INSERT INTO [dbo].[users] ([id], [email]) VALUES (1, N'alice@example.com'); unique_within_domain: None, as_string: None, locale: Some("de_de".to_string()), + faker: None, + format: None, }, ); rules.insert("public.contacts".to_string(), cols); diff --git a/src/transform.rs b/src/transform.rs index a3a2f88..7731c05 100644 --- a/src/transform.rs +++ b/src/transform.rs @@ -1,7 +1,7 @@ +use crate::faker_dispatch::faker_string_with_rng; use crate::settings::{AnonymizerSpec, ResolvedConfig}; use chrono::Timelike; use chrono::{DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime}; -use fake::faker::name::raw::{FirstName, LastName, Name}; use fake::faker::phone_number::raw::PhoneNumber; use fake::locales::{AR_SA, CY_GB, DE_DE, EN, FR_FR, IT_IT, JA_JP, PT_BR, PT_PT, ZH_CN, ZH_TW}; use fake::Fake; @@ -230,59 +230,6 @@ fn make_deterministic_rng(stream: &mut DeterministicByteStream) -> StdRng { StdRng::from_seed(seed) } -/// Generate a localized full name using a `rand` RNG. -/// -/// The `locale` string is expected to be lowercase-normalized (e.g. `"en"`, `"fr_fr"`). -fn fake_name_with_rng(locale: &str, rng: &mut StdRng) -> String { - match locale { - "fr_fr" => Name(FR_FR).fake_with_rng(rng), - "de_de" => Name(DE_DE).fake_with_rng(rng), - "it_it" => Name(IT_IT).fake_with_rng(rng), - "pt_br" => Name(PT_BR).fake_with_rng(rng), - "pt_pt" => Name(PT_PT).fake_with_rng(rng), - "ar_sa" => Name(AR_SA).fake_with_rng(rng), - "zh_cn" => Name(ZH_CN).fake_with_rng(rng), - "zh_tw" => Name(ZH_TW).fake_with_rng(rng), - "ja_jp" => Name(JA_JP).fake_with_rng(rng), - "cy_gb" => Name(CY_GB).fake_with_rng(rng), - _ => Name(EN).fake_with_rng(rng), - } -} - -/// Generate a localized first name using a `rand` RNG. -fn fake_first_name_with_rng(locale: &str, rng: &mut StdRng) -> String { - match locale { - "fr_fr" => FirstName(FR_FR).fake_with_rng(rng), - "de_de" => FirstName(DE_DE).fake_with_rng(rng), - "it_it" => FirstName(IT_IT).fake_with_rng(rng), - "pt_br" => FirstName(PT_BR).fake_with_rng(rng), - "pt_pt" => FirstName(PT_PT).fake_with_rng(rng), - "ar_sa" => FirstName(AR_SA).fake_with_rng(rng), - "zh_cn" => FirstName(ZH_CN).fake_with_rng(rng), - "zh_tw" => FirstName(ZH_TW).fake_with_rng(rng), - "ja_jp" => FirstName(JA_JP).fake_with_rng(rng), - "cy_gb" => FirstName(CY_GB).fake_with_rng(rng), - _ => FirstName(EN).fake_with_rng(rng), - } -} - -/// Generate a localized last name using a `rand` RNG. -fn fake_last_name_with_rng(locale: &str, rng: &mut StdRng) -> String { - match locale { - "fr_fr" => LastName(FR_FR).fake_with_rng(rng), - "de_de" => LastName(DE_DE).fake_with_rng(rng), - "it_it" => LastName(IT_IT).fake_with_rng(rng), - "pt_br" => LastName(PT_BR).fake_with_rng(rng), - "pt_pt" => LastName(PT_PT).fake_with_rng(rng), - "ar_sa" => LastName(AR_SA).fake_with_rng(rng), - "zh_cn" => LastName(ZH_CN).fake_with_rng(rng), - "zh_tw" => LastName(ZH_TW).fake_with_rng(rng), - "ja_jp" => LastName(JA_JP).fake_with_rng(rng), - "cy_gb" => LastName(CY_GB).fake_with_rng(rng), - _ => LastName(EN).fake_with_rng(rng), - } -} - /// Generate a localized phone number using a `rand` RNG. fn fake_phone_with_rng(locale: &str, rng: &mut StdRng) -> String { match locale { @@ -362,38 +309,14 @@ fn apply_random_anonymizer( Replacement::unquoted(hex) } } - "email" => { - let user: String = random_alnum(10).to_lowercase(); - let domain = "example.com"; - let email = format!("{}@{}", user, domain); - Replacement::quoted(email) - } - "name" => { - let locale = spec - .locale - .as_deref() - .map(|l| l.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "en".to_string()); - let mut rng = make_random_rng(); - Replacement::quoted(fake_name_with_rng(&locale, &mut rng)) - } - "first_name" => { - let locale = spec - .locale - .as_deref() - .map(|l| l.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "en".to_string()); + "faker" => { let mut rng = make_random_rng(); - Replacement::quoted(fake_first_name_with_rng(&locale, &mut rng)) - } - "last_name" => { - let locale = spec - .locale - .as_deref() - .map(|l| l.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "en".to_string()); - let mut rng = make_random_rng(); - Replacement::quoted(fake_last_name_with_rng(&locale, &mut rng)) + let value = faker_string_with_rng(spec, &mut rng).unwrap_or_else(|| { + unreachable!( + "faker strategy must be validated at config load; unsupported faker should never reach apply_random_anonymizer" + ) + }); + Replacement::quoted(value) } "phone" => { let locale = spec @@ -565,36 +488,14 @@ fn apply_deterministic_anonymizer( Replacement::unquoted(hex) } } - "email" => { - let user = deterministic_alnum(10, &mut stream).to_lowercase(); - Replacement::quoted(format!("{}@example.com", user)) - } - "name" => { - let locale = spec - .locale - .as_deref() - .map(|l| l.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "en".to_string()); + "faker" => { let mut rng = make_deterministic_rng(&mut stream); - Replacement::quoted(fake_name_with_rng(&locale, &mut rng)) - } - "first_name" => { - let locale = spec - .locale - .as_deref() - .map(|l| l.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "en".to_string()); - let mut rng = make_deterministic_rng(&mut stream); - Replacement::quoted(fake_first_name_with_rng(&locale, &mut rng)) - } - "last_name" => { - let locale = spec - .locale - .as_deref() - .map(|l| l.trim().to_ascii_lowercase()) - .unwrap_or_else(|| "en".to_string()); - let mut rng = make_deterministic_rng(&mut stream); - Replacement::quoted(fake_last_name_with_rng(&locale, &mut rng)) + let value = faker_string_with_rng(spec, &mut rng).unwrap_or_else(|| { + unreachable!( + "faker strategy must be validated at config load; unsupported faker should never reach apply_deterministic_anonymizer" + ) + }); + Replacement::quoted(value) } "phone" => { let locale = spec @@ -1029,8 +930,15 @@ mod tests { use std::collections::HashMap; fn make_spec(strategy: &str, salt: Option<&str>, domain: Option<&str>) -> AnonymizerSpec { + let (strategy, faker) = match strategy { + "email" => ("faker".to_string(), Some("internet::SafeEmail".to_string())), + "name" => ("faker".to_string(), Some("name::Name".to_string())), + "first_name" => ("faker".to_string(), Some("name::FirstName".to_string())), + "last_name" => ("faker".to_string(), Some("name::LastName".to_string())), + _ => (strategy.to_string(), None), + }; AnonymizerSpec { - strategy: strategy.to_string(), + strategy, salt: salt.map(|s| s.to_string()), min: None, max: None, @@ -1043,6 +951,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: None, + faker, + format: None, } } @@ -1227,8 +1137,15 @@ mod tests { // --- Localized name and phone strategies --- fn make_spec_with_locale(strategy: &str, locale: Option<&str>) -> AnonymizerSpec { + let (strategy, faker) = match strategy { + "email" => ("faker".to_string(), Some("internet::SafeEmail".to_string())), + "name" => ("faker".to_string(), Some("name::Name".to_string())), + "first_name" => ("faker".to_string(), Some("name::FirstName".to_string())), + "last_name" => ("faker".to_string(), Some("name::LastName".to_string())), + _ => (strategy.to_string(), None), + }; AnonymizerSpec { - strategy: strategy.to_string(), + strategy, salt: None, min: None, max: None, @@ -1241,6 +1158,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: locale.map(|l| l.to_string()), + faker, + format: None, } } @@ -1328,7 +1247,7 @@ mod tests { fn test_localized_name_domain_deterministic() { // Same original value + domain + locale must always produce the same pseudonym. let spec = AnonymizerSpec { - strategy: "name".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -1341,12 +1260,14 @@ mod tests { unique_within_domain: None, as_string: None, locale: Some("fr_fr".to_string()), + faker: Some("name::Name".to_string()), + format: None, }; let r1 = apply_anonymizer(&make_registry(None), &spec, Some("Original Name"), None); let r2 = apply_anonymizer( &make_registry(None), &AnonymizerSpec { - strategy: "name".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -1359,6 +1280,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: Some("fr_fr".to_string()), + faker: Some("name::Name".to_string()), + format: None, }, Some("Original Name"), None, @@ -1385,6 +1308,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: Some("de_de".to_string()), + faker: None, + format: None, }; let r1 = apply_anonymizer(&make_registry(None), &spec, Some("+49 30 12345678"), None); let r2 = apply_anonymizer( @@ -1403,6 +1328,8 @@ mod tests { unique_within_domain: None, as_string: None, locale: Some("de_de".to_string()), + faker: None, + format: None, }, Some("+49 30 12345678"), None, @@ -1487,7 +1414,7 @@ mod tests { // unique_within_domain=true must not affect NULL: NULL stays NULL. let registry = make_registry(None); let spec = AnonymizerSpec { - strategy: "email".to_string(), + strategy: "faker".to_string(), salt: None, min: None, max: None, @@ -1500,6 +1427,8 @@ mod tests { unique_within_domain: Some(true), as_string: None, locale: None, + faker: Some("internet::SafeEmail".to_string()), + format: None, }; let result = apply_anonymizer(®istry, &spec, None, None); assert!(