Skip to content

Commit 3f78d8d

Browse files
committed
test(eql): smoke-test between over every ordered family, and pin the exact cast
postgres_between_smoke_test_over_every_ordered_family loops over the catalog: for each of the nine ordered families it stores five ascending values in every column domain that carries an ordering term, and checks that a range from the second value to the fourth finds the rows at both bounds and the one inside, and neither row outside, through every one of the 29 between overloads (the _search and _search_ore ones included). For text it also checks that a btree on the order term serves between, for ORE on _ord_ore and for OPE on _search. Every family gets its terms from cipherstash-client's indexers, as the other range tests do; c, hm and bf are real Stack Encrypt values sealed from the value's text, since between never reads them and most of these families have no producible type. postgres_between_needs_the_exact_range_domain prepares between with an uncast $1, a jsonb parameter, a range of another family and one of another term, and pins 42725 for each, beside the exact cast and the single-bound operator, which both prepare. The cipherstash-client comment in the test crate's manifest now names v4_range.rs, and chrono and rust_decimal (already in the lockfile) name the date, timestamp and numeric plaintexts.
1 parent 9182624 commit 3f78d8d

3 files changed

Lines changed: 299 additions & 4 deletions

File tree

‎packages/eql/Cargo.lock‎

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/eql/tests/encryption/Cargo.toml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,17 @@ postgres = "0.19"
3636
# protect-ffi resolves cipherstash-client from its own workspace and lockfile,
3737
# so the pin covers the version named here, not necessarily the one protect-ffi
3838
# ships: a protect-ffi bump is covered once this line moves to match it.
39+
#
40+
# The EQL v4 range tests (tests/v4_range.rs) use it too: EQL compares
41+
# block-ORE and CLLW-OPE terms, which Stack Encrypt does not derive for EQL
42+
# yet, so cipherstash-client's `OreIndexer` / `OpeIndexer` write the order
43+
# terms of their rows and ranges, offline over the same index key.
3944
cipherstash-client = "=0.42.0"
45+
# The plaintexts of the date, timestamp and numeric rows in tests/v4_range.rs,
46+
# as cipherstash-client's `Plaintext` names them. Both are already in the
47+
# lockfile through cipherstash-client.
48+
chrono = { version = "0.4", default-features = false }
49+
rust_decimal = "1"
4050
# The tampering test decodes a stored body, changes one byte and re-encodes
4151
# it. The same line eql-bindings uses, so the lockfile gains no second copy.
4252
base64 = "0.22"

‎packages/eql/tests/encryption/tests/v4_range.rs‎

Lines changed: 287 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -385,10 +385,10 @@ fn postgres_between_finds_integers_in_an_inclusive_range() {
385385
.collect();
386386
assert!(none.is_empty());
387387

388-
// A range over the other term has no `between` for the column, so the
389-
// call fails at plan time. PostgreSQL reports it as ambiguous: with no
390-
// exact match it treats both domains as `jsonb`, which every
391-
// `between` overload would take.
388+
// A range over the other term has no `between` for the column. The
389+
// statement fails when it is parsed, with 42725 ("not unique"):
390+
// PostgreSQL resolves a function's domain arguments as their base
391+
// type, `jsonb`, which every `between` overload takes.
392392
let err = db
393393
.query(
394394
"SELECT n FROM v4_int_range WHERE eql_v4.between(ore, $1::text::jsonb::eql_v4.query_integer_range_ope)",
@@ -621,3 +621,286 @@ fn postgres_range_domains_check_their_shape() {
621621
}
622622
}
623623
}
624+
625+
/// Five plaintexts of `family`, strictly ascending, as cipherstash-client's
626+
/// indexers take them: what the smoke test stores and ranges over. A new
627+
/// ordered family must add a row here, or the test fails naming it.
628+
fn ascending(family: &str) -> [Plaintext; 5] {
629+
use chrono::{DateTime, NaiveDate};
630+
use rust_decimal::Decimal;
631+
let date = |y, m, d| Plaintext::NaiveDate(NaiveDate::from_ymd_opt(y, m, d));
632+
let at = |secs| Plaintext::Timestamp(DateTime::from_timestamp(secs, 0));
633+
let dec = |n, scale| Plaintext::Decimal(Some(Decimal::new(n, scale)));
634+
let text = |s: &str| Plaintext::Text(Some(s.to_owned()));
635+
match family {
636+
"integer" => [-20, -1, 0, 7, 100].map(|n| Plaintext::Int(Some(n))),
637+
"smallint" => [-300, -1, 0, 5, 300].map(|n| Plaintext::SmallInt(Some(n))),
638+
"bigint" => [i64::MIN + 1, -1, 0, 1 << 40, i64::MAX].map(|n| Plaintext::BigInt(Some(n))),
639+
"date" => [
640+
date(1969, 12, 31),
641+
date(1970, 1, 1),
642+
date(2000, 2, 29),
643+
date(2024, 6, 1),
644+
date(2099, 12, 31),
645+
],
646+
"timestamp" => [-86_400, 0, 951_782_400, 1_717_200_000, 4_102_444_800].map(at),
647+
"numeric" => [
648+
dec(-15, 1),
649+
dec(0, 0),
650+
dec(25, 2),
651+
dec(314_159, 5),
652+
dec(1000, 0),
653+
],
654+
"text" => ["apple", "banana", "cherry", "damson", "elderberry"].map(text),
655+
// `real` values are exact in f32; both float widths encrypt as f64.
656+
"real" => [-2.5, -0.5, 0.0, 1.25, 1e10].map(|f| Plaintext::Float(Some(f))),
657+
"double" => [-1e300, -1.0, 0.0, 2.5, 1e300].map(|f| Plaintext::Float(Some(f))),
658+
other => panic!("no smoke-test values for the ordered family {other:?}: add them here"),
659+
}
660+
}
661+
662+
/// The text a smoke-test value seals as for its `c` (and, for text, its
663+
/// `hm` and `bf`): `between` never reads them, and most ordered families
664+
/// have no producible type to seal the value itself.
665+
fn display(plaintext: &Plaintext) -> String {
666+
match plaintext {
667+
Plaintext::Text(Some(s)) => s.clone(),
668+
other => format!("{other:?}"),
669+
}
670+
}
671+
672+
/// Needs an EQL v4 installation. A smoke test over every ordered family
673+
/// and every `between` overload, generated from the catalog: five ascending
674+
/// rows per family in each column domain that carries an ordering term, and
675+
/// a range from the second value to the fourth, which must find exactly the
676+
/// rows at both bounds and the one inside, and neither row outside. For text
677+
/// it also checks that an order-term index serves `between`, for ORE on
678+
/// `_ord_ore` and for OPE on `_search`.
679+
#[test]
680+
#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"]
681+
fn postgres_between_smoke_test_over_every_ordered_family() {
682+
let runtime = runtime();
683+
let key = runtime.block_on(index_key());
684+
let (cipher, _) = runtime.block_on(common::cipher());
685+
let keyset = cipher.default_keyset();
686+
let mut db = connect();
687+
let ranges = v4::all_range_query();
688+
let mut checked = 0;
689+
690+
for family in eql_domains::CATALOG
691+
.iter()
692+
.filter(|f| !f.range_terms().is_empty())
693+
{
694+
let values = ascending(family.name);
695+
let table = format!("v4_smoke_{}", family.name);
696+
let columns: Vec<&eql_domains::Domain> = family
697+
.domains
698+
.iter()
699+
.filter(|d| d.terms.iter().any(|t| t.range_suffix().is_some()))
700+
.collect();
701+
db.batch_execute(&format!(
702+
"CREATE TEMP TABLE {table} (id int PRIMARY KEY, {})",
703+
columns
704+
.iter()
705+
.map(|d| format!("c_{} public.eql_v4_{}", d.name, d.full_name(family.name)))
706+
.collect::<Vec<_>>()
707+
.join(", ")
708+
))
709+
.unwrap();
710+
711+
for (id, value) in values.iter().enumerate() {
712+
let text = display(value);
713+
let (eq, matched) = runtime.block_on(async {
714+
let eq: text::TextEq = keyset.encrypt_as(&text, column()).await.unwrap();
715+
let matched = match family.name {
716+
"text" => Some(
717+
keyset
718+
.encrypt_as::<_, text::TextMatch>(&text, column())
719+
.await
720+
.unwrap(),
721+
),
722+
_ => None,
723+
};
724+
(eq, matched)
725+
});
726+
let eq = serde_json::to_value(&eq).unwrap();
727+
let payloads: Vec<String> = columns
728+
.iter()
729+
.map(|d| {
730+
let mut p = json!({"v": eq["v"], "i": eq["i"], "c": eq["c"]});
731+
for term in d.terms {
732+
match term {
733+
eql_domains::Term::Hm => p["hm"] = eq["hm"].clone(),
734+
eql_domains::Term::Bloom => {
735+
let m = matched.as_ref().expect("only text has a match term");
736+
p["bf"] = serde_json::to_value(m).unwrap()["bf"].clone();
737+
}
738+
eql_domains::Term::Ore => p["ob"] = ob(&key, value.clone(), false),
739+
eql_domains::Term::Ope => p["op"] = op(&key, value.clone()),
740+
}
741+
}
742+
p.to_string()
743+
})
744+
.collect();
745+
let placeholders: Vec<String> = columns
746+
.iter()
747+
.enumerate()
748+
.map(|(i, d)| {
749+
format!(
750+
"${}::text::jsonb::public.eql_v4_{}",
751+
i + 2,
752+
d.full_name(family.name)
753+
)
754+
})
755+
.collect();
756+
let id = id as i32;
757+
let mut params: Vec<&(dyn postgres::types::ToSql + Sync)> = vec![&id];
758+
params.extend(
759+
payloads
760+
.iter()
761+
.map(|p| p as &(dyn postgres::types::ToSql + Sync)),
762+
);
763+
db.execute(
764+
&format!(
765+
"INSERT INTO {table} VALUES ($1, {})",
766+
placeholders.join(", ")
767+
),
768+
&params,
769+
)
770+
.unwrap();
771+
}
772+
773+
for term in family.range_terms() {
774+
let bound = |v: &Plaintext| match term {
775+
eql_domains::Term::Ore => ob(&key, v.clone(), true),
776+
eql_domains::Term::Ope => op(&key, v.clone()),
777+
other => unreachable!("{other:?} has no range"),
778+
};
779+
let domain = family.range_query_name(term);
780+
let range = json!({"v": 4, "i": identifier(), "lo": bound(&values[1]), "hi": bound(&values[3])});
781+
// The binding for the domain accepts the range.
782+
ranges
783+
.iter()
784+
.find(|r| r.sql_domain() == format!("eql_v4.{domain}"))
785+
.unwrap_or_else(|| panic!("no binding for {domain}"))
786+
.parse_value(&range)
787+
.unwrap();
788+
for d in family.range_columns(term) {
789+
let found: Vec<i32> = db
790+
.query(
791+
&format!(
792+
"SELECT id FROM {table} \
793+
WHERE eql_v4.between(c_{}, $1::text::jsonb::eql_v4.{domain}) ORDER BY id",
794+
d.name
795+
),
796+
&[&range.to_string()],
797+
)
798+
.unwrap()
799+
.iter()
800+
.map(|r| r.get(0))
801+
.collect();
802+
assert_eq!(
803+
found,
804+
[1, 2, 3],
805+
"{}: between(_{}, {domain})",
806+
family.name,
807+
d.name
808+
);
809+
checked += 1;
810+
}
811+
}
812+
813+
if family.name == "text" {
814+
let range = |term: eql_domains::Term| {
815+
let bound = |v: &Plaintext| match term {
816+
eql_domains::Term::Ore => ob(&key, v.clone(), true),
817+
_ => op(&key, v.clone()),
818+
};
819+
json!({"v": 4, "i": identifier(), "lo": bound(&values[1]), "hi": bound(&values[3])})
820+
.to_string()
821+
};
822+
db.batch_execute(&format!(
823+
"CREATE INDEX {table}_ore ON {table} USING btree (eql_v4.ord_term_ore(c_ord_ore));
824+
CREATE INDEX {table}_search ON {table} USING btree (eql_v4.ord_term(c_search));
825+
ANALYZE {table};
826+
SET enable_seqscan = off;"
827+
))
828+
.unwrap();
829+
for (column, domain, index, value) in [
830+
(
831+
"c_ord_ore",
832+
"query_text_range_ore",
833+
format!("{table}_ore"),
834+
range(eql_domains::Term::Ore),
835+
),
836+
(
837+
"c_search",
838+
"query_text_range_ope",
839+
format!("{table}_search"),
840+
range(eql_domains::Term::Ope),
841+
),
842+
] {
843+
let plan = explain(
844+
&mut db,
845+
&format!(
846+
"SELECT id FROM {table} WHERE eql_v4.between({column}, '{}'::jsonb::eql_v4.{domain})",
847+
value.replace('\'', "''")
848+
),
849+
);
850+
let mut scans = Vec::new();
851+
index_scans(&plan, &mut scans);
852+
let (_, cond) = scans
853+
.iter()
854+
.find(|(name, _)| *name == index)
855+
.unwrap_or_else(|| {
856+
panic!("text {column}: no index scan on {index}:\n{plan:#}")
857+
});
858+
assert!(
859+
cond.contains(">=") && cond.contains("<="),
860+
"text {column}: {cond}"
861+
);
862+
}
863+
db.batch_execute("RESET enable_seqscan;").unwrap();
864+
}
865+
}
866+
// 8 numeric and temporal families with 3 overloads each, and text's 5.
867+
assert_eq!(checked, 29, "every between overload was exercised");
868+
}
869+
870+
/// Needs an EQL v4 installation. The range parameter must be cast to its
871+
/// exact range domain. An uncast `$1`, a `jsonb` parameter, or a range of
872+
/// another family matches several `between` overloads (PostgreSQL resolves
873+
/// a function's domain arguments as their base type, `jsonb`), so preparing
874+
/// the statement fails with 42725, before any value is bound. The
875+
/// single-bound operators resolve an uncast `$1` on the same column.
876+
#[test]
877+
#[ignore = "requires EQL_TEST_DATABASE_URL pointing to an EQL v4 database"]
878+
fn postgres_between_needs_the_exact_range_domain() {
879+
let mut db = connect();
880+
db.batch_execute("CREATE TEMP TABLE v4_range_cast (ore public.eql_v4_integer_ord_ore);")
881+
.unwrap();
882+
for range in [
883+
"$1",
884+
"$1::jsonb",
885+
"$1::eql_v4.query_bigint_range_ore",
886+
"$1::eql_v4.query_integer_range_ope",
887+
] {
888+
let err = db
889+
.prepare(&format!(
890+
"SELECT 1 FROM v4_range_cast WHERE eql_v4.between(ore, {range})"
891+
))
892+
.unwrap_err();
893+
assert_eq!(sqlstate(&err), "42725", "{range}: {err}");
894+
assert!(
895+
err.as_db_error()
896+
.is_some_and(|e| e.message().contains("is not unique")),
897+
"{range}: {err}"
898+
);
899+
}
900+
db.prepare(
901+
"SELECT 1 FROM v4_range_cast WHERE eql_v4.between(ore, $1::eql_v4.query_integer_range_ore)",
902+
)
903+
.unwrap();
904+
db.prepare("SELECT 1 FROM v4_range_cast WHERE ore < $1")
905+
.unwrap();
906+
}

0 commit comments

Comments
 (0)