From 70dcc1736d118276733c6d68efcf30584370c1a0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 14 Oct 2026 18:05:19 +1100 Subject: [PATCH 1/3] feat(stack-encrypt): the ordering operators, Between as one term, and no query_index ADR-0009 decisions 1 and 7. `op::Lt`, `Lte`, `Gt` and `Gte` take one value and `op::Between` a pair of bounds, inclusive at both ends. The ORE and OPE indexes answer them over the integer types (`op::Ordered`), ORE by default and OPE through `.via(Ope)`; an ordering on text does not compile, since text has no order term this crate produces. A comparison's query term is the index's own term for the value: this crate's ORE is CLLW, whose ciphertext has no left half to send alone, so what is sent is what is stored, as for an HMAC, and `Supports` is where the left-only term of block ORE (#1140) goes when it lands. A range's query term is one `sem::RangeQuery` carrying both bounds' terms, derived under the one label, read with `lo()` and `hi()`. A range whose low bound is above its high bound is refused when the query is awaited, before either term is derived and before any keyset is loaded (`TermError::InvertedRange`). An operator now says what a query takes through `op::Arity`: `One` plaintext or a `Pair` of them. `op::Input` relates the value a query is given to the plaintext the plan is checked against, and derives one term per plaintext. `Supports`, `Answers` and `DefaultIndex` are keyed by that input, so `Supports` reads as before and `Between` is `Supports`; `Supports::query` takes the input's terms. `StackCipher::query_index` goes, with `QueryIndexBuilder` and `QueryIndex`: every index a plan can declare is reachable through an operator now, and the chain holds its own input, index and label. `Resolves::resolve` takes the index and the call's context and returns the index and the label. ADR-0009's decision 2 shows the shape that ships, and its mentions of `query_index` are past tense. Closes #1186 Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB --- packages/stack-encrypt/CHANGELOG.md | 28 ++ ...and-a-query-term-is-not-the-stored-term.md | 74 ++- packages/stack-encrypt/src/codes.rs | 2 + packages/stack-encrypt/src/op/mod.rs | 451 ++++++++++++++---- packages/stack-encrypt/src/plan/chain.rs | 371 ++++---------- packages/stack-encrypt/src/plan/mod.rs | 4 +- packages/stack-encrypt/src/sem/mod.rs | 64 ++- packages/stack-encrypt/tests/op.rs | 306 +++++++++++- packages/stack-encrypt/tests/plan.rs | 17 +- packages/stack-encrypt/tests/plan_grammar.rs | 21 +- .../tests/ui/op_between_takes_a_pair.rs | 16 + .../tests/ui/op_between_takes_a_pair.stderr | 37 ++ .../tests/ui/op_matches_on_integer.stderr | 23 +- .../tests/ui/op_order_on_text.rs | 16 + .../tests/ui/op_order_on_text.stderr | 18 + .../tests/ui/op_target_does_not_answer.stderr | 6 +- .../stack-encrypt/tests/ui/pass/plan_chain.rs | 3 +- .../tests/ui/query_index_answered.rs | 11 - .../tests/ui/query_index_answered.stderr | 25 - 19 files changed, 1037 insertions(+), 456 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/op_between_takes_a_pair.rs create mode 100644 packages/stack-encrypt/tests/ui/op_between_takes_a_pair.stderr create mode 100644 packages/stack-encrypt/tests/ui/op_order_on_text.rs create mode 100644 packages/stack-encrypt/tests/ui/op_order_on_text.stderr delete mode 100644 packages/stack-encrypt/tests/ui/query_index_answered.rs delete mode 100644 packages/stack-encrypt/tests/ui/query_index_answered.stderr diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a41ef4161..8df8be140 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -45,6 +45,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 copies of the text. An exact term has no `verify`, and none of this crate's indexes can answer `op::Matches` but with a probabilistic term (`op::TermFor`). +- **The ordering operators, and `Between` as one term.** `op::Lt`, `Lte`, + `Gt` and `Gte` take one value and `op::Between` a pair of bounds, + `cipher.query(op::Between, &(18, 65)).on(&age_plan)`, inclusive at both + ends. They are answered over the integer types (`op::Ordered`) by the + `Ore` index by default and by `Ope` through `.via(Ope)`; an ordering on + text does not compile. A comparison's query term is the index's own term + for the value (`OreTerm`, `OpeTerm`); a range's is one `sem::RangeQuery` + carrying both bounds' terms, read with `lo()` and `hi()`, so no statement + can pair the low bound of one query with the high bound of another. A + range whose low bound is above its high bound is refused when the query + is awaited, before either term is derived (`TermError::InvertedRange`). + What a query takes is the operator's `op::Arity` (`op::One` or + `op::Pair`), and `op::Input` relates the value given to the plaintext the + plan is checked against (ADR-0009, decision 7). - **`target_limit` says how many rows a match query should fetch.** `query.target_limit(want, &stats)` is `want` plus the false positives expected at the confidence `stats` names (99% by default), as an @@ -118,6 +132,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 value is empty, separator-only or shorter than the n-gram length (ADR-0009). +- **`StackCipher::query_index` is gone**, with `QueryIndexBuilder` and + `QueryIndex`: every index a plan can declare is reachable through an + operator now that the orderings have arrived. `query_index(Ore, &v)` is + `query(op::Lt, &v)` (or any ordering; the term is the same), and + `query_index(Match, text)` is `query(op::Matches, text)`, whose + `MatchQuery` carries the same filter. +- **`op::Supports`, `op::Answers` and `op::DefaultIndex` are keyed by the + query's input**, `Supports` with `In: Input`, in + place of the plaintext; for every operator but `Between` the input is + the plaintext, so `Supports` reads as before. `Supports::check` + takes the input and returns `Error`, and `Supports::query` takes the + input's terms (`In::Terms`: one term, or a pair for a + range). `plan::Resolves::resolve` takes the index and the call's context + and returns the index and the label. - **The query chain starts with the operator.** `cipher.query(op, v)` replaces `cipher.query(v)`: `cipher.query(op::Eq, v).on(&plan)` replaces `cipher.query(v).using(&plan).equality()`, and `on` replaces `using` for diff --git a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md index c2987bfd8..53f19e25c 100644 --- a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md +++ b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md @@ -141,30 +141,46 @@ query against a case-folding index is a type error, not an empty result. ### 2. An index declares the operators it supports, and the query term for each ```rust -pub trait Operator: Copy + 'static {} - -pub trait Supports: Index { - type Query: TermFor; // what this index sends for `Op` - type Verifier; // what the query keeps to check a row - fn check(&self) -> Result<(), PlanError>; - fn verifier(&self, value: &S) -> Self::Verifier; - fn query(verifier: Self::Verifier, term: Self::Term, token: QueryToken) -> Self::Query; +pub trait Operator: Copy + 'static { type Arity: Arity; } // `One` plaintext, or a `Pair` + +pub trait Supports>: Index { + type Query: TermFor; // what this index sends for `Op` + type Verifier; // what the query keeps to check a row + fn check(&self, input: &In) -> Result<(), Error>; // a refusal, before any term exists + fn verifier(&self, input: &In) -> Self::Verifier; + fn query(verifier: Self::Verifier, terms: In::Terms, token: QueryToken) -> Self::Query; } ``` -`S` is the plaintext type the index is over, and `Index::Term` is the -stored term it derives. `Query` is the query term, and `query` makes it from -the term the index derived for the query's value. `QueryToken` can be made -only inside the crate, so only a query chain calls `query`, and no caller can -turn a stored term into a query term by hand. `check` refuses a configuration -that cannot answer `Op` although its type says it can: a match index whose -declared case disagrees with its `downcase` option. +`In::Plaintext` is the plaintext type the index is over, and its +`Index::Term` is the stored term it derives. `Query` is the query term, and +`query` makes it from the verifier and the terms the index derived for the +query's input. `QueryToken` can be made only inside the crate, so only a +query chain calls `query`, and no caller can turn a stored term into a query +term by hand. `QueryTerm` and the two traits that refine it are defined in decision 4. `Query` is not `Term`. An ORE index's `Supports::Query` is the left -ciphertext; its `Term` is left and right. A Bloom index's -`Supports::Query` is a filter over the needle (`MatchQuery`); its -`Term` is a filter over the value (`MatchTerms`). +ciphertext; its `Term` is left and right. (This crate's ORE is CLLW today, +whose ciphertext has no left half to send alone, so until block ORE lands, +#1140, the query term is the ciphertext itself, as an HMAC's is; `Supports` +is where the left-only term goes when it arrives.) A Bloom index's +`Supports::Query` is a filter over the query's value +(`MatchQuery`); its `Term` is a filter over the value (`MatchTerms`). There is +no other path from a derived term to the server: +`cipher.query_index(index, v).on(&plan)`, which returned an index's own term, +went with the ordering operators (#1186). + +An operator says what a query takes through its arity. `Eq`, the orderings +and `Matches` take the plaintext the index is declared over +(`Supports`); `Between` takes a pair of them +(`Supports`); `Input` relates the input to that plaintext, +so the plan is checked against `S` either way, and the chain derives one +term per plaintext under the one label. `check` is where a query is refused +before any term exists: a match index whose declared case disagrees with its +`downcase` option, a `Between` whose bounds are the wrong way round, a match +query with no tokens. `AtPath` takes a path and `Op`'s input, and gains +its shape with the JSON index. The bound tightens per operator, through `TermFor`: any `QueryTerm` answers `Eq`, but only a `Probabilistic` one answers `Matches`, and only a @@ -554,10 +570,14 @@ the ranked loop is worth packaging; it is deferred, not refused. ### 7. `Between` is one term and one operand -`cipher.query(op::Between, (lo, hi))` produces one term carrying both ORE left -ciphertexts, inclusive at both ends, and the client refuses `lo > hi`. One -call derives both bounds, under one context, so a caller cannot pair a bound -from one column with a bound from another. +`cipher.query(op::Between, &(lo, hi)).on(&plan)` produces one term, +`sem::RangeQuery`, carrying the index's term for each bound (the ORE left +ciphertexts, once block ORE lands), inclusive at both ends, and the client +refuses `lo > hi` (`TermError::InvertedRange`) before either term is +derived; `(v, v)` is one value. One call derives both bounds, under one +context, so a caller cannot pair a bound from one column with a bound from +another. The ordering operators are answered over the integer types +(`op::Ordered`), by the ORE index by default and by OPE through `.via(Ope)`. In SQL the term stays one operand. EQL v4 gains a function that takes the column and the range, and a range query domain beside each ordered query @@ -685,9 +705,13 @@ EQL v4 gains `eql_v4.between(column, range)` and a range query domain per ordered query domain, ORE and OPE, under the inlining requirement of decision 7. That is EQL-side work this ADR asks for, not a deferral. -It is a breaking change to the query chain. `QueryUsing::index(..)` goes; -`QueryUsing::equality()` becomes `query(op::Eq, ..)`. The write side -(`encrypt`, targets, plans) does not change. +It is a breaking change to the query chain. `cipher.query(v).using(&plan)` +with `.equality()` becomes `cipher.query(op::Eq, v).on(&plan)`: the operator +first, and `on` for a query where a write says `using`. +`cipher.query_index(index, v).on(&plan)`, the index's own term, is gone: +every index a plan can declare is reachable through an operator, ORE and OPE +through the ordering operators (#1186). The write side (`encrypt`, targets, +plans) does not change. ## Deferred diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 7d6929fc8..6ac2e381a 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -80,6 +80,7 @@ mod tests { TermError::Ore(_) => TermError::Ore(cllw_ore::Error), TermError::InvalidOptions(_) => TermError::InvalidOptions("k out of range"), TermError::EmptyTermText => TermError::EmptyTermText, + TermError::InvertedRange => TermError::InvertedRange, TermError::Bytes(_) => TermError::Bytes(TermBytesError::OddMatchTermsLength(3)), ])); errors.extend(boxed(variants![ @@ -385,6 +386,7 @@ mod tests { json!({ "len": 3 }), ), (Box::new(TermError::EmptyTermText), json!({})), + (Box::new(TermError::InvertedRange), json!({})), ( Box::new(TermBytesError::MalformedCllwCiphertext(5)), json!({ "len": 5 }), diff --git a/packages/stack-encrypt/src/op/mod.rs b/packages/stack-encrypt/src/op/mod.rs index 9d77f0e6f..53d03e4ec 100644 --- a/packages/stack-encrypt/src/op/mod.rs +++ b/packages/stack-encrypt/src/op/mod.rs @@ -1,22 +1,27 @@ //! Operators: what a query asks of the server, as a type. //! -//! A query names an *operator* ([`Eq`](struct@Eq), [`Matches`](struct@Matches)), and the plan it -//! runs on finds the index that answers it. The operator is a unit type -//! passed by value, as an index is (`.via(Ore)`): +//! A query names an *operator* ([`Eq`](struct@Eq), [`Lt`], [`Between`], +//! [`Matches`](struct@Matches)), and the plan it runs on finds the index +//! that answers it. The operator is a unit type passed by value, as an +//! index is (`.via(Ore)`), and it decides what the query takes: one value +//! for most, a pair of bounds for `Between`: //! //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { //! use stack_encrypt::kms::FakeDataKeySource; -//! use stack_encrypt::{op, Equality, Match, Plan, StackCipher}; +//! use stack_encrypt::{op, Equality, Match, Ore, Plan, StackCipher}; //! //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let email_plan = Plan::context("users/email") //! .with::((Equality, Match)) //! .build()?; +//! let age_plan = Plan::context("users/age").with::(Ore).build()?; //! //! let exact = cipher.query(op::Eq, "bob@example.com").on(&email_plan).await?; //! let search = cipher.query(op::Matches, "bob").on(&email_plan).await?; -//! # let _ = (exact, search); +//! let under = cipher.query(op::Lt, &40u32).on(&age_plan).await?; +//! let range = cipher.query(op::Between, &(18u32, 65)).on(&age_plan).await?; +//! # let _ = (exact, search, under, range); //! # Ok(()) //! # } //! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); @@ -24,34 +29,42 @@ //! //! Three traits connect an operator to an index, and ADR-0009 records why: //! -//! - [`Supports`] is on an index, for each operator it can answer -//! over plaintexts `S`, and names the [`QueryTerm`] it sends. That term is -//! not the stored term: an equality index sends the same HMAC it stores, -//! but a match index sends a filter over the query's value -//! ([`MatchQuery`]), where it stores a filter over the stored value -//! ([`MatchTerms`]). -//! - [`Answers`] is on a target type, a domain type such as an EQL +//! - [`Supports`] is on an index, for each operator it can answer +//! and the input `In` the query takes, and names the [`QueryTerm`] it +//! sends. That term is not the stored term: an equality index sends the +//! same HMAC it stores, but a match index sends a filter over the query's +//! value ([`MatchQuery`]), where it stores a filter over the stored value +//! ([`MatchTerms`]), and an ORE index sends one [`RangeQuery`] for +//! `Between`, carrying both bounds, where it stores one term per value. +//! - [`Answers`] is on a target type, a domain type such as an EQL //! type, and names the index that answers `Op` for it. It is written by //! hand per type, because the choice decides what the query term reveals. -//! - [`DefaultIndex`] is on an operator, and names the index a plan that +//! - [`DefaultIndex`] is on an operator, and names the index a plan that //! declares its indexes, rather than a target type, answers it with. //! +//! What a query takes is the operator's [`Arity`]: [`One`] plaintext, or a +//! [`Pair`] of them as a tuple. [`Input`] says, for the value a query is +//! given, which plaintext the index is declared over (`S` for `(S, S)`), +//! so the plan's type is checked against it. +//! //! What the types can see is checked at compile time: a match query on an -//! integer, an operator a target type does not answer, and, on a one-value -//! plan, an index the plan does not hold or a case-sensitive match against -//! a case-folding index. A field of a fields plan is data, so there an -//! index the field does not declare, or declares with other options, is -//! refused when the query is awaited, as with -//! [`query_index`](crate::StackCipher::query_index), before any key is -//! requested. -//! So is a match configuration whose [`MatchCase`] disagrees with its -//! options, and so is the one query value the library refuses: text that -//! cuts no token under the match index's tokenizer, whose filter would -//! match every row ([`TermError::EmptyTermText`]). The stored side has no -//! such refusal: a value with no tokens is stored as the empty filter, -//! which no query matches. +//! integer, an ordering on text, an operator a target type does not answer, +//! and, on a one-value plan, an index the plan does not hold or a +//! case-sensitive match against a case-folding index. A field of a fields +//! plan is data, so there an index the field does not declare, or declares +//! with other options, is refused when the query is awaited, before any +//! key is requested. So is a match configuration whose [`MatchCase`] +//! disagrees with its options, and so are the two query values the library +//! refuses: match text that cuts no token under the index's tokenizer, +//! whose filter would match every row ([`TermError::EmptyTermText`]), and +//! a `Between` whose low bound is above its high bound +//! ([`TermError::InvertedRange`]). The stored side has no such refusal: a +//! value with no tokens is stored as the empty filter, which no query +//! matches. //! //! [`TermError::EmptyTermText`]: crate::sem::TermError::EmptyTermText +//! [`TermError::InvertedRange`]: crate::sem::TermError::InvertedRange +//! [`RangeQuery`]: crate::sem::RangeQuery //! //! The module is meant to be used by path (`op::Eq`): an imported `Eq` //! would shadow the prelude's `Eq` trait. @@ -66,21 +79,195 @@ use stack_kms::MaybeSend; pub(crate) mod stats; pub use stats::{Limit, Stats, DEFAULT_CONFIDENCE, MAX_ROWS}; -use crate::sem::{DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, MatchTerms, QueryTokens}; -use crate::target::{Equality, Index, Match}; -use crate::{Error, PlanError}; +use crate::sem::{ + CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, + MatchTerms, OpeTerm, OreTerm, QueryTokens, RangeQuery, TermError, +}; +use crate::target::{DeclaredContext, Equality, Index, Match, Ope, Ore, Owned, Pending}; +use crate::{Error, KeysetCipher, Label, NonEmpty, PlanError}; -/// A query operator: a unit type naming what a query asks of the server. +/// A query operator: a unit type naming what a query asks of the server, +/// and how many plaintexts it asks about ([`Arity`]). /// /// The trait is open, as [`Index`] is: an operator defined in another crate /// is answered by the indexes that implement [`Supports`] for it. -pub trait Operator: Copy + 'static {} +pub trait Operator: Copy + 'static { + /// What a query for this operator takes: [`One`] plaintext, or a + /// [`Pair`] of them as a tuple. + type Arity: Arity; +} /// Equality: rows whose value equals the query's. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Eq; -impl Operator for Eq {} +impl Operator for Eq { + type Arity = One; +} + +/// Less than: rows whose value is below the query's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Lt; + +/// Less than or equal: rows whose value is at or below the query's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Lte; + +/// Greater than: rows whose value is above the query's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Gt; + +/// Greater than or equal: rows whose value is at or above the query's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Gte; + +/// A range: rows whose value is between the query's two bounds, inclusive +/// at both ends. It takes a [`Pair`], `cipher.query(op::Between, &(lo, hi))`, +/// derives both bounds under one context, and sends them as one term +/// ([`RangeQuery`]), so no statement can pair the +/// low bound of one query with the high bound of another. A pair whose low +/// bound is above its high bound is refused when the query is awaited +/// ([`TermError::InvertedRange`]); one +/// value is `(v, v)`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Between; + +impl Operator for Lt { + type Arity = One; +} +impl Operator for Lte { + type Arity = One; +} +impl Operator for Gt { + type Arity = One; +} +impl Operator for Gte { + type Arity = One; +} +impl Operator for Between { + type Arity = Pair; +} + +/// How many plaintexts a query for an operator takes: [`One`] or a +/// [`Pair`]. Sealed: the chain derives a term per plaintext and joins them +/// as the arity says, so there are exactly these two. +pub trait Arity: sealed::Sealed + 'static {} + +/// One plaintext: the query is given an `S` (`Eq`, `Lt`, `Matches`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct One; + +/// Two plaintexts: the query is given an `(S, S)` (`Between`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Pair; + +impl Arity for One {} +impl Arity for Pair {} + +/// What a query of arity `A` is given, and the plaintext its index is +/// declared over: an `S` is an input of [`One`] over `S`, and an `(S, S)` +/// an input of [`Pair`] over `S`. The chain's value type is the input, and +/// the plan is checked against the plaintext. +/// +/// Those are the two impls. The arity decides how the chain derives terms +/// from the input, which is what [`derive`](Self::derive) does. +pub trait Input: Sized + 'static { + /// The plaintext the index is declared over. + type Plaintext: 'static; + /// The index's terms for this input: one, or a pair of them. + type Terms: 'static; + + /// Derive the index's term for each plaintext of the input under + /// `label`, as one pending. Only a query chain calls this. + #[doc(hidden)] + fn derive<'a, 'p, I, K>( + self, + index: &I, + keyset: &'p KeysetCipher<'a, K>, + label: Label, + extend: DeclaredContext, + ) -> Pending<'p, Self::Terms, K> + where + I: Index, + K: 'static; +} + +impl Input for S { + type Plaintext = S; + type Terms = T; + + fn derive<'a, 'p, I, K>( + self, + index: &I, + keyset: &'p KeysetCipher<'a, K>, + label: Label, + extend: DeclaredContext, + ) -> Pending<'p, I::Term, K> + where + I: Index, + K: 'static, + { + keyset.run( + index.operation::().under(NonEmpty::from(label)), + self, + extend, + ) + } +} + +/// Both bounds under the one label, settled together. +impl Input for (S, S) { + type Plaintext = S; + type Terms = (T, T); + + fn derive<'a, 'p, I, K>( + self, + index: &I, + keyset: &'p KeysetCipher<'a, K>, + label: Label, + extend: DeclaredContext, + ) -> Pending<'p, (I::Term, I::Term), K> + where + I: Index, + K: 'static, + { + let (lo, hi) = self; + let lo = keyset.run( + index + .operation::() + .under(NonEmpty::from(label.clone())), + lo, + extend.clone(), + ); + let hi = keyset.run( + index.operation::().under(NonEmpty::from(label)), + hi, + extend, + ); + lo.zip(hi) + } +} + +/// A plaintext the ordering operators ([`Lt`], [`Lte`], [`Gt`], [`Gte`], +/// [`Between`]) are answered over: the integer types, whose ORE and OPE +/// terms order as the values do and whose order key is the value itself. +/// Text is not one: its order term is a catalog question (ADR-0009, ADR-0002), +/// and an ordering on a text plan does not compile. +pub trait Ordered: + PartialOrd + + CllwOreEncrypt + + CllwOpeEncrypt + + Send + + 'static +{ +} + +macro_rules! ordered { + ($($int:ty),+ $(,)?) => {$( + impl Ordered for $int {} + )+}; +} +ordered!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); /// Text search: rows whose text contains the query's tokens, under the /// match index's tokenizer. Not containment: a Bloom match is order- and @@ -146,12 +333,16 @@ impl fmt::Debug for Matches { } } -impl Operator for Matches {} +impl Operator for Matches { + type Arity = One; +} mod sealed { pub trait Sealed {} impl Sealed for super::CaseInsensitive {} impl Sealed for super::CaseSensitive {} + impl Sealed for super::One {} + impl Sealed for super::Pair {} } /// The case a text operator is asked in: [`CaseInsensitive`] or @@ -209,6 +400,9 @@ pub trait QueryTerm {} impl QueryTerm for EqualityTerm {} impl QueryTerm for MatchQuery {} +impl QueryTerm for OreTerm {} +impl QueryTerm for OpeTerm {} +impl QueryTerm for RangeQuery {} /// A query term whose answer can hold **false positives and no false /// negatives**: a row the server returns may not match, and a row that @@ -270,28 +464,40 @@ pub trait Probabilistic: QueryTerm { pub trait TermFor: QueryTerm {} impl TermFor for T {} +impl TermFor for T {} +impl TermFor for T {} +impl TermFor for T {} +impl TermFor for T {} +impl TermFor for T {} impl TermFor> for T {} -/// An index that answers the operator `Op` over plaintexts `S`, and the +/// An index that answers the operator `Op` for the input `In`, and the /// query term it sends for it. /// -/// [`Index::Term`] is what the index derives; [`query`](Self::query) makes -/// the query term from it and from the query's [`Verifier`](Self::Verifier). -/// For an equality index the two are the same HMAC and there is nothing to -/// verify. For a match index they are not: the query is a filter over the -/// query's value, which matches some rows that do not hold the value, so -/// the query keeps that value's tokens to -/// [`verify`](Probabilistic::verify) each decrypted row. +/// `In` is what the query is given: the plaintext the index is declared +/// over for an operator of arity [`One`] (`Supports`), a pair of +/// them for [`Between`] (`Supports`); [`Input`] relates +/// the two. +/// +/// [`Index::Term`] is what the index derives, once per plaintext of the +/// input; [`query`](Self::query) makes the query term from those and from +/// the query's [`Verifier`](Self::Verifier). For an equality index the two +/// are the same HMAC and there is nothing to verify. For a match index they +/// are not: the query is a filter over the query's value, which matches +/// some rows that do not hold the value, so the query keeps that value's +/// tokens to [`verify`](Probabilistic::verify) each decrypted row. +/// For `Between` the two terms of the bounds become one +/// [`RangeQuery`]. #[diagnostic::on_unimplemented( - message = "`{Self}` does not answer `{Op}` over `{S}`", - label = "this index cannot answer the operator", - note = "an equality index answers `op::Eq`; a match index answers `op::Matches` over text, in the case its configuration fixes" + message = "`{Self}` does not answer `{Op}` given `{In}`", + label = "this index cannot answer the operator for this input", + note = "an equality index answers `op::Eq`; a match index answers `op::Matches` over text, in the case its configuration fixes; an ORE or OPE index answers `op::Lt`, `Lte`, `Gt` and `Gte` over an integer, and `op::Between` over a pair of them" )] -pub trait Supports: Index { +pub trait Supports>: Index { /// What this index sends for `Op`. type Query: TermFor; - /// What the query term keeps of the query's plaintext so that it can - /// later check a decrypted row, taken before the plaintext is consumed. + /// What the query term keeps of its input so that it can later check a + /// decrypted row, taken before the input is consumed. /// /// An exact term checks nothing, so its verifier is `()`. A match /// query's is [`crate::sem::QueryTokens`]: `Supports: Index { /// its own text holds all three. The filter cannot give the tokens back, /// so without the verifier a Bloom false positive would reach the caller. type Verifier: MaybeSend + 'static; - /// Why this index cannot answer `Op` as configured, or over `value`, if + /// Why this index cannot answer `Op` as configured, or for `input`, if /// it cannot: a match index whose [`MatchCase`] disagrees with its - /// options ([`PlanError::MatchCase`]), or a match query of text that - /// cuts no token ([`TermError::EmptyTermText`](crate::sem::TermError::EmptyTermText)), whose filter would - /// match every row. Run when the query is awaited, before any key is - /// requested; an index that refuses nothing returns `Ok(())`. - fn check(&self, value: &S) -> Result<(), Error>; - /// The verifier of the query's plaintext `value`. - fn verifier(&self, value: &S) -> Self::Verifier; + /// options ([`PlanError::MatchCase`]), a match query of text that cuts + /// no token ([`TermError::EmptyTermText`]), whose filter would match + /// every row, or a range whose low bound is above its high bound + /// ([`TermError::InvertedRange`]). + /// Run when the query is awaited, before any key is requested; an + /// index that refuses nothing returns `Ok(())`. + fn check(&self, input: &In) -> Result<(), Error>; + /// The verifier of the query's `input`. + fn verifier(&self, input: &In) -> Self::Verifier; /// [`check`](Self::check), then [`verifier`](Self::verifier): the query's - /// verifier of `value`, or why the index refuses it. A query chain + /// verifier of `input`, or why the index refuses it. A query chain /// calls this when it derives the term. An index whose check and verifier /// do the same work overrides it to do that work once: a match index /// tokenises the query's value for both. - fn admit(&self, value: &S) -> Result { - self.check(value)?; - Ok(self.verifier(value)) + fn admit(&self, input: &In) -> Result { + self.check(input)?; + Ok(self.verifier(input)) } - /// The query term, from the verifier and the term the index derived for - /// the query's value. Only a query chain calls this: `QueryToken` - /// cannot be made outside this crate, so no caller can pair one query's tokens - /// with another value's filter, or pass a stored term off as a query. - fn query(verifier: Self::Verifier, term: Self::Term, token: QueryToken) -> Self::Query; + /// The query term, from the verifier and the terms the index derived + /// for the input's plaintexts. Only a query chain calls this: + /// `QueryToken` cannot be made outside this crate, so no caller can + /// pair one query's tokens with another value's filter, or pass a + /// stored term off as a query. + fn query(verifier: Self::Verifier, terms: In::Terms, token: QueryToken) -> Self::Query; } /// Proof that [`Supports::query`] is called by a query chain. Only this @@ -337,7 +546,7 @@ impl QueryToken { } } -impl Supports for Equality { +impl Supports for Equality { type Query = EqualityTerm; type Verifier = (); fn check(&self, _: &S) -> Result<(), Error> { @@ -351,7 +560,7 @@ impl Supports for Equality { impl Supports, S> for Match where - S: AsRef, + S: AsRef + 'static, O: MatchCase, { type Query = MatchQuery; @@ -383,7 +592,68 @@ where } } -/// A target type that answers the operator `Op` over plaintexts `S`, and +/// The ordering operators on the ORE and OPE indexes, over the integer +/// types ([`Ordered`]). The query term for a comparison is the index's own +/// term for the value: this crate's ORE is CLLW, whose ciphertext has no +/// left half to send alone, so what is sent is what is stored, as for an +/// HMAC. `Between` sends both bounds' terms as one `RangeQuery`. +macro_rules! orders { + ($($index:ident => $term:ident),+ $(,)?) => {$( + orders!(@one $index => $term; Lt, Lte, Gt, Gte); + impl Supports for $index { + type Query = RangeQuery<$term>; + type Verifier = (); + fn check(&self, (lo, hi): &(S, S)) -> Result<(), Error> { + if lo > hi { + Err(TermError::InvertedRange.into()) + } else { + Ok(()) + } + } + fn verifier(&self, _: &(S, S)) {} + fn query((): (), (lo, hi): ($term, $term), _: QueryToken) -> Self::Query { + RangeQuery::new(lo, hi) + } + } + impl Answers for $term { + type Via = $index; + } + )+}; + (@one $index:ident => $term:ident; $($op:ident),+) => {$( + impl Supports<$op, S> for $index { + type Query = $term; + type Verifier = (); + fn check(&self, _: &S) -> Result<(), Error> { + Ok(()) + } + fn verifier(&self, _: &S) {} + fn query((): (), term: $term, _: QueryToken) -> $term { + term + } + } + impl Answers<$op, S> for $term { + type Via = $index; + } + )+}; +} +orders!(Ore => OreTerm, Ope => OpeTerm); + +/// The ordering operators default to the ORE index; an OPE index is named +/// with `.via(Ope)`. +macro_rules! default_ore { + ($($op:ident),+ $(,)?) => {$( + impl DefaultIndex for $op { + type Via = Ore; + } + )+}; +} +default_ore!(Lt, Lte, Gt, Gte); + +impl DefaultIndex<(S, S)> for Between { + type Via = Ore; +} + +/// A target type that answers the operator `Op` for the input `In`, and /// the index it answers it with. /// /// Written by hand per type: a type with two indexes that both answer an @@ -392,23 +662,23 @@ where /// that does not answer an operator has no impl, and the query does not /// compile. #[diagnostic::on_unimplemented( - message = "`{Self}` does not answer `{Op}` over `{S}`", + message = "`{Self}` does not answer `{Op}` given `{In}`", label = "this target type does not answer the operator", note = "a target answers the operators its type implements `Answers` for; `.via(index)` names an index the target declares" )] -pub trait Answers { +pub trait Answers> { /// The index that answers `Op` for this type. An index is a unit type, /// so its `Default` is the index. - type Via: Supports + Default; + type Via: Supports + Default; } -impl Answers for EqualityTerm { +impl Answers for EqualityTerm { type Via = Equality; } impl Answers, S> for MatchTerms where - S: AsRef, + S: AsRef + 'static, O: MatchCase, { type Via = Match; @@ -416,14 +686,18 @@ where /// A ciphertext with one term beside it, the shape of a `plaintext = ..` /// record without the struct, answers what its term answers. -impl> Answers for (crate::StackCipherText, T) { +impl, T: Answers> Answers + for (crate::StackCipherText, T) +{ type Via = T::Via; } /// A ciphertext with one term, as [`indexed`](crate::target::indexed) /// produces it, answers what its term answers. One with several terms /// names the index with `.via(index)`. -impl> Answers for crate::target::Encrypted { +impl, T: Answers> Answers + for crate::target::Encrypted +{ type Via = T::Via; } @@ -437,20 +711,20 @@ impl> Answers for crate::target::Encry /// [`Answers`] impl is not consulted, and a field whose type answers /// through another index needs `via`. #[diagnostic::on_unimplemented( - message = "`{Self}` has no default index over `{S}`", + message = "`{Self}` has no default index given `{In}`", label = "no default index answers this operator here", - note = "`op::Eq` defaults to `Equality`; `op::Matches` defaults to `Match` with the default configuration, which is case-insensitive and over text only; name another index with `.via(index)`" + note = "`op::Eq` defaults to `Equality`; `op::Matches` defaults to `Match` with the default configuration, which is case-insensitive and over text only; `op::Lt`, `Lte`, `Gt`, `Gte` and `Between` default to `Ore` over an integer, and `Between` takes a pair; name another index, such as `Ope`, with `.via(index)`" )] -pub trait DefaultIndex: Operator { +pub trait DefaultIndex>: Operator { /// The index. An index is a unit type, so its `Default` is the index. - type Via: Supports + Default; + type Via: Supports + Default; } -impl DefaultIndex for Eq { +impl DefaultIndex for Eq { type Via = Equality; } -impl> DefaultIndex for Matches<::Case> { +impl + 'static> DefaultIndex for Matches<::Case> { type Via = Match; } @@ -479,5 +753,22 @@ mod tests { "Matches" ); assert_eq!(format!("{:?}", Eq), "Eq"); + assert_eq!(format!("{:?}", Between), "Between"); + } + + /// The ordering operators default to ORE, and a range is refused only + /// when its bounds are the wrong way round: `(v, v)` is one value. + #[test] + fn a_range_is_checked_before_any_term_is_derived() { + let ok = |pair: (u32, u32)| Supports::::check(&Ore, &pair).is_ok(); + assert!(ok((1, 2))); + assert!(ok((2, 2))); + assert!(!ok((3, 2))); + assert!(matches!( + Supports::::check(&Ope, &(1, -1)), + Err(Error::Term(TermError::InvertedRange)) + )); + assert!(Supports::::check(&Ore, &7).is_ok()); + assert!(Supports::::check(&Ope, &-7).is_ok()); } } diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 6e38b5ed2..7812708ef 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -11,10 +11,10 @@ use super::build::{FieldPlan, FieldsBuilder, IntoLabel, Opens, PlanContext, Runs use super::field_ref::FieldRef; use super::value::{Indexed, Typed, ValuePlan, ValueShape}; use super::PlanError; -use crate::op::{Answers, DefaultIndex, Operator, QueryToken, Supports}; +use crate::op::{Answers, DefaultIndex, Input, Operator, QueryToken, Supports}; use crate::target::{ ciphertext, Borrowed, CallerContext, DeclaredContext, DecryptField, Decryptable, EncryptFrom, - Index, Indexes, Owned, Pending, PendingFuture, Select, Whole, + Index, Indexes, Pending, PendingFuture, Select, Whole, }; use crate::{Error, KeysetCipher, Label, LabelError, NonEmpty, StackCipher, StackCipherText}; @@ -286,38 +286,20 @@ impl StackCipher { /// index the plan declares beside that one is named first, with /// [`via`](QueryBuilder::via). /// - /// The value is copied once (`to_owned`), so `query(op::Eq, "bob@example.com")` + /// The value is what the operator takes ([`Input`]): one plaintext, or + /// for `op::Between` a pair of them, `cipher.query(op::Between, &(18, 65))`. + /// It is copied once (`to_owned`), so `query(op::Eq, "bob@example.com")` /// queries a `String` field. pub fn query<'a, Op, Q>(&'a self, op: Op, value: &Q) -> QueryBuilder<'a, Op, F, K> where Op: Operator, Q: ToOwned + ?Sized, { + let _: Op = op; QueryBuilder { common: Common::new(self), value: value.to_owned(), - op, - } - } - - /// Start a chain that derives the term of `index` over `value`: the - /// index's own term, not a query term, so nothing says what it may be - /// compared with. Only for an index no operator answers yet ([`Ore`] - /// and [`Ope`], the [`Unanswered`] indexes); a query for an operator is - /// [`query`](Self::query), and this goes when the ordering operators - /// arrive. - /// - /// [`Ore`]: crate::target::Ore - /// [`Ope`]: crate::target::Ope - pub fn query_index<'a, I, Q>(&'a self, index: I, value: &Q) -> QueryIndexBuilder<'a, I, F, K> - where - I: Unanswered, - Q: ToOwned + ?Sized, - { - QueryIndexBuilder { - common: Common::new(self), - value: value.to_owned(), - index, + op: PhantomData, } } @@ -623,10 +605,10 @@ type F = ::Owned; pub struct QueryBuilder<'a, Op, F, K> { common: Common<'a, K>, value: F, - op: Op, + op: PhantomData Op>, } -impl<'a, Op: Operator, F: 'static, K: 'static> QueryBuilder<'a, Op, F, K> { +impl<'a, Op: Operator, F: Input, K: 'static> QueryBuilder<'a, Op, F, K> { /// Name the context the term is derived under, for a plan built /// without one: the context the write's call named, or for a plan with /// a context field, the value that field held. A plan built with its @@ -638,9 +620,9 @@ impl<'a, Op: Operator, F: 'static, K: 'static> QueryBuilder<'a, Op, F, K> { /// Answer the operator through `index` rather than the index the plan /// chooses for it ([`Chooses`]): a match index with its own - /// configuration, or an index of a target type that has no - /// [`Answers`] impl for the operator. The plan named next must declare - /// it, as [`on`](Self::on) says. + /// configuration, an OPE index where ORE is the default, or an index + /// of a target type that has no [`Answers`] impl for the operator. The + /// plan named next must declare it, as [`on`](Self::on) says. pub fn via>(self, index: I) -> QueryVia<'a, Op, F, I, K> { QueryVia { common: self.common, @@ -657,10 +639,11 @@ impl<'a, Op: Operator, F: 'static, K: 'static> QueryBuilder<'a, Op, F, K> { /// label the write used. /// /// What the plan's type can see is checked here: an operator it does - /// not answer, or a one-value plan without the index, does not compile. - /// A field of a fields plan is data, so there an index the field does - /// not declare, or declares with other options, is refused when the - /// query is awaited ([`PlanError::IndexNotDeclared`], + /// not answer, a value that is not what the operator takes over the + /// plan's plaintext, or a one-value plan without the index, does not + /// compile. A field of a fields plan is data, so there an index the + /// field does not declare, or declares with other options, is refused + /// when the query is awaited ([`PlanError::IndexNotDeclared`], /// [`PlanError::IndexOptions`]), before any key is requested. pub fn on(self, plan: &P) -> QueryOp<'a, F, Op, P::Via, K> where @@ -675,11 +658,16 @@ impl<'a, Op: Operator, F: 'static, K: 'static> QueryBuilder<'a, Op, F, K> { pub struct QueryVia<'a, Op, F, I, K> { common: Common<'a, K>, value: F, - op: Op, + op: PhantomData Op>, index: I, } -impl<'a, Op: Operator, F: 'static, I: Supports, K: 'static> QueryVia<'a, Op, F, I, K> { +impl<'a, Op, F, I, K: 'static> QueryVia<'a, Op, F, I, K> +where + Op: Operator, + F: Input, + I: Supports, +{ /// Name the context the term is derived under, as /// [`QueryBuilder::context`], before or after the index. pub fn context(mut self, context: impl IntoLabel) -> Self { @@ -694,112 +682,73 @@ impl<'a, Op: Operator, F: 'static, I: Supports, K: 'static> QueryVia<'a, /// own index of that type, so the named index says only which. pub fn on(self, plan: &P) -> QueryOp<'a, F, Op, I, K> where - P: Resolves, + P: Resolves, { let QueryVia { - common, + mut common, value, op, index, } = self; - let inner = plan.resolve(QueryIndexBuilder { + let (index, label) = match common.call_context() { + Ok(call) => plan.resolve(index, call), + Err(error) => (index, Err(error)), + }; + QueryOp { common, value, index, - }); - QueryOp::new(inner, op) - } -} - -/// An index no operator answers yet, so its own term is what a query sends: -/// [`Ore`](crate::target::Ore) and [`Ope`](crate::target::Ope). The bound on -/// [`StackCipher::query_index`]. Sealed: an index an operator answers is -/// queried through the operator ([`StackCipher::query`]), so its query term -/// cannot be taken for its stored term. Goes with `query_index` when the -/// ordering operators arrive. -#[diagnostic::on_unimplemented( - message = "`{Self}` is answered by an operator, so `query_index` cannot derive its term", - label = "an operator answers this index", - note = "query it through its operator: `cipher.query(op::Eq, v)` for `Equality`, `cipher.query(op::Matches, v)` for `Match`; `query_index` is for `Ore` and `Ope` only" -)] -pub trait Unanswered: unanswered_sealed::Sealed {} - -impl Unanswered for crate::target::Ore {} -impl Unanswered for crate::target::Ope {} - -mod unanswered_sealed { - pub trait Sealed {} - impl Sealed for crate::target::Ore {} - impl Sealed for crate::target::Ope {} -} - -/// A chain deriving an index's own term, before its plan is named. Started -/// by [`StackCipher::query_index`]. -pub struct QueryIndexBuilder<'a, I, F, K> { - common: Common<'a, K>, - value: F, - index: I, -} - -impl<'a, I, F: 'static, K: 'static> QueryIndexBuilder<'a, I, F, K> { - /// Name the context the term is derived under, as - /// [`QueryBuilder::context`]. - pub fn context(mut self, context: impl IntoLabel) -> Self { - self.common.context = Some(context.into_label()); - self - } - - /// Derive the index's term on `plan`, which must declare it - /// ([`Resolves`]); otherwise awaiting is - /// [`PlanError::IndexNotDeclared`] or [`PlanError::FieldType`], never a - /// term that matches nothing. - pub fn on(self, plan: &P) -> QueryIndex<'a, F, I, K> - where - I: Index, - P: Resolves, - { - plan.resolve(self) + label, + op, + } } } -/// A plan a query for the operator `Op` over `F` runs on, and the index it -/// answers the operator with. Sealed: the three plan shapes implement it. +/// A plan a query for the operator `Op`, given `In`, runs on, and the +/// index it answers the operator with. Sealed: the three plan shapes +/// implement it. /// /// A field of a fields plan and a one-value plan built `with` indexes /// answer through the operator's [`DefaultIndex`]; a one-value plan for a -/// target type answers through the type's [`Answers`] impl. `At` is the -/// position of that index in a plan built `with` several, and is inferred. +/// target type answers through the type's [`Answers`] impl. The plan's +/// plaintext is the input's ([`Input::Plaintext`]): `S` for a query given +/// an `S`, and for a `Between` given an `(S, S)`. `At` is the position of +/// that index in a plan built `with` several, and is inferred. #[diagnostic::on_unimplemented( - message = "`{Self}` does not answer `{Op}` over `{F}`", + message = "`{Self}` does not answer `{Op}` given `{In}`", label = "no index answers this operator on this plan", - note = "a field of a fields plan or a plan built `with` indexes answers an operator through its default index (`op::Eq`: `Equality`; `op::Matches`: `Match` with the default configuration, case-insensitive and over text only); a typed plan answers the operators its target type implements `Answers` for; `.via(index)` names an index the plan declares" + note = "a field of a fields plan or a plan built `with` indexes answers an operator through its default index (`op::Eq`: `Equality`; `op::Matches`: `Match` with the default configuration, case-insensitive and over text only; `op::Lt`, `Lte`, `Gt`, `Gte` and `Between`: `Ore` over an integer, `Between` given a pair); a typed plan answers the operators its target type implements `Answers` for; `.via(index)` names an index the plan declares" )] -pub trait Chooses: Resolves { +pub trait Chooses, At>: + Resolves +{ /// The index that answers `Op` on this plan. - type Via: Supports + Default; + type Via: Supports + Default; } -impl, F: 'static> Chooses for FieldPlan { +impl, In: Input> Chooses for FieldPlan { type Via = Op::Via; } -impl Chooses for ValuePlan> +impl Chooses for ValuePlan> where - Op: DefaultIndex, + Op: DefaultIndex, Op::Via: Clone, - F: 'static, - X: Indexes + Select, + In: Input, + S: 'static, + X: Indexes + Select, { type Via = Op::Via; } -impl Chooses for ValuePlan> +impl Chooses for ValuePlan> where Op: Operator, - F: 'static, - T: Answers, + In: Input, + S: 'static, + T: Answers, T::Via: Default, - Typed: ValueShape, + Typed: ValueShape, { type Via = T::Via; } @@ -812,8 +761,9 @@ mod plan_sealed { impl Sealed for ValuePlan> {} } -/// A plan the index `I` over `F` can be queried on. Sealed: the three plan -/// shapes implement it, and each finds the label the write used. +/// A plan the index `I` over the plaintext `S` can be queried on. Sealed: +/// the three plan shapes implement it, and each finds the label the write +/// used. /// /// A field of a fields plan ([`Plan::field`](super::Plan::field)) and a /// one-value plan for a target type take the index as given, and refuse @@ -823,102 +773,66 @@ mod plan_sealed { /// the position `At`, which is inferred; a plan without that index does /// not compile. #[diagnostic::on_unimplemented( - message = "`{Self}` cannot be queried through `{I}` over `{F}`", + message = "`{Self}` cannot be queried through `{I}` over `{S}`", label = "this plan does not declare the index, or is not a plan a query runs on", note = "query on a field of a fields plan (`plan.field(..)`) or on a one-value plan; a plan built `with` indexes must hold one of this type" )] -pub trait Resolves: plan_sealed::Sealed { - /// The chain for the index's term on this plan, under the label the - /// write used. - fn resolve<'a, K: 'static>( - &self, - query: QueryIndexBuilder<'a, I, F, K>, - ) -> QueryIndex<'a, F, I, K>; +pub trait Resolves: plan_sealed::Sealed { + /// The index the query runs through on this plan, and the label the + /// write used, or why the plan refuses the query. `call` is the context + /// the call named, if any. + fn resolve(&self, index: I, call: Option