From cb4e0e6eaae4189cfb3c5c8d89c768424884f98f Mon Sep 17 00:00:00 2001 From: James Sadler Date: Wed, 1 Apr 2026 16:29:06 +1100 Subject: [PATCH 1/4] docs(plans): add migration plan for canonical encryption config Add implementation plan for migrating proxy to use CanonicalEncryptionConfig from the cipherstash-config crate. --- ...-migrate-to-canonical-encryption-config.md | 259 ++++++++++++++++++ 1 file changed, 259 insertions(+) create mode 100644 docs/plans/2026-04-01-migrate-to-canonical-encryption-config.md diff --git a/docs/plans/2026-04-01-migrate-to-canonical-encryption-config.md b/docs/plans/2026-04-01-migrate-to-canonical-encryption-config.md new file mode 100644 index 00000000..0073908d --- /dev/null +++ b/docs/plans/2026-04-01-migrate-to-canonical-encryption-config.md @@ -0,0 +1,259 @@ +# Migrate Proxy to Canonical Encryption Config — Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use cipherpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Remove the proxy's local `CastAs` enum and `ColumnEncryptionConfig` parser, replacing them with `CanonicalEncryptionConfig` from the `cipherstash-config` crate. + +**Architecture:** The proxy currently has its own JSON config parser in `encrypt_config/config.rs` (~490 lines) that duplicates what `cipherstash-config` provides. We replace it with the canonical parser, keeping only the `EncryptConfig` wrapper and `EncryptConfigManager` which handle proxy-specific concerns (Arc-wrapped config, background reload). + +**Tech Stack:** Rust, serde, serde_json, cipherstash-config + +**Prerequisite:** The canonical config work in `cipherstash-suite` (CIP-2871) must be completed first — specifically, `CanonicalEncryptionConfig`, `PlaintextType`, `Identifier`, and `into_config_map()` must exist in `cipherstash-config`. + +**Design doc:** `~/cipherstash/cipherstash-suite/docs/plans/2026-04-01-canonical-encryption-config-design.md` + +--- + +### Task 1: Add `cipherstash-config` dependency + +**Files:** +- Modify: `Cargo.toml` (workspace root) +- Modify: `packages/cipherstash-proxy/Cargo.toml` + +**Step 1: Add to workspace dependencies** + +In the root `Cargo.toml`, add `cipherstash-config` to `[workspace.dependencies]`. Match the version/source used for `cipherstash-client`. + +**Step 2: Add to cipherstash-proxy package** + +In `packages/cipherstash-proxy/Cargo.toml`, add: + +```toml +cipherstash-config = { workspace = true } +``` + +**Step 3: Verify it compiles** + +Run: `cargo check -p cipherstash-proxy` +Expected: Clean build + +**Step 4: Commit** + +```bash +git add Cargo.toml Cargo.lock packages/cipherstash-proxy/Cargo.toml +git commit --no-gpg-sign -m "chore: add cipherstash-config dependency to cipherstash-proxy" +``` + +--- + +### Task 2: Replace `ColumnEncryptionConfig` with `CanonicalEncryptionConfig` in the manager + +**Files:** +- Modify: `packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs` + +**Step 1: Update imports** + +Replace the import of local `ColumnEncryptionConfig`: + +```rust +// Before +use super::config::ColumnEncryptionConfig; + +// After +use cipherstash_config::CanonicalEncryptionConfig; +``` + +**Step 2: Update `load_encrypt_config` function** + +The function currently does (around line 216): + +```rust +let encrypt_config: ColumnEncryptionConfig = serde_json::from_value(json_value)?; +let encrypt_config = EncryptConfig::new_from_config(encrypt_config.into_config_map()); +``` + +Change to: + +```rust +let encrypt_config: CanonicalEncryptionConfig = serde_json::from_value(json_value) + .map_err(|e| /* map to existing error type */)?; +let config_map = encrypt_config.into_config_map() + .map_err(|e| /* map ConfigError to proxy Error */)?; +let encrypt_config = EncryptConfig::new_from_config(config_map); +``` + +Note: The canonical `into_config_map()` returns `Result, ConfigError>` (fallible, with validation) while the proxy's was infallible. You'll need to handle the `Result` — map `ConfigError` to the proxy's error type. + +Also note: The canonical `Identifier` is from `cipherstash_config::Identifier`, not `cipherstash_client::eql::Identifier`. Check that `EncryptConfig::new_from_config` and `EncryptConfig::get_column_config` use the same `Identifier` type. If they differ, update `EncryptConfig` to use the canonical `Identifier`. + +**Step 3: Run tests** + +Run: `cargo test -p cipherstash-proxy --lib -- encrypt_config` +Expected: All pass + +**Step 4: Commit** + +```bash +git add packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs +git commit --no-gpg-sign -m "refactor: use CanonicalEncryptionConfig in EncryptConfigManager" +``` + +--- + +### Task 3: Remove local config types + +**Files:** +- Modify: `packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs` + +**Step 1: Delete local types** + +Remove the following from `config.rs`: +- `ColumnEncryptionConfig` struct +- `Tables` struct and its `IntoIterator` impl +- `Table` struct and its `IntoIterator` impl +- `Column` struct +- `CastAs` enum +- `From for ColumnType` impl +- `OreIndexOpts`, `MatchIndexOpts`, `SteVecIndexOpts`, `UniqueIndexOpts` structs +- `Indexes` struct +- `FromStr for ColumnEncryptionConfig` impl +- `ColumnEncryptionConfig::into_config_map` method +- `Column::into_column_config` method +- All default functions (`default_tokenizer`, `default_k`, `default_m`, `default_array_index_mode`) + +This should remove ~200 lines of code. What remains in `config.rs` (if anything) depends on whether the proxy has any config types not covered by the canonical module. + +**Step 2: Update `mod.rs` if needed** + +If `config.rs` is now empty or only has tests, update `packages/cipherstash-proxy/src/proxy/encrypt_config/mod.rs` accordingly. + +**Step 3: Run tests** + +Run: `cargo test -p cipherstash-proxy --lib -- encrypt_config` +Expected: All pass + +Run: `cargo clippy --no-deps --tests --all-features --all-targets -p cipherstash-proxy -- -D warnings` +Expected: No warnings + +**Step 4: Commit** + +```bash +git add packages/cipherstash-proxy/src/proxy/encrypt_config/ +git commit --no-gpg-sign -m "refactor: remove local CastAs and ColumnEncryptionConfig, use canonical types" +``` + +--- + +### Task 4: Update tests to use canonical types + +**Files:** +- Modify: `packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs` (test module) + +**Step 1: Migrate tests** + +The existing tests (lines 210-489) test JSON parsing of the local types. Rewrite them to test via `CanonicalEncryptionConfig` and `into_config_map()`. Key tests to preserve: + +- `column_with_empty_options_gets_defaults` — empty column defaults to `Text` with no indexes +- `can_parse_column_with_cast_as` — `"cast_as": "int"` parses correctly +- `can_parse_ore_index` — ORE index deserializes +- `can_parse_unique_index_with_token_filter` — unique with downcase filter +- `can_parse_match_index_with_defaults` — match index gets k=6, m=2048, Standard tokenizer +- `can_parse_match_index_with_all_opts_set` — custom match options +- `can_parse_ste_vec_index` — STE vec with prefix and array_index_mode + +Each test should: +1. Build JSON with `serde_json::json!` +2. Deserialize to `CanonicalEncryptionConfig` +3. Call `into_config_map()` +4. Assert on the resulting `ColumnConfig` + +Example: + +```rust +#[test] +fn column_with_empty_options_gets_defaults() { + let json = json!({ + "v": 1, + "tables": { + "users": { + "email": {} + } + } + }); + + let config: CanonicalEncryptionConfig = serde_json::from_value(json).unwrap(); + let map = config.into_config_map().unwrap(); + + let id = Identifier::new("users", "email"); + let col = map.get(&id).unwrap(); + assert_eq!(col.cast_type, ColumnType::Text); + assert!(col.indexes.is_empty()); +} +``` + +Add a backwards-compat test: + +```rust +#[test] +fn it_accepts_old_cast_as_jsonb() { + let json = json!({ + "v": 1, + "tables": { + "events": { + "data": { + "cast_as": "jsonb", + "indexes": { + "ste_vec": { "prefix": "test" } + } + } + } + } + }); + + let config: CanonicalEncryptionConfig = serde_json::from_value(json).unwrap(); + let map = config.into_config_map().unwrap(); + let id = Identifier::new("events", "data"); + let col = map.get(&id).unwrap(); + assert_eq!(col.cast_type, ColumnType::Json); +} +``` + +**Step 2: Run tests** + +Run: `cargo test -p cipherstash-proxy --lib -- encrypt_config` +Expected: All pass + +**Step 3: Commit** + +```bash +git add packages/cipherstash-proxy/src/proxy/encrypt_config/ +git commit --no-gpg-sign -m "test: migrate encrypt_config tests to use canonical types" +``` + +--- + +### Task 5: Full build and test verification + +**Files:** None (verification only) + +**Step 1: Workspace clippy** + +Run: `cargo clippy --no-deps --tests --all-features --all-targets --workspace -- -D warnings` +Expected: No warnings + +**Step 2: Unit tests** + +Run: `cargo test --workspace --all-features --lib` +Expected: All pass + +**Step 3: Integration tests (if environment available)** + +Run: `mise run test:local:integration` (requires PostgreSQL running) +Expected: All pass + +**Step 4: If any failures, fix and commit** + +```bash +git add -u +git commit --no-gpg-sign -m "fix: resolve build issues from canonical config migration" +``` From dbeee4477d03e9df012756e462cbfdfb8909212e Mon Sep 17 00:00:00 2001 From: James Sadler Date: Wed, 1 Apr 2026 21:34:46 +1100 Subject: [PATCH 2/4] refactor(proxy): replace local ColumnEncryptionConfig with canonical CanonicalEncryptionConfig Migrate from the proxy-local encryption config types to the canonical types provided by the cipherstash-config crate. This removes ~200 lines of duplicated type definitions (CastAs, Column, Indexes, etc.) and consolidates config parsing into the shared crate. - Bump cipherstash-client/cts-common to 0.34.0-alpha.5 - Add cipherstash-config workspace dependency - Add InvalidEncryptionConfig error variant for config crate errors - Update manager to use CanonicalEncryptionConfig with Identifier conversion - Rename Utf8Str -> Text and JsonB -> Json to match canonical types - Add [patch.crates-io] for local development against cipherstash-suite NOTE: The [patch.crates-io] section and version bumps to 0.34.0-alpha.5 must be updated when the canonical types are published to crates.io. --- Cargo.lock | 39 +-- Cargo.toml | 11 +- packages/cipherstash-proxy/Cargo.toml | 1 + packages/cipherstash-proxy/src/error.rs | 3 + .../src/postgresql/context/column.rs | 6 +- .../src/postgresql/data/from_sql.rs | 24 +- .../src/postgresql/data/to_sql.rs | 8 +- .../src/proxy/encrypt_config/config.rs | 229 ++---------------- .../src/proxy/encrypt_config/manager.rs | 14 +- 9 files changed, 68 insertions(+), 267 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11adb87b..88c66420 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -762,9 +762,7 @@ dependencies = [ [[package]] name = "cipherstash-client" -version = "0.34.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "200537bf2ab562b085e34df7e3391d0426ab04eea3ed588a7fc27f1bd218ee33" +version = "0.34.0-alpha.5" dependencies = [ "aes-gcm-siv", "anyhow", @@ -776,7 +774,7 @@ dependencies = [ "blake3", "cfg-if", "chrono", - "cipherstash-config 0.34.0-alpha.4", + "cipherstash-config 0.34.0-alpha.5", "cipherstash-core", "cllw-ore", "cts-common", @@ -837,20 +835,17 @@ dependencies = [ [[package]] name = "cipherstash-config" -version = "0.34.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "333ba6c42338ce6bbbc515fb75e43b57311ece1a9ea41e7daabe50478c342841" +version = "0.34.0-alpha.5" dependencies = [ "bitflags", "serde", + "serde_json", "thiserror 1.0.69", ] [[package]] name = "cipherstash-core" -version = "0.34.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32921e505e39f8f7cae9f55e82462d8dd92764a9148f479b42abf52e60e90437" +version = "0.34.0-alpha.5" dependencies = [ "hmac", "lazy_static", @@ -873,6 +868,7 @@ dependencies = [ "bytes", "chrono", "cipherstash-client", + "cipherstash-config 0.34.0-alpha.5", "clap", "config", "cts-common", @@ -982,8 +978,6 @@ checksum = "f46ad14479a25103f283c0f10005961cf086d8dc42205bb44c46ac563475dca6" [[package]] name = "cllw-ore" version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c676b8e0a3130e6f8b4398d9aa5b287c3ce7074ac89f1ccf1570ebeb22281629" dependencies = [ "blake3", "hex", @@ -1192,9 +1186,7 @@ dependencies = [ [[package]] name = "cts-common" -version = "0.34.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7817fb03b19c6a588bc9120fd876a6d65f531a0b2aa0d39384bc78f3c4c4340" +version = "0.34.0-alpha.5" dependencies = [ "arrayvec", "axum", @@ -3502,8 +3494,6 @@ dependencies = [ [[package]] name = "recipher" version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "061598013445a8bb847d0c95ee33b5e95c1d198d5242b6a8b9f3078aa7437e79" dependencies = [ "aes", "async-trait", @@ -4349,9 +4339,7 @@ checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" [[package]] name = "stack-auth" -version = "0.34.0-alpha.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e8a681ffc8eb40575fb5f40b8316f1b9e03074eb1e4951e0690b00b0349fed" +version = "0.34.0-alpha.5" dependencies = [ "aquamarine", "cts-common", @@ -4370,13 +4358,12 @@ dependencies = [ "vitaminc", "vitaminc-protected", "zeroize", + "zerokms-protocol", ] [[package]] name = "stack-profile" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56fdb1e5ef2111e616fb46da39ad63485b3f3c82de3245fe3c14ce52e8775112" +version = "0.34.0-alpha.5" dependencies = [ "dirs", "gethostname", @@ -6286,12 +6273,10 @@ dependencies = [ [[package]] name = "zerokms-protocol" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52f1d857d2e6d4fe258c49906d53f8b2666c4841dc2e39e67cfea3717382294" +version = "0.12.4" dependencies = [ "base64", - "cipherstash-config 0.34.0-alpha.4", + "cipherstash-config 0.34.0-alpha.5", "const-hex", "cts-common", "fake 2.10.0", diff --git a/Cargo.toml b/Cargo.toml index d1a09aec..e4fadb75 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,8 +43,9 @@ debug = true [workspace.dependencies] sqltk = { version = "0.10.0" } -cipherstash-client = { version = "0.34.0-alpha.4" } -cts-common = { version = "0.34.0-alpha.4" } +cipherstash-client = { version = "0.34.0-alpha.5" } +cipherstash-config = { version = "0.34.0-alpha.5" } +cts-common = { version = "0.34.0-alpha.5" } thiserror = "2.0.9" tokio = { version = "1.44.2", features = ["full"] } @@ -55,3 +56,9 @@ tracing-subscriber = { version = "^0.3.20", features = [ "env-filter", "std", ] } + +[patch.crates-io] +cipherstash-client = { path = "../cipherstash-suite/packages/cipherstash-client" } +cipherstash-config = { path = "../cipherstash-suite/packages/cipherstash-config" } +cts-common = { path = "../cipherstash-suite/packages/cts-common" } +zerokms-protocol = { path = "../cipherstash-suite/packages/zerokms-protocol" } diff --git a/packages/cipherstash-proxy/Cargo.toml b/packages/cipherstash-proxy/Cargo.toml index 077764ff..34abe065 100644 --- a/packages/cipherstash-proxy/Cargo.toml +++ b/packages/cipherstash-proxy/Cargo.toml @@ -12,6 +12,7 @@ arc-swap = "1.7.1" bytes = { version = "1.9", default-features = false } chrono = { version = "0.4.39", features = ["clock"] } cipherstash-client = { workspace = true, features = ["tokio"] } +cipherstash-config = { workspace = true } clap = { version = "4.5.31", features = ["derive", "env"] } config = { version = "0.15", features = [ "async", diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index c192cb9d..aee61575 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -185,6 +185,9 @@ pub enum ConfigError { #[error(transparent)] Parse(#[from] serde_json::Error), + #[error("Invalid encryption configuration: {0}")] + InvalidEncryptionConfig(#[from] cipherstash_config::errors::ConfigError), + #[error("Database schema could not be loaded")] SchemaCouldNotBeLoaded, diff --git a/packages/cipherstash-proxy/src/postgresql/context/column.rs b/packages/cipherstash-proxy/src/postgresql/context/column.rs index 0088f0b3..c4deb993 100644 --- a/packages/cipherstash-proxy/src/postgresql/context/column.rs +++ b/packages/cipherstash-proxy/src/postgresql/context/column.rs @@ -77,8 +77,8 @@ fn column_type_to_postgres_type( (ColumnType::Int, _) => postgres_types::Type::INT4, (ColumnType::SmallInt, _) => postgres_types::Type::INT2, (ColumnType::Timestamp, _) => postgres_types::Type::TIMESTAMPTZ, - (ColumnType::Utf8Str, _) => postgres_types::Type::TEXT, - (ColumnType::JsonB, EqlTermVariant::JsonAccessor) => postgres_types::Type::TEXT, - (ColumnType::JsonB, _) => postgres_types::Type::JSONB, + (ColumnType::Text, _) => postgres_types::Type::TEXT, + (ColumnType::Json, EqlTermVariant::JsonAccessor) => postgres_types::Type::TEXT, + (ColumnType::Json, _) => postgres_types::Type::JSONB, } } diff --git a/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs b/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs index 5736bdee..f70e4962 100644 --- a/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs +++ b/packages/cipherstash-proxy/src/postgresql/data/from_sql.rs @@ -115,7 +115,7 @@ pub fn literal_from_sql( /// /// | Input Type | Target Column Type | Result | /// |------------|--------------------|--------| -/// | `Type::INT4` | `ColumnType::Utf8Str` | `Plaintext::Utf8Str` | +/// | `Type::INT4` | `ColumnType::Text` | `Plaintext::Text` | /// | `Type::INT2` | `ColumnType::Int` | `Plaintext::Int` | /// | `Type::INT8` | `ColumnType::Int` | `Error`` | fn text_from_sql( @@ -126,7 +126,7 @@ fn text_from_sql( debug!(target: ENCODING, ?val, ?eql_term, ?col_type); match (eql_term, col_type) { - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Utf8Str) => { + (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Text) => { Ok(Plaintext::new(val)) } (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Float) => { @@ -168,7 +168,7 @@ fn text_from_sql( } // If JSONB, JSONPATH values are treated as strings - (EqlTermVariant::JsonPath | EqlTermVariant::JsonAccessor, ColumnType::JsonB) => { + (EqlTermVariant::JsonPath | EqlTermVariant::JsonAccessor, ColumnType::Json) => { let val = if val.starts_with("$.") { val.to_string() } else { @@ -176,12 +176,12 @@ fn text_from_sql( }; Ok(Plaintext::new(val)) } - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::JsonB) => { + (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Json) => { serde_json::from_str::(val) .map_err(|_| MappingError::CouldNotParseParameter) .map(Plaintext::new) } - (EqlTermVariant::Tokenized, ColumnType::Utf8Str) => Ok(Plaintext::new(val)), + (EqlTermVariant::Tokenized, ColumnType::Text) => Ok(Plaintext::new(val)), (eql_term, col_type) => Err(MappingError::UnsupportedParameterType { eql_term, @@ -202,7 +202,7 @@ fn binary_from_sql( debug!(target: ENCODING, ?pg_type, ?eql_term, ?col_type); match (eql_term, col_type, pg_type) { - (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Utf8Str, _) => { + (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Text, _) => { parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new) } (EqlTermVariant::Full | EqlTermVariant::Partial, ColumnType::Boolean, _) => { @@ -253,7 +253,7 @@ fn binary_from_sql( } // If JSONB, JSONPATH values are treated as strings - (EqlTermVariant::JsonPath, ColumnType::JsonB, &Type::JSONPATH) => { + (EqlTermVariant::JsonPath, ColumnType::Json, &Type::JSONPATH) => { parse_bytes_from_sql::(bytes, pg_type).map(|val| { let val = if val.starts_with("$.") { val @@ -263,7 +263,7 @@ fn binary_from_sql( Plaintext::new(val) }) } - (EqlTermVariant::JsonAccessor, ColumnType::JsonB, &Type::TEXT | &Type::VARCHAR) => { + (EqlTermVariant::JsonAccessor, ColumnType::Json, &Type::TEXT | &Type::VARCHAR) => { parse_bytes_from_sql::(bytes, pg_type).map(|val| { let val = if val.starts_with("$.") { val @@ -276,7 +276,7 @@ fn binary_from_sql( // Python psycopg sends JSON/B as BYTEA ( EqlTermVariant::Full | EqlTermVariant::Partial, - ColumnType::JsonB, + ColumnType::Json, &Type::JSON | &Type::JSONB | &Type::BYTEA, ) => parse_bytes_from_sql::(bytes, pg_type).map(Plaintext::new), @@ -356,9 +356,9 @@ fn decimal_from_sql( .ok_or(MappingError::CouldNotParseParameter) .map(Plaintext::new), - ColumnType::Utf8Str => Ok(Plaintext::new(decimal.to_string())), + ColumnType::Text => Ok(Plaintext::new(decimal.to_string())), - ColumnType::JsonB => { + ColumnType::Json => { let val: serde_json::Value = serde_json::from_str(&decimal.to_string()) .map_err(|_| MappingError::CouldNotParseParameter)?; Ok(Plaintext::new(val)) @@ -408,7 +408,7 @@ mod tests { config: ColumnConfig { name: "column".to_owned(), in_place: false, - cast_type: ColumnType::Utf8Str, + cast_type: ColumnType::Text, indexes: vec![], mode: ColumnMode::PlaintextDuplicate, }, diff --git a/packages/cipherstash-proxy/src/postgresql/data/to_sql.rs b/packages/cipherstash-proxy/src/postgresql/data/to_sql.rs index 6bc57d73..1007d266 100644 --- a/packages/cipherstash-proxy/src/postgresql/data/to_sql.rs +++ b/packages/cipherstash-proxy/src/postgresql/data/to_sql.rs @@ -16,7 +16,7 @@ pub fn to_sql(plaintext: &Plaintext, format_code: &FormatCode) -> Result Result { let s = match &plaintext { - Plaintext::Utf8Str(Some(x)) => x.to_string(), + Plaintext::Text(Some(x)) => x.to_string(), Plaintext::Int(Some(x)) => x.to_string(), Plaintext::BigInt(Some(x)) => x.to_string(), Plaintext::BigUInt(Some(x)) => x.to_string(), @@ -26,7 +26,7 @@ fn text_to_sql(plaintext: &Plaintext) -> Result { Plaintext::NaiveDate(Some(x)) => x.to_string(), Plaintext::SmallInt(Some(x)) => x.to_string(), Plaintext::Timestamp(Some(x)) => x.to_string(), - Plaintext::JsonB(Some(x)) => x.to_string(), + Plaintext::Json(Some(x)) => x.to_string(), _ => "".to_string(), }; @@ -44,8 +44,8 @@ fn binary_to_sql(plaintext: &Plaintext) -> Result { Plaintext::NaiveDate(x) => x.to_sql_checked(&Type::DATE, &mut bytes), Plaintext::SmallInt(x) => x.to_sql_checked(&Type::INT2, &mut bytes), Plaintext::Timestamp(x) => x.to_sql_checked(&Type::TIMESTAMPTZ, &mut bytes), - Plaintext::Utf8Str(x) => x.to_sql_checked(&Type::TEXT, &mut bytes), - Plaintext::JsonB(x) => x.to_sql_checked(&Type::JSONB, &mut bytes), + Plaintext::Text(x) => x.to_sql_checked(&Type::TEXT, &mut bytes), + Plaintext::Json(x) => x.to_sql_checked(&Type::JSONB, &mut bytes), Plaintext::Decimal(x) => x.to_sql_checked(&Type::NUMERIC, &mut bytes), // TODO: Implement these Plaintext::BigUInt(_x) => unimplemented!(), diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs index 41ae3e13..a55713dd 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs @@ -1,223 +1,21 @@ -use crate::error::{ConfigError, Error}; -use cipherstash_client::{ - eql::Identifier, - schema::{ - column::{ArrayIndexMode, Index, IndexType, TokenFilter, Tokenizer}, - ColumnConfig, ColumnType, - }, -}; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, str::FromStr}; - -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct ColumnEncryptionConfig { - #[serde(rename = "v")] - pub version: u32, - pub tables: Tables, -} - -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct Tables(HashMap); - -impl IntoIterator for Tables { - type Item = (String, Table); - type IntoIter = std::collections::hash_map::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -#[derive(Debug, Deserialize, Serialize, Clone, Default)] -pub struct Table(HashMap); - -impl IntoIterator for Table { - type Item = (String, Column); - type IntoIter = std::collections::hash_map::IntoIter; - - fn into_iter(self) -> Self::IntoIter { - self.0.into_iter() - } -} - -#[derive(Debug, Default, Deserialize, Serialize, Clone, PartialEq)] -pub struct Column { - #[serde(default)] - cast_as: CastAs, - #[serde(default)] - indexes: Indexes, -} - -#[derive(Debug, Default, Clone, Copy, Serialize, Deserialize, PartialEq)] -#[serde(rename_all = "snake_case")] -pub enum CastAs { - BigInt, - Boolean, - Date, - Real, - Double, - Int, - SmallInt, - #[default] - Text, - #[serde(rename = "jsonb")] - JsonB, -} - -#[derive(Debug, Deserialize, Serialize, Clone, Default, PartialEq)] -pub struct Indexes { - #[serde(rename = "ore")] - ore_index: Option, - #[serde(rename = "unique")] - unique_index: Option, - #[serde(rename = "match")] - match_index: Option, - #[serde(rename = "ste_vec")] - ste_vec_index: Option, -} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -pub struct OreIndexOpts {} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -pub struct MatchIndexOpts { - #[serde(default = "default_tokenizer")] - tokenizer: Tokenizer, - #[serde(default)] - token_filters: Vec, - #[serde(default = "default_k")] - k: usize, - #[serde(default = "default_m")] - m: usize, - #[serde(default)] - include_original: bool, -} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -pub struct SteVecIndexOpts { - prefix: String, - #[serde(default)] - term_filters: Vec, - #[serde(default = "default_array_index_mode")] - array_index_mode: ArrayIndexMode, -} - -fn default_array_index_mode() -> ArrayIndexMode { - ArrayIndexMode::ALL -} - -fn default_tokenizer() -> Tokenizer { - Tokenizer::Standard -} - -fn default_k() -> usize { - 6 -} - -fn default_m() -> usize { - 2048 -} - -#[derive(Debug, Deserialize, Serialize, Clone, PartialEq)] -pub struct UniqueIndexOpts { - #[serde(default)] - token_filters: Vec, -} - -impl From for ColumnType { - fn from(value: CastAs) -> Self { - match value { - CastAs::BigInt => ColumnType::BigInt, - CastAs::SmallInt => ColumnType::SmallInt, - CastAs::Int => ColumnType::Int, - CastAs::Boolean => ColumnType::Boolean, - CastAs::Date => ColumnType::Date, - CastAs::Real | CastAs::Double => ColumnType::Float, - CastAs::Text => ColumnType::Utf8Str, - CastAs::JsonB => ColumnType::JsonB, - } - } -} - -impl FromStr for ColumnEncryptionConfig { - type Err = Error; - - fn from_str(data: &str) -> Result { - let config = serde_json::from_str(data).map_err(ConfigError::Parse)?; - Ok(config) - } -} - -impl ColumnEncryptionConfig { - pub fn is_empty(&self) -> bool { - self.tables.0.is_empty() - } - - pub fn into_config_map(self) -> HashMap { - let mut map = HashMap::new(); - for (table_name, columns) in self.tables.into_iter() { - for (column_name, column) in columns.into_iter() { - let column_config = column.into_column_config(&column_name); - let key = Identifier::new(&table_name, &column_name); - map.insert(key, column_config); - } - } - map - } -} - -impl Column { - pub fn into_column_config(self, name: &String) -> ColumnConfig { - let mut config = ColumnConfig::build(name.to_string()).casts_as(self.cast_as.into()); - - if self.indexes.ore_index.is_some() { - config = config.add_index(Index::new_ore()); - } - - if let Some(opts) = self.indexes.match_index { - config = config.add_index(Index::new(IndexType::Match { - tokenizer: opts.tokenizer, - token_filters: opts.token_filters, - k: opts.k, - m: opts.m, - include_original: opts.include_original, - })); - } - - if let Some(opts) = self.indexes.unique_index { - config = config.add_index(Index::new(IndexType::Unique { - token_filters: opts.token_filters, - })) - } - - if let Some(SteVecIndexOpts { - prefix, - term_filters, - array_index_mode, - }) = self.indexes.ste_vec_index - { - config = config.add_index(Index::new(IndexType::SteVec { - prefix, - term_filters, - array_index_mode, - })) - } - - config - } -} - #[cfg(test)] mod tests { use cipherstash_client::eql::Identifier; + use cipherstash_client::schema::ColumnConfig; + use cipherstash_config::column::{ArrayIndexMode, IndexType, TokenFilter, Tokenizer}; + use cipherstash_config::{CanonicalEncryptionConfig, ColumnType}; use serde_json::json; - - use super::*; + use std::collections::HashMap; fn parse(json: serde_json::Value) -> HashMap { - serde_json::from_value::(json) - .map(|config| config.into_config_map()) - .expect("Error ok") + let config: CanonicalEncryptionConfig = + serde_json::from_value(json).expect("Failed to parse config"); + config + .into_config_map() + .expect("Failed to build config map") + .into_iter() + .map(|(id, col)| (Identifier::new(id.table, id.column), col)) + .collect() } #[test] @@ -237,7 +35,7 @@ mod tests { let column = encrypt_config.get(&ident).expect("column exists"); - assert_eq!(column.cast_type, ColumnType::Utf8Str); + assert_eq!(column.cast_type, ColumnType::Text); assert!(column.indexes.is_empty()); } @@ -461,6 +259,7 @@ mod tests { "tables": { "users": { "event_data": { + "cast_as": "jsonb", "indexes": { "ste_vec": { "prefix": "event-data" diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs index 5bfc3014..fc9d0bf5 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs @@ -8,13 +8,12 @@ use crate::{ use arc_swap::ArcSwap; use cipherstash_client::eql; use cipherstash_client::schema::ColumnConfig; +use cipherstash_config::CanonicalEncryptionConfig; use serde_json::Value; use std::{collections::HashMap, sync::Arc, time::Duration}; use tokio::{task::JoinHandle, time}; use tracing::{debug, error, info, warn}; -use super::config::ColumnEncryptionConfig; - /// /// Column configuration keyed by table name and column name /// - key: `{table_name}.{column_name}` @@ -213,8 +212,15 @@ pub async fn load_encrypt_config(config: &DatabaseConfig) -> Result Date: Wed, 1 Apr 2026 22:01:03 +1100 Subject: [PATCH 3/4] docs(plan): add backwards compatibility test plan for canonical config migration Covers proxy-side integration tests for JSON -> CanonicalEncryptionConfig -> EncryptConfig pipeline including Identifier bridging, ColumnType mapping, error propagation, and a realistic schema fixture matching the integration test database. --- ...2026-04-01-proxy-backwards-compat-tests.md | 275 ++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 docs/plans/2026-04-01-proxy-backwards-compat-tests.md diff --git a/docs/plans/2026-04-01-proxy-backwards-compat-tests.md b/docs/plans/2026-04-01-proxy-backwards-compat-tests.md new file mode 100644 index 00000000..888c0505 --- /dev/null +++ b/docs/plans/2026-04-01-proxy-backwards-compat-tests.md @@ -0,0 +1,275 @@ +# Proxy Backwards Compatibility Tests for Canonical Config Migration + +> **For Claude:** REQUIRED SUB-SKILL: Use cipherpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Verify the proxy's integration pipeline from JSON → `CanonicalEncryptionConfig` → `EncryptConfig` works correctly, including Identifier conversion, error handling, and ColumnType mapping. + +**Tech Stack:** Rust, serde_json, cipherstash-config, cipherstash-client + +--- + +### Task 1: Test Identifier bridging in EncryptConfig + +**File:** `packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs` (test module) + +The `load_encrypt_config` function converts `cipherstash_config::Identifier` → `cipherstash_client::eql::Identifier`. Test that this preserves table/column names correctly. + +Add these tests to the existing test module: + +```rust +#[test] +fn config_map_preserves_table_and_column_names() { + let json = json!({ + "v": 1, + "tables": { + "my_schema.users": { + "email_address": { + "cast_as": "text", + "indexes": { "unique": {} } + } + } + } + }); + + let config = parse(json); + + let ident = Identifier::new("my_schema.users", "email_address"); + let column = config.get(&ident).expect("column exists"); + assert_eq!(column.name, "email_address"); + assert_eq!(column.cast_type, ColumnType::Text); +} + +#[test] +fn config_map_handles_multiple_tables() { + let json = json!({ + "v": 1, + "tables": { + "users": { + "email": { "cast_as": "text" } + }, + "orders": { + "total": { "cast_as": "int" } + } + } + }); + + let config = parse(json); + + assert_eq!(config.len(), 2); + assert!(config.contains_key(&Identifier::new("users", "email"))); + assert!(config.contains_key(&Identifier::new("orders", "total"))); +} +``` + +**Verify:** `cargo test -p cipherstash-proxy --lib -- encrypt_config` + +--- + +### Task 2: Test ColumnType mapping through column_type_to_postgres_type + +**File:** `packages/cipherstash-proxy/src/postgresql/context/column.rs` + +The rename from `Utf8Str` → `Text` and `JsonB` → `Json` must produce the same PostgreSQL types. Add tests to verify the mapping. + +Add a test module to `column.rs`: + +```rust +#[cfg(test)] +mod tests { + use super::*; + use eql_mapper::EqlTermVariant; + + #[test] + fn text_column_maps_to_postgres_text() { + assert_eq!( + column_type_to_postgres_type(&ColumnType::Text, EqlTermVariant::Full), + postgres_types::Type::TEXT + ); + } + + #[test] + fn json_column_maps_to_postgres_jsonb() { + assert_eq!( + column_type_to_postgres_type(&ColumnType::Json, EqlTermVariant::Full), + postgres_types::Type::JSONB + ); + } + + #[test] + fn json_accessor_maps_to_postgres_text() { + assert_eq!( + column_type_to_postgres_type(&ColumnType::Json, EqlTermVariant::JsonAccessor), + postgres_types::Type::TEXT + ); + } + + #[test] + fn all_column_types_have_postgres_mapping() { + let types = vec![ + ColumnType::Boolean, + ColumnType::BigInt, + ColumnType::BigUInt, + ColumnType::Date, + ColumnType::Decimal, + ColumnType::Float, + ColumnType::Int, + ColumnType::SmallInt, + ColumnType::Timestamp, + ColumnType::Text, + ColumnType::Json, + ]; + + for ct in types { + // Should not panic + let _ = column_type_to_postgres_type(&ct, EqlTermVariant::Full); + } + } +} +``` + +**Verify:** `cargo test -p cipherstash-proxy --lib -- context::column` + +--- + +### Task 3: Test error propagation for invalid configs + +**File:** `packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs` (test module) + +The canonical `into_config_map()` can now return errors (e.g., ste_vec on non-JSON column). Verify the error surfaces correctly through the proxy's error types. + +```rust +#[test] +fn invalid_config_returns_error() { + let json = json!({ + "v": 1, + "tables": { + "users": { + "email": { + "cast_as": "text", + "indexes": { + "ste_vec": { "prefix": "test" } + } + } + } + } + }); + + let config: CanonicalEncryptionConfig = serde_json::from_value(json).unwrap(); + let result = config.into_config_map(); + assert!(result.is_err(), "ste_vec on text column should fail validation"); +} + +#[test] +fn malformed_json_returns_parse_error() { + let json = json!({ + "v": 1, + "tables": "not a map" + }); + + let result = serde_json::from_value::(json); + assert!(result.is_err()); +} +``` + +**Verify:** `cargo test -p cipherstash-proxy --lib -- encrypt_config` + +--- + +### Task 4: Test real integration schema config shape + +**File:** `packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs` (test module) + +Use the same fixture from the cipherstash-config plan — the JSON shape matching the proxy's integration test schema. Verify the full pipeline including Identifier conversion. + +```rust +#[test] +fn real_eql_config_produces_correct_encrypt_config() { + let json = json!({ + "v": 1, + "tables": { + "encrypted": { + "encrypted_text": { + "cast_as": "text", + "indexes": { "unique": {}, "match": {}, "ore": {} } + }, + "encrypted_bool": { + "cast_as": "boolean", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_int2": { + "cast_as": "small_int", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_int4": { + "cast_as": "int", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_int8": { + "cast_as": "big_int", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_float8": { + "cast_as": "double", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_date": { + "cast_as": "date", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_jsonb": { + "cast_as": "jsonb", + "indexes": { + "ste_vec": { "prefix": "encrypted/encrypted_jsonb" } + } + }, + "encrypted_jsonb_filtered": { + "cast_as": "jsonb", + "indexes": { + "ste_vec": { + "prefix": "encrypted/encrypted_jsonb_filtered", + "term_filters": [{ "kind": "downcase" }] + } + } + } + } + } + }); + + let config = parse(json); + + // All 9 columns present with correct Identifiers + assert_eq!(config.len(), 9); + + // Verify legacy type aliases map correctly + let float_col = config.get(&Identifier::new("encrypted", "encrypted_float8")).unwrap(); + assert_eq!(float_col.cast_type, ColumnType::Float); + + let jsonb_col = config.get(&Identifier::new("encrypted", "encrypted_jsonb")).unwrap(); + assert_eq!(jsonb_col.cast_type, ColumnType::Json); + + // Verify index counts + let text_col = config.get(&Identifier::new("encrypted", "encrypted_text")).unwrap(); + assert_eq!(text_col.indexes.len(), 3); + + let bool_col = config.get(&Identifier::new("encrypted", "encrypted_bool")).unwrap(); + assert_eq!(bool_col.indexes.len(), 2); + + let jsonb_filtered = config.get(&Identifier::new("encrypted", "encrypted_jsonb_filtered")).unwrap(); + assert_eq!(jsonb_filtered.indexes.len(), 1); +} +``` + +**Verify:** `cargo test -p cipherstash-proxy --lib -- encrypt_config` + +--- + +### Task 5: Full verification + +Run the complete test suite: + +```bash +cargo clippy --no-deps --tests --all-features --all-targets -p cipherstash-proxy -- -D warnings +cargo test -p cipherstash-proxy --lib +``` + +All tests must pass, zero clippy warnings. From 83611df097b82e3cd4993db11354fa90c16ec815 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Wed, 1 Apr 2026 22:25:55 +1100 Subject: [PATCH 4/4] test(proxy): add backwards compatibility tests for canonical config migration Add 9 unit tests covering ColumnType-to-Postgres type mapping and CanonicalEncryptionConfig parsing to establish a safety net before further refactoring: - column.rs: 4 tests for column_type_to_postgres_type (text, json, json accessor, exhaustive variant coverage) - config.rs: 5 tests for config map construction (identifier bridging, multi-table configs, invalid index validation, realistic EQL schema fixture, malformed JSON error handling) --- .../src/postgresql/context/column.rs | 52 ++++++ .../src/proxy/encrypt_config/config.rs | 155 ++++++++++++++++++ 2 files changed, 207 insertions(+) diff --git a/packages/cipherstash-proxy/src/postgresql/context/column.rs b/packages/cipherstash-proxy/src/postgresql/context/column.rs index c4deb993..4fd9c720 100644 --- a/packages/cipherstash-proxy/src/postgresql/context/column.rs +++ b/packages/cipherstash-proxy/src/postgresql/context/column.rs @@ -82,3 +82,55 @@ fn column_type_to_postgres_type( (ColumnType::Json, _) => postgres_types::Type::JSONB, } } + +#[cfg(test)] +mod tests { + use super::*; + use eql_mapper::EqlTermVariant; + + #[test] + fn text_column_maps_to_postgres_text() { + assert_eq!( + column_type_to_postgres_type(&ColumnType::Text, EqlTermVariant::Full), + postgres_types::Type::TEXT + ); + } + + #[test] + fn json_column_maps_to_postgres_jsonb() { + assert_eq!( + column_type_to_postgres_type(&ColumnType::Json, EqlTermVariant::Full), + postgres_types::Type::JSONB + ); + } + + #[test] + fn json_accessor_maps_to_postgres_text() { + assert_eq!( + column_type_to_postgres_type(&ColumnType::Json, EqlTermVariant::JsonAccessor), + postgres_types::Type::TEXT + ); + } + + #[test] + fn all_column_types_have_postgres_mapping() { + let types = vec![ + ColumnType::Boolean, + ColumnType::BigInt, + ColumnType::BigUInt, + ColumnType::Date, + ColumnType::Decimal, + ColumnType::Float, + ColumnType::Int, + ColumnType::SmallInt, + ColumnType::Timestamp, + ColumnType::Text, + ColumnType::Json, + ]; + + for ct in types { + // Should not panic + let _ = column_type_to_postgres_type(&ct, EqlTermVariant::Full); + } + } +} diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs index a55713dd..b68db73e 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/config.rs @@ -285,4 +285,159 @@ mod tests { }, ); } + + #[test] + fn config_map_preserves_table_and_column_names() { + let json = json!({ + "v": 1, + "tables": { + "my_schema.users": { + "email_address": { + "cast_as": "text", + "indexes": { "unique": {} } + } + } + } + }); + + let config = parse(json); + + let ident = Identifier::new("my_schema.users", "email_address"); + let column = config.get(&ident).expect("column exists"); + assert_eq!(column.name, "email_address"); + assert_eq!(column.cast_type, ColumnType::Text); + } + + #[test] + fn config_map_handles_multiple_tables() { + let json = json!({ + "v": 1, + "tables": { + "users": { + "email": { "cast_as": "text" } + }, + "orders": { + "total": { "cast_as": "int" } + } + } + }); + + let config = parse(json); + + assert_eq!(config.len(), 2); + + let email = config.get(&Identifier::new("users", "email")).expect("users.email exists"); + assert_eq!(email.cast_type, ColumnType::Text); + + let total = config.get(&Identifier::new("orders", "total")).expect("orders.total exists"); + assert_eq!(total.cast_type, ColumnType::Int); + } + + #[test] + fn invalid_config_returns_error() { + let json = json!({ + "v": 1, + "tables": { + "users": { + "email": { + "cast_as": "text", + "indexes": { + "ste_vec": { "prefix": "test" } + } + } + } + } + }); + + let config: CanonicalEncryptionConfig = serde_json::from_value(json).unwrap(); + let result = config.into_config_map(); + assert!(result.is_err(), "ste_vec on text column should fail validation"); + } + + #[test] + fn real_eql_config_produces_correct_encrypt_config() { + let json = json!({ + "v": 1, + "tables": { + "encrypted": { + "encrypted_text": { + "cast_as": "text", + "indexes": { "unique": {}, "match": {}, "ore": {} } + }, + "encrypted_bool": { + "cast_as": "boolean", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_int2": { + "cast_as": "small_int", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_int4": { + "cast_as": "int", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_int8": { + "cast_as": "big_int", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_float8": { + "cast_as": "double", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_date": { + "cast_as": "date", + "indexes": { "unique": {}, "ore": {} } + }, + "encrypted_jsonb": { + "cast_as": "jsonb", + "indexes": { + "ste_vec": { "prefix": "encrypted/encrypted_jsonb" } + } + }, + "encrypted_jsonb_filtered": { + "cast_as": "jsonb", + "indexes": { + "ste_vec": { + "prefix": "encrypted/encrypted_jsonb_filtered", + "term_filters": [{ "kind": "downcase" }] + } + } + } + } + } + }); + + let config = parse(json); + + // All 9 columns present with correct Identifiers + assert_eq!(config.len(), 9); + + // Verify legacy type aliases map correctly + let float_col = config.get(&Identifier::new("encrypted", "encrypted_float8")).unwrap(); + assert_eq!(float_col.cast_type, ColumnType::Float); + + let jsonb_col = config.get(&Identifier::new("encrypted", "encrypted_jsonb")).unwrap(); + assert_eq!(jsonb_col.cast_type, ColumnType::Json); + + // Verify index counts + let text_col = config.get(&Identifier::new("encrypted", "encrypted_text")).unwrap(); + assert_eq!(text_col.indexes.len(), 3); + + let bool_col = config.get(&Identifier::new("encrypted", "encrypted_bool")).unwrap(); + assert_eq!(bool_col.indexes.len(), 2); + + let jsonb_filtered = config.get(&Identifier::new("encrypted", "encrypted_jsonb_filtered")).unwrap(); + assert_eq!(jsonb_filtered.indexes.len(), 1); + } + + #[test] + fn malformed_json_returns_parse_error() { + let json = json!({ + "v": 1, + "tables": "not a map" + }); + + let result = serde_json::from_value::(json); + assert!(result.is_err()); + } }