From f157fa8e98f03f5e475ab7eca9b0e1091fa88ab4 Mon Sep 17 00:00:00 2001 From: James Sadler Date: Tue, 27 Oct 2026 12:04:35 +1100 Subject: [PATCH 01/10] ci: remove the Multitudes deployment notification (2.2) The Multitudes endpoint has returned 500 on every release run since 2026-08-17, so the v2.2.5 release run would end red after the images are published. main removed this step in #477. --- .github/workflows/release.yml | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0dcce7a0f..6760ad776 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -198,13 +198,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"}' From 578dfe45f16fa757b2f60348212e10a0eda8acbb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Tue, 27 Oct 2026 16:34:33 +1100 Subject: [PATCH 02/10] fix(proxy): refuse an encryption result with fewer values than requested Bind rewrites pair encrypted values with parameters by position. ZeroKms::encrypt padded a short result from encrypt_eql with None, and Bind skips a None value, so the parameter's original plaintext was forwarded to PostgreSQL. ZeroKms::encrypt and Context::encrypt now fail with EncryptionResultCountMismatch when they receive fewer ciphertexts than values sent for encryption. --- CHANGELOG.md | 2 + docs/errors.md | 27 +++++++++ packages/cipherstash-proxy/src/error.rs | 7 +++ .../src/postgresql/context/mod.rs | 39 ++++++++++++- .../src/proxy/zerokms/zerokms.rs | 57 ++++++++++++++++--- 5 files changed, 122 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff2ec9d6..04d542165 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,8 @@ 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. + ## [2.2.4] - 2026-06-18 ### Fixed diff --git a/docs/errors.md b/docs/errors.md index 4777ff8c3..118c72162 100644 --- a/docs/errors.md +++ b/docs/errors.md @@ -34,6 +34,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) @@ -808,6 +809,32 @@ 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/src/error.rs b/packages/cipherstash-proxy/src/error.rs index f9b6a1f58..faf76a907 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -269,6 +269,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 diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs index e65909941..9bdfae82e 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, @@ -1518,9 +1518,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 +2722,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/proxy/zerokms/zerokms.rs b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs index 15e120d3e..6e4cf3b2e 100644 --- a/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs +++ b/packages/cipherstash-proxy/src/proxy/zerokms/zerokms.rs @@ -230,13 +230,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 +288,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 + } + )) + )); + } +} From 99d193c81a2b5d7b00239346d88357dc0a8909f8 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:09:05 +1100 Subject: [PATCH 03/10] fix(proxy): refuse a Bind to a statement Proxy did not see prepared Proxy keeps a Statement only for statements that touch encrypted columns. A Bind naming a statement Proxy had no record of was treated as plaintext and its values sent as they were, so any path that lost or dropped a statement too early would send plaintext for an encrypted column. Every Parse already records the statement's session, so while mapping is enabled a Bind to a name with no recorded Parse now fails closed with SQLSTATE 26000, PostgreSQL's own code for a missing prepared statement. The refusal goes through PostgreSQL as other refusals do, and the connection stays usable. A Parse that Proxy refuses is forgotten, as main's refuse_parse does, since PostgreSQL never prepares it. Otherwise the recorded session would let a later Bind to it through with its values as written. Backport of main's 292ebffa, with the refused-Parse handling from main's refuse_parse (context/lifecycle.rs), which is part of main's protocol lifecycle rewrite and does not cherry-pick onto 2.2. --- CHANGELOG.md | 2 + docs/errors.md | 24 ++++++++ packages/cipherstash-proxy/src/error.rs | 5 ++ .../src/postgresql/context/mod.rs | 10 ++++ .../src/postgresql/error_handler.rs | 7 ++- .../src/postgresql/frontend.rs | 15 +++++ .../src/postgresql/messages/error_response.rs | 2 +- .../cipherstash-proxy/src/postgresql/tests.rs | 60 +++++++++++++++++++ 8 files changed, 121 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04d542165..80eb01a7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **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. This includes a statement whose `Parse` Proxy refused, which PostgreSQL never prepared. A statement prepared with SQL `PREPARE` can no longer be bound with a `Bind` message through Proxy. + ## [2.2.4] - 2026-06-18 ### Fixed diff --git a/docs/errors.md b/docs/errors.md index 118c72162..ae03628f2 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) @@ -408,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. + + + diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index faf76a907..e592e3835 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -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), } diff --git a/packages/cipherstash-proxy/src/postgresql/context/mod.rs b/packages/cipherstash-proxy/src/postgresql/context/mod.rs index 9bdfae82e..830ca957d 100644 --- a/packages/cipherstash-proxy/src/postgresql/context/mod.rs +++ b/packages/cipherstash-proxy/src/postgresql/context/mod.rs @@ -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() 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..44a27aeca 100644 --- a/packages/cipherstash-proxy/src/postgresql/frontend.rs +++ b/packages/cipherstash-proxy/src/postgresql/frontend.rs @@ -259,6 +259,10 @@ where let name = Parse::try_from(&bytes) .map(|parse| parse.name) .unwrap_or_else(|_| Name::unnamed()); + // PostgreSQL will not prepare the statement, so Proxy must not count + // it as prepared either, or the Bind guard would forward a later Bind + // to it with its values as written + self.context.close_statement(&name); bytes = self.refuse(err, |refusal| refused_parse(&name, refusal))?; self.error_state = Some(ErrorState); } @@ -1353,6 +1357,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..c0b33912a 100644 --- a/packages/cipherstash-proxy/src/postgresql/tests.rs +++ b/packages/cipherstash-proxy/src/postgresql/tests.rs @@ -2073,6 +2073,66 @@ 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_bind_to_a_statement_whose_parse_proxy_refused_is_refused() { + let mut connection = Connection::started().await; + connection.forwarded().await; + connection + .client(parse("s", "SET CIPHERSTASH.UNKNOWN_SETTING = 1")) + .await; + connection.client(sync()).await; + let forwarded = connection.forwarded().await; + connection.server(rejection(&forwarded)).await; + connection.server(ready(b'I')).await; + connection.errors_sent_to_client(); + + // PostgreSQL never prepared `s`, so a later Bind to it is refused like any other + // statement Proxy did not see prepared, and its values never reach PostgreSQL + connection.client(bind_text("", "s", "secret")).await; + connection.client(execute("")).await; + connection.client(sync()).await; + + 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"]); + } + #[tokio::test] async fn a_refused_execute_is_forwarded_as_an_execute_postgresql_rejects() { let mut connection = Connection::started().await; From 19e7e5d7cc5599d032030cb4e4b568dae0381e99 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:09:16 +1100 Subject: [PATCH 04/10] fix(mapper): type check ON CONFLICT DO UPDATE assignments The mapper never inferred the DO UPDATE SET assignments of an INSERT ... ON CONFLICT, so a literal or param assigned to an encrypted column on the conflict path was left untyped and sent to PostgreSQL as plaintext. Because the statement still type-checked, the refusal of writes Proxy cannot type check did not catch it. Each assignment value is now unified with the type stored in its target column, as in a plain UPDATE, so it is encrypted. The insert target is named so qualified references resolve. A conflict target on an encrypted column, MySQL INSERT ... SET and ON DUPLICATE KEY UPDATE are rejected. The `excluded` pseudo-table is in scope only inside DO UPDATE, as in PostgreSQL, so `excluded.` carries the column's EQL type there while RETURNING and the INSERT source see only the target table. An upsert ending in RETURNING id therefore type checks, and RETURNING * projects only the target table. A target table that is itself named `excluded` is shadowed for the clause and restored after it. This is the ON CONFLICT part of the mapper fix on main (39d03e56), with main's excluded scoping from 8b6ce3be and 10ce4c3c. Its window, aggregate and SELECT ... INTO changes are left out: those gaps act on ciphertext rather than forwarding plaintext. release/2.2 lacked the on_conflict_is_update helper and ImportError::TraversalInvariant (from main's 5ff506b0), which are added as main has them. Co-authored-by: James Sadler --- CHANGELOG.md | 2 + packages/eql-mapper/src/importer.rs | 84 +++- .../infer_type_impls/insert_statement.rs | 161 +++++++- packages/eql-mapper/src/lib.rs | 365 +++++++++++++++++- packages/eql-mapper/src/scope_tracker.rs | 56 +++ 5 files changed, 645 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80eb01a7d..8687809d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **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. This includes a statement whose `Parse` Proxy refused, which PostgreSQL never prepared. 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. + ## [2.2.4] - 2026-06-18 ### Fixed diff --git a/packages/eql-mapper/src/importer.rs b/packages/eql-mapper/src/importer.rs index 9d744ee6d..f5ed47072 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, TableAlias, TableFactor, + TableObject, }; use sqltk::{Break, Visitable, Visitor}; use std::{cell::RefCell, fmt::Debug, marker::PhantomData, ops::ControlFlow, rc::Rc, sync::Arc}; @@ -17,6 +18,8 @@ pub struct Importer<'ast> { table_resolver: Arc, registry: Rc>>, scope_tracker: Rc>>, + insert_projections: Vec>, + shadowed_excluded_relations: Vec>>, _ast: PhantomData<&'ast ()>, } @@ -30,11 +33,16 @@ impl<'ast> Importer<'ast> { registry: registry.into(), table_resolver: table_resolver.into(), scope_tracker: scope.into(), + insert_projections: Vec::new(), + shadowed_excluded_relations: Vec::new(), _ast: PhantomData, } } - fn update_scope_for_insert_statement(&mut self, insert: &Insert) -> Result<(), ImportError> { + fn update_scope_for_insert_statement( + &mut self, + insert: &Insert, + ) -> Result, ImportError> { if let Insert { table: TableObject::TableName(table_name), table_alias, @@ -45,12 +53,20 @@ 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(), })?; - Ok(()) + Ok(Type::Value(Value::Projection(projection)).into()) } else { Err(ImportError::Unsupported( "unsupported TableObject variant in Insert".to_string(), @@ -292,6 +308,9 @@ pub enum ImportError { #[error(transparent)] ScopeError(#[from] ScopeError), + #[error("Importer traversal invariant failed: {0}")] + TraversalInvariant(&'static str), + #[error("Expected projection")] ExpectedProjection, @@ -312,8 +331,31 @@ impl<'ast> Visitor<'ast> for Importer<'ast> { // 2. Child nodes of the `Insert` need to resolve identifiers in the context of the scope, so exit would be too // late. if let Some(insert) = node.downcast_ref::() { - if let Err(err) = self.update_scope_for_insert_statement(insert) { - return ControlFlow::Break(Break::Err(err)); + match self.update_scope_for_insert_statement(insert) { + Ok(projection) => self.insert_projections.push(projection), + Err(err) => return ControlFlow::Break(Break::Err(err)), + } + } + + // `excluded` exists only inside `ON CONFLICT DO UPDATE`. Adding it at + // the clause boundary keeps it visible to assignments and the WHERE + // predicate, but not to the INSERT source or RETURNING clause. + if let Some(on_conflict) = node.downcast_ref::() { + if matches!(on_conflict.action, OnConflictAction::DoUpdate(_)) { + let Some(projection_type) = self.insert_projections.last().cloned() else { + return ControlFlow::Break(Break::Err(ImportError::ExpectedProjection)); + }; + + match self + .scope_tracker + .borrow_mut() + .add_shadowing_relation(Relation { + name: Some(Ident::new("excluded")), + projection_type, + }) { + Ok(shadowed) => self.shadowed_excluded_relations.push(shadowed), + Err(err) => return ControlFlow::Break(Break::Err(err.into())), + } } } @@ -321,6 +363,30 @@ impl<'ast> Visitor<'ast> for Importer<'ast> { } fn exit(&mut self, node: &'ast N) -> ControlFlow> { + if let Some(on_conflict) = node.downcast_ref::() { + if on_conflict_is_update(on_conflict) { + // Remove the pseudo-relation added on entry before traversal + // continues into the INSERT's RETURNING clause, restoring a + // target table binding that it temporarily shadowed. + let Some(shadowed) = self.shadowed_excluded_relations.pop() else { + return ControlFlow::Break(Break::Err(ImportError::TraversalInvariant( + "ON CONFLICT DO UPDATE exited without a shadow record", + ))); + }; + if let Err(err) = self + .scope_tracker + .borrow_mut() + .remove_shadowing_relation(&Ident::new("excluded"), shadowed) + { + return ControlFlow::Break(Break::Err(err.into())); + } + } + } + + if node.downcast_ref::().is_some() && self.insert_projections.pop().is_none() { + return ControlFlow::Break(Break::Err(ImportError::ExpectedProjection)); + } + if let Some(cte) = node.downcast_ref::() { if let Err(err) = self.update_scope_for_cte(cte) { return ControlFlow::Break(Break::Err(err)); @@ -336,3 +402,7 @@ impl<'ast> Visitor<'ast> for Importer<'ast> { ControlFlow::Continue(()) } } + +fn on_conflict_is_update(on_conflict: &OnConflict) -> bool { + matches!(on_conflict.action, OnConflictAction::DoUpdate(_)) +} 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..5fc72c282 100644 --- a/packages/eql-mapper/src/lib.rs +++ b/packages/eql-mapper/src/lib.rs @@ -32,7 +32,7 @@ pub(crate) use transformation_rules::*; #[cfg(test)] mod test { - use super::{test_helpers::*, type_check}; + use super::{test_helpers::*, type_check, EqlMapperError, ScopeError, TypeError}; use crate::{ projection, schema, test_helpers, unifier::{ @@ -2232,4 +2232,367 @@ 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:#?}") + } + } + + #[test] + fn insert_on_conflict_returning_cannot_reference_excluded() { + 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 = excluded.salary \ + RETURNING excluded.salary", + ); + + assert!( + type_check(schema, &statement).is_err(), + "excluded must not be visible outside ON CONFLICT DO UPDATE" + ); + } + + #[test] + fn insert_source_cannot_reference_excluded() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary (EQL), + } + } + }); + let statement = parse( + "INSERT INTO employees (id, salary) \ + SELECT excluded.id, excluded.salary FROM employees \ + ON CONFLICT (id) DO UPDATE SET salary = excluded.salary", + ); + + assert_eq!( + type_check(schema, &statement).unwrap_err(), + EqlMapperError::Type(TypeError::ScopeError(ScopeError::NoMatch( + "excluded.id".into() + ))) + ); + } + + #[test] + fn insert_on_conflict_returning_cannot_reference_excluded_wildcard() { + 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 = excluded.salary \ + RETURNING excluded.*", + ); + + assert_eq!( + type_check(schema, &statement).unwrap_err(), + EqlMapperError::Type(TypeError::ScopeError(ScopeError::NoMatch( + "excluded".into() + ))) + ); + } + + #[test] + fn insert_into_table_named_excluded_is_valid() { + let schema = resolver(schema! { + tables: { + excluded: { + id, + salary, + } + } + }); + let statement = parse( + "INSERT INTO excluded (id, salary) VALUES (1, 20000) \ + ON CONFLICT (id) DO UPDATE SET salary = excluded.salary", + ); + + type_check(schema, &statement).unwrap(); + } + + #[test] + fn insert_on_conflict_update_keeps_unqualified_columns_ambiguous() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary, + } + } + }); + let statement = parse( + "INSERT INTO employees (id, salary) VALUES (1, 20000) \ + ON CONFLICT (id) DO UPDATE SET salary = salary", + ); + + assert_eq!( + type_check(schema, &statement).unwrap_err(), + EqlMapperError::Type(TypeError::ScopeError(ScopeError::AmbiguousMatch( + "salary".into() + ))) + ); + } + + #[test] + fn insert_on_conflict_do_nothing_does_not_add_excluded() { + let schema = resolver(schema! { + tables: { + employees: { + id, + salary, + } + } + }); + + for sql in [ + "INSERT INTO employees (id, salary) VALUES (1, 20000) ON CONFLICT (id) DO NOTHING", + "INSERT INTO employees (id, salary) VALUES (1, 20000) ON CONFLICT (id) DO NOTHING RETURNING *", + ] { + type_check(Arc::clone(&schema), &parse(sql)).unwrap(); + } + } + + #[test] + fn insert_on_conflict_returning_unqualified_column_is_not_ambiguous() { + 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 = excluded.salary \ + RETURNING salary", + ); + + let typed = type_check(schema, &statement) + .expect("the target table must be the only relation visible to RETURNING"); + + assert_eq!( + typed.projection, + projection![(EQL(employees.salary) as salary)] + ); + } + + #[test] + fn insert_on_conflict_returning_wildcard_only_projects_target_table() { + 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 = excluded.salary \ + RETURNING *", + ); + + let typed = type_check(schema, &statement).expect("RETURNING * must type check"); + + assert_eq!( + typed.projection, + projection![ + (NATIVE(employees.id) as id), + (EQL(employees.salary) as salary) + ] + ); + } + + /// 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" + ); + } } diff --git a/packages/eql-mapper/src/scope_tracker.rs b/packages/eql-mapper/src/scope_tracker.rs index d6f867c92..b15d77e70 100644 --- a/packages/eql-mapper/src/scope_tracker.rs +++ b/packages/eql-mapper/src/scope_tracker.rs @@ -67,6 +67,28 @@ impl<'ast> ScopeTracker<'ast> { self.current_scope()?.borrow_mut().add_relation(relation) } + /// Add a relation that temporarily shadows the last relation with the same name. + pub(crate) fn add_shadowing_relation( + &mut self, + relation: Relation, + ) -> Result>, ScopeError> { + Ok(self + .current_scope()? + .borrow_mut() + .add_shadowing_relation(relation)) + } + + /// Remove a temporary relation and restore the relation it shadowed, if any. + pub(crate) fn remove_shadowing_relation( + &mut self, + name: &Ident, + shadowed: Option>, + ) -> Result<(), ScopeError> { + self.current_scope()? + .borrow_mut() + .remove_shadowing_relation(name, shadowed) + } + pub(crate) fn resolve_relation(&self, name: &ObjectName) -> Result, ScopeError> { self.current_scope()?.borrow().resolve_relation(name) } @@ -234,6 +256,40 @@ impl<'ast> Scope<'ast> { Ok(()) } + fn add_shadowing_relation(&mut self, relation: Relation) -> Option> { + let shadowed = relation.name.as_ref().and_then(|name| { + let name = IdentCase(name); + self.relations + .iter() + .rposition(|relation| { + relation.name.as_ref().map(IdentCase::from).as_ref() == Some(&name) + }) + .map(|index| self.relations.remove(index)) + }); + self.relations.push(Rc::new(relation)); + shadowed + } + + fn remove_shadowing_relation( + &mut self, + name: &Ident, + shadowed: Option>, + ) -> Result<(), ScopeError> { + let name = IdentCase(name); + match self.relations.iter().rposition(|relation| { + relation.name.as_ref().map(IdentCase::from).as_ref() == Some(&name) + }) { + Some(index) => { + self.relations.remove(index); + if let Some(shadowed) = shadowed { + self.relations.push(shadowed); + } + Ok(()) + } + None => Err(ScopeError::NoMatch(name.to_string())), + } + } + pub(crate) fn resolve_relation(&self, name: &ObjectName) -> Result, ScopeError> { if name.0.len() > 1 { return Err(ScopeError::UnsupportedSqlFeature( From 3fb1fac8d9c7fcef9a7e5cd935c88d6505c1e1db Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:29:49 +1100 Subject: [PATCH 05/10] fix(proxy): track mapping settings for prepared COPY statements A COPY ... FROM is not type checked, so a prepared one recorded no settings, and Proxy checks it against the schema only at Parse, where the mapping-disabled path skips the check. A client could prepare COPY users (email) FROM STDIN with mapping disabled, turn mapping back on, then Bind and Execute it, and its copy data reached the encrypted column as plaintext. A prepared COPY ... FROM now records the settings it was prepared under, as a type-checked statement does, so it is refused with SQLSTATE 26000 once mapping has been turned on or off since. This adds main's copies_into_table and requires_schema (from e4cad150), which release/2.2's COPY backport (cab9b974) left out. --- CHANGELOG.md | 2 +- .../src/postgresql/frontend.rs | 5 ++-- .../cipherstash-proxy/src/postgresql/tests.rs | 25 ++++++++++++++++++ packages/eql-mapper/src/eql_mapper.rs | 26 +++++++++++++++++++ 4 files changed, 55 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8687809d9..d1d57158e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,7 +16,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Connection settings in transactions**: `SET CIPHERSTASH.KEYSET_ID`, `KEYSET_NAME` and `UNSAFE_DISABLE_MAPPING` now follow PostgreSQL's rules for settings in transactions. A change, including a `RESET`, `SET ... TO DEFAULT` or `DISCARD ALL`, takes effect once PostgreSQL confirms it, is kept on `COMMIT`, and is undone on `ROLLBACK` or `ROLLBACK TO SAVEPOINT`, or when the transaction fails, including the implicit transaction of a simple query or extended-protocol batch. `SET LOCAL` now lasts only until the end of the transaction. Previously Proxy kept these settings outside PostgreSQL's transactions: a rolled-back reset left Proxy with no keyset, so later writes were encrypted under `default_keyset_id` instead of the selected keyset; a rolled-back `SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true` left mapping disabled; and `SET LOCAL` was treated as a plain `SET`. Setting names are now matched as PostgreSQL matches them, ignoring case and quoting, and `UNSAFE_DISABLE_MAPPING` accepts every PostgreSQL boolean spelling. A `SET CIPHERSTASH.*` that Proxy cannot honour, such as an unknown setting name or a value it cannot interpret, is now refused with an error instead of being ignored. A simple query that Proxy rewrites now forwards every statement in it; previously statements Proxy did not map, such as a `SET`, could be dropped from a query that also encrypted values. A prepared statement is now refused with SQLSTATE `26000` if mapping has been turned on or off since it was prepared or bound, or if the keyset has changed and it holds values encrypted under the previous keyset, for example after a rollback undid the change it was prepared under; prepare it again to run it under the new settings. A statement or message that Proxy refuses now fails the batch, and an open transaction, in PostgreSQL as well, as an error from PostgreSQL would. Previously Proxy answered a refused extended-protocol batch itself, so statements earlier in the batch could still commit and the client's transaction carried on as if nothing had failed. A statement pipelined after a query or batch that may change these settings now waits for PostgreSQL to answer it, so it is mapped under the settings PostgreSQL confirmed. Previously Proxy assumed the change succeeded, so a `SET` that failed, or a `SET LOCAL` that ended with its implicit transaction, still applied to the statements sent after it. -- **`COPY ... FROM STDIN`**: Proxy now forwards a `COPY ... FROM STDIN` into a table with no encrypted columns, with its copy data, so `psql`'s `\copy` and driver `COPY` APIs can bulk-load plaintext tables through Proxy. Previously Proxy refused the statement as invalid SQL unless it ended with `;`, with or without an encryption configuration. A `COPY ... FROM` into an encrypted column is now refused with an error that names the column, as Proxy does not encrypt `COPY` data; previously a `COPY` from a file or program was forwarded with its plaintext. When encrypted columns are configured, a `COPY ... FROM` into a table or column that Proxy has not loaded, or into a table name that more than one schema has, is refused, as Proxy cannot tell whether it is encrypted. Statements after a `COPY ... FROM STDIN;` in the same simple query are now checked and encrypted like any other; previously Proxy read them as copy data and forwarded them unchanged, so a plaintext `INSERT` into an encrypted column could reach PostgreSQL. A simple query that has a `COPY` and a statement Proxy encrypts is refused, and the `COPY` must be sent in a query of its own. A `COPY (INSERT ... RETURNING ...) TO STDOUT`, or a `COPY` of another query that writes, is refused when encrypted columns are configured; previously Proxy forwarded its values as plaintext. Proxy now parses every `COPY`, so it refuses as invalid SQL the few `COPY` options its SQL parser does not support, such as `WHERE`, `HEADER MATCH` and `ON_ERROR`. +- **`COPY ... FROM STDIN`**: Proxy now forwards a `COPY ... FROM STDIN` into a table with no encrypted columns, with its copy data, so `psql`'s `\copy` and driver `COPY` APIs can bulk-load plaintext tables through Proxy. Previously Proxy refused the statement as invalid SQL unless it ended with `;`, with or without an encryption configuration. A `COPY ... FROM` into an encrypted column is now refused with an error that names the column, as Proxy does not encrypt `COPY` data; previously a `COPY` from a file or program was forwarded with its plaintext. When encrypted columns are configured, a `COPY ... FROM` into a table or column that Proxy has not loaded, or into a table name that more than one schema has, is refused, as Proxy cannot tell whether it is encrypted. Statements after a `COPY ... FROM STDIN;` in the same simple query are now checked and encrypted like any other; previously Proxy read them as copy data and forwarded them unchanged, so a plaintext `INSERT` into an encrypted column could reach PostgreSQL. A simple query that has a `COPY` and a statement Proxy encrypts is refused, and the `COPY` must be sent in a query of its own. A `COPY (INSERT ... RETURNING ...) TO STDOUT`, or a `COPY` of another query that writes, is refused when encrypted columns are configured; previously Proxy forwarded its values as plaintext. Proxy now parses every `COPY`, so it refuses as invalid SQL the few `COPY` options its SQL parser does not support, such as `WHERE`, `HEADER MATCH` and `ON_ERROR`. A `COPY ... FROM` prepared while mapping is disabled is refused if mapping has been turned back on before it runs, as other prepared statements are, since Proxy did not check it when it was prepared. - **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. diff --git a/packages/cipherstash-proxy/src/postgresql/frontend.rs b/packages/cipherstash-proxy/src/postgresql/frontend.rs index 44a27aeca..961fc1a38 100644 --- a/packages/cipherstash-proxy/src/postgresql/frontend.rs +++ b/packages/cipherstash-proxy/src/postgresql/frontend.rs @@ -946,9 +946,10 @@ where self.effective_settings_command(command.clone(), &settings)?; self.context .set_statement_settings(message.name.to_owned(), command); - // A statement Proxy maps runs only under the settings it was prepared under + // A statement Proxy maps or checks against the schema, including a COPY ... FROM, runs + // only under the settings it was prepared under let prepared = - eql_mapper::requires_type_check(&statement).then(|| EffectiveSettings::new(&settings)); + eql_mapper::requires_schema(&statement).then(|| EffectiveSettings::new(&settings)); self.context .set_prepared_settings(message.name.to_owned(), prepared); diff --git a/packages/cipherstash-proxy/src/postgresql/tests.rs b/packages/cipherstash-proxy/src/postgresql/tests.rs index c0b33912a..d36b75c18 100644 --- a/packages/cipherstash-proxy/src/postgresql/tests.rs +++ b/packages/cipherstash-proxy/src/postgresql/tests.rs @@ -2423,6 +2423,31 @@ mod copy_from_stdin { assert!(errors[0].starts_with("COPY into encrypted column 'email'")); } + /// A COPY prepared while mapping was disabled was never checked, so it must not run once + /// mapping is enabled again, or its copy data would reach the encrypted column as plaintext + #[tokio::test] + async fn a_copy_prepared_while_mapping_was_disabled_is_refused_once_it_is_enabled() { + let mut connection = Connection::encrypted().await; + connection + .simple("SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = true", "SET") + .await; + connection + .client(parse("s", "COPY users (email) FROM STDIN")) + .await; + connection.client(sync()).await; + connection.server(parse_complete()).await; + connection.server(ready(b'I')).await; + connection + .simple("SET CIPHERSTASH.UNSAFE_DISABLE_MAPPING = false", "SET") + .await; + assert_eq!(connection.errors_sent_to_client(), 0); + + connection.client(bind("", "s")).await; + connection.client(sync()).await; + connection.rejected(b'I').await; + assert_eq!(connection.errors_sent_to_client(), 1); + } + /// PostgreSQL reads a file or program into the table as it reads copy data #[tokio::test] async fn a_copy_from_a_file_into_an_encrypted_column_is_refused() { diff --git a/packages/eql-mapper/src/eql_mapper.rs b/packages/eql-mapper/src/eql_mapper.rs index efb97df74..b3846c041 100644 --- a/packages/eql-mapper/src/eql_mapper.rs +++ b/packages/eql-mapper/src/eql_mapper.rs @@ -95,6 +95,32 @@ pub fn writes_data(statement: &Statement) -> bool { statement.accept(&mut FindWrite).is_break() } +/// Returns the table and column list a `COPY ... FROM` (a client stream, a file +/// or a program) fills, or `None` for any other statement. An empty column list +/// fills every column of the table. +/// +/// A `COPY` is not type checked, so it is checked against the schema on its own. +pub fn copies_into_table(statement: &Statement) -> Option<(&ast::ObjectName, &[ast::Ident])> { + match statement { + Statement::Copy { + source: + ast::CopySource::Table { + table_name, + columns, + }, + to: false, + .. + } => Some((table_name, columns)), + _ => None, + } +} + +/// Returns whether checking the [`Statement`] needs the schema: it is type +/// checked, or it is a `COPY ... FROM` into a table. +pub fn requires_schema(statement: &Statement) -> bool { + requires_type_check(statement) || copies_into_table(statement).is_some() +} + /// Returns the tables the [`Statement`] writes rows to, including the writes [`writes_data`] finds nested in it. /// /// Returns `None` when a write's target is not a plain table name, as with `MERGE`. From 7d3a1f93c69bbb5ef766cabf58e12b77e748b69b Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:10:52 +1100 Subject: [PATCH 06/10] fix(deps): update tokio-postgres to 0.7.18 Addresses GHSA-3gjw-f78c-vvpw (affected >= 0.4.0, < 0.7.18). Every package now requires 0.7.18, so a lockfile refresh cannot resolve an affected version. tokio-postgres 0.7.15 and later render an Error as only its kind, "db error", with the server's message reached through source(). Two things on 2.2 depended on the old text: - Proxy detected a missing EQL configuration table by matching the rendered text, so the check never matched and Proxy exited at startup against a database without EQL. It now checks for SQLSTATE 42P01 (undefined_table) naming eql_v2_configuration. main has no such check, as it infers the Encrypt configuration from the EQL v3 schema (5b4e85ce). - Integration tests asserted on err.to_string(). They now assert on the structured error: assert_db_error reads severity and message off as_db_error(), assert_client_error reads the kind off Display and the detail off source(). The customer-visible message text stays pinned. tokio-postgres 0.7.18 also moved from socket2 0.5 to 0.6. Proxy relied on it enabling socket2 0.5's "all" feature for TcpKeepalive::with_retries and set_tcp_user_timeout, so Proxy moves to socket2 0.6 as main has, sharing tokio-postgres's copy with "all" enabled. No source changes are needed. Backport of main's 6e3c1e26, 9461b8c2 and 9eaa4bc7, and the tokio-postgres requirement from ee77169b. Two 2.2-only integration tests are switched to structured assertions too. --- Cargo.lock | 312 +++++++++++++----- .../cipherstash-proxy-integration/Cargo.toml | 2 +- .../src/common.rs | 46 +++ .../src/copy_from_stdin.rs | 8 +- .../src/extended_protocol_error_messages.rs | 55 ++- .../src/multitenant/set_keyset_id.rs | 16 +- .../src/multitenant/set_keyset_name.rs | 16 +- .../src/passthrough.rs | 20 +- .../src/set_keyset_error.rs | 13 +- packages/cipherstash-proxy/Cargo.toml | 4 +- packages/cipherstash-proxy/src/error.rs | 13 +- .../src/proxy/encrypt_config/manager.rs | 9 +- packages/showcase/Cargo.toml | 2 +- 13 files changed, 347 insertions(+), 169 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index cfd764708..7fd0087f0 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", ] @@ -544,6 +544,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" @@ -737,7 +746,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 +774,7 @@ dependencies = [ "dirs", "futures", "hex", - "hmac", + "hmac 0.12.1", "itertools 0.12.1", "lazy_static", "log", @@ -789,7 +798,7 @@ dependencies = [ "serde_cbor", "serde_json", "serdect", - "sha2", + "sha2 0.10.8", "stack-auth", "stack-profile", "static_assertions", @@ -824,12 +833,12 @@ 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", ] @@ -852,7 +861,7 @@ dependencies = [ "eql-mapper", "exitcode", "hex", - "md-5", + "md-5 0.10.6", "metrics", "metrics-exporter-prometheus", "moka", @@ -869,7 +878,7 @@ dependencies = [ "rustls-platform-verifier 0.5.1", "serde", "serde_json", - "socket2 0.5.8", + "socket2 0.6.1", "sqltk", "temp-env", "thiserror 2.0.18", @@ -978,7 +987,7 @@ checksum = "8543454e3c3f5126effff9cd44d562af4e31fb8ce1cc0d3dcd8f084515dbc1aa" dependencies = [ "cipher", "dbl", - "digest", + "digest 0.10.7", ] [[package]] @@ -990,6 +999,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 +1064,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" @@ -1158,6 +1179,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,6 +1230,15 @@ dependencies = [ "vitaminc", ] +[[package]] +name = "ctutils" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +dependencies = [ + "cmov", +] + [[package]] name = "darling" version = "0.20.10" @@ -1256,7 +1295,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", @@ -1389,11 +1428,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" @@ -1977,7 +2028,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 +2086,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" @@ -2434,11 +2503,12 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.77" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cfaf33c695fc6e08064efbc1f72ec937429614f25eef83af942d0e227c3a28f" +checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" dependencies = [ - "once_cell", + "cfg-if", + "futures-util", "wasm-bindgen", ] @@ -2471,9 +2541,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 +2553,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 +2635,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]] @@ -2814,6 +2893,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 +3095,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 +3105,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 +3124,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 +3135,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 +3150,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 +3197,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 +3209,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.0", + "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 +3237,7 @@ dependencies = [ "fallible-iterator", "postgres-derive", "postgres-protocol", - "serde", + "serde_core", "serde_json", "uuid", ] @@ -3485,7 +3601,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_cbor", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", "zeroize", ] @@ -3506,7 +3622,7 @@ dependencies = [ "rand_chacha 0.3.1", "serde", "serde_cbor", - "sha2", + "sha2 0.10.8", "thiserror 1.0.69", "zeroize", ] @@ -4101,14 +4217,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 +4310,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]] @@ -4467,6 +4595,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" @@ -4706,9 +4845,9 @@ dependencies = [ [[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 +4858,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.0", + "socket2 0.6.1", "tokio", "tokio-util", "whoami", @@ -4736,7 +4875,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 +5156,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 +5235,7 @@ checksum = "458f7a779bf54acc9f347480ac654f68407d3aab21269a6e3c9f922acd9e2da9" dependencies = [ "atomic", "getrandom 0.3.2", - "md-5", + "md-5 0.10.6", "serde", "sha1_smol", ] @@ -5195,7 +5334,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "af693c39d3cd1c818ef6267539433c6ceca87840b12d24124adbc9c8ecba1709" dependencies = [ "bitvec", - "digest", + "digest 0.10.7", "serde", "serde_bytes", "subtle", @@ -5308,54 +5447,41 @@ 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.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" +checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" 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.80" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" dependencies = [ - "cfg-if", "js-sys", - "once_cell", + "tokio", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.100" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fe63fc6d09ed3792bd0897b314f53de8e16568c2b3f7982f468c0bf9bd0b407" +checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5363,22 +5489,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.100" +version = "0.2.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae87ea40c9f689fc23f209965b6fb8a99ad69aeeb0231408be24920604395de" +checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" 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.130" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a05d73b933a847d6cccdda8f838a22ff101ad9bf93e33684f39c1f5f0eece3d" +checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" dependencies = [ "unicode-ident", ] @@ -5446,9 +5572,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.77" +version = "0.3.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33b6dd2ef9186f1f2072e409e99cd22a975331a6b3591b12c764e0e55c60d5d2" +checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" dependencies = [ "js-sys", "wasm-bindgen", @@ -5484,11 +5610,13 @@ dependencies = [ [[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", ] @@ -6140,7 +6268,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 +6445,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/packages/cipherstash-proxy-integration/Cargo.toml b/packages/cipherstash-proxy-integration/Cargo.toml index c4e6c48ab..aa7edb07e 100644 --- a/packages/cipherstash-proxy-integration/Cargo.toml +++ b/packages/cipherstash-proxy-integration/Cargo.toml @@ -19,7 +19,7 @@ serde = "1.0" serde_json = "1.0" tap = "1.0.1" tokio = { workspace = true } -tokio-postgres = { version = "0.7", features = [ +tokio-postgres = { version = "0.7.18", features = [ "with-chrono-0_4", "with-serde_json-1", "with-uuid-1", diff --git a/packages/cipherstash-proxy-integration/src/common.rs b/packages/cipherstash-proxy-integration/src/common.rs index 4e4157b1f..1a3b757e2 100644 --- a/packages/cipherstash-proxy-integration/src/common.rs +++ b/packages/cipherstash-proxy-integration/src/common.rs @@ -567,6 +567,52 @@ 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 unchanged and still +/// reachable through `as_db_error()`, which is where `Display` used to read it +/// from, so asserting there keeps the exact customer-visible wording pinned. +/// +/// `message` is the primary message field only: the `"db error: "` kind prefix +/// and the `"ERROR: "`/`"FATAL: "` severity prefix that `Display` used to +/// compose are asserted as `severity`, not as part of the text. +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` — a `ToSql` conversion failure, for example. The +/// kind is what `tokio_postgres::Error` itself renders; the detail is the cause +/// it no longer appends, read back through `source()`. +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/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/passthrough.rs b/packages/cipherstash-proxy-integration/src/passthrough.rs index 676f94e54..f9ef83369 100644 --- a/packages/cipherstash-proxy-integration/src/passthrough.rs +++ b/packages/cipherstash-proxy-integration/src/passthrough.rs @@ -1,8 +1,9 @@ #[cfg(test)] mod tests { - use crate::common::{clear, connect_with_tls, random_id, random_string, PROXY}; + use crate::common::{ + assert_db_error, clear, connect_with_tls, random_id, random_string, PROXY, + }; use rand::Rng; - use std::error::Error; #[tokio::test] async fn passthrough_statement() { @@ -36,20 +37,7 @@ mod tests { let sql = "SELECT * FROM blahvtha"; let result = client.query(sql, &[]).await; - assert!(result.is_err()); - - match result { - Ok(_) => unreachable!(), - Err(error) => match error.source() { - Some(db_error) => { - assert_eq!( - db_error.to_string(), - "ERROR: relation \"blahvtha\" does not exist" - ); - } - None => unreachable!(), - }, - } + assert_db_error(result, "ERROR", "relation \"blahvtha\" does not exist"); } #[tokio::test] 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..658d9d153 100644 --- a/packages/cipherstash-proxy/Cargo.toml +++ b/packages/cipherstash-proxy/Cargo.toml @@ -42,11 +42,11 @@ rustls-platform-verifier = "0.5.0" rustls-pki-types = "1.10.0" serde = "1.0" serde_json = "1.0" -socket2 = "0.5.7" +socket2 = "0.6" sqltk = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } -tokio-postgres = { version = "0.7", features = [ +tokio-postgres = { version = "0.7.18", features = [ "with-chrono-0_4", "with-serde_json-1", ] } diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index e592e3835..7e8a46c36 100644 --- a/packages/cipherstash-proxy/src/error.rs +++ b/packages/cipherstash-proxy/src/error.rs @@ -136,7 +136,18 @@ pub enum ConfigError { #[error(transparent)] Certificate(#[from] rustls_pki_types::pem::Error), - #[error(transparent)] + /// Renders the tokio-postgres error *and* its cause. + /// + /// `tokio_postgres::Error`'s own `Display` is only the error kind — since + /// 0.7.18 it no longer appends the cause — so forwarding it transparently + /// would log a bare `db error` and drop the server's message, which is the + /// only part an operator can act on. Append the cause here so the logged + /// string stays as informative as it was. + #[error( + "{}{}", + _0, + std::error::Error::source(_0).map(|cause| format!(": {cause}")).unwrap_or_default() + )] Database(#[from] tokio_postgres::Error), #[error(transparent)] diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs index d121ab111..7e1944e65 100644 --- a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs +++ b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs @@ -238,9 +238,14 @@ pub async fn load_encrypt_config(config: &DatabaseConfig) -> Result bool { - let msg = e.to_string(); - msg.contains("eql_v2_configuration") && msg.contains("does not exist") + e.as_db_error().is_some_and(|db| { + *db.code() == tokio_postgres::error::SqlState::UNDEFINED_TABLE + && db.message().contains("eql_v2_configuration") + }) } fn canonical_to_map(canonical: CanonicalEncryptionConfig) -> Result { diff --git a/packages/showcase/Cargo.toml b/packages/showcase/Cargo.toml index f84ec7508..33780a8b4 100644 --- a/packages/showcase/Cargo.toml +++ b/packages/showcase/Cargo.toml @@ -9,7 +9,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" uuid = { version = "1.11.0", features = ["serde", "v4"] } tokio = { workspace = true } -tokio-postgres = { version = "0.7", features = [ +tokio-postgres = { version = "0.7.18", features = [ "with-chrono-0_4", "with-serde_json-1", "with-uuid-1", From f4bd554d7a3ea455b592d15e030312346a2085ea Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:11:08 +1100 Subject: [PATCH 07/10] fix(proxy): log the full error source chain tokio-postgres 0.7.18 follows the convention that an error's Display describes only that error, with the cause reached through source(). The previous fix worked against that by re-embedding the cause in ConfigError::Database's Display, and it only covered errors converted into that variant: sites logging a raw tokio_postgres::Error still dropped the server's message. Add ErrorChain, a Display wrapper that walks source(), and use it at every log site that can carry a database error. ConfigError::Database is transparent again. Recording the error as a dyn Error field is not enough: the Structured (JSON) format, the default off a terminal, renders only Display. ErrorChain skips a cause the message already ends with, so variants that embed their cause in Display are not printed twice. On 2.2, ErrorChain is also applied to 2.2's own schema load and reload log sites in proxy/schema/manager.rs and the encrypt config load and reload log sites in proxy/encrypt_config/manager.rs, which main replaced with its coalesced schema reload. Backport of main's b31fbb96. --- CHANGELOG.md | 2 + .../cipherstash-proxy/src/cli/migrate/mod.rs | 8 +- packages/cipherstash-proxy/src/connect/mod.rs | 9 +- packages/cipherstash-proxy/src/error.rs | 101 +++++++++++++++--- packages/cipherstash-proxy/src/main.rs | 8 +- .../src/proxy/encrypt_config/manager.rs | 10 +- packages/cipherstash-proxy/src/proxy/mod.rs | 4 +- .../src/proxy/schema/manager.rs | 6 +- 8 files changed, 115 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1d57158e..c89d71866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **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. +- **Database error detail in Proxy's logs**: logged database errors again include the message PostgreSQL returned, not just the error kind. The upgraded PostgreSQL client library stopped appending the underlying cause when an error is rendered as text, which left entries such as `Database connection error` reading only `db error`. Proxy now renders the cause itself, so the server's message is back in the log line. + ## [2.2.4] - 2026-06-18 ### Fixed 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) diff --git a/packages/cipherstash-proxy/src/error.rs b/packages/cipherstash-proxy/src/error.rs index 7e8a46c36..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"; @@ -136,18 +136,7 @@ pub enum ConfigError { #[error(transparent)] Certificate(#[from] rustls_pki_types::pem::Error), - /// Renders the tokio-postgres error *and* its cause. - /// - /// `tokio_postgres::Error`'s own `Display` is only the error kind — since - /// 0.7.18 it no longer appends the cause — so forwarding it transparently - /// would log a bare `db error` and drop the server's message, which is the - /// only part an operator can act on. Append the cause here so the logged - /// string stays as informative as it was. - #[error( - "{}{}", - _0, - std::error::Error::source(_0).map(|cause| format!(": {cause}")).unwrap_or_default() - )] + #[error(transparent)] Database(#[from] tokio_postgres::Error), #[error(transparent)] @@ -570,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::*; @@ -589,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..8d14be0d5 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); } } @@ -114,7 +114,7 @@ fn main() -> Result<(), Box> { warn!(msg = "Database connection timeout", error = err.to_string()); } _ => { - error!(msg = "Database connection error", error = err.to_string()); + error!(msg = "Database connection error", error = %ErrorChain(&err)); } } }, @@ -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); } diff --git a/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs b/packages/cipherstash-proxy/src/proxy/encrypt_config/manager.rs index 7e1944e65..2bab51860 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) ); } } From 015b9b1a59ff1e64a8e5dd1ef53b321b63da9946 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:11:23 +1100 Subject: [PATCH 08/10] chore: satisfy Rust 1.99 Clippy and run integration tests from tests/ Explain the clippy::double_must_use allowance on the async_trait EncryptionService trait, whose generated methods add must_use to a Future that is already must_use, and drop a redundant bool cast in an integration test. Run the integration test tasks from tests/, as main does. Backport of main's ee77169b, apart from its tokio-postgres requirement, which is in the tokio-postgres update. Its AGENTS.md and burn-in package changes do not apply to 2.2, where those files do not exist. --- mise.toml | 2 ++ .../src/insert/insert_with_params.rs | 2 +- packages/cipherstash-proxy/src/proxy/mod.rs | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mise.toml b/mise.toml index 75110dd19..3b4dbc63a 100644 --- a/mise.toml +++ b/mise.toml @@ -205,12 +205,14 @@ mise --env tls run test:wait_for_postgres_to_quack --port 6432 --max-retries 20 """ [tasks."test:integration:without_multitenant"] +dir = "./tests" description = "Runs integration tests excluding multitenant (run test:integration:setup:tls first for standalone use)" run = """ cargo nextest run --no-fail-fast --nocapture -E 'package(cipherstash-proxy-integration) and not test(multitenant)' """ [tasks."test:integration:multitenant"] +dir = "./tests" description = "Runs multitenant integration tests only" run = """ cargo nextest run --no-fail-fast --nocapture -E 'package(cipherstash-proxy-integration) and test(multitenant)' diff --git a/packages/cipherstash-proxy-integration/src/insert/insert_with_params.rs b/packages/cipherstash-proxy-integration/src/insert/insert_with_params.rs index 170e0e688..191112980 100644 --- a/packages/cipherstash-proxy-integration/src/insert/insert_with_params.rs +++ b/packages/cipherstash-proxy-integration/src/insert/insert_with_params.rs @@ -15,7 +15,7 @@ mod tests { "i32" => Box::new(rng.random_range(1..=i32::MAX) as i32), "i64" => Box::new(rng.random_range(1..=i64::MAX) as i64), "f64" => Box::new(rng.random_range(1.0..=f64::MAX) as f64), - "bool" => Box::new(rand::random_bool(0.5) as bool), + "bool" => Box::new(rand::random_bool(0.5)), "String" => { let i = random_limited(); Box::new(((b'A' + (i - 1) as u8) as char).to_string()) diff --git a/packages/cipherstash-proxy/src/proxy/mod.rs b/packages/cipherstash-proxy/src/proxy/mod.rs index 95777a69a..617e7d342 100644 --- a/packages/cipherstash-proxy/src/proxy/mod.rs +++ b/packages/cipherstash-proxy/src/proxy/mod.rs @@ -148,7 +148,8 @@ impl Proxy { } } -// The async-trait expansion trips this lint on newer toolchains. +// async_trait adds must_use to its generated methods. Clippy on Rust 1.99 +// reports these annotations as redundant because Future is already must_use. #[allow(clippy::double_must_use)] #[async_trait::async_trait] pub trait EncryptionService: Send + Sync { From 710d3caf08470daf8a6959ad3294acdbf767b2ec Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 12:13:30 +1100 Subject: [PATCH 09/10] fix(deps): update dependencies with security advisories Bring the security patches that main already carries to release/2.2: - rustls 0.23.46 (RUSTSEC-2026-0285, 0346, 0347, 0348 and 0349) - rustls-webpki 0.103.16 (RUSTSEC-2026-0382) - webpki-root-certs 1.0.11 (RUSTSEC-2026-0383) - aws-lc-rs 1.18.2, with aws-lc-sys 0.46.0 - tokio 1.51.6 (RUSTSEC-2026-0386) - h2 0.4.20 (RUSTSEC-2026-0258) - crossbeam-epoch 0.9.21 (RUSTSEC-2026-0204) - rand 0.9.3 and 0.10.2 - quinn-proto 0.11.15 - hickory-proto and hickory-resolver 0.26.3, via reqwest 0.13.4 - diesel 2.3.12 - chacha20 0.10.2, replacing the yanked 0.10.0 Lockfile only; no code changes were needed. tokio-postgres 0.7.18 and postgres-protocol 0.6.12 come with the tokio-postgres update. Co-authored-by: James Sadler --- CHANGELOG.md | 4 + Cargo.lock | 390 ++++++++++++++++++++++++++++++++++----------------- 2 files changed, 268 insertions(+), 126 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c89d71866..b42ab3722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - **Database error detail in Proxy's logs**: logged database errors again include the message PostgreSQL returned, not just the error kind. The upgraded PostgreSQL client library stopped appending the underlying cause when an error is rendered as text, which left entries such as `Database connection error` reading only `db error`. Proxy now renders the cause itself, so the server's message is back in the log line. +### 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 diff --git a/Cargo.lock b/Cargo.lock index 7fd0087f0..83ad095df 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] @@ -673,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", @@ -717,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", @@ -869,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", @@ -878,7 +879,7 @@ dependencies = [ "rustls-platform-verifier 0.5.1", "serde", "serde_json", - "socket2 0.6.1", + "socket2 0.6.5", "sqltk", "temp-env", "thiserror 2.0.18", @@ -907,7 +908,7 @@ dependencies = [ "futures-util", "hex", "postgres-types", - "rand 0.9.2", + "rand 0.9.3", "reqwest", "rustls", "serde", @@ -1155,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", ] @@ -1232,9 +1233,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.7" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2376ffd6f96d8a9db59875b4b6b0237d6900b5baf64279714e05493ce64828a" +checksum = "fe3e9d21d39e6f39ff46e03d5b621424f88ee23d8a1672c6bfc9ccc7f2da4f6a" dependencies = [ "cmov", ] @@ -1245,8 +1246,18 @@ 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]] @@ -1263,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", ] @@ -1385,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", @@ -1409,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", ] @@ -1476,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", @@ -1496,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", @@ -1508,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", @@ -1520,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", @@ -1550,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" @@ -1668,7 +1699,7 @@ dependencies = [ "chrono", "deunicode", "dummy 0.11.0", - "rand 0.9.2", + "rand 0.9.3", ] [[package]] @@ -1904,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", @@ -1977,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", @@ -2154,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", @@ -2404,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" @@ -2479,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" @@ -2503,9 +2610,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.107" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2755,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]] @@ -2786,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" @@ -3220,7 +3333,7 @@ dependencies = [ "hmac 0.13.0", "md-5 0.11.0", "memchr", - "rand 0.10.0", + "rand 0.10.2", "sha2 0.11.1", "stringprep", ] @@ -3279,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" @@ -3348,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", @@ -3421,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", @@ -3495,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", @@ -3505,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", @@ -3707,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", @@ -3815,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]] @@ -3967,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", @@ -3994,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]] @@ -4009,7 +4134,7 @@ checksum = "4a5467026f437b4cb2a533865eaa73eb840019a0916f4b9ec563c6e617e086c9" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -4018,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", ] @@ -4030,7 +4155,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" dependencies = [ "core-foundation 0.10.0", "core-foundation-sys", - "jni", + "jni 0.21.1", "log", "once_cell", "rustls", @@ -4039,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]] @@ -4051,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", @@ -4343,7 +4468,7 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" name = "showcase" version = "2.2.5" dependencies = [ - "rand 0.9.2", + "rand 0.9.3", "rustls", "serde", "serde_json", @@ -4364,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" @@ -4421,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]] @@ -4637,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" @@ -4817,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", @@ -4827,20 +4973,20 @@ 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]] @@ -4862,8 +5008,8 @@ dependencies = [ "pin-project-lite", "postgres-protocol", "postgres-types", - "rand 0.10.0", - "socket2 0.6.1", + "rand 0.10.2", + "socket2 0.6.5", "tokio", "tokio-util", "whoami", @@ -5262,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", @@ -5359,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", @@ -5456,11 +5602,12 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.130" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", + "once_cell", "rustversion", "wasm-bindgen-macro", "wasm-bindgen-shared", @@ -5468,9 +5615,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.80" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e14e0327cb5368ad60ccb22d04a9827a693c95a926d8d125e9fa39e0baec501" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", "tokio", @@ -5479,9 +5626,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.130" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5489,9 +5636,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.130" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", @@ -5502,9 +5649,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.130" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -5533,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", @@ -5572,9 +5719,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.107" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7d179596ff5ec0f6de4db64d54011bde6289cc49603d3a6e4ce3f329207ab88" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -5592,18 +5739,18 @@ 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", ] @@ -5846,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" From 135c625a394c5ce01845022afd6eb9e2d866d34c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 2 Nov 2026 10:22:51 +1100 Subject: [PATCH 10/10] chore(release): keep 2.2.x releases off latest and document them Set `latest=false` on the Docker metadata steps so that publishing a 2.2.x release does not move the `cipherstash/proxy:latest` tag off 3.x. Document how 2.2.x maintenance releases are cut from release/2.2: the manual tag steps, `--latest=false`, and `--notes-start-tag` so the generated notes start from the previous 2.2.x tag. Date the 2.2.5 CHANGELOG section for the re-cut release. --- .github/workflows/release.yml | 6 ++++++ CHANGELOG.md | 2 +- DEVELOPMENT.md | 16 ++++++++++++++++ 3 files changed, 23 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6760ad776..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 diff --git a/CHANGELOG.md b/CHANGELOG.md index b42ab3722..2f85da28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -## [2.2.5] - 2026-10-30 +## [2.2.5] - 2026-11-02 ### Fixed 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: