Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).

## [Unreleased]

### Added

- **OPE (Order-Preserving Encryption) index**: New `ope` index type alongside the existing `ore` for range and ordering queries. OPE ciphertexts compare under standard lexicographic byte ordering, so they're a drop-in alternative to ORE for range and `ORDER BY`. Configure with `SELECT eql_v2.add_search_config('table', 'column', 'ope', 'int')` (any cast type that `ore` supports). Requires EQL with OPE support and a CipherStash client/config build that includes `IndexType::Ope`.
Comment on lines +9 to +11
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Make the changelog entry release-facing.

The first sentence is changelog material; the SQL example and IndexType::Ope note read like implementation details. I'd move those details into docs or the PR description so [Unreleased] stays user-facing.

As per coding guidelines, "Write changelog entries from the user's perspective, not implementation details."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@CHANGELOG.md` around lines 9 - 11, The changelog entry includes
implementation-level details; revise the "Added" OPE entry to be release-facing
by keeping only the user-visible summary (e.g., "Added OPE (Order-Preserving
Encryption) index for range and ORDER BY queries") and remove the SQL example
and internal note about IndexType::Ope and CipherStash build requirements; move
the SQL usage example (SELECT eql_v2.add_search_config...) and the
IndexType::Ope/CipherStash build details into the docs or PR description instead
so the CHANGELOG remains high-level and user-oriented.


## [2.2.0-alpha.1] - 2026-03-25

### Changed
Expand Down
44 changes: 15 additions & 29 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 5 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,11 @@ debug = true

[workspace.dependencies]
sqltk = { version = "0.10.0" }
cipherstash-client = { version = "0.34.0-alpha.4" }
cts-common = { version = "0.34.0-alpha.4" }
# TODO: revert to crates.io once the cipherstash-suite version with `IndexType::Ope`
# is published. See https://github.com/cipherstash/cipherstash-suite for the
# OPE-enabled change.
cipherstash-client = { path = "../cipherstash-suite/packages/cipherstash-client" }
cts-common = { path = "../cipherstash-suite/packages/cts-common" }
Comment on lines +46 to +50
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Path dependencies that leave the repository:"
rg -n 'path\s*=\s*"\.\./cipherstash-suite/'

echo
echo "Bootstrap / CI references to that sibling checkout:"
rg -n -C2 'cipherstash-suite|../cipherstash-suite'

Repository: cipherstash/proxy

Length of output: 3799


Don't merge sibling path dependencies into main.

Lines 49-50 reference ../cipherstash-suite/packages/... which only resolve when that repository is checked out as a sibling. The search found no CI or bootstrap setup that provisions this checkout, meaning clean builds in CI and for contributors will fail. The TODO comment (lines 46-48) acknowledges this is temporary, but it cannot ship in this state.

Resolve this before merge by either:

  • Switching back to a published crate.io version of cipherstash-client and cts-common
  • Implementing explicit CI/bootstrap provisioning of the sibling repository
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Cargo.toml` around lines 46 - 50, The Cargo.toml currently uses local sibling
path dependencies cipherstash-client and cts-common which break clean
CI/contributor builds; either revert these entries to the published crates.io
versions by replacing the path entries for cipherstash-client and cts-common
with the appropriate crate version specifications, or add explicit provisioning
in CI/bootstrap to checkout the sibling cipherstash-suite repository before
building (ensuring the workspace path "../cipherstash-suite/packages/..."
exists), and remove or update the TODO comment accordingly so builds no longer
rely on an unchecked-out sibling repo.


thiserror = "2.0.9"
tokio = { version = "1.44.2", features = ["full"] }
Expand Down
12 changes: 12 additions & 0 deletions packages/cipherstash-proxy-integration/src/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,18 @@ pub async fn clear_with_client(client: &Client) {
client.simple_query(sql).await.unwrap();
}

/// Truncate a single table by name. Useful for tests that own a dedicated
/// fixture table (e.g. the per-test ORE/OPE tables) and don't need to wipe
/// the shared `encrypted`/`plaintext` tables.
pub async fn clear_table_with_client(client: &Client, table: &str) {
let sql = format!("TRUNCATE {}", table);
client.simple_query(&sql).await.unwrap();
}
Comment on lines +94 to +97
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Guard table names before building TRUNCATE SQL.

Line 95 interpolates table directly into SQL. Even in tests, this helper is pub and can execute unintended SQL if passed unexpected input. Constrain fixture table names before formatting.

Suggested hardening
 pub async fn clear_table_with_client(client: &Client, table: &str) {
+    assert!(
+        table
+            .chars()
+            .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
+        "invalid fixture table name: {table}"
+    );
     let sql = format!("TRUNCATE {}", table);
     client.simple_query(&sql).await.unwrap();
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub async fn clear_table_with_client(client: &Client, table: &str) {
let sql = format!("TRUNCATE {}", table);
client.simple_query(&sql).await.unwrap();
}
pub async fn clear_table_with_client(client: &Client, table: &str) {
assert!(
table
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_'),
"invalid fixture table name: {table}"
);
let sql = format!("TRUNCATE {}", table);
client.simple_query(&sql).await.unwrap();
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cipherstash-proxy-integration/src/common.rs` around lines 94 - 97,
The helper clear_table_with_client is interpolating the table name directly into
a TRUNCATE SQL string; instead validate/whitelist the table identifier before
formatting to prevent injection or accidental SQL execution. Update
clear_table_with_client to first check the table argument (e.g. match against a
whitelist of allowed fixture names or validate with a strict regex like only
ASCII letters/numbers/underscores) and return/raise an error if it fails
validation, and avoid using unwrap() on client.simple_query (propagate or handle
the Result); only format and execute the TRUNCATE when the table name passes the
guard.


pub async fn clear_table(table: &str) {
clear_table_with_client(&connect_with_tls(PROXY).await, table).await;
}

pub async fn reset_schema() {
let port = std::env::var("CS_DATABASE__PORT")
.map(|s| s.parse().unwrap())
Expand Down
2 changes: 2 additions & 0 deletions packages/cipherstash-proxy-integration/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ mod map_concat;
mod map_literals;
mod map_match_index;
mod map_nulls;
mod map_ope_index_order;
mod map_ope_index_where;
mod map_ore_index_order;
mod map_ore_index_where;
mod map_params;
Expand Down
165 changes: 165 additions & 0 deletions packages/cipherstash-proxy-integration/src/map_ope_index_order.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
#[cfg(test)]
mod tests {
use crate::common::{
clear_table, connect_with_tls, interleaved_indices, random_id, trace, PROXY,
};

#[tokio::test]
async fn map_ope_order_text_asc() {
trace();
let table = "encrypted_ope_order_text_asc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values = ["aardvark", "aplomb", "chimera", "chrysalis", "zephyr"];

let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<String> = rows.iter().map(|r| r.get(0)).collect();
let expected: Vec<String> = values.iter().map(|s| s.to_string()).collect();

assert_eq!(actual, expected);
}

#[tokio::test]
async fn map_ope_order_text_desc() {
trace();
let table = "encrypted_ope_order_text_desc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values = ["aardvark", "aplomb", "chimera", "chrysalis", "zephyr"];

let insert = format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text DESC");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<String> = rows.iter().map(|r| r.get(0)).collect();
let expected: Vec<String> = values.iter().rev().map(|s| s.to_string()).collect();

assert_eq!(actual, expected);
}

#[tokio::test]
async fn map_ope_order_int4_asc() {
trace();
let table = "encrypted_ope_order_int4_asc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values: Vec<i32> = vec![-100, -1, 0, 1, 42, 1000, i32::MAX];

let insert = format!("INSERT INTO {table} (id, encrypted_int4) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_int4 FROM {table} ORDER BY encrypted_int4");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<i32> = rows.iter().map(|r| r.get(0)).collect();
assert_eq!(actual, values);
}

#[tokio::test]
async fn map_ope_order_int4_desc() {
trace();
let table = "encrypted_ope_order_int4_desc";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let values: Vec<i32> = vec![-100, -1, 0, 1, 42, 1000, i32::MAX];

let insert = format!("INSERT INTO {table} (id, encrypted_int4) VALUES ($1, $2)");
for idx in interleaved_indices(values.len()) {
client
.query(&insert, &[&random_id(), &values[idx]])
.await
.unwrap();
}

let select = format!("SELECT encrypted_int4 FROM {table} ORDER BY encrypted_int4 DESC");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<i32> = rows.iter().map(|r| r.get(0)).collect();
let expected: Vec<i32> = values.into_iter().rev().collect();
assert_eq!(actual, expected);
}

#[tokio::test]
async fn map_ope_order_nulls_last_by_default() {
trace();
let table = "encrypted_ope_order_nulls_last";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let null_insert = format!("INSERT INTO {table} (id) VALUES ($1)");
client.query(&null_insert, &[&random_id()]).await.unwrap();

let insert =
format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4)");
client
.query(&insert, &[&random_id(), &"a", &random_id(), &"b"])
.await
.unwrap();

let select = format!("SELECT encrypted_text FROM {table} ORDER BY encrypted_text");
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<Option<String>> = rows.iter().map(|r| r.get(0)).collect();
assert_eq!(
actual,
vec![Some("a".into()), Some("b".into()), None],
"NULLs should sort last by default"
);
}

#[tokio::test]
async fn map_ope_order_nulls_first() {
trace();
let table = "encrypted_ope_order_nulls_first";
clear_table(table).await;
let client = connect_with_tls(PROXY).await;

let insert =
format!("INSERT INTO {table} (id, encrypted_text) VALUES ($1, $2), ($3, $4)");
client
.query(&insert, &[&random_id(), &"a", &random_id(), &"b"])
.await
.unwrap();

let null_insert = format!("INSERT INTO {table} (id) VALUES ($1)");
client.query(&null_insert, &[&random_id()]).await.unwrap();

let select = format!(
"SELECT encrypted_text FROM {table} ORDER BY encrypted_text NULLS FIRST"
);
let rows = client.query(&select, &[]).await.unwrap();

let actual: Vec<Option<String>> = rows.iter().map(|r| r.get(0)).collect();
assert_eq!(
actual,
vec![None, Some("a".into()), Some("b".into())],
"NULLS FIRST should explicitly sort NULLs first"
);
}
}
Loading
Loading