diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dcce7a0f..f8b91ad35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -101,6 +101,9 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY_IMAGE }} + # 2.2.x releases are maintenance releases: never move `latest` off 3.x. + flavor: | + latest=false - name: Login to Docker Hub uses: docker/login-action@v3 @@ -180,6 +183,9 @@ jobs: uses: docker/metadata-action@v5 with: images: ${{ env.REGISTRY_IMAGE }} + # 2.2.x releases are maintenance releases: never move `latest` off 3.x. + flavor: | + latest=false tags: | type=ref,event=branch type=ref,event=pr @@ -198,13 +204,3 @@ jobs: if: ${{ fromJSON(env.publish) }} run: | docker buildx imagetools inspect ${{ env.REGISTRY_IMAGE }}:${{ steps.meta.outputs.version }} - - - name: Notify Multitudes - if: ${{ fromJSON(env.publish) }} - run: | - curl --request POST \ - --fail-with-body \ - --url "https://api.developer.multitudes.co/deployments" \ - --header "Content-Type: application/json" \ - --header "Authorization: ${{ env.MULTITUDES_ACCESS_TOKEN }}" \ - --data '{"commitSha": "${{ github.sha }}", "environmentName":"dockerhub"}' diff --git a/CHANGELOG.md b/CHANGELOG.md index d8f443e4f..cb59b8019 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +## [2.2.5] - 2026-10-28 + ### Fixed - **Memory growth on long-lived connections**: Proxy now releases the prepared-statement, portal and statement-metrics state it keeps for each client connection once PostgreSQL has finished with it. Previously some common client patterns left state behind until the connection closed, so with a connection pool that keeps connections open, memory grew steadily until Proxy was restarted. The affected patterns were statements prepared and described but never executed, prepared statements removed with SQL `DEALLOCATE`, `DEALLOCATE ALL` or `DISCARD ALL` (used by psycopg 3, Rails and connection-pool reset queries, whether sent as a simple query or through the extended protocol), uniquely named portals, pipelined batches that fail part-way, statements rejected by Proxy, and connections that use `COPY ... FROM STDIN`. No configuration change is required. @@ -18,6 +20,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Plaintext forwarded beside a statement Proxy cannot type check**: When encrypted columns are configured, Proxy now refuses a write that it cannot type check, such as an `INSERT` into a table it has not loaded, instead of forwarding it to PostgreSQL unchanged. Such a write is still forwarded when Proxy knows that every table it writes to has no encrypted columns. Proxy also refuses a simple query that has a statement it cannot type check together with a statement it needs to encrypt or decrypt. Previously, with mapping errors off (the default), Proxy forwarded the whole query as written, so an `INSERT` into an encrypted column in the same query reached PostgreSQL with its plaintext values. A read that Proxy cannot type check is still forwarded unchanged when it is sent in a query of its own or only with statements that Proxy does not need to map. A statement that Proxy forwards unchanged is sent as written, so a plaintext value that it compares with an encrypted column still reaches the database. +- **Encryption results checked before use**: Proxy now refuses a statement when the encryption service returns fewer encrypted values than the values it was asked to encrypt. Previously the parameters left without an encrypted value were sent to PostgreSQL as written, so a misbehaving encryption service could cause plaintext to reach the database. + +- **Bind to a statement Proxy did not see prepared**: Proxy now refuses a `Bind` that names a prepared statement it did not see prepared on the connection, with SQLSTATE `26000`, PostgreSQL's own code for a missing prepared statement. Previously Proxy sent the bound values as written, so if it had lost track of a statement that writes an encrypted column, the values reached PostgreSQL unencrypted. A statement prepared with SQL `PREPARE` can no longer be bound with a `Bind` message through Proxy. + +- **Plaintext in `INSERT ... ON CONFLICT DO UPDATE`**: Proxy now encrypts a value assigned to an encrypted column in the `DO UPDATE SET` clause of an upsert. Previously the clause was not type checked, so a literal or parameter assigned to an encrypted column there reached PostgreSQL as plaintext. `excluded.` now carries the column's encrypted type, and an `ON CONFLICT` target on an encrypted column is refused, as a conflict judged on randomised ciphertext would never fire. + +### Security + +- **Dependency updates**: Updated `rustls` to 0.23.46, `rustls-webpki` to 0.103.16, `webpki-root-certs` to 1.0.11, `aws-lc-rs` to 1.18.2 (with `aws-lc-sys` 0.46.0), `tokio-postgres` to 0.7.18, `postgres-protocol` to 0.6.12, `tokio` to 1.51.6, `h2` to 0.4.20, `crossbeam-epoch` to 0.9.21, `rand` to 0.9.3 and 0.10.2, `quinn-proto` to 0.11.15, `hickory-proto` and `hickory-resolver` to 0.26.3 (with `reqwest` 0.13.4), and `diesel` to 2.3.12, to pick up the fixes for published security advisories in those crates. No configuration change is required. + ## [2.2.4] - 2026-06-18 ### Fixed @@ -285,7 +297,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Integration with CipherStash ZeroKMS. - Encrypt Query Language (EQL) for indexing and searching encrypted data. -[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.4...HEAD +[Unreleased]: https://github.com/cipherstash/proxy/compare/v2.2.5...HEAD +[2.2.5]: https://github.com/cipherstash/proxy/compare/v2.2.4...v2.2.5 [2.2.4]: https://github.com/cipherstash/proxy/compare/v2.2.3...v2.2.4 [2.2.3]: https://github.com/cipherstash/proxy/compare/v2.2.2...v2.2.3 [2.2.2]: https://github.com/cipherstash/proxy/compare/v2.2.1...v2.2.2 diff --git a/Cargo.lock b/Cargo.lock index f095eb2a0..b01c06f75 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -23,7 +23,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "generic-array", ] @@ -321,9 +321,9 @@ checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" [[package]] name = "aws-lc-rs" -version = "1.16.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "untrusted 0.7.1", @@ -332,14 +332,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.38.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -544,6 +545,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-modes" version = "0.9.1" @@ -664,7 +674,7 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f42a145ed2d10dce2191e1dcf30cfccfea9026660e143662ba5eec4017d5daa" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -708,9 +718,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -737,7 +747,7 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "inout", ] @@ -765,7 +775,7 @@ dependencies = [ "dirs", "futures", "hex", - "hmac", + "hmac 0.12.1", "itertools 0.12.1", "lazy_static", "log", @@ -789,7 +799,7 @@ dependencies = [ "serde_cbor", "serde_json", "serdect", - "sha2", + "sha2 0.10.8", "stack-auth", "stack-profile", "static_assertions", @@ -824,18 +834,18 @@ version = "0.34.1-alpha.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5bb7181053c3fc35569e0800fa7510c85ee2bffee21abbd2a7aeb498f5f0972" dependencies = [ - "hmac", + "hmac 0.12.1", "lazy_static", "num-bigint", "rand 0.8.6", "regex", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", ] [[package]] name = "cipherstash-proxy" -version = "2.2.4" +version = "2.2.5" dependencies = [ "arc-swap", "async-trait", @@ -852,7 +862,7 @@ dependencies = [ "eql-mapper", "exitcode", "hex", - "md-5", + "md-5 0.10.6", "metrics", "metrics-exporter-prometheus", "moka", @@ -860,7 +870,7 @@ dependencies = [ "pg_escape", "postgres-protocol", "postgres-types", - "rand 0.9.2", + "rand 0.9.3", "recipher 0.1.3", "regex", "rust_decimal", @@ -898,7 +908,7 @@ dependencies = [ "futures-util", "hex", "postgres-types", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "rustls", "serde", @@ -978,7 +988,7 @@ checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ "cipher", "dbl", - "digest", + "digest 0.10.7", ] [[package]] @@ -990,6 +1000,12 @@ dependencies = [ "cc", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" version = "1.0.3" @@ -1049,6 +1065,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1134,9 +1156,9 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] @@ -1158,6 +1180,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "ctr" version = "0.9.2" @@ -1200,14 +1231,33 @@ dependencies = [ "vitaminc", ] +[[package]] +name = "ctutils" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", +] + +[[package]] +name = "darling" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" +dependencies = [ + "darling_core 0.21.3", + "darling_macro 0.21.3", ] [[package]] @@ -1224,13 +1274,38 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "darling_core" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +dependencies = [ + "darling_core 0.21.3", "quote", "syn 2.0.117", ] @@ -1256,7 +1331,7 @@ version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f55bf8e7b65898637379c1b74eb1551107c8294ed26d855ceb9fd1a09cfc9bc0" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der_derive", "flagset", "zeroize", @@ -1346,20 +1421,21 @@ checksum = "dc55fe0d1f6c107595572ec8b107c0999bb1a2e0b75e37429a4fb0d6474a0e7d" [[package]] name = "diesel" -version = "2.2.10" +version = "2.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff3e1edb1f37b4953dd5176916347289ed43d7119cc2e6c7c3f7849ff44ea506" +checksum = "715377c6e464cb44bb89bd8487584240516c8d5052bc645d6babc50bb8be46c3" dependencies = [ "chrono", "diesel_derives", + "downcast-rs", "uuid", ] [[package]] name = "diesel_derives" -version = "2.2.5" +version = "2.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68d4216021b3ea446fd2047f5c8f8fe6e98af34508a254a01e4d6bc1e844f84d" +checksum = "ecbd51fb6c020672543641167efa4e6417ff7ad76849ed556ace3595e72de03a" dependencies = [ "diesel_table_macro_syntax", "dsl_auto_type", @@ -1370,9 +1446,9 @@ dependencies = [ [[package]] name = "diesel_table_macro_syntax" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "209c735641a413bc68c4923a9d6ad4bcb3ca306b794edaa7eb0b3228a99ffb25" +checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" dependencies = [ "syn 2.0.117", ] @@ -1389,11 +1465,23 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.6", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "4.0.0" @@ -1425,13 +1513,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dsl_auto_type" -version = "0.1.3" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139ae9aca7527f85f26dd76483eb38533fd84bd571065da1739656ef71c5ff5b" +checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" dependencies = [ - "darling", + "darling 0.21.3", "either", "heck", "proc-macro2", @@ -1445,7 +1539,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1cac124e13ae9aa56acc4241f8c8207501d93afdd8d8e62f0c1f2e12f6508c65" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -1457,7 +1551,7 @@ version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abcba80bdf851db5616e27ff869399468e2d339d7c6480f5887681e6bdfc2186" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -1469,7 +1563,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3bbcf21279103a67372982cb1156a2154a452451dff2b884cf897ccecce389e0" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.117", @@ -1499,18 +1593,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "enum-as-inner" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1e6a265c649f3f5979b601d26f1d05ada116434c87741c9493cb56218f76cbc" -dependencies = [ - "heck", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "eql-mapper" version = "1.0.0" @@ -1528,7 +1610,7 @@ dependencies = [ [[package]] name = "eql-mapper-macros" -version = "2.2.4" +version = "2.2.5" dependencies = [ "pretty_assertions", "proc-macro2", @@ -1617,7 +1699,7 @@ dependencies = [ "chrono", "deunicode", "dummy 0.11.0", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -1853,9 +1935,9 @@ checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "h2" -version = "0.4.8" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5017294ff4bb30944501348f6f8e42e6ad28f42c8bbef7a74029aff064a4e3c2" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1926,46 +2008,70 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7ebdb29d2ea9ed0083cd8cece49bbd968021bd99b0849edb4a9a7ee0fdf6a4e0" [[package]] -name = "hickory-proto" -version = "0.25.2" +name = "hickory-net" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8a6fe56c0038198998a6f217ca4e7ef3a5e51f46163bd6dd60b5c71ca6c6502" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" dependencies = [ "async-trait", "cfg-if", "data-encoding", - "enum-as-inner", "futures-channel", "futures-io", "futures-util", + "hickory-proto", + "idna", + "ipnet", + "jni 0.22.4", + "rand 0.10.2", + "thiserror 2.0.18", + "tinyvec", + "tokio", + "tracing", + "url", +] + +[[package]] +name = "hickory-proto" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" +dependencies = [ + "data-encoding", "idna", "ipnet", + "jni 0.22.4", "once_cell", - "rand 0.9.2", + "prefix-trie", + "rand 0.10.2", "ring", "thiserror 2.0.18", "tinyvec", - "tokio", "tracing", "url", ] [[package]] name = "hickory-resolver" -version = "0.25.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc62a9a99b0bfb44d2ab95a7208ac952d31060efc16241c87eaf36406fecf87a" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" dependencies = [ "cfg-if", "futures-util", + "hickory-net", "hickory-proto", "ipconfig", + "ipnet", + "jni 0.22.4", "moka", + "ndk-context", "once_cell", "parking_lot", - "rand 0.9.2", + "rand 0.10.2", "resolv-conf", "smallvec", + "system-configuration 0.7.0", "thiserror 2.0.18", "tokio", "tracing", @@ -1977,7 +2083,16 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", +] + +[[package]] +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", ] [[package]] @@ -2026,6 +2141,15 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" +[[package]] +name = "hybrid-array" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944cf8cf766b40e2a1a333ee5e9b563f854d5fa49d6a8ca2764e97c6eddb214" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.8.1" @@ -2085,8 +2209,8 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.1", - "system-configuration", + "socket2 0.6.5", + "system-configuration 0.6.1", "tokio", "tower-service", "tracing", @@ -2335,6 +2459,9 @@ name = "ipnet" version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +dependencies = [ + "serde", +] [[package]] name = "iri-string" @@ -2410,19 +2537,68 @@ dependencies = [ "cesu8", "cfg-if", "combine", - "jni-sys", + "jni-sys 0.3.0", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link 0.2.1", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.117", +] + [[package]] name = "jni-sys" version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "jobserver" version = "0.1.32" @@ -2434,11 +2610,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2471,9 +2648,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.177" +version = "0.2.191" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" [[package]] name = "libm" @@ -2483,11 +2660,10 @@ checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" [[package]] name = "libredox" -version = "0.1.3" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ - "bitflags", "libc", ] @@ -2566,7 +2742,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", ] [[package]] @@ -2676,13 +2862,13 @@ dependencies = [ [[package]] name = "mio" -version = "1.0.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2886843bf800fba2e3377cff24abf6379b4c4d5c6681eaf9ea5b0d15090450bd" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.0+wasi-snapshot-preview1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2707,6 +2893,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + [[package]] name = "nom" version = "7.1.3" @@ -2814,6 +3006,24 @@ dependencies = [ "autocfg", ] +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.36.7" @@ -2998,7 +3208,7 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c7bc82ccbe2c7ef7ceed38dcac90d7ff46681e061e9d7310cbcd409113e303" dependencies = [ - "phf", + "phf 0.11.3", ] [[package]] @@ -3008,7 +3218,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", ] [[package]] @@ -3017,7 +3237,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.6", ] @@ -3028,7 +3248,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.117", @@ -3043,6 +3263,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3081,9 +3310,9 @@ checksum = "350e9b48cbc6b0e028b0473b114454c6316e57336ee184ceab6e53f72c178b3e" [[package]] name = "postgres-derive" -version = "0.4.6" +version = "0.4.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69700ea4603c5ef32d447708e6a19cd3e8ac197a000842e97f527daea5e4175f" +checksum = "4d9d9089bb0ce62f4b5d52a0be0f4acfb35738b979380670d3dea85fe38d6ddd" dependencies = [ "heck", "proc-macro2", @@ -3093,27 +3322,27 @@ dependencies = [ [[package]] name = "postgres-protocol" -version = "0.6.8" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ff0abab4a9b844b93ef7b81f1efc0a366062aaef2cd702c76256b5dc075c54" +checksum = "08808e3c483c46e999108051c78334f473d5adb59d78bb80a1268c7e6aa6c514" dependencies = [ "base64", "byteorder", "bytes", "fallible-iterator", - "hmac", - "md-5", + "hmac 0.13.0", + "md-5 0.11.0", "memchr", - "rand 0.9.2", - "sha2", + "rand 0.10.2", + "sha2 0.11.1", "stringprep", ] [[package]] name = "postgres-types" -version = "0.2.9" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" +checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ "array-init", "bytes", @@ -3121,7 +3350,7 @@ dependencies = [ "fallible-iterator", "postgres-derive", "postgres-protocol", - "serde", + "serde_core", "serde_json", "uuid", ] @@ -3163,6 +3392,17 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "prefix-trie" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7" +dependencies = [ + "either", + "ipnet", + "num-traits", +] + [[package]] name = "pretty_assertions" version = "1.4.1" @@ -3232,7 +3472,7 @@ dependencies = [ "bitflags", "lazy_static", "num-traits", - "rand 0.9.2", + "rand 0.9.3", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -3305,15 +3545,15 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" dependencies = [ "aws-lc-rs", "bytes", "getrandom 0.3.2", "lru-slab", - "rand 0.9.2", + "rand 0.9.3", "ring", "rustc-hash", "rustls", @@ -3379,9 +3619,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "7ec095654a25171c2124e9e3393a930bddbffdc939556c914957a4c3e0a87166" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.3", @@ -3389,9 +3629,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.0" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3485,7 +3725,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_cbor", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", "zeroize", ] @@ -3506,7 +3746,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_cbor", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", "zeroize", ] @@ -3591,9 +3831,9 @@ dependencies = [ [[package]] name = "reqwest" -version = "0.13.1" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e9018c9d814e5f30cc16a0f03271aeab3571e609612d9fe78c1aa8d11c2f62" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", @@ -3699,7 +3939,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46a4bd6027df676bcb752d3724db0ea3c0c5fc1dd0376fec51ac7dcaf9cc69be" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -3851,9 +4091,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.28" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "log", @@ -3878,11 +4118,12 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.11.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "917ce264624a4b4db1c364dcc35bfca9ded014d0a958cd47ad3e960e988ea51c" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", + "zeroize", ] [[package]] @@ -3893,7 +4134,7 @@ checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -3902,7 +4143,7 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 0.26.8", + "webpki-root-certs 0.26.11", "windows-sys 0.59.0", ] @@ -3914,7 +4155,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -3923,8 +4164,8 @@ dependencies = [ "rustls-webpki", "security-framework", "security-framework-sys", - "webpki-root-certs 1.0.5", - "windows-sys 0.61.2", + "webpki-root-certs 1.0.11", + "windows-sys 0.59.0", ] [[package]] @@ -3935,9 +4176,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.3" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -4101,14 +4342,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ "itoa", "memchr", - "ryu", "serde", + "serde_core", + "zmij", ] [[package]] @@ -4193,7 +4435,18 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -4213,9 +4466,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "showcase" -version = "2.2.4" +version = "2.2.5" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", "rustls", "serde", "serde_json", @@ -4236,6 +4489,16 @@ dependencies = [ "libc", ] +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simdutf8" version = "0.1.5" @@ -4293,12 +4556,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.1" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4467,6 +4730,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4498,6 +4772,17 @@ dependencies = [ "system-configuration-sys", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + [[package]] name = "system-configuration-sys" version = "0.6.0" @@ -4678,9 +4963,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.48.0" +version = "1.51.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff360e02eab121e0bc37a2d3b4d4dc622e6eda3a8e5253d5435ecf5bd4c68408" +checksum = "a13e32eacd0222f9658be2ccbd83b718bfd9bfb0d54b5921913c184cb86124a5" dependencies = [ "bytes", "libc", @@ -4688,27 +4973,27 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.1", + "socket2 0.6.5", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] name = "tokio-postgres" -version = "0.7.13" +version = "0.7.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c95d533c83082bb6490e0189acaa0bbeef9084e60471b696ca6988cd0541fb0" +checksum = "a528f7d280f6d5b9cd149635c8705b0dd049754bc67d81d31fa25169a93809d3" dependencies = [ "async-trait", "byteorder", @@ -4719,12 +5004,12 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.13.1", "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.9.2", - "socket2 0.5.8", + "rand 0.10.2", + "socket2 0.6.5", "tokio", "tokio-util", "whoami", @@ -4736,7 +5021,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27d684bad428a0f2481f42241f821db42c54e2dc81d8c00db8536c506b0a0144" dependencies = [ - "const-oid", + "const-oid 0.9.6", "ring", "rustls", "tokio", @@ -5017,7 +5302,7 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" dependencies = [ - "crypto-common", + "crypto-common 0.1.6", "subtle", ] @@ -5096,7 +5381,7 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "atomic", "getrandom 0.3.2", - "md-5", + "md-5 0.10.6", "serde", "sha1_smol", ] @@ -5123,7 +5408,7 @@ version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" dependencies = [ - "darling", + "darling 0.20.10", "once_cell", "proc-macro-error2", "proc-macro2", @@ -5195,7 +5480,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af693c39d3cd1c818ef6267539433c6ceca87840b12d24124adbc9c8ecba1709" dependencies = [ "bitvec", - "digest", + "digest 0.10.7", "serde", "serde_bytes", "subtle", @@ -5220,7 +5505,7 @@ version = "0.1.0-pre4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ea9de431cb93359d293ec7e70d05d87117a57f34bfc5bc94f040b81d4dd1afd6" dependencies = [ - "rand 0.10.0", + "rand 0.10.2", "thiserror 2.0.18", "vitaminc-protected", "vitaminc-random-derives", @@ -5308,54 +5593,42 @@ dependencies = [ [[package]] name = "wasite" -version = "0.1.0" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.2+wasi-0.2.4", +] [[package]] name = "wasm-bindgen" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", "rustversion", "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.100" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f0a0651a5c2bc21487bde11ee802ccaf4c51935d0d3d42a6101f98161700bc6" -dependencies = [ - "bumpalo", - "log", - "proc-macro2", - "quote", - "syn 2.0.117", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.50" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ - "cfg-if", "js-sys", - "once_cell", + "tokio", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5363,22 +5636,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ + "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", - "wasm-bindgen-backend", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.100" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -5407,9 +5680,9 @@ dependencies = [ [[package]] name = "wasm-streams" -version = "0.4.2" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" dependencies = [ "futures-util", "js-sys", @@ -5446,9 +5719,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -5466,29 +5739,31 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "0.26.8" +version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09aed61f5e8d2c18344b3faa33a4c837855fe56642757754775548fee21386c4" +checksum = "75c7f0ef91146ebfb530314f5f1d24528d7f0767efbfd31dce919275413e393e" dependencies = [ - "rustls-pki-types", + "webpki-root-certs 1.0.11", ] [[package]] name = "webpki-root-certs" -version = "1.0.5" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] [[package]] name = "whoami" -version = "1.6.0" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6994d13118ab492c3c80c1f81928718159254c53c472bf9ce36f8dae4add02a7" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" dependencies = [ - "redox_syscall", + "libc", + "libredox", + "objc2-system-configuration", "wasite", "web-sys", ] @@ -5718,15 +5993,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -6140,7 +6406,7 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ - "const-oid", + "const-oid 0.9.6", "der", "spki", "tls_codec", @@ -6317,3 +6583,9 @@ dependencies = [ "quote", "syn 2.0.117", ] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 5cbfa7183..3b67fcc92 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,7 +5,7 @@ members = ["packages/*"] exclude = ["vendor/stack-auth"] [workspace.package] -version = "2.2.4" +version = "2.2.5" edition = "2021" [profile.dev] diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index fded1e846..7672babb9 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -782,6 +782,22 @@ git push origin vX.Y.Z gh release create vX.Y.Z --generate-notes ``` +### Maintenance releases (2.2.x) + +2.2.x patch releases are cut from the `release/2.2` branch, not `main`, which carries 3.x. +`mise run release` only runs on `main`, so open the prepare-release PR against `release/2.2` and use the manual steps. +`release.yml` on `release/2.2` sets `latest=false` on its Docker metadata steps, so a 2.2.x release publishes `cipherstash/proxy:X.Y.Z` and `:2.2` but never moves `:latest` off 3.x. +Keep the GitHub "Latest" release on 3.x too, and start the generated notes from the previous 2.2.x tag so they don't diff against a 3.x tag: + +```bash +# From the merged prepare-release commit on release/2.2 +git tag v2.2.Z +git push origin v2.2.Z + +gh release create v2.2.Z --target release/2.2 --latest=false \ + --generate-notes --notes-start-tag v2.2.Y +``` + ### Re-releasing a version If a release fails and you need to re-release the same version: diff --git a/docs/errors.md b/docs/errors.md index 4777ff8c3..b84d180fa 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -17,6 +17,7 @@ - [COPY with a statement that uses an encrypted column](#mapping-copy-with-encrypted-statement) - [Internal Error](#mapping-internal-error) - [Prepared settings changed](#mapping-prepared-settings-changed) + - [Unknown prepared statement](#mapping-unknown-prepared-statement) - Encrypt errors: - [Column could not be encrypted](#encrypt-column-could-not-be-encrypted) @@ -34,6 +35,7 @@ - [Unknown table](#encrypt-unknown-table) - [Unknown index term](#encrypt-unknown-index-term) - [Column configuration mismatch](#encrypt-column-config-mismatch) + - [Encryption result count mismatch](#encrypt-result-count-mismatch) - Decrypt errors: - [Column could not be deserialised](#encrypt-column-could-not-be-deserialised) @@ -407,6 +409,29 @@ Prefer bound parameters over encrypted literals when a prepared statement must b + + + +## Unknown prepared statement + +A Bind names a prepared statement that Proxy did not see prepared on this connection. +Proxy only knows whether a statement's values must be encrypted from the Parse that prepared it, so it refuses the Bind rather than send the values to the database as they are. + +### Error message + +``` +Prepared statement 'my_statement' does not exist on this connection. +``` + +### How to fix + +The error uses SQLSTATE `26000`, PostgreSQL's own code for a missing prepared statement, so drivers that cache statements can prepare it again. + +Prepare the statement with a Parse message on the same connection before binding it. +A statement prepared with SQL `PREPARE` cannot be bound through Proxy. + + + @@ -808,6 +833,30 @@ If the error persists, please contact CipherStash [support](https://cipherstash. + + + +## Encryption result count mismatch + +The encryption service returned a different number of encrypted values than the number of values it was asked to encrypt. + +### Error message + +``` +Expected 2 encrypted values but received 0. +``` + +### Notes + +Proxy matches encrypted values to statement parameters by position. If any value were missing, the parameters after it could be sent to the database unencrypted, so Proxy refuses the statement instead. + +### How to Fix + +This indicates an internal error in the encryption pipeline. Retry the statement, and if the error persists, contact CipherStash support with the Proxy logs. + + + + diff --git a/packages/cipherstash-proxy-integration/src/common.rs b/packages/cipherstash-proxy-integration/src/common.rs index 4e4157b1f..d99ed8e7b 100644 --- a/packages/cipherstash-proxy-integration/src/common.rs +++ b/packages/cipherstash-proxy-integration/src/common.rs @@ -567,6 +567,46 @@ pub fn interleaved_indices(len: usize) -> Vec { indices } +/// Asserts that `result` failed with a PostgreSQL `ErrorResponse` carrying +/// exactly this severity and this message. +/// +/// Read the message off the `DbError` rather than off `err.to_string()`. +/// `tokio_postgres::Error`'s `Display` renders only the error *kind*: since +/// 0.7.18 it no longer appends its cause, so `to_string()` yields a bare +/// `"db error"` and pins nothing. The server's text is still reachable through +/// `as_db_error()`, so asserting there keeps the exact customer-visible wording +/// pinned. +pub fn assert_db_error(result: Result, severity: &str, message: &str) { + let Err(err) = result else { + panic!("expected a database error, got a successful result"); + }; + + let db_error = err + .as_db_error() + .unwrap_or_else(|| panic!("expected a database error, got: {err:?}")); + + assert_eq!(db_error.severity(), severity); + assert_eq!(db_error.message(), message); +} + +/// Asserts that `result` failed in the client, before the statement reached the +/// server, with exactly this error kind and this underlying cause. +/// +/// The counterpart to [`assert_db_error`] for errors that never become a +/// PostgreSQL `ErrorResponse`, such as a `ToSql` conversion failure. +pub fn assert_client_error(result: Result, kind: &str, cause: &str) { + let Err(err) = result else { + panic!("expected a client error, got a successful result"); + }; + + assert_eq!(err.to_string(), kind); + + let source = std::error::Error::source(&err) + .unwrap_or_else(|| panic!("expected the error to carry a cause, got: {err:?}")); + + assert_eq!(source.to_string(), cause); +} + /// /// Configure the client TLS settings. /// These are the settings for connecting to the database with TLS. diff --git a/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs b/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs index c1f560896..8ff304280 100644 --- a/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs +++ b/packages/cipherstash-proxy-integration/src/copy_from_stdin.rs @@ -47,7 +47,7 @@ mod tests { .await .err() .unwrap(); - assert!(err.to_string().contains( + assert!(err.as_db_error().unwrap().message().contains( "COPY into encrypted column 'encrypted_text' of table 'encrypted' is not supported" )); @@ -57,7 +57,11 @@ mod tests { .await .err() .unwrap(); - assert!(err.to_string().contains("COPY into encrypted column")); + assert!(err + .as_db_error() + .unwrap() + .message() + .contains("COPY into encrypted column")); // The connection is still usable let rows = client.simple_query("SELECT 1").await.unwrap(); diff --git a/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs b/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs index 9148e2faf..70ebf6f8a 100644 --- a/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs +++ b/packages/cipherstash-proxy-integration/src/extended_protocol_error_messages.rs @@ -1,8 +1,11 @@ #[cfg(test)] mod tests { - use tracing::{debug, info}; + use tracing::debug; - use crate::common::{clear, connect_with_tls, random_id, reset_schema, trace, PROXY}; + use crate::common::{ + assert_client_error, assert_db_error, clear, connect_with_tls, random_id, reset_schema, + trace, PROXY, + }; struct Reset; @@ -35,9 +38,12 @@ mod tests { // EQL Mapper cannot type check a write to a column it does not know, so Proxy refuses it // rather than forward the values as written - let msg = result.unwrap_err().to_string(); + let err = result.unwrap_err(); + let db_error = err.as_db_error().unwrap(); + assert_eq!(db_error.severity(), "ERROR"); + let msg = db_error.message(); assert!( - msg.starts_with("db error: ERROR: Statement could not be type checked:"), + msg.starts_with("Statement could not be type checked:"), "{msg}" ); assert!(msg.contains("encrypted_unconfigured"), "{msg}"); @@ -61,15 +67,11 @@ mod tests { let sql = "INSERT INTO unconfigured (id, encrypted_unconfigured) VALUES ($1, $2)"; let result = client.query(sql, &[&id, &encrypted_text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - - assert_eq!(msg, "db error: ERROR: Column 'encrypted_unconfigured' in table 'unconfigured' has no Encrypt configuration. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-column"); - } else { - unreachable!(); - } + assert_db_error( + result, + "ERROR", + "Column 'encrypted_unconfigured' in table 'unconfigured' has no Encrypt configuration. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-column", + ); } /// The error here is in the Tokio/Postgres layer @@ -89,14 +91,11 @@ mod tests { let sql = "INSERT INTO encrypted (id, encrypted_date) VALUES ($1, $2)"; let result = client.query(sql, &[&id, &encrypted_date]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "error serializing parameter 1: cannot convert between the Rust type `i32` and the Postgres type `date`"); - } else { - unreachable!(); - } + assert_client_error( + result, + "error serializing parameter 1", + "cannot convert between the Rust type `i32` and the Postgres type `date`", + ); } #[tokio::test] @@ -117,14 +116,10 @@ mod tests { let sql = "INSERT INTO encrypted id, encrypted_text VALUES ($1, $2)"; let result = client.query(sql, &[&id, &encrypted_text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - info!("{}", msg); - assert_eq!(msg, "db error: ERROR: sql parser error: Expected: SELECT, VALUES, or a subquery in the query body, found: id at Line: 1, Column: 23. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#mapping-invalid-sql-statement"); - } else { - unreachable!(); - } + assert_db_error( + result, + "ERROR", + "sql parser error: Expected: SELECT, VALUES, or a subquery in the query body, found: id at Line: 1, Column: 23. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#mapping-invalid-sql-statement", + ); } } diff --git a/packages/cipherstash-proxy-integration/src/generate_ore_fixtures.rs b/packages/cipherstash-proxy-integration/src/generate_ore_fixtures.rs new file mode 100644 index 000000000..dacf2bcce --- /dev/null +++ b/packages/cipherstash-proxy-integration/src/generate_ore_fixtures.rs @@ -0,0 +1,381 @@ +//! ORE Test Fixture Generators +//! +//! These tests generate encrypted ORE fixture data for the EQL project by inserting +//! plaintext values through the CipherStash Proxy (which encrypts them), reading back +//! the raw encrypted JSON directly from PostgreSQL (bypassing the proxy), extracting +//! the ORE block (`ob`) field, and writing SQL fixture files. +//! +//! ## Usage +//! +//! Prerequisites: proxy and PostgreSQL must be running. +//! +//! ```bash +//! # Generate both numeric and text fixtures +//! cargo test -p cipherstash-proxy-integration generate_ore -- --ignored --nocapture +//! +//! # Generate only numeric fixtures +//! cargo test -p cipherstash-proxy-integration generate_ore_numeric -- --ignored --nocapture +//! +//! # Generate only text fixtures +//! cargo test -p cipherstash-proxy-integration generate_ore_text -- --ignored --nocapture +//! ``` +//! +//! Output defaults to `/Users/tobyhede/src/encrypt-query-language/tests/` but can be +//! overridden with the `ORE_FIXTURE_OUTPUT_DIR` environment variable. + +#[cfg(test)] +mod tests { + use crate::common::{clear, connect_with_tls, get_database_port, PROXY}; + use serde_json::Value; + use std::fmt::Write as FmtWrite; + use std::fs; + use std::path::PathBuf; + + const DEFAULT_OUTPUT_DIR: &str = "/Users/tobyhede/src/encrypt-query-language/tests"; + + fn output_dir() -> PathBuf { + std::env::var("ORE_FIXTURE_OUTPUT_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_OUTPUT_DIR)) + } + + /// Determine the ORE field name by inspecting a sample encrypted value. + /// Returns "ob" or "o" depending on what the proxy produces. + fn detect_ore_field(json: &Value) -> &'static str { + if json.get("ob").is_some() { + "ob" + } else if json.get("o").is_some() { + "o" + } else { + panic!( + "Could not detect ORE field in encrypted JSON. \ + Expected 'ob' or 'o' field. Got: {}", + json + ); + } + } + + /// Unwrap PostgreSQL composite type text representation. + /// + /// When casting `eql_v2_encrypted::text`, PostgreSQL returns the value wrapped + /// in a composite type format like: `("{"c": "...", "ob": [...]}")`. + /// The inner double-quotes are escaped as `""`. + fn unwrap_composite_text(raw: &str) -> String { + let trimmed = raw.trim(); + if trimmed.starts_with("(\"") && trimmed.ends_with("\")") { + // Strip outer ("...") and unescape doubled double-quotes + trimmed[2..trimmed.len() - 2].replace("\"\"", "\"") + } else { + // Already plain JSON + trimmed.to_string() + } + } + + /// Extract just the ORE block from the encrypted JSON, returning a minimal JSON object. + fn extract_ore_json(ciphertext: &str) -> String { + let json_str = unwrap_composite_text(ciphertext); + let json: Value = serde_json::from_str(&json_str) + .unwrap_or_else(|e| panic!("Failed to parse encrypted JSON: {e}\nJSON: {json_str}")); + + let field = detect_ore_field(&json); + let ore_value = &json[field]; + + // Return compact JSON with just the ORE field + format!("{{\"{field}\": {}}}", serde_json::to_string(ore_value).unwrap()) + } + + /// Generate the numeric ORE fixture file with 1000 values (ids 1-1000). + /// + /// Inserts integers 1..=1000 as `encrypted_int4` through the proxy, reads back + /// the encrypted ORE data directly from PostgreSQL, and writes a SQL fixture file. + #[tokio::test] + #[ignore] + async fn generate_ore_numeric_fixture() { + let proxy_client = connect_with_tls(PROXY).await; + let db_port = get_database_port(); + let db_client = connect_with_tls(db_port).await; + + // Truncate the encrypted table + clear().await; + + println!("Inserting 1000 numeric values through proxy..."); + + // Insert values 1..=1000 through the proxy + for i in 1..=1000_i32 { + let id = i as i64; + proxy_client + .execute( + "INSERT INTO encrypted (id, encrypted_int4) VALUES ($1, $2)", + &[&id, &i], + ) + .await + .unwrap_or_else(|e| panic!("Failed to insert id={id}: {e}")); + + if i % 100 == 0 { + println!(" Inserted {i}/1000"); + } + } + + println!("Reading back encrypted values directly from PostgreSQL..."); + + // Read all encrypted values directly (bypassing proxy) + let rows = db_client + .query( + "SELECT id, encrypted_int4::text FROM encrypted WHERE id BETWEEN 1 AND 1000 ORDER BY id", + &[], + ) + .await + .unwrap(); + + assert_eq!( + rows.len(), + 1000, + "Expected 1000 rows, got {}", + rows.len() + ); + + // Build the SQL fixture file + let mut sql = String::new(); + writeln!(sql, "DROP TABLE IF EXISTS ore;").unwrap(); + writeln!(sql, "CREATE TABLE ore").unwrap(); + writeln!(sql, "(").unwrap(); + writeln!(sql, " id bigint,").unwrap(); + writeln!(sql, " e eql_v2_encrypted,").unwrap(); + writeln!(sql, " PRIMARY KEY(id)").unwrap(); + writeln!(sql, ");").unwrap(); + writeln!(sql).unwrap(); + + for row in &rows { + let id: i64 = row.get(0); + let ciphertext: String = row.get(1); + let ore_json = extract_ore_json(&ciphertext); + + writeln!( + sql, + "INSERT INTO ore(id, e) VALUES ({id}, '{ore_json}'::jsonb::eql_v2_encrypted);" + ) + .unwrap(); + } + + // Write the file + let output_path = output_dir().join("ore.sql"); + fs::write(&output_path, &sql) + .unwrap_or_else(|e| panic!("Failed to write {}: {e}", output_path.display())); + + println!("Wrote numeric ORE fixture: {}", output_path.display()); + println!(" {} INSERT statements", rows.len()); + println!(" {} total lines", sql.lines().count()); + } + + /// Generate the text ORE fixture file with 100 lexicographically diverse strings. + /// + /// Inserts sorted lowercase strings through the proxy, reads back the encrypted + /// ORE data directly from PostgreSQL, and writes a SQL fixture file that includes + /// both the plaintext and encrypted values. + #[tokio::test] + #[ignore] + async fn generate_ore_text_fixture() { + let proxy_client = connect_with_tls(PROXY).await; + let db_port = get_database_port(); + let db_client = connect_with_tls(db_port).await; + + // Truncate the encrypted table + clear().await; + + let words = text_fixture_words(); + assert_eq!(words.len(), 100, "Expected exactly 100 words"); + + // Verify words are sorted + let mut sorted = words.clone(); + sorted.sort(); + assert_eq!(words, sorted, "Words must be pre-sorted lexicographically"); + + println!("Inserting {} text values through proxy...", words.len()); + + // Use IDs starting at 1001 to avoid collisions if run alongside numeric + let id_offset = 1001_i64; + + for (i, word) in words.iter().enumerate() { + let id = id_offset + i as i64; + let text = word.to_string(); + proxy_client + .execute( + "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)", + &[&id, &text], + ) + .await + .unwrap_or_else(|e| panic!("Failed to insert id={id} word='{word}': {e}")); + } + + println!("Reading back encrypted values directly from PostgreSQL..."); + + let max_id = id_offset + words.len() as i64 - 1; + let rows = db_client + .query( + "SELECT id, encrypted_text::text FROM encrypted WHERE id BETWEEN $1 AND $2 ORDER BY id", + &[&id_offset, &max_id], + ) + .await + .unwrap(); + + assert_eq!( + rows.len(), + words.len(), + "Expected {} rows, got {}", + words.len(), + rows.len() + ); + + // Build the SQL fixture file + let mut sql = String::new(); + writeln!(sql, "DROP TABLE IF EXISTS ore_text;").unwrap(); + writeln!(sql, "CREATE TABLE ore_text").unwrap(); + writeln!(sql, "(").unwrap(); + writeln!(sql, " id bigint,").unwrap(); + writeln!(sql, " plaintext text,").unwrap(); + writeln!(sql, " e eql_v2_encrypted,").unwrap(); + writeln!(sql, " PRIMARY KEY(id)").unwrap(); + writeln!(sql, ");").unwrap(); + writeln!(sql).unwrap(); + + for (i, row) in rows.iter().enumerate() { + let _id: i64 = row.get(0); + let ciphertext: String = row.get(1); + let ore_json = extract_ore_json(&ciphertext); + let word = &words[i]; + + // Use sequential IDs starting from 1 for the fixture file + let fixture_id = i + 1; + + // Escape single quotes in the word for SQL safety + let escaped_word = word.replace('\'', "''"); + + writeln!( + sql, + "INSERT INTO ore_text(id, plaintext, e) VALUES ({fixture_id}, '{escaped_word}', '{ore_json}'::jsonb::eql_v2_encrypted);" + ) + .unwrap(); + } + + // Write the file + let output_path = output_dir().join("ore_text.sql"); + fs::write(&output_path, &sql) + .unwrap_or_else(|e| panic!("Failed to write {}: {e}", output_path.display())); + + println!("Wrote text ORE fixture: {}", output_path.display()); + println!(" {} INSERT statements", rows.len()); + println!(" {} total lines", sql.lines().count()); + } + + /// 100 lexicographically sorted, diverse lowercase English words. + /// + /// Includes: + /// - Coverage across A-Z + /// - Prefix overlaps: "app", "apple", "application" + /// - Similar starts: "car", "card", "care" + /// - Varied lengths from 3 to 11 characters + fn text_fixture_words() -> Vec<&'static str> { + vec![ + "aardvark", + "able", + "abstract", + "ace", + "anchor", + "app", + "apple", + "application", + "arctic", + "badge", + "balance", + "barn", + "basket", + "beacon", + "blaze", + "bond", + "brave", + "breeze", + "bronze", + "cabin", + "canal", + "car", + "card", + "care", + "castle", + "cedar", + "chief", + "cloud", + "coral", + "crane", + "dagger", + "dawn", + "delta", + "desert", + "diamond", + "dome", + "dragon", + "drift", + "eagle", + "earth", + "eclipse", + "ember", + "epic", + "falcon", + "fern", + "flame", + "forest", + "frost", + "garden", + "glacier", + "globe", + "grain", + "grove", + "harbor", + "hawk", + "horizon", + "iron", + "island", + "ivory", + "jade", + "jasmine", + "jungle", + "kernel", + "kite", + "knot", + "lake", + "lemon", + "light", + "lunar", + "maple", + "marsh", + "meadow", + "mist", + "noble", + "north", + "oak", + "ocean", + "olive", + "orbit", + "palm", + "pearl", + "pine", + "plaza", + "quartz", + "quest", + "raven", + "reef", + "ridge", + "river", + "sage", + "shadow", + "silver", + "stone", + "summit", + "thunder", + "valley", + "willow", + "wolf", + "zero", + "zinc", + ] + } +} diff --git a/packages/cipherstash-proxy-integration/src/large_json_insert.rs b/packages/cipherstash-proxy-integration/src/large_json_insert.rs new file mode 100644 index 000000000..b62983ab0 --- /dev/null +++ b/packages/cipherstash-proxy-integration/src/large_json_insert.rs @@ -0,0 +1,601 @@ +//! Integration tests for large JSON payload INSERT performance. +//! +//! These tests investigate customer-reported timeout issues when inserting +//! large JSON documents into two encrypted JSONB columns. +//! +//! Customer issue (Bloom Credit): +//! - Two encrypted JSON blob columns causing 25s+ timeouts on INSERTs +//! - Blanking either column alleviates timeouts +//! - Blanking both makes insertion times negligible (milliseconds) +//! +//! Test configuration: +//! - Uses `bloom_credit` table with encrypted columns configured WITHOUT ste_vec indexes +//! +//! Test categories: +//! - **Synthetic tests** - Generated data matching customer report structure (reproducible) +//! - **Customer data tests** - Actual Bloom Credit data from `bloom_credit/` directory + +#[cfg(test)] +mod tests { + use crate::common::{clear, connect_with_tls, random_id, trace, PROXY}; + use serde_json::{json, Value}; + use std::time::Instant; + + // ========================================================================= + // Configuration + // ========================================================================= + + /// Number of records to insert in each test. + /// Adjust this to test performance at different volumes. + const NUM_RECORDS: usize = 10; + + // ========================================================================= + // Data Generators + // ========================================================================= + + /// Generate a synthetic payment snapshot matching customer data structure. + fn generate_payment_snapshot(month_offset: u32) -> Value { + let statuses = ["CURRENT", "LATE_30", "LATE_60", "LATE_90"]; + let status = statuses[(month_offset as usize) % statuses.len()]; + json!({ + "occurrence_date": format!("2025-{:02}-01", (month_offset % 12) + 1), + "payment_status": status + }) + } + + /// Generate a synthetic tradeline matching customer data structure. + fn generate_tradeline(index: u32) -> Value { + let creditors = [ + "THE BANK OF MISSOURI", + "CAPITAL ONE", + "CITIBANK NA", + "CHASE BANK", + "AMERICAN EXPRESS", + "DISCOVER BANK", + "WELLS FARGO", + "US BANK", + ]; + let types = ["REVOLVING", "INSTALLMENT", "MORTGAGE", "OPEN"]; + let type_descriptions = ["Credit Card", "Auto Loan", "Home Mortgage", "Line of Credit"]; + + // Generate 18 payment snapshots per tradeline + let payment_snapshots: Vec = (0..18) + .map(|m| generate_payment_snapshot(m + index)) + .collect(); + + let creditor_name = creditors[(index as usize) % creditors.len()]; + let account_type = types[(index as usize) % types.len()]; + let account_type_desc = type_descriptions[(index as usize) % type_descriptions.len()]; + let year = (index % 5) + 1; + let month = (index % 12) + 1; + let day = (index % 28) + 1; + let delinquency_date: Value = if index % 3 == 0 { + Value::String("01092026".to_string()) + } else { + Value::Null + }; + let past_due = if index % 2 == 0 { (index + 1) * 10 } else { 0 }; + let terms_duration = if index % 2 == 0 { 36 } else { 60 }; + + json!({ + "account": { + "account_number": "", + "date_closed": null, + "date_opened": format!("202{}-{:02}-{:02}", year, month, day), + "name": "", + "type": account_type, + "type_description": account_type_desc, + "creditor": { + "address": null, + "email": null, + "industry": null, + "name": creditor_name, + "phone": null, + "website": null + } + }, + "account_rating": format!("{}", (index % 5) + 1), + "credit_limit": (index + 1) * 1000, + "current_balance": (index + 1) * 500, + "date_effective": "2026-01-09", + "delinquency_date": delinquency_date, + "high_credit": (index + 1) * 800, + "late_30_days_total": index % 3, + "late_60_days_total": index % 4, + "late_90_days_total": index % 5, + "months_reviewed_count": 18, + "most_recent_payment_amount": (index + 1) * 50, + "most_recent_payment_date": "2025-09-01", + "past_due": past_due, + "payment_pattern_start_date": "2024-07-09", + "scheduled_monthly_payment": (index + 1) * 25, + "payment_snapshots": payment_snapshots, + "remarks": [ + {"code": "CLO", "type": "COMPLIANCE"}, + {"code": format!("RMK{}", index), "type": "ACCOUNT"} + ], + "terms_duration": terms_duration, + "terms_frequency": "MONTHLY" + }) + } + + /// Generate a synthetic inquiry matching customer data structure. + fn generate_inquiry(index: u32) -> Value { + let inquirers = [ + "TD BANK USA/TARGET C", + "CAPITAL ONE", + "CITIBANK NA., BEST B", + "CHASE BANK USA", + "AMERICAN EXPRESS", + ]; + let categories = [ + "DEPARTMENT_AND_VARIETY", + "BANKS", + "HOME_FURNISHINGS", + "FINANCE_COMPANIES", + "CREDIT_UNIONS", + ]; + let names = [ + "Complete Dept. Stores", + "All Banks", + "Television & Radio", + "Personal Finance Companies", + "Credit Unions", + ]; + + json!({ + "date": format!("2025-{:02}-{:02}", (index % 12) + 1, (index % 28) + 1), + "inquirer_name": inquirers[(index as usize) % inquirers.len()], + "industry": { + "category": categories[(index as usize) % categories.len()], + "name": names[(index as usize) % names.len()] + } + }) + } + + /// Generate a synthetic credit score matching customer data structure. + fn generate_credit_score() -> Value { + json!({ + "model": "VANTAGE4", + "score_reasons": [ + {"narrative": "Lack of real estate secured loan information"}, + {"narrative": "Too many bankcards with high balance compared to credit limit"}, + {"narrative": "Balances on bankcards are too high compared with credit limits"}, + {"narrative": "Length of time accounts have been established"} + ], + "value": 650 + }) + } + + /// Generate synthetic "full report" JSON (~250KB) matching customer structure. + /// + /// Structure matches Bloom Credit's full-report.json with: + /// - 140 tradelines (each with 18 payment snapshots) + /// - 50 inquiries + /// - Credit scores, alerts, collections, etc. + /// + /// The `seed` parameter ensures each generated report is unique. + fn generate_synthetic_full_report(seed: u32) -> Value { + let tradelines: Vec = (0..140).map(|i| generate_tradeline(i + seed * 200)).collect(); + let inquiries: Vec = (0..50).map(|i| generate_inquiry(i + seed * 200)).collect(); + + json!({ + "fullReport": { + "credit_data_order": { + "order_date": "2026-01-12", + "order_id": "14d69ece-6e96-4bce-a8f7-ca452ff74072", + "alerts": [], + "bankruptcies": [], + "collections": [], + "consumer_id": "dc0b6c07-4f44-4b5f-9942-aeba35177a22", + "credit_scores": [generate_credit_score()], + "inquiries": inquiries, + "mla_statuses": [], + "ofac_statuses": [], + "tradelines": tradelines + } + } + }) + } + + /// Generate a synthetic consumer record for raw report. + fn generate_consumer_record(index: u32) -> Value { + let cities = ["New York", "Los Angeles", "Chicago", "Houston", "Phoenix"]; + let states = ["NY", "CA", "IL", "TX", "AZ"]; + let positions = ["Manager", "Engineer", "Analyst", "Director"]; + + let addresses: Vec = (0..3u32) + .map(|a| { + let city = cities[(a as usize) % 5]; + let state = states[(a as usize) % 5]; + let addr_type = if a == 0 { "CURRENT" } else { "PREVIOUS" }; + json!({ + "street": format!("{} Main Street", (index * 100) + a), + "city": city, + "state": state, + "zip": format!("{:05}", 10000 + (index * 100) + a), + "type": addr_type, + "date_reported": format!("2025-{:02}-01", (a % 12) + 1) + }) + }) + .collect(); + + let employers: Vec = (0..2u32) + .map(|e| { + let position = positions[(e as usize) % 4]; + json!({ + "name": format!("Employer {} Inc.", (index * 10) + e), + "address": format!("{} Business Park", (index * 10) + e), + "date_hired": format!("20{:02}-{:02}-01", 15 + (e % 10), (e % 12) + 1), + "position": position + }) + }) + .collect(); + + let phone_numbers: Vec = (0..2u32) + .map(|p| { + let phone_type = if p == 0 { "HOME" } else { "MOBILE" }; + json!({ + "number": format!("555-{:03}-{:04}", 100 + (index % 900), (p * 1000) + index), + "type": phone_type + }) + }) + .collect(); + + let middle_name = if index % 2 == 0 { + Value::String(format!("M{}", index)) + } else { + Value::Null + }; + + json!({ + "consumer_id": format!("consumer-{:08}", index), + "personal_info": { + "first_name": format!("FirstName{}", index), + "last_name": format!("LastName{}", index), + "middle_name": middle_name, + "suffix": null, + "date_of_birth": format!("19{:02}-{:02}-{:02}", 70 + (index % 30), (index % 12) + 1, (index % 28) + 1), + "ssn_last_four": format!("{:04}", (index * 1234) % 10000) + }, + "addresses": addresses, + "employers": employers, + "phone_numbers": phone_numbers + }) + } + + /// Generate synthetic "raw report" JSON (~500KB) matching customer structure. + /// + /// Structure matches Bloom Credit's raw-report.json with: + /// - Multiple consumer records with detailed personal info + /// - Addresses, employers, phone numbers + /// - Additional metadata fields + /// + /// The `seed` parameter ensures each generated report is unique. + fn generate_synthetic_raw_report(seed: u32) -> Value { + let consumers: Vec = (0..270).map(|i| generate_consumer_record(i + seed * 1000)).collect(); + let consumer_count = consumers.len(); + + let data_sources = ["EQUIFAX", "EXPERIAN", "TRANSUNION"]; + let statuses = ["VERIFIED", "PENDING", "FAILED"]; + + // Add extra bulk data to reach ~500KB target + let extra_data: Vec = (0..550u32) + .map(|i| { + let idx = i + seed * 1000; + let source = data_sources[(idx as usize) % 3]; + let status = statuses[(idx as usize) % 3]; + let flags: Vec = (0..5).map(|f| format!("FLAG_{}", f + idx)).collect(); + let day = (idx % 28) + 1; + let hour = idx % 24; + let minute = idx % 60; + let response_code = 200 + (idx % 100); + let processing_time = 50 + (idx % 500); + let cache_hit = idx % 2 == 0; + let retry_count = idx % 3; + json!({ + "record_id": format!("rec-{:08}", idx), + "timestamp": format!("2026-01-{:02}T{:02}:{:02}:00Z", day, hour, minute), + "data_source": source, + "processing_notes": format!("Processing note {} with additional context and details for record tracking and audit purposes. This field contains extended text to simulate realistic data volumes.", idx), + "verification_status": status, + "metadata": { + "request_id": format!("req-{:012}", idx * 12345), + "response_code": response_code, + "processing_time_ms": processing_time, + "cache_hit": cache_hit, + "retry_count": retry_count, + "flags": flags + } + }) + }) + .collect(); + + json!({ + "rawReport": { + "consumers": consumers, + "status": "COMPLETE", + "processing_records": extra_data, + "summary": { + "total_consumers": consumer_count, + "processing_date": "2026-01-12", + "report_version": "2.0", + "data_sources": ["EQUIFAX", "EXPERIAN", "TRANSUNION"] + } + } + }) + } + + /// Load customer JSON data from bloom_credit directory. + fn load_customer_json(filename: &str) -> Value { + // Find the project root by looking for the bloom_credit directory + let possible_paths = [ + format!("bloom_credit/{}", filename), + format!("../../bloom_credit/{}", filename), + format!("../../../bloom_credit/{}", filename), + ]; + + for path in &possible_paths { + if let Ok(content) = std::fs::read_to_string(path) { + return serde_json::from_str(&content) + .unwrap_or_else(|_| panic!("Failed to parse {} as JSON", path)); + } + } + + // If we can't find it, give a helpful error message + let cwd = std::env::current_dir().unwrap_or_default(); + panic!( + "Failed to find bloom_credit/{}. Current directory: {}. Tried: {:?}", + filename, + cwd.display(), + possible_paths + ) + } + + // ========================================================================= + // Synthetic Tests (generated data - reproducible, no external dependencies) + // ========================================================================= + + /// Synthetic test - INSERT with only first encrypted JSONB column populated. + /// + /// Uses generated data matching customer structure (~250KB). + /// Each record is uniquely generated. + #[tokio::test] + async fn synthetic_insert_first_column() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + // Generate first record to show size + let sample = generate_synthetic_full_report(0); + let full_size = serde_json::to_string(&sample).unwrap().len(); + + println!("Synthetic full report size: {}KB", full_size / 1024); + println!("Inserting {} unique records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full) VALUES ($1, $2)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + let full_report = generate_synthetic_full_report(i as u32); + client.query(sql, &[&id, &full_report]).await.unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "SYNTHETIC INSERT first column only ({}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + /// Synthetic test - INSERT with BOTH encrypted JSONB columns populated. + /// + /// Uses generated data matching customer structure: + /// - encrypted_jsonb_full: ~250KB + /// - encrypted_jsonb_raw: ~500KB + /// Each record is uniquely generated. + #[tokio::test] + async fn synthetic_insert_both_columns() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + // Generate first record to show sizes + let sample_full = generate_synthetic_full_report(0); + let sample_raw = generate_synthetic_raw_report(0); + + let full_size = serde_json::to_string(&sample_full).unwrap().len(); + let raw_size = serde_json::to_string(&sample_raw).unwrap().len(); + + println!("Synthetic full report size: {}KB", full_size / 1024); + println!("Synthetic raw report size: {}KB", raw_size / 1024); + println!("Inserting {} unique records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full, encrypted_jsonb_raw) VALUES ($1, $2, $3)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + let full_report = generate_synthetic_full_report(i as u32); + let raw_report = generate_synthetic_raw_report(i as u32); + client + .query(sql, &[&id, &full_report, &raw_report]) + .await + .unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "SYNTHETIC INSERT both columns (full: {}KB, raw: {}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + raw_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + // ========================================================================= + // Customer Data Tests (actual Bloom Credit data from bloom_credit/) + // ========================================================================= + + /// Customer data test - INSERT with only first encrypted JSONB column populated. + /// + /// Uses actual customer data: full-report.json (~237KB) + #[tokio::test] + async fn customer_insert_first_column() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + let full_report = load_customer_json("full-report.json"); + let full_size = serde_json::to_string(&full_report).unwrap().len(); + + println!("Customer full-report.json size: {}KB", full_size / 1024); + println!("Inserting {} records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full) VALUES ($1, $2)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + client.query(sql, &[&id, &full_report]).await.unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "CUSTOMER INSERT first column only ({}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + /// Customer data test - INSERT with BOTH encrypted JSONB columns populated. + /// + /// Uses actual customer data: + /// - encrypted_jsonb_full: full-report.json (~237KB) + /// - encrypted_jsonb_raw: raw-report.json (~345KB) + /// + /// This should reproduce the customer's timeout issue where inserts take 25s+. + #[tokio::test] + async fn customer_insert_both_columns() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + let full_report = load_customer_json("full-report.json"); + let raw_report = load_customer_json("raw-report.json"); + + let full_size = serde_json::to_string(&full_report).unwrap().len(); + let raw_size = serde_json::to_string(&raw_report).unwrap().len(); + + println!("Customer full-report.json size: {}KB", full_size / 1024); + println!("Customer raw-report.json size: {}KB", raw_size / 1024); + println!("Inserting {} records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id, encrypted_jsonb_full, encrypted_jsonb_raw) VALUES ($1, $2, $3)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + client + .query(sql, &[&id, &full_report, &raw_report]) + .await + .unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "CUSTOMER INSERT both columns (full: {}KB, raw: {}KB): {} records in {:?} (avg: {:.1}ms/record)", + full_size / 1024, + raw_size / 1024, + NUM_RECORDS, + duration, + avg_ms + ); + + if avg_ms > 10000.0 { + println!("WARNING: Average insert took >10s - in customer timeout territory!"); + } + } + + // ========================================================================= + // Baseline Tests + // ========================================================================= + + /// Baseline test - INSERT with both encrypted JSONB columns as NULL. + /// + /// This establishes the baseline timing without encryption overhead. + #[tokio::test] + async fn baseline_insert_both_null() { + trace(); + clear().await; + + let client = connect_with_tls(PROXY).await; + + println!("Inserting {} records...", NUM_RECORDS); + + let sql = "INSERT INTO bloom_credit (id) VALUES ($1)"; + + let start = Instant::now(); + for i in 0..NUM_RECORDS { + let id = random_id(); + client.query(sql, &[&id]).await.unwrap(); + if (i + 1) % 10 == 0 || i + 1 == NUM_RECORDS { + println!(" Inserted {}/{} records", i + 1, NUM_RECORDS); + } + } + let duration = start.elapsed(); + + let avg_ms = duration.as_millis() as f64 / NUM_RECORDS as f64; + println!( + "BASELINE INSERT both columns NULL: {} records in {:?} (avg: {:.1}ms/record)", + NUM_RECORDS, duration, avg_ms + ); + + // Average should be fast (under 100ms per record for NULL inserts) + assert!( + avg_ms < 100.0, + "Baseline insert average should be <100ms, got {:.1}ms", + avg_ms + ); + } +} diff --git a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs index f1f600adf..8789a90de 100644 --- a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs +++ b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_id.rs @@ -8,7 +8,8 @@ #[cfg(test)] mod tests { use crate::common::{ - clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, trace, PROXY, + assert_db_error, clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, + trace, PROXY, }; use uuid::Uuid; @@ -377,15 +378,12 @@ mod tests { let insert_sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)"; let result = client.query(insert_sql, &[&id, &text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "db error: FATAL: Unknown keyset name or id '2cace9db-3a2a-4b46-a184-ba412b3e0730'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset"); - } else { - unreachable!(); - } + assert_db_error( + result, + "FATAL", + "Unknown keyset name or id '2cace9db-3a2a-4b46-a184-ba412b3e0730'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset", + ); // -------- // Switch back to TENANT_1 diff --git a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs index 7919e059c..95cfd71f7 100644 --- a/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs +++ b/packages/cipherstash-proxy-integration/src/multitenant/set_keyset_name.rs @@ -8,7 +8,8 @@ #[cfg(test)] mod tests { use crate::common::{ - clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, trace, PROXY, + assert_db_error, clear, connect_with_tls, random_id, rows_to_vec, simple_query_with_client, + trace, PROXY, }; /// @@ -342,15 +343,12 @@ mod tests { let insert_sql = "INSERT INTO encrypted (id, encrypted_text) VALUES ($1, $2)"; let result = client.query(insert_sql, &[&id, &text]).await; - assert!(result.is_err()); - - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "db error: FATAL: Unknown keyset name or id 'BLAHVTHA'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset"); - } else { - unreachable!(); - } + assert_db_error( + result, + "FATAL", + "Unknown keyset name or id 'BLAHVTHA'. Check the configured credentials. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unknown-keyset", + ); // -------- // Switch back to TENANT_1 diff --git a/packages/cipherstash-proxy-integration/src/set_keyset_error.rs b/packages/cipherstash-proxy-integration/src/set_keyset_error.rs index e4b75f9b7..e2025febf 100644 --- a/packages/cipherstash-proxy-integration/src/set_keyset_error.rs +++ b/packages/cipherstash-proxy-integration/src/set_keyset_error.rs @@ -11,16 +11,15 @@ mod tests { use tracing::info; - use crate::common::{connect_with_tls, trace, PROXY}; + use crate::common::{assert_db_error, connect_with_tls, trace, PROXY}; /// Helper function to assert that a result contains the expected "Cannot SET CIPHERSTASH.KEYSET" error fn assert_keyset_error(result: Result) { - if let Err(err) = result { - let msg = err.to_string(); - assert_eq!(msg, "db error: FATAL: Cannot SET CIPHERSTASH.KEYSET if a default keyset has been configured. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unexpected-set-keyset"); - } else { - unreachable!(); - } + assert_db_error( + result, + "FATAL", + "Cannot SET CIPHERSTASH.KEYSET if a default keyset has been configured. For help visit https://github.com/cipherstash/proxy/blob/main/docs/errors.md#encrypt-unexpected-set-keyset", + ); } /// Tests error handling of unknown keyset id diff --git a/packages/cipherstash-proxy/Cargo.toml b/packages/cipherstash-proxy/Cargo.toml index 790cea6fe..0992d2a6a 100644 --- a/packages/cipherstash-proxy/Cargo.toml +++ b/packages/cipherstash-proxy/Cargo.toml @@ -42,7 +42,7 @@ rustls-platform-verifier = "0.5.0" rustls-pki-types = "1.10.0" serde = "1.0" serde_json = "1.0" -socket2 = "0.5.7" +socket2 = { version = "0.5.7", features = ["all"] } sqltk = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } diff --git a/packages/cipherstash-proxy/src/cli/migrate/mod.rs b/packages/cipherstash-proxy/src/cli/migrate/mod.rs index 4a6943302..f82b50e21 100644 --- a/packages/cipherstash-proxy/src/cli/migrate/mod.rs +++ b/packages/cipherstash-proxy/src/cli/migrate/mod.rs @@ -1,4 +1,4 @@ -use crate::error::Error; +use crate::error::{Error, ErrorChain}; use crate::log::MIGRATE; use crate::tls::NoCertificateVerification; use crate::TandemConfig; @@ -136,7 +136,7 @@ impl Migrate { let rows = match client.simple_query(&sql).await { Ok(rows) => rows, Err(err) => { - error!(target: MIGRATE, msg = "Error fetching records", table = self.table, error = err.to_string()); + error!(target: MIGRATE, msg = "Error fetching records", table = self.table, error = %ErrorChain(&err)); std::process::exit(exitcode::SOFTWARE); } }; @@ -270,7 +270,7 @@ pub async fn connect_with_tls( tokio::spawn(async move { if let Err(err) = connection.await { - error!("Connection error: {}", err); + error!("Connection error: {}", ErrorChain(&err)); } }); Ok(client) @@ -285,7 +285,7 @@ pub async fn connect_with_no_tls(connection_string: &str) -> Result Result { tokio::spawn(async move { if let Err(err) = connection.await { - error!(msg = "Connection error", error = err.to_string()); + error!(msg = "Connection error", error = %ErrorChain(&err)); } }); Ok(client) @@ -66,7 +71,7 @@ pub async fn bind_with_retry(server: &ServerConfig) -> TcpListener { error!( msg = "Error binding connection", retries = MAX_RETRY_COUNT, - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -91,7 +96,7 @@ pub async fn connect_with_retry(addr: &str) -> Result { } Err(err) => { if retry_count > MAX_RETRY_COUNT { - error!(msg = "Could not connect to database", retries = ?retry_count, error = err.to_string()); + error!(msg = "Could not connect to database", retries = ?retry_count, error = %ErrorChain(&err)); return Err(Error::DatabaseConnection); } } @@ -117,7 +122,7 @@ pub fn configure(stream: &TcpStream) { stream.set_nodelay(true).unwrap_or_else(|err| { warn!( msg = "Error configuring nodelay for connection", - error = err.to_string() + error = %ErrorChain(&err) ); }); @@ -127,7 +132,7 @@ pub fn configure(stream: &TcpStream) { Err(err) => { warn!( msg = "Error configuring tcp_user_timeout for connection", - error = err.to_string() + error = %ErrorChain(&err) ); } } @@ -144,7 +149,7 @@ pub fn configure(stream: &TcpStream) { Err(err) => { warn!( msg = "Error configuring keepalive for connection", - error = err.to_string() + error = %ErrorChain(&err) ); } } @@ -152,7 +157,7 @@ pub fn configure(stream: &TcpStream) { Err(err) => { warn!( msg = "Error configuring connection", - error = err.to_string() + error = %ErrorChain(&err) ); } } diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index f9b6a1f58..c7d120369 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -3,7 +3,7 @@ use bytes::BytesMut; use cipherstash_client::{encryption, schema::ColumnType}; use eql_mapper::{EqlMapperError, EqlTermVariant}; use metrics_exporter_prometheus::BuildError; -use std::{io, time::Duration}; +use std::{fmt, io, time::Duration}; use thiserror::Error; const ERROR_DOC_BASE_URL: &str = "https://github.com/cipherstash/proxy/blob/main/docs/errors.md"; @@ -122,6 +122,11 @@ pub enum MappingError { #[error("The prepared statement or bound values no longer match the connection's CipherStash settings. Close the statement, then prepare and bind it again. For help visit {}#mapping-prepared-settings-changed", ERROR_DOC_BASE_URL)] PreparedSettingsChanged, + /// A Bind names a statement Proxy has no Parse for, so Proxy cannot tell whether its + /// values must be encrypted. + #[error("Prepared statement '{name}' does not exist on this connection. For help visit {}#mapping-unknown-prepared-statement", ERROR_DOC_BASE_URL)] + UnknownPreparedStatement { name: String }, + #[error(transparent)] EqlMapper(#[from] EqlMapperError), } @@ -269,6 +274,13 @@ pub enum EncryptError { #[error("InvalidIndexTerm")] InvalidIndexTerm, + /// The encryption service returned a different number of values than it was given. + /// + /// Bind rewrites pair encrypted values with parameters by position, so a short result + /// would forward the remaining parameters unencrypted. The statement is refused instead. + #[error("Expected {expected} encrypted values but received {actual}. For help visit {}#encrypt-result-count-mismatch", ERROR_DOC_BASE_URL)] + EncryptionResultCountMismatch { expected: usize, actual: usize }, + #[error( "KeysetId `{id}` could not be parsed using `SET CIPHERSTASH.KEYSET_ID`. KeysetId should be a valid UUID. For help visit {}#encrypt-keyset-id-could-not-be-parsed", ERROR_DOC_BASE_URL @@ -547,6 +559,39 @@ impl From for Error { } } +/// Renders an error and its full `source()` chain on one line: `a: b: c`. +/// +/// Use this wherever an error is logged. `Display` on its own is not enough: +/// by convention an error's `Display` describes only that error, and the cause +/// is reached through `source()`. `tokio_postgres::Error` follows that +/// convention from 0.7.18, so `err.to_string()` logs a bare `db error` and +/// drops the server's message. +/// +/// Recording the error as a `dyn Error` field does not help either: the +/// `Structured` (JSON) log format renders only `Display`, and it is the default +/// whenever stdout is not a terminal. +/// +/// A cause whose text the rendered message already ends with is skipped, so an +/// error that embeds its cause in `Display` (`"...: {0}"` with `#[from]`) is not +/// printed twice. +pub struct ErrorChain<'a>(pub &'a (dyn std::error::Error + 'static)); + +impl fmt::Display for ErrorChain<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let mut rendered = self.0.to_string(); + let mut source = self.0.source(); + while let Some(cause) = source { + let cause_text = cause.to_string(); + if !rendered.ends_with(&cause_text) { + rendered.push_str(": "); + rendered.push_str(&cause_text); + } + source = cause.source(); + } + f.write_str(&rendered) + } +} + #[cfg(test)] mod tests { use super::*; @@ -566,4 +611,57 @@ mod tests { }; assert_eq!(error.to_string(), "Connection timed out after 5000 ms"); } + + #[derive(Debug, Error)] + #[error("{message}")] + struct Leaf { + message: &'static str, + } + + #[derive(Debug, Error)] + #[error("{message}")] + struct Wrapper { + message: &'static str, + #[source] + source: Leaf, + } + + #[test] + fn error_chain_renders_an_error_without_a_source() { + let error = Leaf { + message: "db error", + }; + + assert_eq!(ErrorChain(&error).to_string(), "db error"); + } + + #[test] + fn error_chain_appends_each_cause() { + let error = Wrapper { + message: "db error", + source: Leaf { + message: "ERROR: permission denied for table encrypted", + }, + }; + + assert_eq!( + ErrorChain(&error).to_string(), + "db error: ERROR: permission denied for table encrypted" + ); + } + + #[test] + fn error_chain_skips_a_cause_already_in_the_message() { + let error = Wrapper { + message: "Invalid encryption configuration: bad column", + source: Leaf { + message: "bad column", + }, + }; + + assert_eq!( + ErrorChain(&error).to_string(), + "Invalid encryption configuration: bad column" + ); + } } diff --git a/packages/cipherstash-proxy/src/main.rs b/packages/cipherstash-proxy/src/main.rs index a11da143d..7ceb1c25b 100644 --- a/packages/cipherstash-proxy/src/main.rs +++ b/packages/cipherstash-proxy/src/main.rs @@ -1,6 +1,6 @@ use cipherstash_proxy::config::TandemConfig; use cipherstash_proxy::connect::{self, AsyncStream}; -use cipherstash_proxy::error::{ConfigError, Error}; +use cipherstash_proxy::error::{ConfigError, Error, ErrorChain}; use cipherstash_proxy::prometheus::CLIENTS_ACTIVE_CONNECTIONS; use cipherstash_proxy::proxy::Proxy; use cipherstash_proxy::{cli, log, postgresql as pg, prometheus, tls, Args}; @@ -43,7 +43,7 @@ fn main() -> Result<(), Box> { } Err(err) => { - error!(msg = "Error running command", error = err.to_string()); + error!(msg = "Error running command", error = %ErrorChain(&err)); std::process::exit(exitcode::USAGE); } } @@ -66,7 +66,7 @@ fn main() -> Result<(), Box> { Err(err) => { error!( msg = "Could not start CipherStash proxy", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -111,10 +111,10 @@ fn main() -> Result<(), Box> { info!(msg = "Database connection closed after cancel request"); } Error::ConnectionTimeout{..} => { - warn!(msg = "Database connection timeout", error = err.to_string()); + warn!(msg = "Database connection timeout", error = %ErrorChain(&err)); } _ => { - error!(msg = "Database connection error", error = err.to_string()); + error!(msg = "Database connection error", error = %ErrorChain(&err)); } } }, @@ -155,7 +155,7 @@ async fn init(mut config: TandemConfig) -> Proxy { Err(err) => { error!( msg = "Could not start CipherStash proxy", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -201,7 +201,7 @@ async fn init(mut config: TandemConfig) -> Proxy { Err(err) => { error!( msg = "Server Transport Layer Security (TLS) configuration error", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::CONFIG); } @@ -236,7 +236,7 @@ async fn init(mut config: TandemConfig) -> Proxy { Err(err) => { error!( msg = "Could not start CipherStash proxy", - error = err.to_string() + error = %ErrorChain(&err) ); std::process::exit(exitcode::UNAVAILABLE); } @@ -272,7 +272,7 @@ async fn reload_application_config(config: &TandemConfig, args: &Args) -> Result Err(err) => { warn!( msg = "Configuration could not be reloaded: {}", - error = err.to_string() + error = %ErrorChain(&err) ); return Err(err); } diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs index e65909941..830ca957d 100644 --- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs +++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs @@ -17,7 +17,7 @@ use super::{ }; use crate::{ config::TandemConfig, - error::Error, + error::{EncryptError, Error}, log::{CONTEXT, SLOW_STATEMENTS}, prometheus::{ SLOW_STATEMENTS_TOTAL, STATEMENTS_EXECUTION_DURATION_SECONDS, @@ -953,6 +953,16 @@ where .map(|mut sessions| sessions.insert(name, session_id)); } + /// Whether a Parse on this connection prepared `name`. + /// + /// Every Parse records its session, including one Proxy does not map, and closing the + /// statement removes it. + pub fn is_prepared(&self, name: &Name) -> bool { + self.statement_sessions + .read() + .is_ok_and(|sessions| sessions.contains_key(name)) + } + pub fn get_statement_session(&self, name: &Name) -> Option { let sessions = self.statement_sessions.read().ok()?; sessions.get(name).copied() @@ -1518,9 +1528,22 @@ where plaintexts: Vec>, columns: &[Option], ) -> Result>, Error> { - self.encryption + let expected = plaintexts.len(); + + let encrypted = self + .encryption .encrypt(keyset_id, plaintexts, columns) - .await + .await?; + + // Values are paired with parameters by position, so a short result must never be used + if encrypted.len() != expected { + return Err(EncryptError::EncryptionResultCountMismatch { + expected, + actual: encrypted.len(), + } + .into()); + } + Ok(encrypted) } pub async fn decrypt( @@ -2709,4 +2732,24 @@ mod tests { assert!(context.get_statement(&name).is_some()); } + + #[tokio::test] + async fn encryption_service_returning_fewer_values_fails_closed() { + let context = create_context(); + // TestService returns no outputs at all + let plaintext = cipherstash_client::encryption::Plaintext::new("secret".to_owned()); + let result = context + .encrypt(None, vec![Some(plaintext), None], &[None, None]) + .await; + + assert!(matches!( + result, + Err(Error::Encrypt( + crate::error::EncryptError::EncryptionResultCountMismatch { + expected: 2, + actual: 0 + } + )) + )); + } } diff --git a/packages/cipherstash-proxy/src/postgresql/error_handler.rs b/packages/cipherstash-proxy/src/postgresql/error_handler.rs index a440b9553..15b268ecf 100644 --- a/packages/cipherstash-proxy/src/postgresql/error_handler.rs +++ b/packages/cipherstash-proxy/src/postgresql/error_handler.rs @@ -42,9 +42,10 @@ pub trait PostgreSqlErrorHandler { &column.column_name(), ) } - Error::Mapping(MappingError::PreparedSettingsChanged) => { - ErrorResponse::prepared_statement_invalidated(err.to_string()) - } + Error::Mapping( + MappingError::PreparedSettingsChanged + | MappingError::UnknownPreparedStatement { .. }, + ) => ErrorResponse::prepared_statement_invalidated(err.to_string()), Error::Mapping(err) => ErrorResponse::invalid_sql_statement(err.to_string()), Error::Encrypt(EncryptError::UnknownColumn { ref table, diff --git a/packages/cipherstash-proxy/src/postgresql/frontend.rs b/packages/cipherstash-proxy/src/postgresql/frontend.rs index 122a4a88b..a71a44ef3 100644 --- a/packages/cipherstash-proxy/src/postgresql/frontend.rs +++ b/packages/cipherstash-proxy/src/postgresql/frontend.rs @@ -1353,6 +1353,17 @@ where } let mut bind = Bind::try_from(bytes)?; + + // Proxy stores a Statement only for statements that touch encrypted columns, so a name it + // has no Parse for could be one whose values must be encrypted. Refuse it rather than + // send the values as they are. + if !self.context.is_prepared(&bind.prepared_statement) { + return Err(MappingError::UnknownPreparedStatement { + name: bind.prepared_statement.as_str().to_owned(), + } + .into()); + } + let statement = self.context.get_statement(&bind.prepared_statement); let encrypted_values = statement .as_ref() diff --git a/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs b/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs index 52e8ddb9f..c862c9ed6 100644 --- a/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs +++ b/packages/cipherstash-proxy/src/postgresql/messages/error_response.rs @@ -160,7 +160,7 @@ impl ErrorResponse { } /// - /// A prepared statement Proxy no longer runs as PostgreSQL error + /// A prepared statement Proxy no longer runs, or never saw prepared, as PostgreSQL error /// Code: 26000 invalid_sql_statement_name /// /// Drivers such as PostgreSQL JDBC invalidate their cached statements on this code. diff --git a/packages/cipherstash-proxy/src/postgresql/tests.rs b/packages/cipherstash-proxy/src/postgresql/tests.rs index 1261427b8..dbf6d48a2 100644 --- a/packages/cipherstash-proxy/src/postgresql/tests.rs +++ b/packages/cipherstash-proxy/src/postgresql/tests.rs @@ -2073,6 +2073,35 @@ mod refusals { assert_eq!(connection.context.state_sizes().pending_settings, 0); } + #[tokio::test] + async fn a_bind_to_a_statement_proxy_did_not_see_prepared_is_refused() { + let mut connection = Connection::started().await; + connection.client(bind_text("", "missing", "secret")).await; + connection.client(execute("")).await; + connection.client(sync()).await; + + // Proxy cannot tell whether the statement's values must be encrypted, so they never + // reach PostgreSQL under that name + let forwarded = connection.forwarded().await; + assert_eq!(forwarded[0].0, b'B'); + assert!(target(&forwarded[0].1, true).starts_with("__cipherstash_")); + assert!(!forwarded + .iter() + .any(|(_, body)| body.windows(6).any(|w| w == b"secret"))); + + connection.server(rejection(&forwarded)).await; + connection.server(ready(b'I')).await; + assert_eq!(connection.error_codes_sent_to_client(), vec!["26000"]); + + // The connection stays usable + connection.prepare("s", "SELECT 1").await; + connection.forwarded().await; + connection.client(bind("", "s")).await; + let forwarded = connection.forwarded().await; + assert_eq!(forwarded[0].0, b'B'); + assert_eq!(target(&forwarded[0].1, true), "s"); + } + #[tokio::test] async fn a_refused_execute_is_forwarded_as_an_execute_postgresql_rejects() { let mut connection = Connection::started().await; diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs index d121ab111..508c007ff 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs @@ -1,7 +1,7 @@ use crate::{ config::DatabaseConfig, connect, - error::{ConfigError, Error}, + error::{ConfigError, Error, ErrorChain}, log::ENCRYPT_CONFIG, proxy::ENCRYPT_CONFIG_QUERY, }; @@ -88,7 +88,7 @@ impl EncryptConfigManager { Err(err) => { warn!( msg = "Error reloading encrypt configuration", - error = err.to_string() + error = %ErrorChain(&err) ); } }; @@ -118,7 +118,7 @@ async fn init_reloader(config: DatabaseConfig) -> Result { error!( msg = "Error loading Encrypt configuration", - error = err.to_string() + error = %ErrorChain(&err) ); return Err(err); } @@ -161,7 +161,7 @@ async fn init_reloader(config: DatabaseConfig) -> Result { warn!( msg = "Error reloading Encrypt configuration", - error = err.to_string() + error = %ErrorChain(&err) ); } } @@ -197,7 +197,7 @@ async fn load_encrypt_config_with_retry(config: &DatabaseConfig) -> Result { warn!( msg = "Could not query EQL version from database", - error = err.to_string() + error = %ErrorChain(&err) ); None } diff --git a/packages/cipherstash-proxy/src/proxy/schema/manager.rs b/packages/cipherstash-proxy/src/proxy/schema/manager.rs index 5fa2710d9..dc81a2e4b 100644 --- a/packages/cipherstash-proxy/src/proxy/schema/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/schema/manager.rs @@ -1,5 +1,5 @@ use crate::config::DatabaseConfig; -use crate::error::Error; +use crate::error::{Error, ErrorChain}; use crate::proxy::{AGGREGATE_QUERY, SCHEMA_QUERY}; use crate::{connect, log::SCHEMA}; use arc_swap::ArcSwap; @@ -37,7 +37,7 @@ impl SchemaManager { Err(err) => { warn!( msg = "Error reloading database schema", - error = err.to_string() + error = %ErrorChain(&err) ); } }; @@ -72,7 +72,7 @@ async fn init_reloader(config: DatabaseConfig) -> Result { Err(err) => { warn!( msg = "Error loading database schema", - error = err.to_string() + error = %ErrorChain(&err) ); } } diff --git a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs index 15e120d3e..7ed350e51 100644 --- a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs +++ b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs @@ -16,6 +16,7 @@ use cipherstash_client::{ PreparedPlaintext, }, schema::column::IndexType, + IdentifiedBy, }; use eql_mapper::EqlTermVariant; use metrics::{counter, histogram}; @@ -93,7 +94,15 @@ impl ZeroKms { info!(target: ZEROKMS, msg = "Initializing ZeroKMS ScopedCipher (cache miss)", ?keyset_id); counter!(KEYSET_CIPHER_CACHE_MISS_TOTAL).increment(1); - let identified_by = keyset_id.as_ref().map(|id| id.0.clone()); + // A connection-level keyset takes precedence. Otherwise, scope the + // cipher to Proxy's configured default instead of passing `None` and + // silently falling back to the ZeroKMS client's account default. The + // two defaults are not required to be the same, and using the account + // default would derive different searchable-encryption terms. + let identified_by = keyset_id + .as_ref() + .map(|id| id.0.clone()) + .or_else(|| self.default_keyset_id.map(IdentifiedBy::Uuid)); let start = Instant::now(); let result = ScopedCipher::init(zerokms_client, identified_by).await; @@ -230,13 +239,7 @@ impl EncryptionService for ZeroKms { let encrypt_duration = encrypt_start.elapsed(); debug!(target: ENCRYPT, msg="encrypt_eql completed", count = encrypted.len(), duration_ms = encrypt_duration.as_millis()); - // Reconstruct the result vector with None values in the right places - let mut result: Vec> = vec![None; plaintexts.len()]; - for (idx, ciphertext) in indices.into_iter().zip(encrypted.into_iter()) { - result[idx] = Some(ciphertext); - } - - Ok(result) + place_ciphertexts(plaintexts.len(), indices, encrypted) } /// @@ -294,3 +297,52 @@ impl EncryptionService for ZeroKms { Ok(result) } } + +/// Put each ciphertext back at the position of the plaintext it encrypts, with `None` everywhere +/// else. +/// +/// Fails when there are fewer ciphertexts than encrypted positions: padding the gap with `None` +/// would forward the original value of that parameter unencrypted. +fn place_ciphertexts( + len: usize, + indices: Vec, + ciphertexts: Vec, +) -> Result>, Error> { + if ciphertexts.len() != indices.len() { + return Err(EncryptError::EncryptionResultCountMismatch { + expected: indices.len(), + actual: ciphertexts.len(), + } + .into()); + } + let mut result: Vec> = (0..len).map(|_| None).collect(); + for (idx, ciphertext) in indices.into_iter().zip(ciphertexts) { + result[idx] = Some(ciphertext); + } + Ok(result) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ciphertexts_are_placed_at_the_positions_that_were_encrypted() { + let result = place_ciphertexts(3, vec![0, 2], vec!["a", "c"]).unwrap(); + assert_eq!(result, vec![Some("a"), None, Some("c")]); + } + + #[test] + fn fewer_ciphertexts_than_encrypted_positions_fails_closed() { + let result = place_ciphertexts(3, vec![0, 2], vec!["a"]); + assert!(matches!( + result, + Err(Error::Encrypt( + EncryptError::EncryptionResultCountMismatch { + expected: 2, + actual: 1 + } + )) + )); + } +} diff --git a/packages/eql-mapper/src/importer.rs b/packages/eql-mapper/src/importer.rs index 9d744ee6d..a0b8002e0 100644 --- a/packages/eql-mapper/src/importer.rs +++ b/packages/eql-mapper/src/importer.rs @@ -5,7 +5,8 @@ use crate::{ Relation, ScopeError, ScopeTracker, }; use sqltk::parser::ast::{ - Cte, Ident, Insert, ObjectNamePart, TableAlias, TableFactor, TableObject, + Cte, Ident, Insert, ObjectNamePart, OnConflict, OnConflictAction, OnInsert, TableAlias, + TableFactor, TableObject, }; use sqltk::{Break, Visitable, Visitor}; use std::{cell::RefCell, fmt::Debug, marker::PhantomData, ops::ControlFlow, rc::Rc, sync::Arc}; @@ -38,6 +39,7 @@ impl<'ast> Importer<'ast> { if let Insert { table: TableObject::TableName(table_name), table_alias, + on, .. } = insert { @@ -45,11 +47,39 @@ impl<'ast> Importer<'ast> { let projection = Projection::new_from_schema_table(table.clone()); + // The relation is named — by its alias when one is written, by the + // table name otherwise — so that qualified references (`t.col` in + // `RETURNING` or in `ON CONFLICT DO UPDATE`) can resolve. + let name = table_alias.clone().or_else(|| { + let ObjectNamePart::Identifier(ident) = table_name.0.last().unwrap(); + Some(ident.clone()) + }); + self.scope_tracker.borrow_mut().add_relation(Relation { - name: table_alias.clone(), - projection_type: Type::Value(Value::Projection(projection)).into(), + name, + projection_type: Type::Value(Value::Projection(projection.clone())).into(), })?; + // `ON CONFLICT DO UPDATE` can read the row proposed for insertion + // through the `excluded` pseudo-table, which projects exactly the + // target table's columns. Bringing it into scope is what gives + // `excluded.` a type — including the column's EQL type, so an + // upsert like `SET enc = excluded.enc` is fully constrained. + // + // An unqualified column reference in the `DO UPDATE` expressions is + // now ambiguous (both relations project it), which mirrors + // PostgreSQL's own `column reference is ambiguous` error there. + if let Some(OnInsert::OnConflict(OnConflict { + action: OnConflictAction::DoUpdate(_), + .. + })) = on + { + self.scope_tracker.borrow_mut().add_relation(Relation { + name: Some(Ident::new("excluded")), + projection_type: Type::Value(Value::Projection(projection)).into(), + })?; + } + Ok(()) } else { Err(ImportError::Unsupported( diff --git a/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs b/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs index 815628490..32cc688fe 100644 --- a/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs +++ b/packages/eql-mapper/src/inference/infer_type_impls/insert_statement.rs @@ -3,10 +3,29 @@ use std::sync::Arc; use crate::{ inference::{type_error::TypeError, unifier::Type, InferType}, unifier::{EqlTerm, EqlValue, NativeValue, Value}, - ColumnKind, TableColumn, TypeInferencer, + ColumnKind, SchemaTableColumn, TableColumn, TypeInferencer, }; use eql_mapper_macros::trace_infer; -use sqltk::parser::ast::{Insert, TableObject}; +use sqltk::parser::ast::{ + AssignmentTarget, ConflictTarget, Insert, ObjectName, ObjectNamePart, OnConflict, + OnConflictAction, OnInsert, TableObject, +}; + +/// The type of the value stored in a schema column: the column's EQL type when +/// it is encrypted, its native identity otherwise. +fn stored_value_type(stc: &SchemaTableColumn) -> (Value, TableColumn) { + let tc = TableColumn { + table: stc.table.clone(), + column: stc.column.clone(), + }; + + let value_ty = match &stc.kind { + ColumnKind::Native => Value::Native(NativeValue(Some(tc.clone()))), + ColumnKind::Eql(features) => Value::Eql(EqlTerm::Full(EqlValue(tc.clone(), *features))), + }; + + (value_ty, tc) +} #[trace_infer] impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { @@ -16,6 +35,8 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { table_alias, columns, source, + assignments, + on, .. } = insert { @@ -23,6 +44,12 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { return Err(TypeError::UnsupportedSqlFeature("INSERT with ALIAS".into())); } + if !assignments.is_empty() { + return Err(TypeError::UnsupportedSqlFeature( + "MySQL INSERT ... SET".into(), + )); + } + let table_columns = if columns.is_empty() { // When no columns are specified, the source must unify with a projection of ALL table columns. self.table_resolver.resolve_table_columns(table_name)? @@ -37,19 +64,8 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { &table_columns .into_iter() .map(|stc| { - let tc = TableColumn { - table: stc.table.clone(), - column: stc.column.clone(), - }; - - let value_ty = match &stc.kind { - ColumnKind::Native => Value::Native(NativeValue(Some(tc.clone()))), - ColumnKind::Eql(features) => { - Value::Eql(EqlTerm::Full(EqlValue(tc.clone(), *features))) - } - }; - - (Arc::new(Type::Value(value_ty)), Some(tc.column.clone())) + let (value_ty, tc) = stored_value_type(&stc); + (Arc::new(Type::Value(value_ty)), Some(tc.column)) }) .collect::>(), ); @@ -58,6 +74,29 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { self.unify_node_with_type(&**source, target_columns)?; } + match on { + Some(OnInsert::OnConflict(on_conflict)) => { + self.infer_on_conflict(table_name, on_conflict)?; + } + + Some(OnInsert::DuplicateKeyUpdate(_)) => { + return Err(TypeError::UnsupportedSqlFeature( + "MySQL ON DUPLICATE KEY UPDATE".into(), + )); + } + + // `OnInsert` is non-exhaustive: reject any variant this crate + // does not know rather than let its assignments escape + // inference. + Some(_) => { + return Err(TypeError::UnsupportedSqlFeature( + "unrecognised ON clause in INSERT".into(), + )); + } + + None => {} + } + Ok(()) } else { Err(TypeError::UnsupportedSqlFeature("table functions".into())) @@ -80,3 +119,95 @@ impl<'ast> InferType<'ast, Insert> for TypeInferencer<'ast> { Ok(()) } } + +impl<'ast> TypeInferencer<'ast> { + /// Constrains an `ON CONFLICT` clause of an `INSERT` against the target + /// table. + /// + /// The `DO UPDATE SET` assignments are the upsert path of the statement: + /// each assignment value is unified with the type stored in its target + /// column, exactly as a plain `UPDATE ... SET` is — so a plaintext literal + /// assigned to an encrypted column becomes an EQL literal to encrypt, and + /// never lands in the column unencrypted. + /// + /// Assignment targets are resolved against the target table directly (not + /// the lexical scope): PostgreSQL forbids qualifying them, and the scope + /// also contains the `excluded` pseudo-table, which projects the same + /// column names. Value expressions and the `DO UPDATE ... WHERE` predicate + /// are ordinary expressions resolved against the scope — which is how + /// `excluded.` references get their types (see + /// [`crate::importer::Importer`]). + /// + /// A conflict target on an encrypted column is rejected: a conflict only + /// ever fires off a unique index, and uniqueness of an encrypted column + /// would be judged on the whole jsonb payload — whose ciphertext is + /// randomised per row — so the conflict would never fire and every upsert + /// would silently insert a duplicate. + /// + /// `ON CONFLICT ON CONSTRAINT ` is let through as written: the + /// mapper's schema carries no constraint catalog to resolve the name + /// against, and a constraint over an encrypted column is a schema-design + /// error that exists independently of any statement. + fn infer_on_conflict( + &mut self, + table_name: &ObjectName, + on_conflict: &'ast OnConflict, + ) -> Result<(), TypeError> { + let OnConflict { + conflict_target, + action, + } = on_conflict; + + if let Some(ConflictTarget::Columns(columns)) = conflict_target { + for column in columns { + let stc = self + .table_resolver + .resolve_table_column(table_name, column)?; + if matches!(stc.kind, ColumnKind::Eql(..)) { + return Err(TypeError::UnsupportedSqlFeature(format!( + "ON CONFLICT on encrypted column {}.{}", + stc.table, stc.column + ))); + } + } + } + + match action { + OnConflictAction::DoNothing => {} + + OnConflictAction::DoUpdate(do_update) => { + for assignment in &do_update.assignments { + match &assignment.target { + AssignmentTarget::ColumnName(ObjectName(parts)) if parts.len() == 1 => { + let ObjectNamePart::Identifier(ident) = parts.last().unwrap(); + let stc = self + .table_resolver + .resolve_table_column(table_name, ident)?; + let (value_ty, _) = stored_value_type(&stc); + self.unify_node_with_type(&assignment.value, Type::Value(value_ty))?; + } + + AssignmentTarget::ColumnName(ObjectName(_)) => { + return Err(TypeError::UnsupportedSqlFeature( + "qualified column names in ON CONFLICT DO UPDATE".into(), + )); + } + + AssignmentTarget::Tuple(_) => { + return Err(TypeError::UnsupportedSqlFeature( + "tuple assignment target in ON CONFLICT DO UPDATE".into(), + )); + } + } + } + + // `do_update.selection` needs no handling here: it is an + // ordinary predicate whose identifiers resolve against the + // scope (the target table and `excluded`), and whose operators + // are constrained by the expression rules. + } + } + + Ok(()) + } +} diff --git a/packages/eql-mapper/src/lib.rs b/packages/eql-mapper/src/lib.rs index 1efeafef5..59cfa0a02 100644 --- a/packages/eql-mapper/src/lib.rs +++ b/packages/eql-mapper/src/lib.rs @@ -2232,4 +2232,182 @@ mod test { type_check(schema, &statement).unwrap(); } + + /// `ON CONFLICT DO UPDATE SET enc = ` is the upsert path: the + /// assignment value must be typed as the column's EQL type so the literal + /// is encrypted, exactly as in a plain `UPDATE ... SET`. + #[test] + fn insert_on_conflict_do_update_encrypts_assignment_literal() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES (1, 20000) \ + ON CONFLICT (id) DO UPDATE SET salary = 30000", + ); + + let typed = match type_check(schema, &statement) { + Ok(typed) => typed, + Err(err) => panic!("type check failed: {err:#?}"), + }; + + // Both the inserted literal and the conflict-path literal are EQL + // literals to encrypt. + assert_eq!(typed.literals.len(), 2); + + let encrypted = typed + .literals + .iter() + .map(|(_, node)| { + ( + node.as_node_key(), + ast::Value::SingleQuotedString(format!("ENCRYPTED_{node}")), + ) + }) + .collect::>(); + + match typed.transform(encrypted) { + Ok(transformed_statement) => assert_eq!( + transformed_statement.to_string(), + "INSERT INTO employees (id, salary) VALUES (1, 'ENCRYPTED_20000'::JSONB::eql_v2_encrypted) \ + ON CONFLICT(id) DO UPDATE SET salary = 'ENCRYPTED_30000'::JSONB::eql_v2_encrypted" + ), + Err(err) => panic!("statement transformation failed: {err}"), + } + } + + /// A param assigned to an encrypted column on the conflict path is + /// encrypted too. + #[test] + fn insert_on_conflict_do_update_encrypts_assignment_param() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES ($1, $2) \ + ON CONFLICT (id) DO UPDATE SET salary = $3", + ); + + let typed = match type_check(schema, &statement) { + Ok(typed) => typed, + Err(err) => panic!("type check failed: {err:#?}"), + }; + + assert!( + matches!( + &typed.params[..], + [ + (_, Value::Native(_)), + (_, Value::Eql(EqlTerm::Full(_))), + (_, Value::Eql(EqlTerm::Full(_))) + ] + ), + "expected $1 native and $2, $3 EQL full payloads, got: {:?}", + typed.params + ); + } + + /// The `excluded` pseudo-table projects the target table's columns, so + /// `SET enc = excluded.enc` resolves and the param feeding the insert + /// value gets the column's EQL type. + #[test] + fn insert_on_conflict_do_update_with_excluded_reference() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES ($1, $2) \ + ON CONFLICT (id) DO UPDATE SET salary = excluded.salary", + ); + + let typed = match type_check(schema, &statement) { + Ok(typed) => typed, + Err(err) => panic!("type check failed: {err:#?}"), + }; + + assert!( + matches!( + &typed.params[..], + [(_, Value::Native(_)), (_, Value::Eql(EqlTerm::Full(_)))] + ), + "expected $1 native and $2 EQL full payload, got: {:?}", + typed.params + ); + + match typed.transform(HashMap::new()) { + Ok(transformed_statement) => assert_eq!( + transformed_statement.to_string(), + "INSERT INTO employees (id, salary) VALUES ($1, $2::JSONB::eql_v2_encrypted) \ + ON CONFLICT(id) DO UPDATE SET salary = excluded.salary" + ), + Err(err) => panic!("statement transformation failed: {err}"), + } + } + + /// The `DO UPDATE ... WHERE` predicate is an ordinary predicate over the + /// existing row and `excluded`. + #[test] + fn insert_on_conflict_do_update_where_type_checks() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL: Ord), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES ($1, $2) \ + ON CONFLICT (id) DO UPDATE SET salary = excluded.salary \ + WHERE excluded.salary > employees.salary", + ); + + if let Err(err) = type_check(schema, &statement) { + panic!("type check failed: {err:#?}") + } + } + + /// A conflict only fires off a unique index, and uniqueness of an + /// encrypted column would be judged on the randomised ciphertext, so the + /// conflict would never fire. Rejected explicitly. + #[test] + fn insert_on_conflict_target_on_encrypted_column_is_rejected() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL: Eq), + } + } + }); + + let statement = parse( + "INSERT INTO employees (id, salary) VALUES (1, 2) \ + ON CONFLICT (salary) DO NOTHING", + ); + + assert!( + type_check(schema, &statement).is_err(), + "an encrypted conflict-target column should fail type checking" + ); + } }