From a6de2dcb3385ef0d50ff9548716e688d6efb92f0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 22:10:22 +1100 Subject: [PATCH 1/4] feat(stack-encrypt)!: a query names an operator, not an index `cipher.query(v).using(&plan)` now finishes with `.op(op::Eq)` or `.op(op::Matches::new())`, and the plan finds the index that answers the operator (ADR-0009, decisions 1 to 4). - `stack_encrypt::op` holds the operators `Eq` and `Matches`, the case markers, and the traits `Operator`, `QueryTerm`, `Supports` (on an index), `Answers` (on a target type, by hand) and `DefaultIndex` (the index a plan that declares indexes answers an operator with). - `.op_via(op, index)` names another index, such as a match index with its own configuration, or one of a target's several terms. - A match query is its own type, `sem::MatchQuery`, the needle's filter, so the query term is not typed as the stored `MatchTerms`. - A case-sensitive `Matches` against a case-folding index, an operator a target does not answer, and a text operator on an integer are compile errors (tests/ui). BREAKING CHANGE: `QueryUsing::equality()` is removed; write `.op(op::Eq)`. `QueryUsing::index(..)` stays: it is the only way to query ORE and OPE until the ordering operators exist (#1186). The ADR's code now uses the spelling that ships, and its base trait is `QueryTerm`, since `sem::Term` already names the derivation trait. Closes #1182 Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB --- packages/stack-encrypt/CHANGELOG.md | 18 + packages/stack-encrypt/CONTEXT.md | 19 +- packages/stack-encrypt/src/lib.rs | 1 + packages/stack-encrypt/src/op.rs | 328 +++++++++++++++ packages/stack-encrypt/src/plan/build.rs | 8 +- packages/stack-encrypt/src/plan/chain.rs | 145 ++++++- packages/stack-encrypt/src/plan/mod.rs | 12 +- packages/stack-encrypt/src/plan/value.rs | 4 +- packages/stack-encrypt/src/sem/mod.rs | 62 +++ packages/stack-encrypt/tests/derive_plan.rs | 3 +- packages/stack-encrypt/tests/op.rs | 389 ++++++++++++++++++ packages/stack-encrypt/tests/plan.rs | 27 +- packages/stack-encrypt/tests/plan_grammar.rs | 29 +- .../tests/ui/op_case_mismatch.rs | 18 + .../tests/ui/op_case_mismatch.stderr | 26 ++ .../tests/ui/op_matches_on_integer.rs | 11 + .../tests/ui/op_matches_on_integer.stderr | 21 + .../tests/ui/op_target_does_not_answer.rs | 16 + .../tests/ui/op_target_does_not_answer.stderr | 22 + .../stack-encrypt/tests/ui/pass/plan_chain.rs | 9 +- .../tests/ui/plan_query_undeclared_index.rs | 3 +- .../ui/plan_query_undeclared_index.stderr | 27 +- 22 files changed, 1112 insertions(+), 86 deletions(-) create mode 100644 packages/stack-encrypt/src/op.rs create mode 100644 packages/stack-encrypt/tests/op.rs create mode 100644 packages/stack-encrypt/tests/ui/op_case_mismatch.rs create mode 100644 packages/stack-encrypt/tests/ui/op_case_mismatch.stderr create mode 100644 packages/stack-encrypt/tests/ui/op_matches_on_integer.rs create mode 100644 packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr create mode 100644 packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs create mode 100644 packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index e3396812a..315d7bd70 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **A query names an operator, not an index.** `cipher.query(v).using(&plan)` + finishes with `.op(op::Eq)` or `.op(op::Matches::new())`, and the plan + finds the index that answers the operator (ADR-0009). An index declares the + operators it answers (`op::Supports`), a target type names the index that + answers each one for it (`op::Answers`), and a plan that declares indexes + uses the operator's default index (`op::DefaultIndex`). `.op_via(op, index)` + names another index, such as a match index with its own configuration. An + operator the plan cannot answer is a compile error; a case-sensitive + `Matches` against a case-folding index is one too. +- **A match query has its own type, `sem::MatchQuery`.** It is the filter over + the needle that the server tests a stored `MatchTerms` for, and it comes + only from a query. + - **Errors now expose stable codes, help text, and structured details.** All error types implement `miette::Diagnostic` with codes such as `stack_encrypt::foreign_keyset`. Help text explains how to resolve @@ -47,6 +60,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **`QueryUsing::equality()` is removed.** Write `.op(op::Eq)`. A plan that + declares the equality index answers it the same way, and a target answers + it through `op::Answers`. `QueryUsing::index(..)` stays until the ordering + operators exist. + - **Update pattern matches for `dynamic::Error`.** `Context`, `Plan`, `Source`, and `Record` now carry `{ field: Option, reason: Reason }`; `Term` also gains diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 06e677f72..9a8b342c8 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -223,12 +223,27 @@ _Avoid_: index (that is the declaration; a term is what it produces), token, hash **Query**: -Deriving the terms for a plaintext under a field's plan, with no ciphertext, -to match terms already stored. A query against an index the plan never +Deriving the query term for a plaintext under a field's plan, with no +ciphertext, to match terms already stored. A query names an operator, and the +plan finds the index that answers it. A query against an index the plan never declared is refused, so a query cannot silently match nothing. _Avoid_: probe, search term, lookup, encrypt query (that is the TypeScript package's method name, not the concept) +**Operator**: +What a query asks of the server — equality, text match — as a unit type +(`op::Eq`, `op::Matches`). An index declares the operators it answers and the +query term it sends for each; a target type names the index that answers each +operator for it. An operator nothing answers is a compile error (ADR-0009). +_Avoid_: query type, index (that is what answers the operator) + +**Query term**: +The value a query sends to the server. It is not the stored term: equality +sends the same HMAC it stores, but a match query is a filter over the needle, +where the stored term is a filter over the whole value. A query term is never +stored, and a stored term is never sent as a query. +_Avoid_: probe, stored term (that is what a write keeps) + **Open**: Decrypting through a plan: reading back every field of a record that can come back (sealed and passthrough fields, not index-only ones), each under diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 37b36ba4f..65059a6ae 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -277,6 +277,7 @@ pub mod descriptor; #[cfg(feature = "dynamic")] pub mod dynamic; pub mod keyset; +pub mod op; pub mod plan; pub mod sem; pub mod target; diff --git a/packages/stack-encrypt/src/op.rs b/packages/stack-encrypt/src/op.rs new file mode 100644 index 000000000..9b083ac1b --- /dev/null +++ b/packages/stack-encrypt/src/op.rs @@ -0,0 +1,328 @@ +//! Operators: what a query asks of the server, as a type. +//! +//! A query names an *operator* ([`Eq`](struct@Eq), [`Matches`]), and the plan it runs +//! through finds the index that answers it. The operator is a unit type +//! passed by value, as an index is (`.index(Ore)`): +//! +//! ``` +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::{op, Equality, Match, Plan, StackCipher}; +//! +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let email_plan = Plan::context("users/email") +//! .with::((Equality, Match::default())) +//! .build()?; +//! +//! let exact = cipher.query("bob@example.com").using(&email_plan).op(op::Eq).await?; +//! let search = cipher.query("bob").using(&email_plan).op(op::Matches::new()).await?; +//! # let _ = (exact, search); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! 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 needle ([`MatchQuery`]), +//! where it stores a filter over the whole value ([`MatchTerms`]). +//! - [`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 +//! declares its indexes, rather than a target type, answers it with. +//! +//! An operator a plan cannot answer is a compile error: a match query on an +//! integer, a case-sensitive match against a case-folding index, or an +//! operator a target type does not answer. An index the plan does not +//! declare is refused when the query is awaited, as with +//! [`index`](crate::plan::QueryUsing::index), before any key is requested. +//! +//! The module is meant to be used by path (`op::Eq`): an imported `Eq` +//! would shadow the prelude's `Eq` trait. +//! +//! [`MatchQuery`]: crate::sem::MatchQuery +//! [`MatchTerms`]: crate::sem::MatchTerms +use std::fmt; +use std::marker::PhantomData; + +use crate::sem::{DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, MatchTerms}; +use crate::target::{Equality, Index, Match}; + +/// A query operator: a unit type naming what a query asks of the server. +/// +/// 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 { + /// The operator's name, as a binding or an error message spells it. + const NAME: &'static str; +} + +/// Equality: rows whose value equals the query's. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Eq; + +impl Operator for Eq { + const NAME: &'static str = "eq"; +} + +/// Text search: rows whose text contains the needle's tokens, under the +/// match index's tokenizer. Not containment: a Bloom match is order- and +/// multiplicity-insensitive. +/// +/// `C` is the case the query is asked in, [`Insensitive`] unless named +/// otherwise. A match index answers only the case its configuration fixes +/// ([`MatchCase`]), so asking a case-folding index for a case-sensitive +/// match is a compile error rather than a query that quietly folds case. +pub struct Matches(PhantomData C>); + +impl Matches { + /// The operator. `Matches::new()` takes its case from the index it is + /// asked of; `Matches::::new()` names it. + pub const fn new() -> Self { + Self(PhantomData) + } +} + +// By hand, so `C` (a marker type) need not implement them. +impl Clone for Matches { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Matches {} +impl Default for Matches { + fn default() -> Self { + Self::new() + } +} +impl PartialEq for Matches { + fn eq(&self, _: &Self) -> bool { + true + } +} +impl std::cmp::Eq for Matches {} +impl fmt::Debug for Matches { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Matches<{}>", C::NAME) + } +} + +impl Operator for Matches { + const NAME: &'static str = "matches"; +} + +mod sealed { + pub trait Sealed {} + impl Sealed for super::Insensitive {} + impl Sealed for super::Sensitive {} +} + +/// The case a text operator is asked in: [`Insensitive`] or [`Sensitive`]. +/// Sealed: there are exactly two. +pub trait Case: sealed::Sealed + 'static { + /// Whether text is lower-cased before it is tokenised. + const FOLDS: bool; + /// The case's name, for `Debug`. + const NAME: &'static str; +} + +/// Case-insensitive: text is lower-cased before it is tokenised. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Insensitive; + +/// Case-sensitive: text is tokenised as written. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Sensitive; + +impl Case for Insensitive { + const FOLDS: bool = true; + const NAME: &'static str = "Insensitive"; +} +impl Case for Sensitive { + const FOLDS: bool = false; + const NAME: &'static str = "Sensitive"; +} + +/// The case a match configuration fixes, as a type, so the operators its +/// index answers are known at compile time. +/// +/// `Case::FOLDS` must equal the configuration's +/// [`MatchOptions::downcase`](crate::sem::MatchOptions::downcase). A +/// configuration that does not implement this can still store and query +/// match terms; it cannot be queried by [`Matches`]. +pub trait MatchCase: MatchConfig { + /// The case the configuration's index answers. + type Case: Case; +} + +impl MatchCase for DefaultMatch { + type Case = Insensitive; +} + +/// A value a query sends to the server to compare against stored terms. +/// +/// A term that implements this and nothing more is *exact*: the server's +/// answer is the answer. +pub trait QueryTerm {} + +impl QueryTerm for EqualityTerm {} +impl QueryTerm for MatchQuery {} + +/// An index that answers the operator `Op` over plaintexts `S`, and the +/// query term it sends for it. +/// +/// [`Index::Term`] is what the index derives; [`query`](Self::query) makes +/// the query term from it. For an equality index the two are the same +/// HMAC. For a match index they are not: the query is a filter over the +/// needle, and its type says so. +#[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" +)] +pub trait Supports: Index { + /// What this index sends for `Op`. + type Query: QueryTerm; + /// The query term, from the term the index derived for the query's + /// value. + fn query(term: Self::Term) -> Self::Query; +} + +impl Supports for Equality { + type Query = EqualityTerm; + fn query(term: EqualityTerm) -> EqualityTerm { + term + } +} + +impl Supports, S> for Match +where + S: AsRef, + O: MatchCase, +{ + type Query = MatchQuery; + fn query(term: MatchTerms) -> MatchQuery { + MatchQuery::from_terms(term) + } +} + +/// A target type that answers the operator `Op` over plaintexts `S`, and +/// the index it answers it with. +/// +/// Written by hand per type: a type with two indexes that both answer an +/// operator chooses the cheaper or the more private, as the indexes in play +/// dictate, and that choice decides what the query term reveals. A type +/// 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}`", + label = "this target type does not answer the operator", + note = "a target answers the operators its type implements `Answers` for; `.op_via(op, index)` names an index the target declares" +)] +pub trait Answers { + /// The index that answers `Op` for this type. + type Via: Supports; + /// That index, as a value. + fn via() -> Self::Via; +} + +impl Answers for EqualityTerm { + type Via = Equality; + fn via() -> Equality { + Equality + } +} + +impl Answers, S> for MatchTerms +where + S: AsRef, + O: MatchCase, +{ + type Via = Match; + fn via() -> Match { + Match::new() + } +} + +/// 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) { + type Via = T::Via; + fn via() -> T::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 `.op_via(op, index)`. +impl> Answers for crate::target::Encrypted { + type Via = T::Via; + fn via() -> T::Via { + T::via() + } +} + +/// The index a plan that declares its indexes answers an operator with: +/// a field of a fields plan, or a one-value plan built `with` indexes. +/// +/// A plan that declares a different index for the operator, such as a +/// match index with its own configuration, names it with +/// `.op_via(op, index)`. +#[diagnostic::on_unimplemented( + message = "`{Self}` has no default index over `{S}`", + 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 `.op_via(op, index)`" +)] +pub trait DefaultIndex: Operator { + /// The index. + type Via: Supports; + /// That index, as a value. + fn via() -> Self::Via; +} + +impl DefaultIndex for Eq { + type Via = Equality; + fn via() -> Equality { + Equality + } +} + +impl> DefaultIndex for Matches<::Case> { + type Via = Match; + fn via() -> Match { + Match::default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_default_match_configuration_folds_case_as_its_type_says() { + assert_eq!( + ::Case::FOLDS, + DefaultMatch::options().downcase + ); + } + + #[test] + fn an_operator_is_equal_to_itself() { + assert_eq!(Matches::::new(), Matches::default()); + assert_eq!(Eq, Eq); + } + + #[test] + fn an_operator_debugs_with_its_case() { + assert_eq!( + format!("{:?}", Matches::::new()), + "Matches" + ); + assert_eq!(format!("{:?}", Eq), "Eq"); + } +} diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index d8537f64a..47825db6c 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -244,7 +244,7 @@ pub(crate) fn check_declared( /// # struct User { email: String, id: u64 } /// # async fn example() -> Result<(), stack_encrypt::Error> { /// use stack_encrypt::kms::FakeDataKeySource; -/// use stack_encrypt::{Equality, Plan, StackCipher}; +/// use stack_encrypt::{op, Equality, Plan, StackCipher}; /// /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; /// let user = User { email: "bob@example.com".into(), id: 42 }; @@ -262,7 +262,7 @@ pub(crate) fn check_declared( /// .query("bob@example.com") /// .context("tenants/acme/users") /// .using(&email_plan) -/// .equality() +/// .op(op::Eq) /// .await?; /// # let _ = query_value; /// let back = cipher.open(record).context("tenants/acme/users").using(&users_plan).await?; @@ -915,7 +915,7 @@ impl FieldsBuilder { /// # async fn example() -> Result<(), stack_encrypt::Error> { /// use stack_encrypt::kms::FakeDataKeySource; /// use stack_encrypt::sem::{EqualityTerm, MatchTerms}; - /// use stack_encrypt::{Encrypted, Plan, StackCipher}; + /// use stack_encrypt::{op, Encrypted, Plan, StackCipher}; /// /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; /// let users_plan = Plan::context("users") @@ -928,7 +928,7 @@ impl FieldsBuilder { /// let email: Encrypted<(EqualityTerm, MatchTerms)> = record.take("email")?; /// /// // The target declares an equality index, so the field answers it. - /// let query_value = cipher.query("bob@example.com").using(&users_plan.field("email")?).equality().await?; + /// let query_value = cipher.query("bob@example.com").using(&users_plan.field("email")?).op(op::Eq).await?; /// assert_eq!(query_value, email.terms.0); /// # Ok(()) /// # } diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index abe9b4622..51c2cccca 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -11,9 +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, Supports}; use crate::target::{ ciphertext, Borrowed, CallerContext, DeclaredContext, DecryptField, Decryptable, EncryptFrom, - Equality, Index, Indexes, Owned, Pending, PendingFuture, Select, + Index, Indexes, Owned, Pending, PendingFuture, Select, }; use crate::{Error, KeysetCipher, Label, LabelError, NonEmpty, StackCipher, StackCipherText}; @@ -280,7 +281,7 @@ impl StackCipher { /// Start a chain that derives a query term for `value`, to match terms /// a plan wrote. Name the plan with [`using`](QueryBuilder::using) and - /// the index with `equality()` or `index(..)`. + /// the operator with [`op`](QueryUsing::op): `.op(op::Eq)`. /// /// The value is copied once (`to_owned`), so `query("bob@example.com")` /// queries a `String` field. @@ -625,12 +626,27 @@ pub struct QueryUsing<'a, F, P, K> { } impl<'a, F: 'static, K: 'static> QueryUsing<'a, F, FieldPlan, K> { - /// The equality term: sugar for `index(Equality)`. - pub fn equality(self) -> QueryIndex<'a, F, Equality, K> + /// The query term for the operator `op`, through the index a plan that + /// declares its indexes answers it with ([`DefaultIndex`]): + /// `.op(op::Eq)` is the equality term. The field must have declared + /// that index, with the same options; otherwise awaiting is + /// [`PlanError::IndexNotDeclared`] or [`PlanError::IndexOptions`]. + pub fn op>(self, op: Op) -> QueryOp<'a, F, Op, Op::Via, K> { + self.op_via(op, Op::via()) + } + + /// The query term for `op` through `index`, for a field that declares + /// an index other than the operator's default, such as a match index + /// with its own configuration. + pub fn op_via(self, _op: Op, index: I) -> QueryOp<'a, F, Op, I, K> where - Equality: Index, + Op: Operator, + I: Supports, { - self.index(Equality) + QueryOp { + inner: self.index(index), + op: PhantomData, + } } /// The term of `index`. The field must have declared it, and the query's @@ -652,14 +668,32 @@ impl<'a, F: 'static, K: 'static> QueryUsing<'a, F, FieldPlan, K> { } impl<'a, F: 'static, X: Indexes, K: 'static> QueryUsing<'a, F, ValuePlan>, K> { - /// The equality term, selected from the plan's indexes by type: a plan - /// with no `Equality` index does not compile here. - pub fn equality(self) -> QueryIndex<'a, F, Equality, K> + /// The query term for the operator `op`, through the index a plan that + /// declares its indexes answers it with ([`DefaultIndex`]), selected + /// from the plan's indexes by type: a plan that does not hold that + /// index does not compile here. + pub fn op(self, op: Op) -> QueryOp<'a, F, Op, Op::Via, K> where - Equality: Index, - X: Select, + Op: DefaultIndex, + Op::Via: Clone, + X: Select, { - self.index::() + self.op_via(op, Op::via()) + } + + /// The query term for `op` through the plan's index of the type of + /// `index`, for a plan that holds an index other than the operator's + /// default. The plan's own index is used; `index` names its type. + pub fn op_via(self, _op: Op, _index: I) -> QueryOp<'a, F, Op, I, K> + where + Op: Operator, + I: Supports + Clone, + X: Select, + { + QueryOp { + inner: self.index::(), + op: PhantomData, + } } /// The term of the plan's index of type `I`, selected by type @@ -687,12 +721,30 @@ impl<'a, F: 'static, T, K: 'static> QueryUsing<'a, F, ValuePlan>, K> where Typed: ValueShape, { - /// The equality term: sugar for `index(Equality)`. - pub fn equality(self) -> QueryIndex<'a, F, Equality, K> + /// The query term for the operator `op`, through the index the target + /// type answers it with ([`Answers`]). A target that does not answer + /// `op` does not compile here; one that answers it through an index it + /// does not declare is [`PlanError::IndexNotDeclared`] when awaited. + pub fn op(self, op: Op) -> QueryOp<'a, F, Op, T::Via, K> + where + Op: Operator, + T: Answers, + { + self.op_via(op, T::via()) + } + + /// The query term for `op` through `index`, which the target must + /// declare: for a target with no [`Answers`] impl for `op`, such as + /// an [`Encrypted`](crate::target::Encrypted) tuple of terms. + pub fn op_via(self, _op: Op, index: I) -> QueryOp<'a, F, Op, I, K> where - Equality: Index, + Op: Operator, + I: Supports, { - self.index(Equality) + QueryOp { + inner: self.index(index), + op: PhantomData, + } } /// The term of `index`. The target must declare it @@ -754,6 +806,61 @@ where } } +/// A chain deriving one query term for an operator, ready to await. It +/// resolves to the [`QueryTerm`](crate::op::QueryTerm) the index sends for +/// the operator ([`Supports::Query`]). +pub struct QueryOp<'a, F, Op, I, K> { + inner: QueryIndex<'a, F, I, K>, + op: PhantomData Op>, +} + +impl<'a, F, Op, I, K: 'static> QueryOp<'a, F, Op, I, K> { + /// Run under this keyset, as [`QueryBuilder::keyset`]. + pub fn keyset(mut self, keyset: impl Into>) -> Self { + self.inner = self.inner.keyset(keyset); + self + } + + /// Extend the plan's every context by `parts` for this call, as + /// [`QueryBuilder::extend`]: the same parts the write was extended by. + pub fn extend(mut self, parts: impl Into) -> Self { + self.inner = self.inner.extend(parts); + self + } +} + +impl sealed::Sealed for QueryOp<'_, F, Op, I, K> {} + +impl<'a, F: 'static, Op: Operator, I: Supports + 'static, K: 'static> Operation<'a, K> + for QueryOp<'a, F, Op, I, K> +where + I::Term: MaybeSend, + I::Query: MaybeSend + 'static, +{ + type Output = I::Query; + fn cipher(&self) -> &'a StackCipher { + self.inner.cipher() + } + fn take_keyset(&mut self) -> Option> { + self.inner.take_keyset() + } + fn check(&self) -> Result<(), Error> { + self.inner.check() + } + fn prepare<'p>( + self, + keyset: &'p KeysetCipher<'a, K>, + scoped: bool, + ) -> Pending<'p, Self::Output, K> + where + 'a: 'p, + { + self.inner + .prepare(keyset, scoped) + .map(>::query) + } +} + /// A chain decrypting a stored record, before its plan is named. pub struct OpenBuilder<'a, R, K> { common: Common<'a, K>, @@ -846,6 +953,7 @@ settles! { ['a, S: 'static, K: 'static] EncryptFields<'a, S, K>; ['a, S: ?Sized, P, K] EncryptUsing<'a, S, P, K>; ['a, F, I, K] QueryIndex<'a, F, I, K>; + ['a, F, Op, I, K] QueryOp<'a, F, Op, I, K>; ['a, R, P, K] OpenUsing<'a, R, P, K>; } @@ -869,6 +977,7 @@ opaque_debug! { ['a, F, K] QueryBuilder<'a, F, K>, "QueryBuilder"; ['a, F, P, K] QueryUsing<'a, F, P, K>, "QueryUsing"; ['a, F, I, K] QueryIndex<'a, F, I, K>, "QueryIndex"; + ['a, F, Op, I, K] QueryOp<'a, F, Op, I, K>, "QueryOp"; ['a, R, K] OpenBuilder<'a, R, K>, "OpenBuilder"; ['a, R, P, K] OpenUsing<'a, R, P, K>, "OpenUsing"; } @@ -911,7 +1020,7 @@ impl std::fmt::Debug for All<'_, K, T> { /// ``` /// # async fn example() -> Result<(), stack_encrypt::Error> { /// use stack_encrypt::kms::FakeDataKeySource; -/// use stack_encrypt::{Equality, Plan, StackCipher}; +/// use stack_encrypt::{op, Equality, Plan, StackCipher}; /// /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; /// let email_plan = Plan::context("users/email").with(Equality).build()?; @@ -919,7 +1028,7 @@ impl std::fmt::Debug for All<'_, K, T> { /// let email = String::from("bob@example.com"); /// let (written, query_value) = stack_encrypt::all(( /// cipher.encrypt(&email).using(&email_plan), -/// cipher.query("bob@example.com").using(&email_plan).equality(), +/// cipher.query("bob@example.com").using(&email_plan).op(op::Eq), /// )) /// .await?; /// assert_eq!(written.terms, query_value); diff --git a/packages/stack-encrypt/src/plan/mod.rs b/packages/stack-encrypt/src/plan/mod.rs index 70ffd9f2a..d7fc1512d 100644 --- a/packages/stack-encrypt/src/plan/mod.rs +++ b/packages/stack-encrypt/src/plan/mod.rs @@ -151,7 +151,7 @@ //! # async fn example() -> Result<(), stack_encrypt::Error> { //! use stack_encrypt::kms::FakeDataKeySource; //! use stack_encrypt::sem::{EqualityTerm, MatchTerms}; -//! use stack_encrypt::{Encrypted, Equality, Match, Ore, Plan, StackCipher}; +//! use stack_encrypt::{op, Encrypted, Equality, Match, Ore, Plan, StackCipher}; //! //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let user = User { email: "bob@example.com".into(), age: 34, notes: "hi".into(), id: 42 }; @@ -170,7 +170,7 @@ //! //! // Query: derived under the label the write used, so it matches. //! let email_plan = users_plan.field("email")?; -//! let query_value = cipher.query("bob@example.com").using(&email_plan).equality().await?; +//! let query_value = cipher.query("bob@example.com").using(&email_plan).op(op::Eq).await?; //! assert_eq!(query_value, stored.terms.0); //! //! // Read: every field that can come back. @@ -222,7 +222,7 @@ //! use stack_encrypt::kms::FakeDataKeySource; //! use stack_encrypt::plan::pick; //! use stack_encrypt::sem::EqualityTerm; -//! use stack_encrypt::{Encrypted, EncryptFrom, Plan, StackCipher, StackCipherText}; +//! use stack_encrypt::{op, Encrypted, EncryptFrom, Plan, StackCipher, StackCipherText}; //! //! struct User { //! email: String, @@ -252,7 +252,7 @@ //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let user = User { email: "bob@example.com".into(), nickname: "bob".into() }; //! let record: EncryptedUser = cipher.default_keyset().encrypt_as(&user, ().into()).await?; -//! let query_value = cipher.query("bob@example.com").using(&by_hand.field("email")?).equality().await?; +//! let query_value = cipher.query("bob@example.com").using(&by_hand.field("email")?).op(op::Eq).await?; //! assert_eq!(record.email.terms, query_value); //! # Ok(()) //! # } @@ -301,7 +301,7 @@ //! | `fields()` | each field picked out of the value (by name, or by a picker's accessor as `project` does), `zip`ped | //! | `extend(parts)` | the [`DeclaredContext`](crate::target::DeclaredContext) the description runs under | //! | `using(&plan)` | [`KeysetCipher::run`](crate::KeysetCipher::run) of [`Plan::encryption`] | -//! | `query(v).using(..).equality()` | the selected index's operation alone, `.under(label)` | +//! | `query(v).using(..).op(op)`, `.index(x)` | the answering index's operation alone, `.under(label)`; for `op`, mapped to its query term ([`Supports::query`](crate::op::Supports::query)) | //! | `open(record).using(&plan)` | [`open`](crate::target::open) per field, run by `run_decryption` | //! | `.await`, [`all`] | `Pending::zip`, then one settle | //! @@ -321,7 +321,7 @@ pub use build::{FieldKind, FieldPlan, FieldsBuilder, IntoLabel, Opens, Plan, Pla pub use chain::{ all, All, Batch, EncryptBuilder, EncryptFields, EncryptIndexed, EncryptUsing, EncryptWithContext, KeysetChoice, OpenBuilder, OpenUsing, Operation, PlanKms, QueryBuilder, - QueryIndex, QueryUsing, + QueryIndex, QueryOp, QueryUsing, }; pub use error::PlanError; pub use field_ref::{pick, FieldRef}; diff --git a/packages/stack-encrypt/src/plan/value.rs b/packages/stack-encrypt/src/plan/value.rs index c4dfe4dad..76ae45f23 100644 --- a/packages/stack-encrypt/src/plan/value.rs +++ b/packages/stack-encrypt/src/plan/value.rs @@ -34,7 +34,7 @@ impl Plan<(), ()> { /// # async fn example() -> Result<(), stack_encrypt::Error> { /// use stack_encrypt::kms::FakeDataKeySource; /// use stack_encrypt::sem::EqualityTerm; - /// use stack_encrypt::{Equality, Ore, Plan, StackCipher, StackCipherText}; + /// use stack_encrypt::{op, Equality, Ore, Plan, StackCipher, StackCipherText}; /// /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; /// @@ -55,7 +55,7 @@ impl Plan<(), ()> { /// .query("bob@example.com") /// .context("users/email") /// .using(&email_plan) - /// .equality() + /// .op(op::Eq) /// .await?; /// assert_eq!(query_value, eq); /// let back = cipher.open((c, eq)).context("users/email").using(&email_plan).await?; diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index e0d383d9e..64f8b103f 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -633,6 +633,68 @@ impl PartialEq for MatchTerms { impl Eq for MatchTerms {} +/// The query term of a match index: a Bloom filter over the *needle's* +/// tokens, where the stored [`MatchTerms`] is a filter over the whole +/// value's. A row matches when its stored filter holds every position of +/// the query's ([`MatchTerms::contains`]). +/// +/// The two are different types because they are different things, even +/// where their bytes share an encoding: a query is never stored, and a +/// stored filter is never sent as a query. ADR-0009 records why. A +/// `MatchQuery` comes from a query chain ([`op::Matches`](crate::op::Matches)), +/// never from a target. +pub struct MatchQuery { + terms: MatchTerms, +} + +impl MatchQuery { + /// The query term of the filter `terms`, derived over a needle. + pub(crate) fn from_terms(terms: MatchTerms) -> Self { + Self { terms } + } + + /// The set positions of the needle's filter, sorted ascending, no + /// duplicates: what the server tests a stored filter for. + pub fn positions(&self) -> &[u16] { + self.terms.positions() + } + + /// The transport byte encoding, as [`MatchTerms::to_bytes`]. + pub fn to_bytes(&self) -> Vec { + self.terms.to_bytes() + } + + /// The needle's filter, for a caller that tests stored filters itself + /// ([`MatchTerms::contains`]). + pub fn filter(&self) -> &MatchTerms { + &self.terms + } +} + +impl fmt::Debug for MatchQuery { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("MatchQuery") + .field("positions", &self.terms.positions) + .finish() + } +} + +impl Clone for MatchQuery { + fn clone(&self) -> Self { + Self { + terms: self.terms.clone(), + } + } +} + +impl PartialEq for MatchQuery { + fn eq(&self, other: &Self) -> bool { + self.terms == other.terms + } +} + +impl Eq for MatchQuery {} + /// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into /// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, /// masked to the filter size. diff --git a/packages/stack-encrypt/tests/derive_plan.rs b/packages/stack-encrypt/tests/derive_plan.rs index 408543f82..018f35cda 100644 --- a/packages/stack-encrypt/tests/derive_plan.rs +++ b/packages/stack-encrypt/tests/derive_plan.rs @@ -11,6 +11,7 @@ use std::sync::atomic::Ordering as AtomicOrdering; use common::{counting_cipher, recording_cipher, stack_cipher}; use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::op; use stack_encrypt::plan::{pick, FieldValues}; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OpeTerm, OreTerm}; use stack_encrypt::target::{ @@ -131,7 +132,7 @@ async fn a_derived_record_as_a_typed_field_answers_its_queries() { let query_value = cipher .query("bob@example.com") .using(&users_plan.field("email").unwrap()) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query_value, email.hm); diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs new file mode 100644 index 000000000..e58130d02 --- /dev/null +++ b/packages/stack-encrypt/tests/op.rs @@ -0,0 +1,389 @@ +//! Operator-typed queries (`stack_encrypt::op`, ADR-0009): a query names an +//! operator, the plan finds the index that answers it, and the term that +//! comes back is the query term for that operator, which for a match index +//! is not the stored term. +mod common; + +use common::stack_cipher; +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::op::{self, MatchCase}; +use stack_encrypt::plan::{Field, FieldSchema, Fields, PlanError}; +use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchQuery, MatchTerms}; +use stack_encrypt::target::{Encrypted, IndexSpec}; +use stack_encrypt::{Equality, Error, Match, Plan}; + +/// A match configuration that keeps case, so its index answers only a +/// case-sensitive `Matches`. +struct Exact; +impl MatchConfig for Exact { + fn options() -> MatchOptions { + MatchOptions { + downcase: false, + ..Default::default() + } + } +} +impl MatchCase for Exact { + type Case = op::Sensitive; +} + +#[derive(Clone)] +struct Person { + email: String, + name: String, +} + +impl Fields for Person { + fn field_names(&self) -> Vec<&str> { + vec!["email", "name"] + } + fn schema() -> Option> { + Some(vec![ + FieldSchema::of::("email"), + FieldSchema::of::("name"), + ]) + } +} +impl Field for Person { + fn field(&self, name: &str) -> Option<&String> { + match name { + "email" => Some(&self.email), + "name" => Some(&self.name), + _ => None, + } + } +} + +fn person() -> Person { + Person { + email: "bob@example.com".into(), + name: "Alice Nguyen".into(), + } +} + +fn people_plan() -> Plan { + Plan::context("people") + .fields() + .encrypt_index::("email", (Equality, Match::default())) + .encrypt_index::("name", Match::::new()) + .build() + .expect("a valid plan") +} + +fn plan_error(result: Result) -> PlanError { + match result { + Err(Error::Plan(error)) => error, + Err(other) => panic!("expected a plan error, got {other:?}"), + Ok(_) => panic!("expected a plan error, got a value"), + } +} + +#[tokio::test] +async fn an_eq_query_is_the_stored_equality_term() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let mut row = cipher.encrypt(&person()).using(&plan).await.unwrap(); + let email: Encrypted<(EqualityTerm, MatchTerms)> = row.take("email").unwrap(); + + let query: EqualityTerm = cipher + .query("bob@example.com") + .using(&plan.field("email").unwrap()) + .op(op::Eq) + .await + .unwrap(); + assert_eq!(query, email.terms.0, "HMAC equality sends what it stores"); +} + +#[tokio::test] +async fn a_matches_query_is_a_filter_over_the_needle_that_the_stored_filter_holds() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let mut row = cipher.encrypt(&person()).using(&plan).await.unwrap(); + let email: Encrypted<(EqualityTerm, MatchTerms)> = row.take("email").unwrap(); + let stored = email.terms.1; + let email_plan = plan.field("email").unwrap(); + + let query: MatchQuery = cipher + .query("BOB") + .using(&email_plan) + .op(op::Matches::new()) + .await + .unwrap(); + assert!( + stored.contains(query.filter()), + "a case-folding index matches BOB" + ); + assert!( + query.positions().len() < stored.positions().len(), + "the query is the needle's filter, not the value's" + ); + + // The operator derives the term the index does, under the same label. + let by_index: MatchTerms = cipher + .query("BOB") + .using(&email_plan) + .index(Match::default()) + .await + .unwrap(); + assert_eq!(query.to_bytes(), by_index.to_bytes()); + assert_eq!(query.positions(), by_index.positions()); + assert_eq!(query.filter(), &by_index); + assert_eq!(query.clone(), query); + + let elsewhere: MatchQuery = cipher + .query("zzz") + .using(&email_plan) + .op(op::Matches::new()) + .await + .unwrap(); + assert!(!stored.contains(elsewhere.filter())); + assert_ne!(elsewhere, query); +} + +#[tokio::test] +async fn a_match_index_with_its_own_configuration_is_named_with_op_via() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let mut row = cipher.encrypt(&person()).using(&plan).await.unwrap(); + let name: Encrypted> = row.take("name").unwrap(); + let name_plan = plan.field("name").unwrap(); + + let exact = |needle: &'static str| { + cipher + .query(needle) + .using(&name_plan) + .op_via(op::Matches::::new(), Match::::new()) + }; + let hit: MatchQuery = exact("Nguyen").await.unwrap(); + assert!(name.terms.contains(hit.filter())); + let miss: MatchQuery = exact("nguyen").await.unwrap(); + assert!( + !name.terms.contains(miss.filter()), + "a case-sensitive index does not fold the needle" + ); + + // The operator's default index is the default configuration, which + // this field does not declare. + assert_eq!( + plan_error( + cipher + .query("Nguyen") + .using(&name_plan) + .op(op::Matches::new()) + .await + ), + PlanError::IndexOptions { + field: "name".into(), + declared: IndexSpec::Match(Exact::options()), + asked: IndexSpec::Match(MatchOptions::default()), + } + ); +} + +#[tokio::test] +async fn an_operator_the_field_has_no_index_for_is_refused_when_awaited() { + let cipher = stack_cipher().await; + let plan = people_plan(); + assert_eq!( + plan_error( + cipher + .query("Alice") + .using(&plan.field("name").unwrap()) + .op(op::Eq) + .await + ), + PlanError::IndexNotDeclared { + field: "name".into(), + index: "eq" + } + ); +} + +#[tokio::test] +async fn a_one_value_plan_answers_from_its_indexes_by_type() { + let cipher = stack_cipher().await; + let plan = Plan::context("users/email") + .with::((Equality, Match::default())) + .build() + .unwrap(); + let stored: Encrypted<(EqualityTerm, MatchTerms)> = cipher + .encrypt(&"bob@example.com".to_owned()) + .using(&plan) + .await + .unwrap(); + + let eq = cipher + .query("bob@example.com") + .using(&plan) + .op(op::Eq) + .await + .unwrap(); + assert_eq!(eq, stored.terms.0); + let matches = cipher + .query("example") + .using(&plan) + .op(op::Matches::new()) + .await + .unwrap(); + assert!(stored.terms.1.contains(matches.filter())); + + let exact_plan = Plan::context("users/name") + .with::(Match::::new()) + .build() + .unwrap(); + let name: Encrypted> = cipher + .encrypt(&"Alice".to_owned()) + .using(&exact_plan) + .await + .unwrap(); + let query = cipher + .query("Ali") + .using(&exact_plan) + .op_via(op::Matches::::new(), Match::::new()) + .await + .unwrap(); + assert!(name.terms.contains(query.filter())); +} + +#[tokio::test] +async fn a_target_answers_an_operator_through_the_index_it_names() { + let cipher = stack_cipher().await; + let eq_plan = Plan::value::() + .context("users/email") + .encrypt_into::() + .build() + .unwrap(); + let stored: EqualityTerm = cipher + .encrypt(&"bob@example.com".to_owned()) + .using(&eq_plan) + .await + .unwrap(); + let query = cipher + .query("bob@example.com") + .using(&eq_plan) + .op(op::Eq) + .await + .unwrap(); + assert_eq!(query, stored); + + let match_plan = Plan::value::() + .context("users/email") + .encrypt_into::() + .build() + .unwrap(); + let stored: MatchTerms = cipher + .encrypt(&"bob@example.com".to_owned()) + .using(&match_plan) + .await + .unwrap(); + let query = cipher + .query("bob") + .using(&match_plan) + .op(op::Matches::new()) + .await + .unwrap(); + assert!(stored.contains(query.filter())); +} + +#[tokio::test] +async fn a_target_with_several_terms_names_the_index_with_op_via() { + let cipher = stack_cipher().await; + let plan = Plan::value::() + .context("users/email") + .encrypt_into::>() + .build() + .unwrap(); + let stored = cipher + .encrypt(&"bob@example.com".to_owned()) + .using(&plan) + .await + .unwrap(); + let eq = cipher + .query("bob@example.com") + .using(&plan) + .op_via(op::Eq, Equality) + .await + .unwrap(); + assert_eq!(eq, stored.terms.0); + let matches = cipher + .query("bob") + .using(&plan) + .op_via(op::Matches::new(), Match::default()) + .await + .unwrap(); + assert!(stored.terms.1.contains(matches.filter())); + + // An index the target does not declare is refused when awaited. + let one_term = Plan::value::() + .context("users/email") + .encrypt_into::>() + .build() + .unwrap(); + assert_eq!( + plan_error( + cipher + .query("bob") + .using(&one_term) + .op_via(op::Matches::new(), Match::default()) + .await + ), + PlanError::IndexNotDeclared { + field: "the value".into(), + index: "match" + } + ); + let eq = cipher.query("bob").using(&one_term).op(op::Eq).await; + assert!(eq.is_ok(), "one term answers what the term answers"); +} + +#[tokio::test] +async fn operator_queries_settle_together_and_extend_like_the_write() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let mut row = cipher + .encrypt(&person()) + .using(&plan) + .extend(7u64) + .await + .unwrap(); + let email: Encrypted<(EqualityTerm, MatchTerms)> = row.take("email").unwrap(); + let email_plan = plan.field("email").unwrap(); + + let (eq, matches) = stack_encrypt::all(( + cipher + .query("bob@example.com") + .using(&email_plan) + .op(op::Eq) + .extend(7u64), + cipher + .query("bob") + .using(&email_plan) + .op(op::Matches::new()) + .extend(7u64), + )) + .await + .unwrap(); + assert_eq!(eq, email.terms.0); + assert!(email.terms.1.contains(matches.filter())); + + let unextended = cipher + .query("bob@example.com") + .using(&email_plan) + .op(op::Eq) + .await + .unwrap(); + assert_ne!( + unextended, email.terms.0, + "the query is derived under the extended label" + ); +} + +#[tokio::test] +async fn a_query_chain_debugs_without_its_value() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let email_plan = plan.field("email").unwrap(); + let secret = "bob@example.com"; + let shown = format!("{:?}", cipher.query(secret).using(&email_plan).op(op::Eq)); + assert_eq!(shown, "QueryOp { .. }"); +} diff --git a/packages/stack-encrypt/tests/plan.rs b/packages/stack-encrypt/tests/plan.rs index 48008f65f..76133e69d 100644 --- a/packages/stack-encrypt/tests/plan.rs +++ b/packages/stack-encrypt/tests/plan.rs @@ -8,6 +8,7 @@ use std::sync::atomic::Ordering; use common::{counting_cipher, loads_counting_cipher, recording_cipher, stack_cipher}; use stack_encrypt::kms::{FakeDataKeySource, IdentifiedBy}; +use stack_encrypt::op; use stack_encrypt::plan::{ Field, FieldKind, FieldSchema, FieldValues, Fields, Operation, PlanError, }; @@ -291,7 +292,7 @@ async fn a_query_derives_under_the_label_the_write_used() { let eq: EqualityTerm = cipher .query("bob@example.com") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!( @@ -317,7 +318,7 @@ async fn a_query_derives_under_the_label_the_write_used() { let other = cipher .query("eve@example.com") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_ne!(other, email.terms.0); @@ -341,7 +342,7 @@ async fn a_query_extended_like_the_write_matches_it() { Some(parts) => chain.extend(parts), None => chain, }; - chain.equality() + chain.op(op::Eq) }; assert_eq!(query(Some(7)).await.unwrap(), email.terms.0); assert_ne!(query(None).await.unwrap(), email.terms.0); @@ -363,14 +364,14 @@ async fn a_query_against_an_undeclared_index_or_type_is_refused() { } ); assert_eq!( - plan_error(cipher.query("x").using(¬es_plan).equality().await), + plan_error(cipher.query("x").using(¬es_plan).op(op::Eq).await), PlanError::IndexNotDeclared { field: "notes".into(), index: "eq" } ); assert_eq!( - plan_error(cipher.query(&34u64).using(&age_plan).equality().await), + plan_error(cipher.query(&34u64).using(&age_plan).op(op::Eq).await), PlanError::FieldType { field: "age".into(), expected: "u32" @@ -405,7 +406,7 @@ async fn a_one_value_plan_writes_queries_by_type_and_reads_back() { let eq = cipher .query(&34u32) .using(&age_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(eq, out.terms.0); @@ -510,7 +511,7 @@ async fn a_one_value_plan_extended_on_write_needs_the_extension_to_query_and_ope assert_eq!(sent.lock().unwrap().generated(), ["(users/age)/7u64"]); let query = |parts: Option| { - let chain = cipher.query(&34u32).using(&age_plan).equality(); + let chain = cipher.query(&34u32).using(&age_plan).op(op::Eq); match parts { Some(parts) => chain.extend(parts), None => chain, @@ -563,7 +564,7 @@ async fn an_index_only_field_matches_its_query_and_mints_no_data_key() { let query = cipher .query("bob@example.com") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query, eq, "the query derives under the write's label"); @@ -967,7 +968,7 @@ async fn a_passthrough_name_need_not_be_a_label_segment() { cipher .query(&1u64) .using(&passthrough_plan) - .equality() + .op(op::Eq) .await ), PlanError::IndexNotDeclared { @@ -1262,7 +1263,7 @@ async fn all_settles_several_chains_in_one_request() { cipher .query("bob@example.com") .using(&email_plan) - .equality(), + .op(op::Eq), )) .await .unwrap(); @@ -1749,7 +1750,7 @@ async fn a_chain_shows_no_value() { format!("{:?}", cipher.encrypt(&user()).using(&users_plan)), format!("{:?}", cipher.query(&secret)), format!("{:?}", cipher.query(&secret).using(&email_plan)), - format!("{:?}", cipher.query(&secret).using(&email_plan).equality()), + format!("{:?}", cipher.query(&secret).using(&email_plan).op(op::Eq)), format!("{:?}", cipher.open(secret.clone())), format!("{:?}", cipher.open(FieldValues::new()).using(&users_plan)), format!( @@ -1874,14 +1875,14 @@ async fn every_chain_honours_its_named_keyset() { let under_tenant = cipher .query("bob@example.com") .using(&email_plan) - .equality() + .op(op::Eq) .keyset("tenant") .await .unwrap(); let under_default = cipher .query("bob@example.com") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(under_tenant, email.terms.0); diff --git a/packages/stack-encrypt/tests/plan_grammar.rs b/packages/stack-encrypt/tests/plan_grammar.rs index e88397b63..100c20f72 100644 --- a/packages/stack-encrypt/tests/plan_grammar.rs +++ b/packages/stack-encrypt/tests/plan_grammar.rs @@ -12,6 +12,7 @@ use std::sync::atomic::Ordering; use common::{counting_cipher, loads_counting_cipher, recording_cipher, stack_cipher}; use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::op; use stack_encrypt::plan::{pick, Field, FieldKind, FieldValues, Fields, Opens, PlanError, Runs}; use stack_encrypt::sem::{ EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, Tokenizer, @@ -144,7 +145,7 @@ async fn a_plan_built_without_a_context_takes_the_calls() { .query("bob@example.com") .context("tenants/acme") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!( @@ -155,7 +156,7 @@ async fn a_plan_built_without_a_context_takes_the_calls() { .query("bob@example.com") .context("tenants/globex") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_ne!(elsewhere, stored.terms); @@ -187,7 +188,7 @@ async fn a_plan_run_with_no_context_is_refused_before_any_key_request() { cipher .query("bob@example.com") .using(&users_plan.field("email").unwrap()) - .equality() + .op(op::Eq) .await ), PlanError::NoContext @@ -210,7 +211,7 @@ async fn a_plan_run_with_no_context_is_refused_before_any_key_request() { PlanError::NoContext ); assert_eq!( - plan_error(cipher.query(&34u32).using(&age_plan).equality().await), + plan_error(cipher.query(&34u32).using(&age_plan).op(op::Eq).await), PlanError::NoContext ); let age = cipher @@ -292,7 +293,7 @@ async fn a_context_given_twice_is_refused_at_the_call_before_any_key_request() { .query("bob@example.com") .context("users") .using(&users_plan.field("email").unwrap()) - .equality() + .op(op::Eq) .await ), two("the plan", "the call") @@ -342,7 +343,7 @@ async fn a_context_given_twice_is_refused_at_the_call_before_any_key_request() { .query(&34u32) .context("users/age") .using(&age_plan) - .equality() + .op(op::Eq) .await ), two("the plan", "the call") @@ -368,7 +369,7 @@ async fn a_call_context_that_is_not_a_label_is_refused() { .query("bob@example.com") .context("a//b") .using(&users_plan.field("email").unwrap()) - .equality() + .op(op::Eq) .await ))); assert!(bad(plan_error( @@ -839,7 +840,7 @@ async fn a_context_field_is_the_context_of_every_other_field() { .query("bob@example.com") .context("tenants/acme") .using(&records_plan.field("email").unwrap()) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query_value, stored.terms); @@ -1130,7 +1131,7 @@ async fn a_typed_field_answers_the_queries_its_target_declares_and_no_other() { let query_value = cipher .query("bob@example.com") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query_value, stored.terms.0); @@ -1159,7 +1160,7 @@ async fn a_typed_field_answers_the_queries_its_target_declares_and_no_other() { cipher .query(&34u32) .using(&users_plan.field("age").unwrap()) - .equality() + .op(op::Eq) .await ), PlanError::IndexNotDeclared { @@ -1185,7 +1186,7 @@ async fn a_typed_field_of_terms_alone_does_not_come_back() { let query_value = cipher .query("bob@example.com") .using(&users_plan.field("email").unwrap()) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query_value, term); @@ -1334,7 +1335,7 @@ async fn a_one_value_typed_plan_answers_its_targets_queries_and_no_other() { let query_value = cipher .query("bob@example.com") .using(&email_plan) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query_value, hm); @@ -1632,7 +1633,7 @@ async fn a_pinned_identity_rescues_a_field_name_that_is_not_plain() { let query_value = cipher .query(&21u32) .using(&readings_plan.field("0").unwrap()) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query_value, value.terms); @@ -1686,7 +1687,7 @@ async fn a_pinned_identity_keys_a_call_context_plan_for_query_and_open() { .query(&21u32) .context("reading") .using(&readings_plan.field("0").unwrap()) - .equality() + .op(op::Eq) .await .unwrap(); assert_eq!(query_value, stored.terms); diff --git a/packages/stack-encrypt/tests/ui/op_case_mismatch.rs b/packages/stack-encrypt/tests/ui/op_case_mismatch.rs new file mode 100644 index 000000000..ab1c0c2e4 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_case_mismatch.rs @@ -0,0 +1,18 @@ +//! The default match index folds case, so it answers only a +//! case-insensitive `Matches`: asking it for a case-sensitive one does not +//! compile, rather than quietly folding the needle. +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::{op, Match, Plan, StackCipher}; + +async fn query(cipher: &StackCipher) { + let name_plan = Plan::context("users/name") + .with::(Match::default()) + .build() + .unwrap(); + let _ = cipher + .query("Ali") + .using(&name_plan) + .op_via(op::Matches::::new(), Match::default()); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_case_mismatch.stderr b/packages/stack-encrypt/tests/ui/op_case_mismatch.stderr new file mode 100644 index 000000000..af453429e --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_case_mismatch.stderr @@ -0,0 +1,26 @@ +error[E0308]: mismatched types + --> tests/ui/op_case_mismatch.rs:15:17 + | +15 | .op_via(op::Matches::::new(), Match::default()); + | ------ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Matches`, found `Matches` + | | + | arguments to this method are incorrect + | + = note: expected struct `stack_encrypt::op::Matches` + found struct `stack_encrypt::op::Matches` +help: the return type of this call is `stack_encrypt::op::Matches` due to the type of the argument passed + --> tests/ui/op_case_mismatch.rs:12:13 + | +12 | let _ = cipher + | _____________^ +13 | | .query("Ali") +14 | | .using(&name_plan) +15 | | .op_via(op::Matches::::new(), Match::default()); + | |_________________-----------------------------------__________________^ + | | + | this argument influences the return type of `op_via` +note: method defined here + --> src/plan/chain.rs + | + | pub fn op_via(self, _op: Op, _index: I) -> QueryOp<'a, F, Op, I, K> + | ^^^^^^ diff --git a/packages/stack-encrypt/tests/ui/op_matches_on_integer.rs b/packages/stack-encrypt/tests/ui/op_matches_on_integer.rs new file mode 100644 index 000000000..b92c7917c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_matches_on_integer.rs @@ -0,0 +1,11 @@ +//! `Matches` is text search: a field of integers has no index that answers +//! it, so the query does not compile. +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::{op, Equality, Plan, StackCipher}; + +async fn query(cipher: &StackCipher) { + let age_plan = Plan::context("users/age").with::(Equality).build().unwrap(); + let _ = cipher.query(&34u32).using(&age_plan).op(op::Matches::new()); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr b/packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr new file mode 100644 index 000000000..966ebf8f9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr @@ -0,0 +1,21 @@ +error[E0271]: type mismatch resolving `>::Via == Equality` + --> tests/ui/op_matches_on_integer.rs:8:51 + | +8 | let _ = cipher.query(&34u32).using(&age_plan).op(op::Matches::new()); + | ^^ expected `Equality`, found `Match` + | + = note: expected struct `stack_encrypt::Equality` + found struct `stack_encrypt::Match` + +error[E0277]: the trait bound `u32: AsRef` is not satisfied + --> tests/ui/op_matches_on_integer.rs:8:51 + | +8 | let _ = cipher.query(&34u32).using(&age_plan).op(op::Matches::new()); + | ^^ the trait `AsRef` is not implemented for `u32` + | +help: the trait `DefaultIndex` is implemented for `stack_encrypt::op::Matches` + --> src/op.rs + | + | impl> DefaultIndex for Matches<::Case> { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: required for `stack_encrypt::op::Matches` to implement `DefaultIndex` diff --git a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs new file mode 100644 index 000000000..0b0bdc6e4 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs @@ -0,0 +1,16 @@ +//! A target answers only the operators its type implements `Answers` for: +//! an equality term is not searchable text. +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::{op, Plan, StackCipher}; + +async fn query(cipher: &StackCipher) { + let email_plan = Plan::value::() + .context("users/email") + .encrypt_into::() + .build() + .unwrap(); + let _ = cipher.query("bob").using(&email_plan).op(op::Matches::new()); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr new file mode 100644 index 000000000..3a2dacda9 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr @@ -0,0 +1,22 @@ +error[E0308]: mismatched types + --> tests/ui/op_target_does_not_answer.rs:13:55 + | +13 | let _ = cipher.query("bob").using(&email_plan).op(op::Matches::new()); + | -- ^^^^^^^^^^^^^^^^^^ expected `Eq`, found `Matches<_>` + | | + | arguments to this method are incorrect + | + = note: expected struct `stack_encrypt::op::Eq` + found struct `stack_encrypt::op::Matches<_>` +help: the return type of this call is `stack_encrypt::op::Matches<_>` due to the type of the argument passed + --> tests/ui/op_target_does_not_answer.rs:13:13 + | +13 | let _ = cipher.query("bob").using(&email_plan).op(op::Matches::new()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^------------------^ + | | + | this argument influences the return type of `op` +note: method defined here + --> src/plan/chain.rs + | + | pub fn op(self, op: Op) -> QueryOp<'a, F, Op, T::Via, K> + | ^^ diff --git a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs index 16671c333..4a51aa731 100644 --- a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs +++ b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs @@ -1,6 +1,7 @@ //! The chain shapes that must compile: a value with a context, indexed, //! field by field, a saved plan over a value and a collection, a query and //! an opening, and several chains batched. +use stack_encrypt::op; use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::plan::{Field, Fields}; use stack_encrypt::{Equality, Error, Match, Ore, Plan, StackCipher}; @@ -46,14 +47,18 @@ async fn chains(cipher: &StackCipher, person: &Person) -> Res let row = cipher.encrypt(person).using(&people_plan).await?; let _ = cipher.open(row).using(&people_plan).await?; let email_plan = people_plan.field("email")?; - let _ = cipher.query("bob@example.com").using(&email_plan).equality().await?; + let _ = cipher.query("bob@example.com").using(&email_plan).op(op::Eq).await?; + let name_plan = Plan::context("people/name") + .with::(stack_encrypt::Match::default()) + .build()?; + let _ = cipher.query("ali").using(&name_plan).op(op::Matches::new()).await?; let age_plan = Plan::context("people/age").with((Equality, Ore)).build()?; let ages = vec![1u32, 2]; let _ = cipher.encrypt(&ages).using(&age_plan).await?; let _ = cipher.query(&3u32).using(&age_plan).index::().await?; let _ = stack_encrypt::all(( cipher.encrypt(person).using(&people_plan), - cipher.query(&3u32).using(&age_plan).equality(), + cipher.query(&3u32).using(&age_plan).op(op::Eq), )) .await?; Ok(()) diff --git a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs index 9a53fb472..ce1440102 100644 --- a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs +++ b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs @@ -1,11 +1,12 @@ //! A one-value plan selects a query's index by type, so asking it for an //! index it does not hold does not compile. +use stack_encrypt::op; use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::{Ore, Plan, StackCipher}; async fn query(cipher: &StackCipher) { let age_plan = Plan::context("users/age").with::(Ore).build().unwrap(); - let _ = cipher.query(&34u32).using(&age_plan).equality(); + let _ = cipher.query(&34u32).using(&age_plan).op(op::Eq); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr index 8cb4e2b22..3a5c76063 100644 --- a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr +++ b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr @@ -1,24 +1,5 @@ -error[E0277]: the trait bound `stack_encrypt::Ore: Select` is not satisfied - --> tests/ui/plan_query_undeclared_index.rs:8:51 +error[E0271]: type mismatch resolving `>::Via == Ore` + --> tests/ui/plan_query_undeclared_index.rs:9:51 | -8 | let _ = cipher.query(&34u32).using(&age_plan).equality(); - | ^^^^^^^^ the trait `Select` is not implemented for `stack_encrypt::Ore` - | - = help: the following other types implement trait `Select`: - `(A, B)` implements `Select>` - `(A, B)` implements `Select>` - `(A, B, C)` implements `Select>` - `(A, B, C)` implements `Select>` - `(A, B, C)` implements `Select>` - `(A, B, C, D)` implements `Select>` - `(A, B, C, D)` implements `Select>` - `(A, B, C, D)` implements `Select>` - and $N others -note: required by a bound in `QueryUsing::<'a, F, ValuePlan>, K>::equality` - --> src/plan/chain.rs - | - | pub fn equality(self) -> QueryIndex<'a, F, Equality, K> - | -------- required by a bound in this associated function -... - | X: Select, - | ^^^^^^^^^^^^^^^^^^^^ required by this bound in `QueryUsing::<'a, F, ValuePlan>, K>::equality` +9 | let _ = cipher.query(&34u32).using(&age_plan).op(op::Eq); + | ^^ expected `Ore`, found `Equality` From 44b2e2c1e33ef474afd6d447acf154428290adc6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 13 Oct 2026 22:58:14 +1100 Subject: [PATCH 2/4] fix(stack-encrypt): address the review of the operator-typed query - A match configuration whose `MatchCase` disagrees with its options is refused when the query is awaited, with the new `PlanError::MatchCase`, instead of answering in a case its index does not fold. `Supports` gains `check`. - `Supports::query` takes an `op::QueryToken` only this crate can make, so no caller can turn a stored term into a query term by hand. - `MatchTerms::matches(&MatchQuery)` tests a stored filter against a query; `MatchQuery::filter` is crate-only. - Docs say what is a compile error and what is refused when awaited: on a field of a fields plan, which is data, an undeclared index, other match options and a mislabelled case are runtime refusals. A typed field of a fields plan answers through the operator's default index, not its type's `Answers`; the ADR, `DefaultIndex` and the CHANGELOG say so. - `QueryTerm`'s doc no longer calls every term that refines nothing exact before `Probabilistic` exists. A broken ADR sentence is fixed. - `Operator::NAME` is gone (nothing read it); `Matches` is `Hash`. - Tests: the case mismatch, a refused query loading no named keyset (kills the `QueryOp::check` mutant), a query under a named keyset, and `MatchQuery`'s `Debug`. The three `via` mutants and `Matches::clone`'s are equivalent (unit structs) and are excluded in .cargo/mutants.toml. Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB --- .cargo/mutants.toml | 4 + packages/stack-encrypt/CHANGELOG.md | 16 ++- packages/stack-encrypt/CONTEXT.md | 6 +- packages/stack-encrypt/src/codes.rs | 4 + packages/stack-encrypt/src/dynamic/record.rs | 17 ++- packages/stack-encrypt/src/op.rs | 90 ++++++++---- packages/stack-encrypt/src/plan/chain.rs | 33 +++-- packages/stack-encrypt/src/plan/error.rs | 20 +++ packages/stack-encrypt/src/sem/mod.rs | 16 ++- packages/stack-encrypt/tests/op.rs | 141 +++++++++++++++++-- 10 files changed, 280 insertions(+), 67 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index 1fae9ba39..a1d1e4eba 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -72,6 +72,10 @@ exclude_re = [ 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace client_key_provider -> EnvKeyProvider with Default::default\(\)$', 'stack-encrypt/src/target/pending\.rs:443:9: replace >::into_future -> Self::IntoFuture with Default::default\(\)$', # stack-encrypt — equivalent. + # An operator and an operator's index are unit structs, whose `Default` + # is the value `clone` and `via` already return. + 'stack-encrypt/src/op(/mod)?\.rs:\d+:\d+: replace >::clone -> Self with Default::default\(\)$', + 'stack-encrypt/src/op(/mod)?\.rs:\d+:\d+: replace for EqualityTerm|DefaultIndex for Eq|DefaultIndex for Matches<::Case>)>::via -> (Equality|Match) with Default::default\(\)$', 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace StackCipher::builder -> StackCipherBuilder with Default::default\(\)$', 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace ::options -> MatchOptions with Default::default\(\)$', # Both unit-context conversions explicitly return Self::default(). diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 315d7bd70..81719e35d 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -15,12 +15,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 operators it answers (`op::Supports`), a target type names the index that answers each one for it (`op::Answers`), and a plan that declares indexes uses the operator's default index (`op::DefaultIndex`). `.op_via(op, index)` - names another index, such as a match index with its own configuration. An - operator the plan cannot answer is a compile error; a case-sensitive - `Matches` against a case-folding index is one too. + names another index, such as a match index with its own configuration. What + the plan's type shows is checked at compile time: a match query on an + integer, an operator a target does not answer, and on a one-value plan an + index it does not hold or a case-sensitive `Matches` against a + case-folding index. On a field of a fields plan those are refused when the + query is awaited, before any key is requested, as is a match configuration + whose `op::MatchCase` disagrees with its options (`PlanError::MatchCase`). - **A match query has its own type, `sem::MatchQuery`.** It is the filter over - the needle that the server tests a stored `MatchTerms` for, and it comes - only from a query. + the needle; test a stored filter against it with + `MatchTerms::matches(&query)`. It comes only from a query chain: + `op::Supports::query` takes an `op::QueryToken` that only this crate can + make. - **Errors now expose stable codes, help text, and structured details.** All error types implement `miette::Diagnostic` with codes such as diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 9a8b342c8..3cfab23a8 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -234,14 +234,16 @@ package's method name, not the concept) What a query asks of the server — equality, text match — as a unit type (`op::Eq`, `op::Matches`). An index declares the operators it answers and the query term it sends for each; a target type names the index that answers each -operator for it. An operator nothing answers is a compile error (ADR-0009). +operator for it. An operator nothing answers is refused before any key is +requested: at compile time where the plan's type shows it, when the query is +awaited where the plan is data (ADR-0009). _Avoid_: query type, index (that is what answers the operator) **Query term**: The value a query sends to the server. It is not the stored term: equality sends the same HMAC it stores, but a match query is a filter over the needle, where the stored term is a filter over the whole value. A query term is never -stored, and a stored term is never sent as a query. +stored, and a query chain never sends a stored term as a query. _Avoid_: probe, stored term (that is what a write keeps) **Open**: diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index d2f752f25..b1f8dd5cb 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -149,6 +149,10 @@ mod tests { ..MatchOptions::default() }), }, + PlanError::MatchCase { .. } => PlanError::MatchCase { + declared: "Insensitive", + folds: false, + }, PlanError::TwoContextSources { .. } => PlanError::TwoContextSources { first: "the plan", second: "the call", diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 5625a9731..6b80f4f61 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -556,9 +556,11 @@ fn refusal(error: crate::Error) -> (Option, Reason) { P::IndexNotDeclared { field, .. } | P::IndexOptions { field, .. } => { (Some(field), Reason::Refused) } - P::IdentityWithoutField | P::MixedCiphers | P::TwoContextSources { .. } | P::NoContext => { - (None, Reason::Refused) - } + P::IdentityWithoutField + | P::MixedCiphers + | P::MatchCase { .. } + | P::TwoContextSources { .. } + | P::NoContext => (None, Reason::Refused), } } @@ -6795,6 +6797,15 @@ mod tests { Refused, ), (P::NoContext.into(), None, Refused), + ( + P::MatchCase { + declared: "Sensitive", + folds: true, + } + .into(), + None, + Refused, + ), ]; for (error, field, reason) in cases { let shown = format!("{error:?}"); diff --git a/packages/stack-encrypt/src/op.rs b/packages/stack-encrypt/src/op.rs index 9b083ac1b..3a70dc46a 100644 --- a/packages/stack-encrypt/src/op.rs +++ b/packages/stack-encrypt/src/op.rs @@ -35,11 +35,15 @@ //! - [`DefaultIndex`] is on an operator, and names the index a plan that //! declares its indexes, rather than a target type, answers it with. //! -//! An operator a plan cannot answer is a compile error: a match query on an -//! integer, a case-sensitive match against a case-folding index, or an -//! operator a target type does not answer. An index the plan does not -//! declare is refused when the query is awaited, as with +//! 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 //! [`index`](crate::plan::QueryUsing::index), before any key is requested. +//! So is a match configuration whose [`MatchCase`] disagrees with its +//! options. //! //! The module is meant to be used by path (`op::Eq`): an imported `Eq` //! would shadow the prelude's `Eq` trait. @@ -51,23 +55,19 @@ use std::marker::PhantomData; use crate::sem::{DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, MatchTerms}; use crate::target::{Equality, Index, Match}; +use crate::PlanError; /// A query operator: a unit type naming what a query asks of the server. /// /// 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 { - /// The operator's name, as a binding or an error message spells it. - const NAME: &'static str; -} +pub trait Operator: Copy + 'static {} /// Equality: rows whose value equals the query's. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Eq; -impl Operator for Eq { - const NAME: &'static str = "eq"; -} +impl Operator for Eq {} /// Text search: rows whose text contains the needle's tokens, under the /// match index's tokenizer. Not containment: a Bloom match is order- and @@ -105,15 +105,16 @@ impl PartialEq for Matches { } } impl std::cmp::Eq for Matches {} +impl std::hash::Hash for Matches { + fn hash(&self, _: &mut H) {} +} impl fmt::Debug for Matches { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "Matches<{}>", C::NAME) } } -impl Operator for Matches { - const NAME: &'static str = "matches"; -} +impl Operator for Matches {} mod sealed { pub trait Sealed {} @@ -151,9 +152,12 @@ impl Case for Sensitive { /// index answers are known at compile time. /// /// `Case::FOLDS` must equal the configuration's -/// [`MatchOptions::downcase`](crate::sem::MatchOptions::downcase). A -/// configuration that does not implement this can still store and query -/// match terms; it cannot be queried by [`Matches`]. +/// [`MatchOptions::downcase`](crate::sem::MatchOptions::downcase): the type +/// cannot see the options, so a query through a configuration whose two +/// disagree is refused when it is awaited, with +/// [`PlanError::MatchCase`]. A configuration +/// that does not implement this can still store match terms and query them +/// by index; it cannot be queried by [`Matches`]. pub trait MatchCase: MatchConfig { /// The case the configuration's index answers. type Case: Case; @@ -165,8 +169,10 @@ impl MatchCase for DefaultMatch { /// A value a query sends to the server to compare against stored terms. /// -/// A term that implements this and nothing more is *exact*: the server's -/// answer is the answer. +/// This trait says only that a value is a query term. How far the server's +/// answer can be trusted is said by the traits that refine it: a term that +/// can return false positives says how a row is checked (ADR-0009, +/// decision 4), and one that refines nothing is exact. pub trait QueryTerm {} impl QueryTerm for EqualityTerm {} @@ -187,14 +193,34 @@ impl QueryTerm for MatchQuery {} pub trait Supports: Index { /// What this index sends for `Op`. type Query: QueryTerm; + /// Why this index cannot answer `Op` as configured, if it cannot: a + /// match index whose [`MatchCase`] disagrees with its options. + fn check(&self) -> Result<(), PlanError>; /// The query term, from the term the index derived for the query's - /// value. - fn query(term: Self::Term) -> Self::Query; + /// value. Only a query chain calls this: `QueryToken` cannot be made + /// outside this crate, so a stored term cannot be passed off as a + /// query. + fn query(term: Self::Term, token: QueryToken) -> Self::Query; +} + +/// Proof that [`Supports::query`] is called by a query chain. Only this +/// crate can make one, so an index in another crate can implement +/// [`Supports`] but no caller can turn a term into a query term by hand. +#[derive(Debug)] +pub struct QueryToken(()); + +impl QueryToken { + pub(crate) fn new() -> Self { + Self(()) + } } impl Supports for Equality { type Query = EqualityTerm; - fn query(term: EqualityTerm) -> EqualityTerm { + fn check(&self) -> Result<(), PlanError> { + Ok(()) + } + fn query(term: EqualityTerm, _: QueryToken) -> EqualityTerm { term } } @@ -205,7 +231,18 @@ where O: MatchCase, { type Query = MatchQuery; - fn query(term: MatchTerms) -> MatchQuery { + fn check(&self) -> Result<(), PlanError> { + let folds = O::options().downcase; + if folds == O::Case::FOLDS { + Ok(()) + } else { + Err(PlanError::MatchCase { + declared: O::Case::NAME, + folds, + }) + } + } + fn query(term: MatchTerms, _: QueryToken) -> MatchQuery { MatchQuery::from_terms(term) } } @@ -272,7 +309,10 @@ impl> Answers for crate::target::Encry /// /// A plan that declares a different index for the operator, such as a /// match index with its own configuration, names it with -/// `.op_via(op, index)`. +/// `.op_via(op, index)`. So does a typed field of a fields plan +/// (`encrypt_into::(name)`): the field's type is not known where the +/// query is written, so its [`Answers`] impl is not consulted, and a field +/// whose type answers through another index needs `op_via`. #[diagnostic::on_unimplemented( message = "`{Self}` has no default index over `{S}`", label = "no default index answers this operator here", @@ -313,7 +353,7 @@ mod tests { #[test] fn an_operator_is_equal_to_itself() { - assert_eq!(Matches::::new(), Matches::default()); + assert_eq!(Matches::::new(), Matches::new()); assert_eq!(Eq, Eq); } diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 51c2cccca..6284f3b97 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -11,7 +11,7 @@ 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, Supports}; +use crate::op::{Answers, DefaultIndex, Operator, QueryToken, Supports}; use crate::target::{ ciphertext, Borrowed, CallerContext, DeclaredContext, DecryptField, Decryptable, EncryptFrom, Index, Indexes, Owned, Pending, PendingFuture, Select, @@ -643,10 +643,7 @@ impl<'a, F: 'static, K: 'static> QueryUsing<'a, F, FieldPlan, K> { Op: Operator, I: Supports, { - QueryOp { - inner: self.index(index), - op: PhantomData, - } + QueryOp::new(self.index(index)) } /// The term of `index`. The field must have declared it, and the query's @@ -690,10 +687,7 @@ impl<'a, F: 'static, X: Indexes, K: 'static> QueryUsing<'a, F, ValuePlan + Clone, X: Select, { - QueryOp { - inner: self.index::(), - op: PhantomData, - } + QueryOp::new(self.index::()) } /// The term of the plan's index of type `I`, selected by type @@ -741,10 +735,7 @@ where Op: Operator, I: Supports, { - QueryOp { - inner: self.index(index), - op: PhantomData, - } + QueryOp::new(self.index(index)) } /// The term of `index`. The target must declare it @@ -814,6 +805,20 @@ pub struct QueryOp<'a, F, Op, I, K> { op: PhantomData Op>, } +impl<'a, F, Op: Operator, I: Supports, K: 'static> QueryOp<'a, F, Op, I, K> { + /// The chain for `inner`'s index, refused when awaited if the index + /// cannot answer `Op` as it is configured. + fn new(mut inner: QueryIndex<'a, F, I, K>) -> Self { + if let Err(error) = inner.index.check() { + inner.label = Err(error); + } + Self { + inner, + op: PhantomData, + } + } +} + impl<'a, F, Op, I, K: 'static> QueryOp<'a, F, Op, I, K> { /// Run under this keyset, as [`QueryBuilder::keyset`]. pub fn keyset(mut self, keyset: impl Into>) -> Self { @@ -857,7 +862,7 @@ where { self.inner .prepare(keyset, scoped) - .map(>::query) + .map(|term| I::query(term, QueryToken::new())) } } diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index 43a3da260..85932f299 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -183,6 +183,23 @@ pub enum PlanError { /// The index as the query asked for it. asked: IndexSpec, }, + /// A match configuration's [`MatchCase`](crate::op::MatchCase) names one + /// case and its options fold the other, so a `Matches` query would be + /// asked in a case its index does not answer. + #[error( + "a match configuration declares the case {declared} but its options {}", + if *folds { "fold case" } else { "keep case" } + )] + #[diagnostic( + code(stack_encrypt::plan_match_case), + help("Make `MatchCase::Case` agree with `MatchConfig::options().downcase`: `Insensitive` when it is true, `Sensitive` when it is false.") + )] + MatchCase { + /// The case `MatchCase::Case` names: `Insensitive` or `Sensitive`. + declared: &'static str, + /// Whether the configuration's options fold case. + folds: bool, + }, /// The plan's context was given twice. A plan takes its context from /// exactly one place: the plan, the call that runs it, or a context /// field. @@ -263,6 +280,9 @@ impl crate::ErrorPayload for PlanError { ("declared", index_value(declared)), ("asked", index_value(asked)), ]), + Self::MatchCase { declared, folds } => { + payload([("declared", (*declared).into()), ("folds", (*folds).into())]) + } Self::TwoContextSources { first, second } => { payload([("first", (*first).into()), ("second", (*second).into())]) } diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 64f8b103f..473d21775 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -544,10 +544,19 @@ impl MatchTerms { /// false negative). /// /// An empty `query` returns `false`: containment of zero positions is - /// vacuously true, which would turn an empty probe into a match-every-row + /// vacuously true, which would turn an empty query into a match-every-row /// query. Term generation already refuses to build such a term /// ([`TermError::EmptyTermText`]); this guards any other /// (e.g. deserialized) source of an empty term. + pub fn matches(&self, query: &MatchQuery) -> bool { + self.contains(query.filter()) + } + + /// Whether this term's positions are a superset of `other`'s, for two + /// terms of the same kind. [`matches`](Self::matches) is the check for + /// a query. + /// + /// An empty `other` returns `false`, as an empty query does. pub fn contains(&self, query: &MatchTerms) -> bool { !query.positions.is_empty() && query @@ -664,9 +673,8 @@ impl MatchQuery { self.terms.to_bytes() } - /// The needle's filter, for a caller that tests stored filters itself - /// ([`MatchTerms::contains`]). - pub fn filter(&self) -> &MatchTerms { + /// The needle's filter. + pub(crate) fn filter(&self) -> &MatchTerms { &self.terms } } diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs index e58130d02..03854af9a 100644 --- a/packages/stack-encrypt/tests/op.rs +++ b/packages/stack-encrypt/tests/op.rs @@ -4,7 +4,7 @@ //! is not the stored term. mod common; -use common::stack_cipher; +use common::{loads_counting_cipher, stack_cipher}; use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::op::{self, MatchCase}; use stack_encrypt::plan::{Field, FieldSchema, Fields, PlanError}; @@ -109,10 +109,7 @@ async fn a_matches_query_is_a_filter_over_the_needle_that_the_stored_filter_hold .op(op::Matches::new()) .await .unwrap(); - assert!( - stored.contains(query.filter()), - "a case-folding index matches BOB" - ); + assert!(stored.matches(&query), "a case-folding index matches BOB"); assert!( query.positions().len() < stored.positions().len(), "the query is the needle's filter, not the value's" @@ -127,7 +124,6 @@ async fn a_matches_query_is_a_filter_over_the_needle_that_the_stored_filter_hold .unwrap(); assert_eq!(query.to_bytes(), by_index.to_bytes()); assert_eq!(query.positions(), by_index.positions()); - assert_eq!(query.filter(), &by_index); assert_eq!(query.clone(), query); let elsewhere: MatchQuery = cipher @@ -136,7 +132,7 @@ async fn a_matches_query_is_a_filter_over_the_needle_that_the_stored_filter_hold .op(op::Matches::new()) .await .unwrap(); - assert!(!stored.contains(elsewhere.filter())); + assert!(!stored.matches(&elsewhere)); assert_ne!(elsewhere, query); } @@ -155,10 +151,10 @@ async fn a_match_index_with_its_own_configuration_is_named_with_op_via() { .op_via(op::Matches::::new(), Match::::new()) }; let hit: MatchQuery = exact("Nguyen").await.unwrap(); - assert!(name.terms.contains(hit.filter())); + assert!(name.terms.matches(&hit)); let miss: MatchQuery = exact("nguyen").await.unwrap(); assert!( - !name.terms.contains(miss.filter()), + !name.terms.matches(&miss), "a case-sensitive index does not fold the needle" ); @@ -225,7 +221,7 @@ async fn a_one_value_plan_answers_from_its_indexes_by_type() { .op(op::Matches::new()) .await .unwrap(); - assert!(stored.terms.1.contains(matches.filter())); + assert!(stored.terms.1.matches(&matches)); let exact_plan = Plan::context("users/name") .with::(Match::::new()) @@ -242,7 +238,7 @@ async fn a_one_value_plan_answers_from_its_indexes_by_type() { .op_via(op::Matches::::new(), Match::::new()) .await .unwrap(); - assert!(name.terms.contains(query.filter())); + assert!(name.terms.matches(&query)); } #[tokio::test] @@ -282,7 +278,7 @@ async fn a_target_answers_an_operator_through_the_index_it_names() { .op(op::Matches::new()) .await .unwrap(); - assert!(stored.contains(query.filter())); + assert!(stored.matches(&query)); } #[tokio::test] @@ -311,7 +307,7 @@ async fn a_target_with_several_terms_names_the_index_with_op_via() { .op_via(op::Matches::new(), Match::default()) .await .unwrap(); - assert!(stored.terms.1.contains(matches.filter())); + assert!(stored.terms.1.matches(&matches)); // An index the target does not declare is refused when awaited. let one_term = Plan::value::() @@ -364,7 +360,7 @@ async fn operator_queries_settle_together_and_extend_like_the_write() { .await .unwrap(); assert_eq!(eq, email.terms.0); - assert!(email.terms.1.contains(matches.filter())); + assert!(email.terms.1.matches(&matches)); let unextended = cipher .query("bob@example.com") @@ -387,3 +383,120 @@ async fn a_query_chain_debugs_without_its_value() { let shown = format!("{:?}", cipher.query(secret).using(&email_plan).op(op::Eq)); assert_eq!(shown, "QueryOp { .. }"); } + +/// A configuration whose `MatchCase` says it folds case while its options +/// keep it: the type cannot see the options, so the query is refused. +struct Mislabelled; +impl MatchConfig for Mislabelled { + fn options() -> MatchOptions { + MatchOptions { + downcase: false, + ..Default::default() + } + } +} +impl MatchCase for Mislabelled { + type Case = op::Insensitive; +} + +#[tokio::test] +async fn a_match_case_that_disagrees_with_its_options_is_refused() { + let cipher = stack_cipher().await; + let plan = Plan::context("users/name") + .with::(Match::::new()) + .build() + .unwrap(); + let refused = plan_error( + cipher + .query("Bob") + .using(&plan) + .op_via(op::Matches::new(), Match::::new()) + .await, + ); + assert_eq!( + refused, + PlanError::MatchCase { + declared: "Insensitive", + folds: false + } + ); + assert_eq!( + refused.to_string(), + "a match configuration declares the case Insensitive but its options keep case" + ); +} + +#[tokio::test] +async fn a_refused_operator_query_loads_no_keyset() { + let (cipher, loads) = loads_counting_cipher().await; + let plan: Plan = Plan::context("people") + .fields() + .encrypt::("email") + .encrypt_index::("name", Match::::new()) + .build() + .unwrap(); + let refused = cipher + .query("Alice") + .using(&plan.field("name").unwrap()) + .op(op::Eq) + .keyset("tenant") + .await; + assert_eq!( + plan_error(refused), + PlanError::IndexNotDeclared { + field: "name".into(), + index: "eq" + } + ); + assert_eq!( + loads.load(std::sync::atomic::Ordering::SeqCst), + 0, + "refused before the named keyset is loaded" + ); +} + +#[tokio::test] +async fn a_match_query_debugs_as_its_positions() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let query = cipher + .query("bob") + .using(&plan.field("email").unwrap()) + .op(op::Matches::new()) + .await + .unwrap(); + assert_eq!( + format!("{query:?}"), + format!("MatchQuery {{ positions: {:?} }}", query.positions()) + ); +} + +#[tokio::test] +async fn an_operator_query_runs_under_the_keyset_it_names() { + let cipher = stack_cipher().await; + let plan = Plan::context("users/email") + .with::(Equality) + .build() + .unwrap(); + let stored: Encrypted = cipher + .encrypt(&"bob@example.com".to_owned()) + .using(&plan) + .keyset("tenant") + .await + .unwrap(); + let under_tenant = cipher + .query("bob@example.com") + .using(&plan) + .op(op::Eq) + .keyset("tenant") + .await + .unwrap(); + assert_eq!(under_tenant, stored.terms); + let under_default = cipher + .query("bob@example.com") + .using(&plan) + .op(op::Eq) + .await + .unwrap(); + assert_ne!(under_default, stored.terms, "another keyset, another key"); +} From a69f2e9251f37f462be11a653ec254a22e854e37 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 14 Oct 2026 10:51:25 +1100 Subject: [PATCH 3/4] refactor(stack-encrypt): the query chain starts with the operator and ends on the plan `cipher.query(op::Eq, "bob@example.com").on(&plan)` replaces `cipher.query("bob@example.com").using(&plan).op(op::Eq)`. The operator comes first, by value, and `on` names the plan, which chooses the index that answers the operator (`plan::Chooses`) and resolves the label the write used (`plan::Resolves`). An index the plan declares beside the default is named before the plan: `.via(index).on(&plan)`. An index's own term, for ORE and OPE until the ordering operators arrive, is `cipher.query_index(Ore, &40).on(&plan)`. `op::Matches` is now a value as well as a type, as `PhantomData` is, so `op::Matches::new()` is `op::Matches`; `op::Matches.case_sensitive()` is the other case. The case types are `CaseInsensitive` and `CaseSensitive`: `Sensitive` on its own read as data sensitivity. `Match` is a value the same way, so `Match::default()` is `Match`, and `Default` is implemented for every `Match`. `Answers::via()` and `DefaultIndex::via()` are gone: an index is a unit type, so `Via: Default` says the same thing without a method per impl, and the three equivalent-mutant exclusions for those bodies go with them. "Needle" is gone from the prose: a match query is a filter over the query's value, where the stored term is a filter over the stored value. Claude-Session: https://claude.ai/code/session_01VYKAWbd361xDLmqBdLoUcB --- .cargo/mutants.toml | 7 +- packages/stack-encrypt/CHANGELOG.md | 54 ++- packages/stack-encrypt/CONTEXT.md | 4 +- packages/stack-encrypt/src/codes.rs | 2 +- packages/stack-encrypt/src/dynamic/record.rs | 2 +- packages/stack-encrypt/src/dynamic/term.rs | 4 +- packages/stack-encrypt/src/op.rs | 130 +++---- packages/stack-encrypt/src/plan/build.rs | 7 +- packages/stack-encrypt/src/plan/chain.rs | 368 ++++++++++++------ packages/stack-encrypt/src/plan/error.rs | 4 +- packages/stack-encrypt/src/plan/mod.rs | 14 +- packages/stack-encrypt/src/plan/value.rs | 7 +- packages/stack-encrypt/src/sem/mod.rs | 12 +- packages/stack-encrypt/src/target/index.rs | 25 +- packages/stack-encrypt/tests/derive_plan.rs | 5 +- packages/stack-encrypt/tests/index.rs | 10 +- packages/stack-encrypt/tests/op.rs | 148 +++---- packages/stack-encrypt/tests/plan.rs | 107 ++--- packages/stack-encrypt/tests/plan_grammar.rs | 89 ++--- .../tests/ui/match_in_a_tuple_on_integer.rs | 2 +- .../ui/match_in_a_tuple_on_integer.stderr | 4 +- .../tests/ui/match_on_integer.rs | 2 +- .../tests/ui/match_on_integer.stderr | 4 +- .../tests/ui/op_case_mismatch.rs | 10 +- .../tests/ui/op_case_mismatch.stderr | 51 ++- .../tests/ui/op_matches_on_integer.rs | 2 +- .../tests/ui/op_matches_on_integer.stderr | 82 +++- .../tests/ui/op_target_does_not_answer.rs | 2 +- .../tests/ui/op_target_does_not_answer.stderr | 31 +- .../stack-encrypt/tests/ui/pass/indexes.rs | 4 +- .../stack-encrypt/tests/ui/pass/plan_chain.rs | 12 +- .../tests/ui/plan_match_on_integer.rs | 2 +- .../tests/ui/plan_match_on_integer.stderr | 2 +- .../tests/ui/plan_query_undeclared_index.rs | 2 +- .../ui/plan_query_undeclared_index.stderr | 48 ++- 35 files changed, 719 insertions(+), 540 deletions(-) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index a1d1e4eba..289dfad3f 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -72,10 +72,11 @@ exclude_re = [ 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace client_key_provider -> EnvKeyProvider with Default::default\(\)$', 'stack-encrypt/src/target/pending\.rs:443:9: replace >::into_future -> Self::IntoFuture with Default::default\(\)$', # stack-encrypt — equivalent. - # An operator and an operator's index are unit structs, whose `Default` - # is the value `clone` and `via` already return. + # An operator and an index are unit structs, whose `Default` is the value + # `clone` and `case_sensitive` already return. 'stack-encrypt/src/op(/mod)?\.rs:\d+:\d+: replace >::clone -> Self with Default::default\(\)$', - 'stack-encrypt/src/op(/mod)?\.rs:\d+:\d+: replace for EqualityTerm|DefaultIndex for Eq|DefaultIndex for Matches<::Case>)>::via -> (Equality|Match) with Default::default\(\)$', + 'stack-encrypt/src/op(/mod)?\.rs:\d+:\d+: replace Matches::case_sensitive -> Matches with Default::default\(\)$', + 'stack-encrypt/src/target/index\.rs:\d+:\d+: replace >::clone -> Self with Default::default\(\)$', 'stack-encrypt/src/cipher\.rs:\d+:\d+: replace StackCipher::builder -> StackCipherBuilder with Default::default\(\)$', 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace ::options -> MatchOptions with Default::default\(\)$', # Both unit-context conversions explicitly return Self::default(). diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 81719e35d..d02d7bc31 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -9,24 +9,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **A query names an operator, not an index.** `cipher.query(v).using(&plan)` - finishes with `.op(op::Eq)` or `.op(op::Matches::new())`, and the plan - finds the index that answers the operator (ADR-0009). An index declares the - operators it answers (`op::Supports`), a target type names the index that - answers each one for it (`op::Answers`), and a plan that declares indexes - uses the operator's default index (`op::DefaultIndex`). `.op_via(op, index)` - names another index, such as a match index with its own configuration. What - the plan's type shows is checked at compile time: a match query on an - integer, an operator a target does not answer, and on a one-value plan an - index it does not hold or a case-sensitive `Matches` against a - case-folding index. On a field of a fields plan those are refused when the - query is awaited, before any key is requested, as is a match configuration - whose `op::MatchCase` disagrees with its options (`PlanError::MatchCase`). +- **A query names an operator, not an index.** + `cipher.query(op::Eq, "bob@example.com").on(&plan)` and + `cipher.query(op::Matches, "ali").on(&plan)`: the operator comes first, as + a value, and the plan named by `on` finds the index that answers it + (ADR-0009). An index declares the operators it answers (`op::Supports`), a + target type names the index that answers each one for it (`op::Answers`), + and a plan that declares indexes uses the operator's default index + (`op::DefaultIndex`); both name the index by its type, whose `Default` is + the index. `.via(index)` before `.on(&plan)` names another index the plan + declares, such as a match index with its own configuration. + `op::Matches` is the case-insensitive operator, as a value; + `op::Matches.case_sensitive()` is the other, and the case types are + `op::CaseInsensitive` and `op::CaseSensitive`. What the plan's type shows + is checked at compile time: a match query on an integer, an operator a + target does not answer, and on a one-value plan an index it does not hold + or a case-sensitive `Matches` against a case-folding index. On a field of + a fields plan those are refused when the query is awaited, before any key + is requested, as is a match configuration whose `op::MatchCase` disagrees + with its options (`PlanError::MatchCase`). - **A match query has its own type, `sem::MatchQuery`.** It is the filter over - the needle; test a stored filter against it with + the query's value; test a stored filter against it with `MatchTerms::matches(&query)`. It comes only from a query chain: `op::Supports::query` takes an `op::QueryToken` that only this crate can make. +- **`Match` is a value.** `.with((Equality, Match))` declares the match index + under the default configuration, as `Equality` declares equality; + `Match::::new()` is another configuration. - **Errors now expose stable codes, help text, and structured details.** All error types implement `miette::Diagnostic` with codes such as @@ -66,10 +75,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking -- **`QueryUsing::equality()` is removed.** Write `.op(op::Eq)`. A plan that - declares the equality index answers it the same way, and a target answers - it through `op::Answers`. `QueryUsing::index(..)` stays until the ordering - operators exist. +- **The query chain starts with the operator.** `cipher.query(v)` takes the + operator first: `cipher.query(op::Eq, v).on(&plan)` replaces + `cipher.query(v).using(&plan).equality()`, and `on` replaces `using` for + queries only (a write still runs `using` a plan). An index's own term, for + an index no operator answers yet (`Ore`, `Ope`), is + `cipher.query_index(Ore, v).on(&plan)`, which goes when the ordering + operators arrive. `QueryUsing` is gone; the chain's types are + `QueryBuilder`, `QueryVia`, `QueryIndexBuilder`, `QueryIndex` and + `QueryOp`, and the plan shapes implement `plan::Chooses` and + `plan::Resolves`. +- **`Match::default()` is `Match`.** The match index under the default + configuration is the value `Match`, and `Default` is now implemented for + every `Match`, so `Match::default()` no longer infers the configuration. - **Update pattern matches for `dynamic::Error`.** `Context`, `Plan`, `Source`, and `Record` now carry diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 3cfab23a8..c9a9b678d 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -241,8 +241,8 @@ _Avoid_: query type, index (that is what answers the operator) **Query term**: The value a query sends to the server. It is not the stored term: equality -sends the same HMAC it stores, but a match query is a filter over the needle, -where the stored term is a filter over the whole value. A query term is never +sends the same HMAC it stores, but a match query is a filter over the query's +value, where the stored term is a filter over the whole stored value. A query term is never stored, and a query chain never sends a stored term as a query. _Avoid_: probe, stored term (that is what a write keeps) diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index b1f8dd5cb..7d6929fc8 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -150,7 +150,7 @@ mod tests { }), }, PlanError::MatchCase { .. } => PlanError::MatchCase { - declared: "Insensitive", + declared: "CaseInsensitive", folds: false, }, PlanError::TwoContextSources { .. } => PlanError::TwoContextSources { diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 6b80f4f61..64794dc87 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -6799,7 +6799,7 @@ mod tests { (P::NoContext.into(), None, Refused), ( P::MatchCase { - declared: "Sensitive", + declared: "CaseSensitive", folds: true, } .into(), diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index edeebe492..d02b272c0 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -1206,7 +1206,7 @@ mod wire_form { fn every_typed_index_round_trips_through_the_wire_form() { let specs = [ Index::::spec(&Equality), - Index::::spec(&Match::default()), + Index::::spec(&Match), Index::::spec(&Match::::new()), Index::::spec(&Ore), Index::::spec(&Ope), @@ -1222,7 +1222,7 @@ mod wire_form { #[test] fn a_default_match_index_is_the_bare_match_key() { - let spec = Index::::spec(&Match::default()); + let spec = Index::::spec(&Match); assert!(matches!( spec.to_value(), FfiValue::String(key) if super::utf8(&key) == Some("match") diff --git a/packages/stack-encrypt/src/op.rs b/packages/stack-encrypt/src/op.rs index 3a70dc46a..5f1d26d2e 100644 --- a/packages/stack-encrypt/src/op.rs +++ b/packages/stack-encrypt/src/op.rs @@ -1,8 +1,8 @@ //! Operators: what a query asks of the server, as a type. //! -//! A query names an *operator* ([`Eq`](struct@Eq), [`Matches`]), and the plan it runs -//! through finds the index that answers it. The operator is a unit type -//! passed by value, as an index is (`.index(Ore)`): +//! 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)`): //! //! ``` //! # async fn example() -> Result<(), stack_encrypt::Error> { @@ -11,11 +11,11 @@ //! //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let email_plan = Plan::context("users/email") -//! .with::((Equality, Match::default())) +//! .with::((Equality, Match)) //! .build()?; //! -//! let exact = cipher.query("bob@example.com").using(&email_plan).op(op::Eq).await?; -//! let search = cipher.query("bob").using(&email_plan).op(op::Matches::new()).await?; +//! 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); //! # Ok(()) //! # } @@ -27,8 +27,9 @@ //! - [`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 needle ([`MatchQuery`]), -//! where it stores a filter over the whole value ([`MatchTerms`]). +//! 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 //! 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. @@ -41,7 +42,8 @@ //! 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 -//! [`index`](crate::plan::QueryUsing::index), before any key is requested. +//! [`query_index`](crate::StackCipher::query_index), before any key is +//! requested. //! So is a match configuration whose [`MatchCase`] disagrees with its //! options. //! @@ -69,21 +71,40 @@ pub struct Eq; impl Operator for Eq {} -/// Text search: rows whose text contains the needle's tokens, under the +/// Text search: rows whose text contains the query's tokens, under the /// match index's tokenizer. Not containment: a Bloom match is order- and /// multiplicity-insensitive. /// -/// `C` is the case the query is asked in, [`Insensitive`] unless named +/// `C` is the case the query is asked in, [`CaseInsensitive`] unless named /// otherwise. A match index answers only the case its configuration fixes /// ([`MatchCase`]), so asking a case-folding index for a case-sensitive /// match is a compile error rather than a query that quietly folds case. -pub struct Matches(PhantomData C>); +/// +/// The value [`Matches`](constant@Matches) is the case-insensitive operator, +/// as `PhantomData` is a value of its type; [`case_sensitive`](Matches::case_sensitive) +/// is the other. +pub struct Matches { + case: PhantomData C>, +} + +/// The case-insensitive text search operator, as a value: +/// `cipher.query(op::Matches, "ali")`. +#[allow(non_upper_case_globals)] +pub const Matches: Matches = Matches::new(); impl Matches { - /// The operator. `Matches::new()` takes its case from the index it is - /// asked of; `Matches::::new()` names it. + /// The operator in the case `C`: `Matches::::new()`. pub const fn new() -> Self { - Self(PhantomData) + Self { case: PhantomData } + } +} + +impl Matches { + /// The case-sensitive text search operator: + /// `cipher.query(op::Matches.case_sensitive(), "Ali")`. Only a match + /// index whose configuration does not fold case answers it. + pub const fn case_sensitive(self) -> Matches { + Matches::new() } } @@ -118,12 +139,12 @@ impl Operator for Matches {} mod sealed { pub trait Sealed {} - impl Sealed for super::Insensitive {} - impl Sealed for super::Sensitive {} + impl Sealed for super::CaseInsensitive {} + impl Sealed for super::CaseSensitive {} } -/// The case a text operator is asked in: [`Insensitive`] or [`Sensitive`]. -/// Sealed: there are exactly two. +/// The case a text operator is asked in: [`CaseInsensitive`] or +/// [`CaseSensitive`]. Sealed: there are exactly two. pub trait Case: sealed::Sealed + 'static { /// Whether text is lower-cased before it is tokenised. const FOLDS: bool; @@ -133,19 +154,19 @@ pub trait Case: sealed::Sealed + 'static { /// Case-insensitive: text is lower-cased before it is tokenised. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub struct Insensitive; +pub struct CaseInsensitive; /// Case-sensitive: text is tokenised as written. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] -pub struct Sensitive; +pub struct CaseSensitive; -impl Case for Insensitive { +impl Case for CaseInsensitive { const FOLDS: bool = true; - const NAME: &'static str = "Insensitive"; + const NAME: &'static str = "CaseInsensitive"; } -impl Case for Sensitive { +impl Case for CaseSensitive { const FOLDS: bool = false; - const NAME: &'static str = "Sensitive"; + const NAME: &'static str = "CaseSensitive"; } /// The case a match configuration fixes, as a type, so the operators its @@ -157,14 +178,14 @@ impl Case for Sensitive { /// disagree is refused when it is awaited, with /// [`PlanError::MatchCase`]. A configuration /// that does not implement this can still store match terms and query them -/// by index; it cannot be queried by [`Matches`]. +/// by index; it cannot be queried by [`Matches`](struct@Matches). pub trait MatchCase: MatchConfig { /// The case the configuration's index answers. type Case: Case; } impl MatchCase for DefaultMatch { - type Case = Insensitive; + type Case = CaseInsensitive; } /// A value a query sends to the server to compare against stored terms. @@ -184,7 +205,7 @@ impl QueryTerm for MatchQuery {} /// [`Index::Term`] is what the index derives; [`query`](Self::query) makes /// the query term from it. For an equality index the two are the same /// HMAC. For a match index they are not: the query is a filter over the -/// needle, and its type says so. +/// query's value, and its type says so. #[diagnostic::on_unimplemented( message = "`{Self}` does not answer `{Op}` over `{S}`", label = "this index cannot answer the operator", @@ -258,20 +279,16 @@ where #[diagnostic::on_unimplemented( message = "`{Self}` does not answer `{Op}` over `{S}`", label = "this target type does not answer the operator", - note = "a target answers the operators its type implements `Answers` for; `.op_via(op, index)` names an index the target declares" + note = "a target answers the operators its type implements `Answers` for; `.via(index)` names an index the target declares" )] pub trait Answers { - /// The index that answers `Op` for this type. - type Via: Supports; - /// That index, as a value. - fn via() -> Self::Via; + /// The index that answers `Op` for this type. An index is a unit type, + /// so its `Default` is the index. + type Via: Supports + Default; } impl Answers for EqualityTerm { type Via = Equality; - fn via() -> Equality { - Equality - } } impl Answers, S> for MatchTerms @@ -280,63 +297,46 @@ where O: MatchCase, { type Via = Match; - fn via() -> Match { - Match::new() - } } /// 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) { type Via = T::Via; - fn via() -> T::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 `.op_via(op, index)`. +/// names the index with `.via(index)`. impl> Answers for crate::target::Encrypted { type Via = T::Via; - fn via() -> T::Via { - T::via() - } } /// The index a plan that declares its indexes answers an operator with: /// a field of a fields plan, or a one-value plan built `with` indexes. /// /// A plan that declares a different index for the operator, such as a -/// match index with its own configuration, names it with -/// `.op_via(op, index)`. So does a typed field of a fields plan -/// (`encrypt_into::(name)`): the field's type is not known where the -/// query is written, so its [`Answers`] impl is not consulted, and a field -/// whose type answers through another index needs `op_via`. +/// match index with its own configuration, names it with `.via(index)`. +/// So does a typed field of a fields plan (`encrypt_into::(name)`): +/// the field's type is not known where the query is written, so its +/// [`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}`", 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 `.op_via(op, index)`" + 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)`" )] pub trait DefaultIndex: Operator { - /// The index. - type Via: Supports; - /// That index, as a value. - fn via() -> Self::Via; + /// The index. An index is a unit type, so its `Default` is the index. + type Via: Supports + Default; } impl DefaultIndex for Eq { type Via = Equality; - fn via() -> Equality { - Equality - } } impl> DefaultIndex for Matches<::Case> { type Via = Match; - fn via() -> Match { - Match::default() - } } #[cfg(test)] @@ -353,15 +353,15 @@ mod tests { #[test] fn an_operator_is_equal_to_itself() { - assert_eq!(Matches::::new(), Matches::new()); + assert_eq!(Matches::::new(), Matches); assert_eq!(Eq, Eq); } #[test] fn an_operator_debugs_with_its_case() { assert_eq!( - format!("{:?}", Matches::::new()), - "Matches" + format!("{:?}", Matches.case_sensitive()), + "Matches" ); assert_eq!(format!("{:?}", Eq), "Eq"); } diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index 47825db6c..5ecc20d1e 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -259,10 +259,9 @@ pub(crate) fn check_declared( /// // A query and a read name the same context. /// let email_plan = users_plan.field("email")?; /// let query_value = cipher -/// .query("bob@example.com") +/// .query(op::Eq, "bob@example.com") /// .context("tenants/acme/users") -/// .using(&email_plan) -/// .op(op::Eq) +/// .on(&email_plan) /// .await?; /// # let _ = query_value; /// let back = cipher.open(record).context("tenants/acme/users").using(&users_plan).await?; @@ -928,7 +927,7 @@ impl FieldsBuilder { /// let email: Encrypted<(EqualityTerm, MatchTerms)> = record.take("email")?; /// /// // The target declares an equality index, so the field answers it. - /// let query_value = cipher.query("bob@example.com").using(&users_plan.field("email")?).op(op::Eq).await?; + /// let query_value = cipher.query(op::Eq, "bob@example.com").on(&users_plan.field("email")?).await?; /// assert_eq!(query_value, email.terms.0); /// # Ok(()) /// # } diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 6284f3b97..1c7181829 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -14,7 +14,7 @@ use super::PlanError; use crate::op::{Answers, DefaultIndex, Operator, QueryToken, Supports}; use crate::target::{ ciphertext, Borrowed, CallerContext, DeclaredContext, DecryptField, Decryptable, EncryptFrom, - Index, Indexes, Owned, Pending, PendingFuture, Select, + Index, Indexes, Owned, Pending, PendingFuture, Select, Whole, }; use crate::{Error, KeysetCipher, Label, LabelError, NonEmpty, StackCipher, StackCipherText}; @@ -279,19 +279,43 @@ impl StackCipher { } } - /// Start a chain that derives a query term for `value`, to match terms - /// a plan wrote. Name the plan with [`using`](QueryBuilder::using) and - /// the operator with [`op`](QueryUsing::op): `.op(op::Eq)`. + /// Start a chain that derives the query term for the operator `op` over + /// `value`, to match terms a plan wrote. Name the plan with + /// [`on`](QueryBuilder::on): `cipher.query(op::Eq, "bob@example.com").on(&plan)`. + /// The plan chooses the index that answers the operator there; an + /// index the plan declares beside that one is named first, with + /// [`via`](QueryBuilder::via). /// - /// The value is copied once (`to_owned`), so `query("bob@example.com")` + /// The value is copied once (`to_owned`), so `query(op::Eq, "bob@example.com")` /// queries a `String` field. - pub fn query<'a, Q>(&'a self, value: &Q) -> QueryBuilder<'a, Q::Owned, K> + pub fn query<'a, Op, Q>(&'a self, op: Op, value: &Q) -> QueryBuilder<'a, Op, F, K> where + Op: Operator, Q: ToOwned + ?Sized, { 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. For an index no operator answers yet ([`Ore`] and + /// [`Ope`]); 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 + Q: ToOwned + ?Sized, + { + QueryIndexBuilder { + common: Common::new(self), + value: value.to_owned(), + index, } } @@ -589,13 +613,18 @@ where } } -/// A chain deriving a query term, before its plan is named. -pub struct QueryBuilder<'a, F, K> { +/// The owned form of a queried value: `String` for a `str` query. +type F = ::Owned; + +/// A chain deriving the query term for an operator, before its plan is +/// named. +pub struct QueryBuilder<'a, Op, F, K> { common: Common<'a, K>, value: F, + op: Op, } -impl<'a, F, K: 'static> QueryBuilder<'a, F, K> { +impl<'a, Op: Operator, F: 'static, 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 @@ -605,150 +634,241 @@ impl<'a, F, K: 'static> QueryBuilder<'a, F, K> { self } - /// Query through `plan`: a field of a fields plan - /// ([`Plan::field`](super::Plan::field)), or a one-value plan ([`ValuePlan`]). The term is - /// derived under exactly the label the write used. - pub fn using

(self, plan: &'a P) -> QueryUsing<'a, F, P, K> { - QueryUsing { + /// 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. + pub fn via>(self, index: I) -> QueryVia<'a, Op, F, I, K> { + QueryVia { common: self.common, value: self.value, - plan, + op: self.op, + index, } } + + /// Query on `plan`: a field of a fields plan + /// ([`Plan::field`](super::Plan::field)), or a one-value plan + /// ([`ValuePlan`]). The plan chooses the index that answers the + /// operator ([`Chooses`]), and the term is derived under exactly the + /// 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`], + /// [`PlanError::IndexOptions`]), before any key is requested. + pub fn on(self, plan: &P) -> QueryOp<'a, F, Op, P::Via, K> + where + P: Chooses, + { + self.via(P::Via::default()).on(plan) + } } -/// A chain deriving a query term through a plan, before its index is -/// chosen. -pub struct QueryUsing<'a, F, P, K> { +/// A chain deriving the query term for an operator through a named index, +/// before its plan is named. +pub struct QueryVia<'a, Op, F, I, K> { common: Common<'a, K>, value: F, - plan: &'a P, + op: Op, + index: I, } -impl<'a, F: 'static, K: 'static> QueryUsing<'a, F, FieldPlan, K> { - /// The query term for the operator `op`, through the index a plan that - /// declares its indexes answers it with ([`DefaultIndex`]): - /// `.op(op::Eq)` is the equality term. The field must have declared - /// that index, with the same options; otherwise awaiting is - /// [`PlanError::IndexNotDeclared`] or [`PlanError::IndexOptions`]. - pub fn op>(self, op: Op) -> QueryOp<'a, F, Op, Op::Via, K> { - self.op_via(op, Op::via()) - } - - /// The query term for `op` through `index`, for a field that declares - /// an index other than the operator's default, such as a match index - /// with its own configuration. - pub fn op_via(self, _op: Op, index: I) -> QueryOp<'a, F, Op, I, K> +impl<'a, Op: Operator, F: 'static, I: Supports, K: 'static> QueryVia<'a, Op, F, I, K> { + /// Query on `plan` through the named index, which the plan must + /// declare ([`Resolves`]): a one-value plan without it does not + /// compile; a field of a fields plan without it is refused when the + /// query is awaited. A one-value plan built `with` indexes uses its + /// 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 - Op: Operator, - I: Supports, + P: Resolves, { - QueryOp::new(self.index(index)) - } - - /// The term of `index`. The field must have declared it, and the query's - /// plaintext must be the field's type; otherwise awaiting is - /// [`PlanError::IndexNotDeclared`] or [`PlanError::FieldType`], never a - /// term that matches nothing. - pub fn index>(mut self, index: I) -> QueryIndex<'a, F, I, K> { - let label = self - .common - .call_context() - .and_then(|call| self.plan.query_label::(&index.spec(), call)); - QueryIndex { - common: self.common, - value: self.value, + let QueryVia { + common, + value, + op, index, - label, - } + } = self; + let inner = plan.resolve(QueryIndexBuilder { + common, + value, + index, + }); + QueryOp::new(inner, op) } } -impl<'a, F: 'static, X: Indexes, K: 'static> QueryUsing<'a, F, ValuePlan>, K> { - /// The query term for the operator `op`, through the index a plan that - /// declares its indexes answers it with ([`DefaultIndex`]), selected - /// from the plan's indexes by type: a plan that does not hold that - /// index does not compile here. - pub fn op(self, op: Op) -> QueryOp<'a, F, Op, Op::Via, K> - where - Op: DefaultIndex, - Op::Via: Clone, - X: Select, - { - self.op_via(op, Op::via()) +/// 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 } - /// The query term for `op` through the plan's index of the type of - /// `index`, for a plan that holds an index other than the operator's - /// default. The plan's own index is used; `index` names its type. - pub fn op_via(self, _op: Op, _index: I) -> QueryOp<'a, F, Op, I, K> + /// 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 - Op: Operator, - I: Supports + Clone, - X: Select, + I: Index, + P: Resolves, { - QueryOp::new(self.index::()) + plan.resolve(self) } +} - /// The term of the plan's index of type `I`, selected by type - /// ([`Indexes::select`]); `At` is inferred: `.index::()`. - pub fn index(mut self) -> QueryIndex<'a, F, I, K> - where - I: Index + Clone, - X: Select, - { - let index = self.plan.indexes().select::().clone(); - let label = self - .common +/// 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 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. +#[diagnostic::on_unimplemented( + message = "`{Self}` does not answer `{Op}` over `{F}`", + 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" +)] +pub trait Chooses: Resolves { + /// The index that answers `Op` on this plan. + type Via: Supports + Default; +} + +impl, F: 'static> Chooses for FieldPlan { + type Via = Op::Via; +} + +impl Chooses for ValuePlan> +where + Op: DefaultIndex, + Op::Via: Clone, + F: 'static, + X: Indexes + Select, +{ + type Via = Op::Via; +} + +impl Chooses for ValuePlan> +where + Op: Operator, + F: 'static, + T: Answers, + T::Via: Default, + Typed: ValueShape, +{ + type Via = T::Via; +} + +mod plan_sealed { + use super::{FieldPlan, Indexed, Typed, ValuePlan}; + pub trait Sealed {} + impl Sealed for FieldPlan {} + impl Sealed for ValuePlan> {} + 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 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 +/// one the plan does not declare when the query is awaited +/// ([`PlanError::IndexNotDeclared`]). A one-value plan built `with` +/// indexes selects its own index of that type ([`Indexes::select`]), at +/// 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}`", + 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>; +} + +impl> Resolves for FieldPlan { + fn resolve<'a, K: 'static>( + &self, + query: QueryIndexBuilder<'a, I, F, K>, + ) -> QueryIndex<'a, F, I, K> { + let QueryIndexBuilder { + mut common, + value, + index, + } = query; + let label = common .call_context() - .and_then(|call| self.plan.resolve(call)); + .and_then(|call| self.query_label::(&index.spec(), call)); QueryIndex { - common: self.common, - value: self.value, + common, + value, index, label, } } } -impl<'a, F: 'static, T, K: 'static> QueryUsing<'a, F, ValuePlan>, K> +impl Resolves for ValuePlan> where - Typed: ValueShape, + X: Indexes + Select, + I: Index + Clone, { - /// The query term for the operator `op`, through the index the target - /// type answers it with ([`Answers`]). A target that does not answer - /// `op` does not compile here; one that answers it through an index it - /// does not declare is [`PlanError::IndexNotDeclared`] when awaited. - pub fn op(self, op: Op) -> QueryOp<'a, F, Op, T::Via, K> - where - Op: Operator, - T: Answers, - { - self.op_via(op, T::via()) - } - - /// The query term for `op` through `index`, which the target must - /// declare: for a target with no [`Answers`] impl for `op`, such as - /// an [`Encrypted`](crate::target::Encrypted) tuple of terms. - pub fn op_via(self, _op: Op, index: I) -> QueryOp<'a, F, Op, I, K> - where - Op: Operator, - I: Supports, - { - QueryOp::new(self.index(index)) + fn resolve<'a, K: 'static>( + &self, + query: QueryIndexBuilder<'a, I, F, K>, + ) -> QueryIndex<'a, F, I, K> { + let QueryIndexBuilder { + mut common, value, .. + } = query; + let index = self.indexes().select::().clone(); + let label = common.call_context().and_then(|call| self.resolve(call)); + QueryIndex { + common, + value, + index, + label, + } } +} - /// The term of `index`. The target must declare it - /// ([`EncryptFrom::indexes`]); otherwise awaiting is - /// [`PlanError::IndexNotDeclared`], never a term that matches nothing. - pub fn index>(mut self, index: I) -> QueryIndex<'a, F, I, K> { - let label = self - .common +impl> Resolves for ValuePlan> +where + Typed: ValueShape, +{ + fn resolve<'a, K: 'static>( + &self, + query: QueryIndexBuilder<'a, I, F, K>, + ) -> QueryIndex<'a, F, I, K> { + let QueryIndexBuilder { + mut common, + value, + index, + } = query; + let label = common .call_context() - .and_then(|call| self.plan.query_label(&index.spec(), call)); + .and_then(|call| self.query_label(&index.spec(), call)); QueryIndex { - common: self.common, - value: self.value, + common, + value, index, label, } @@ -808,7 +928,7 @@ pub struct QueryOp<'a, F, Op, I, K> { impl<'a, F, Op: Operator, I: Supports, K: 'static> QueryOp<'a, F, Op, I, K> { /// The chain for `inner`'s index, refused when awaited if the index /// cannot answer `Op` as it is configured. - fn new(mut inner: QueryIndex<'a, F, I, K>) -> Self { + fn new(mut inner: QueryIndex<'a, F, I, K>, _op: Op) -> Self { if let Err(error) = inner.index.check() { inner.label = Err(error); } @@ -945,8 +1065,9 @@ chain_options! { ['a, S, X, K: 'static] EncryptIndexed<'a, S, X, K>; ['a, S: 'static, K: 'static] EncryptFields<'a, S, K>; ['a, S: ?Sized, P, K: 'static] EncryptUsing<'a, S, P, K>; - ['a, F, K: 'static] QueryBuilder<'a, F, K>; - ['a, F, P, K: 'static] QueryUsing<'a, F, P, K>; + ['a, Op, F, K: 'static] QueryBuilder<'a, Op, F, K>; + ['a, Op, F, I, K: 'static] QueryVia<'a, Op, F, I, K>; + ['a, I, F, K: 'static] QueryIndexBuilder<'a, I, F, K>; ['a, F, I, K: 'static] QueryIndex<'a, F, I, K>; ['a, R, K: 'static] OpenBuilder<'a, R, K>; ['a, R, P, K: 'static] OpenUsing<'a, R, P, K>; @@ -979,8 +1100,9 @@ opaque_debug! { ['a, S, X, K] EncryptIndexed<'a, S, X, K>, "EncryptIndexed"; ['a, S: 'static, K: 'static] EncryptFields<'a, S, K>, "EncryptFields"; ['a, S: ?Sized, P, K] EncryptUsing<'a, S, P, K>, "EncryptUsing"; - ['a, F, K] QueryBuilder<'a, F, K>, "QueryBuilder"; - ['a, F, P, K] QueryUsing<'a, F, P, K>, "QueryUsing"; + ['a, Op, F, K] QueryBuilder<'a, Op, F, K>, "QueryBuilder"; + ['a, Op, F, I, K] QueryVia<'a, Op, F, I, K>, "QueryVia"; + ['a, I, F, K] QueryIndexBuilder<'a, I, F, K>, "QueryIndexBuilder"; ['a, F, I, K] QueryIndex<'a, F, I, K>, "QueryIndex"; ['a, F, Op, I, K] QueryOp<'a, F, Op, I, K>, "QueryOp"; ['a, R, K] OpenBuilder<'a, R, K>, "OpenBuilder"; @@ -1033,7 +1155,7 @@ impl std::fmt::Debug for All<'_, K, T> { /// let email = String::from("bob@example.com"); /// let (written, query_value) = stack_encrypt::all(( /// cipher.encrypt(&email).using(&email_plan), -/// cipher.query("bob@example.com").using(&email_plan).op(op::Eq), +/// cipher.query(op::Eq, "bob@example.com").on(&email_plan), /// )) /// .await?; /// assert_eq!(written.terms, query_value); diff --git a/packages/stack-encrypt/src/plan/error.rs b/packages/stack-encrypt/src/plan/error.rs index 85932f299..f029d4761 100644 --- a/packages/stack-encrypt/src/plan/error.rs +++ b/packages/stack-encrypt/src/plan/error.rs @@ -192,10 +192,10 @@ pub enum PlanError { )] #[diagnostic( code(stack_encrypt::plan_match_case), - help("Make `MatchCase::Case` agree with `MatchConfig::options().downcase`: `Insensitive` when it is true, `Sensitive` when it is false.") + help("Make `MatchCase::Case` agree with `MatchConfig::options().downcase`: `CaseInsensitive` when it is true, `CaseSensitive` when it is false.") )] MatchCase { - /// The case `MatchCase::Case` names: `Insensitive` or `Sensitive`. + /// The case `MatchCase::Case` names: `CaseInsensitive` or `CaseSensitive`. declared: &'static str, /// Whether the configuration's options fold case. folds: bool, diff --git a/packages/stack-encrypt/src/plan/mod.rs b/packages/stack-encrypt/src/plan/mod.rs index d7fc1512d..7e58c504f 100644 --- a/packages/stack-encrypt/src/plan/mod.rs +++ b/packages/stack-encrypt/src/plan/mod.rs @@ -87,7 +87,7 @@ //! .encrypt(&user) //! .context("users") //! .fields() -//! .encrypt_index::("email", (Equality, Match::default())) +//! .encrypt_index::("email", (Equality, Match)) //! .encrypt_index::("age", (Equality, Ore)) //! .encrypt::("notes") //! .passthrough::("id") @@ -158,7 +158,7 @@ //! //! let users_plan: Plan = Plan::context("users") //! .fields() -//! .encrypt_index::("email", (Equality, Match::default())) +//! .encrypt_index::("email", (Equality, Match)) //! .encrypt_index::("age", (Equality, Ore)) //! .encrypt::("notes") //! .passthrough::("id") @@ -170,7 +170,7 @@ //! //! // Query: derived under the label the write used, so it matches. //! let email_plan = users_plan.field("email")?; -//! let query_value = cipher.query("bob@example.com").using(&email_plan).op(op::Eq).await?; +//! let query_value = cipher.query(op::Eq, "bob@example.com").on(&email_plan).await?; //! assert_eq!(query_value, stored.terms.0); //! //! // Read: every field that can come back. @@ -252,7 +252,7 @@ //! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; //! let user = User { email: "bob@example.com".into(), nickname: "bob".into() }; //! let record: EncryptedUser = cipher.default_keyset().encrypt_as(&user, ().into()).await?; -//! let query_value = cipher.query("bob@example.com").using(&by_hand.field("email")?).op(op::Eq).await?; +//! let query_value = cipher.query(op::Eq, "bob@example.com").on(&by_hand.field("email")?).await?; //! assert_eq!(record.email.terms, query_value); //! # Ok(()) //! # } @@ -301,7 +301,7 @@ //! | `fields()` | each field picked out of the value (by name, or by a picker's accessor as `project` does), `zip`ped | //! | `extend(parts)` | the [`DeclaredContext`](crate::target::DeclaredContext) the description runs under | //! | `using(&plan)` | [`KeysetCipher::run`](crate::KeysetCipher::run) of [`Plan::encryption`] | -//! | `query(v).using(..).op(op)`, `.index(x)` | the answering index's operation alone, `.under(label)`; for `op`, mapped to its query term ([`Supports::query`](crate::op::Supports::query)) | +//! | `query(op, v).on(..)`, `query_index(x, v).on(..)` | the answering index's operation alone, `.under(label)`; for `op`, mapped to its query term ([`Supports::query`](crate::op::Supports::query)) | //! | `open(record).using(&plan)` | [`open`](crate::target::open) per field, run by `run_decryption` | //! | `.await`, [`all`] | `Pending::zip`, then one settle | //! @@ -319,9 +319,9 @@ mod values; pub use build::{FieldKind, FieldPlan, FieldsBuilder, IntoLabel, Opens, Plan, PlanContext, Runs}; pub use chain::{ - all, All, Batch, EncryptBuilder, EncryptFields, EncryptIndexed, EncryptUsing, + all, All, Batch, Chooses, EncryptBuilder, EncryptFields, EncryptIndexed, EncryptUsing, EncryptWithContext, KeysetChoice, OpenBuilder, OpenUsing, Operation, PlanKms, QueryBuilder, - QueryIndex, QueryOp, QueryUsing, + QueryIndex, QueryIndexBuilder, QueryOp, QueryVia, Resolves, }; pub use error::PlanError; pub use field_ref::{pick, FieldRef}; diff --git a/packages/stack-encrypt/src/plan/value.rs b/packages/stack-encrypt/src/plan/value.rs index 76ae45f23..9ff54b49f 100644 --- a/packages/stack-encrypt/src/plan/value.rs +++ b/packages/stack-encrypt/src/plan/value.rs @@ -52,10 +52,9 @@ impl Plan<(), ()> { /// let email = String::from("bob@example.com"); /// let (c, eq) = cipher.encrypt(&email).context("users/email").using(&email_plan).await?; /// let query_value = cipher - /// .query("bob@example.com") + /// .query(op::Eq, "bob@example.com") /// .context("users/email") - /// .using(&email_plan) - /// .op(op::Eq) + /// .on(&email_plan) /// .await?; /// assert_eq!(query_value, eq); /// let back = cipher.open((c, eq)).context("users/email").using(&email_plan).await?; @@ -152,7 +151,7 @@ impl ValueStart { /// Unlike a fields plan's context field, the context here is a `C`, not /// a [`Label`], so the plan does not run through the chain: /// `cipher.encrypt(..).using`, `cipher.open(..).using` and - /// `cipher.query(..).using` do not take it (it is neither + /// `cipher.query(..).on` do not take it (it is neither /// [`Runs`](super::Runs) nor [`Opens`](super::Opens)), and it has no /// query path. Run it as the derive does, with its two descriptions: /// `keyset.run(plan.encryption_with_context(), &value, context)` and diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 473d21775..2dba9df2c 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -642,27 +642,27 @@ impl PartialEq for MatchTerms { impl Eq for MatchTerms {} -/// The query term of a match index: a Bloom filter over the *needle's* +/// The query term of a match index: a Bloom filter over the *query's* /// tokens, where the stored [`MatchTerms`] is a filter over the whole -/// value's. A row matches when its stored filter holds every position of +/// stored value's. A row matches when its stored filter holds every position of /// the query's ([`MatchTerms::contains`]). /// /// The two are different types because they are different things, even /// where their bytes share an encoding: a query is never stored, and a /// stored filter is never sent as a query. ADR-0009 records why. A -/// `MatchQuery` comes from a query chain ([`op::Matches`](crate::op::Matches)), +/// `MatchQuery` comes from a query chain ([`op::Matches`](struct@crate::op::Matches)), /// never from a target. pub struct MatchQuery { terms: MatchTerms, } impl MatchQuery { - /// The query term of the filter `terms`, derived over a needle. + /// The query term of the filter `terms`, derived over the query's value. pub(crate) fn from_terms(terms: MatchTerms) -> Self { Self { terms } } - /// The set positions of the needle's filter, sorted ascending, no + /// The set positions of the query's filter, sorted ascending, no /// duplicates: what the server tests a stored filter for. pub fn positions(&self) -> &[u16] { self.terms.positions() @@ -673,7 +673,7 @@ impl MatchQuery { self.terms.to_bytes() } - /// The needle's filter. + /// The query's filter. pub(crate) fn filter(&self) -> &MatchTerms { &self.terms } diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index 145cf0be7..7006f16a8 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -115,7 +115,7 @@ impl fmt::Display for IndexSpec { /// /// Implemented for exactly the plaintexts the index's scheme is defined /// over, which is what makes an inapplicable index a compile error: there is -/// no `Index` for [`Match`], because match is defined over text. +/// no `Index` for [`Match`](struct@Match), because match is defined over text. /// /// An index does not seal anything. [`indexed`] puts a ciphertext beside the /// indexes of a field; [`operation`](Self::operation) on its own is the term @@ -163,18 +163,29 @@ pub struct Ope; /// The full-text match index, over text only, with the tokenizer and filter /// parameters its [`MatchConfig`] `O` fixes. /// -/// `Match::default()` is the default configuration ([`DefaultMatch`]); -/// `Match::::new()` is another. The configuration is on the type so -/// that a stored field and the query probing it agree on it by construction. -pub struct Match(PhantomData O>); +/// The value [`Match`](constant@Match) is the index under the default +/// configuration ([`DefaultMatch`]), as `PhantomData` is a value of its +/// type; `Match::::new()` is another. The configuration is on the +/// type so that a stored field and the query probing it agree on it by +/// construction. +pub struct Match { + config: PhantomData O>, +} + +/// The match index under the default configuration, as a value: +/// `.with((Equality, Match))`. +#[allow(non_upper_case_globals)] +pub const Match: Match = Match::new(); impl Match { /// The match index under the configuration `O`. pub const fn new() -> Self { - Self(PhantomData) + Self { + config: PhantomData, + } } } -impl Default for Match { +impl Default for Match { fn default() -> Self { Self::new() } diff --git a/packages/stack-encrypt/tests/derive_plan.rs b/packages/stack-encrypt/tests/derive_plan.rs index 018f35cda..26ae77162 100644 --- a/packages/stack-encrypt/tests/derive_plan.rs +++ b/packages/stack-encrypt/tests/derive_plan.rs @@ -130,9 +130,8 @@ async fn a_derived_record_as_a_typed_field_answers_its_queries() { let mut record = cipher.encrypt(&user).using(&users_plan).await.unwrap(); let email: Email = record.take("email").unwrap(); let query_value = cipher - .query("bob@example.com") - .using(&users_plan.field("email").unwrap()) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&users_plan.field("email").unwrap()) .await .unwrap(); assert_eq!(query_value, email.hm); diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs index bd9e0e5e9..a61f28078 100644 --- a/packages/stack-encrypt/tests/index.rs +++ b/packages/stack-encrypt/tests/index.rs @@ -241,7 +241,7 @@ mod given_a_set_of_indexes { assert_eq!(>::specs(&Ore), [IndexSpec::Ore]); assert_eq!(>::specs(&Ope), [IndexSpec::Ope]); assert_eq!( - >::specs(&Match::default()), + >::specs(&Match), [IndexSpec::Match(MatchOptions::default())] ); assert_eq!( @@ -275,7 +275,7 @@ mod given_a_set_of_indexes { #[test] fn a_match_index_debugs_by_name_whatever_its_configuration() { - assert_eq!(format!("{:?}", Match::default()), "Match"); + assert_eq!(format!("{:?}", Match), "Match"); assert_eq!(format!("{:?}", Match::::new()), "Match"); let copied = Match::::new(); let again = copied; @@ -433,7 +433,7 @@ mod given_a_set_of_indexes { .unwrap(); assert_eq!(q, ope_t, "position 2"); - let four = (Equality, Ore, Ope, Match::default()); + let four = (Equality, Ore, Ope, Match); assert_eq!( Index::::spec(Indexes::::select::(&four)), IndexSpec::Match(MatchOptions::default()), @@ -662,7 +662,7 @@ mod given_encrypted_terms_as_a_target { #[tokio::test] async fn every_term_alone_lowers_to_its_index() { same_bytes::(7, Equality).await; - same_bytes::("hello world".into(), Match::default()).await; + same_bytes::("hello world".into(), Match).await; same_bytes::, _>("Hello World".into(), Match::::new()) .await; same_bytes::, _>(7, Ore).await; @@ -702,7 +702,7 @@ mod given_encrypted_terms_as_a_target { _, >( "Hello World".into(), - (Equality, Match::default(), Ore, Ope, Match::::new()), + (Equality, Match, Ore, Ope, Match::::new()), ) .await; } diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs index 03854af9a..487ffe056 100644 --- a/packages/stack-encrypt/tests/op.rs +++ b/packages/stack-encrypt/tests/op.rs @@ -24,7 +24,7 @@ impl MatchConfig for Exact { } } impl MatchCase for Exact { - type Case = op::Sensitive; + type Case = op::CaseSensitive; } #[derive(Clone)] @@ -64,7 +64,7 @@ fn person() -> Person { fn people_plan() -> Plan { Plan::context("people") .fields() - .encrypt_index::("email", (Equality, Match::default())) + .encrypt_index::("email", (Equality, Match)) .encrypt_index::("name", Match::::new()) .build() .expect("a valid plan") @@ -86,16 +86,15 @@ async fn an_eq_query_is_the_stored_equality_term() { let email: Encrypted<(EqualityTerm, MatchTerms)> = row.take("email").unwrap(); let query: EqualityTerm = cipher - .query("bob@example.com") - .using(&plan.field("email").unwrap()) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&plan.field("email").unwrap()) .await .unwrap(); assert_eq!(query, email.terms.0, "HMAC equality sends what it stores"); } #[tokio::test] -async fn a_matches_query_is_a_filter_over_the_needle_that_the_stored_filter_holds() { +async fn a_matches_query_is_a_filter_over_the_query_value_that_the_stored_filter_holds() { let cipher = stack_cipher().await; let plan = people_plan(); let mut row = cipher.encrypt(&person()).using(&plan).await.unwrap(); @@ -104,22 +103,20 @@ async fn a_matches_query_is_a_filter_over_the_needle_that_the_stored_filter_hold let email_plan = plan.field("email").unwrap(); let query: MatchQuery = cipher - .query("BOB") - .using(&email_plan) - .op(op::Matches::new()) + .query(op::Matches, "BOB") + .on(&email_plan) .await .unwrap(); assert!(stored.matches(&query), "a case-folding index matches BOB"); assert!( query.positions().len() < stored.positions().len(), - "the query is the needle's filter, not the value's" + "the query is the query value's filter, not the stored value's" ); // The operator derives the term the index does, under the same label. let by_index: MatchTerms = cipher - .query("BOB") - .using(&email_plan) - .index(Match::default()) + .query_index(Match, "BOB") + .on(&email_plan) .await .unwrap(); assert_eq!(query.to_bytes(), by_index.to_bytes()); @@ -127,9 +124,8 @@ async fn a_matches_query_is_a_filter_over_the_needle_that_the_stored_filter_hold assert_eq!(query.clone(), query); let elsewhere: MatchQuery = cipher - .query("zzz") - .using(&email_plan) - .op(op::Matches::new()) + .query(op::Matches, "zzz") + .on(&email_plan) .await .unwrap(); assert!(!stored.matches(&elsewhere)); @@ -144,30 +140,24 @@ async fn a_match_index_with_its_own_configuration_is_named_with_op_via() { let name: Encrypted> = row.take("name").unwrap(); let name_plan = plan.field("name").unwrap(); - let exact = |needle: &'static str| { + let exact = |value: &'static str| { cipher - .query(needle) - .using(&name_plan) - .op_via(op::Matches::::new(), Match::::new()) + .query(op::Matches.case_sensitive(), value) + .via(Match::::new()) + .on(&name_plan) }; let hit: MatchQuery = exact("Nguyen").await.unwrap(); assert!(name.terms.matches(&hit)); let miss: MatchQuery = exact("nguyen").await.unwrap(); assert!( !name.terms.matches(&miss), - "a case-sensitive index does not fold the needle" + "a case-sensitive index does not fold the query" ); // The operator's default index is the default configuration, which // this field does not declare. assert_eq!( - plan_error( - cipher - .query("Nguyen") - .using(&name_plan) - .op(op::Matches::new()) - .await - ), + plan_error(cipher.query(op::Matches, "Nguyen").on(&name_plan).await), PlanError::IndexOptions { field: "name".into(), declared: IndexSpec::Match(Exact::options()), @@ -183,9 +173,8 @@ async fn an_operator_the_field_has_no_index_for_is_refused_when_awaited() { assert_eq!( plan_error( cipher - .query("Alice") - .using(&plan.field("name").unwrap()) - .op(op::Eq) + .query(op::Eq, "Alice") + .on(&plan.field("name").unwrap()) .await ), PlanError::IndexNotDeclared { @@ -199,7 +188,7 @@ async fn an_operator_the_field_has_no_index_for_is_refused_when_awaited() { async fn a_one_value_plan_answers_from_its_indexes_by_type() { let cipher = stack_cipher().await; let plan = Plan::context("users/email") - .with::((Equality, Match::default())) + .with::((Equality, Match)) .build() .unwrap(); let stored: Encrypted<(EqualityTerm, MatchTerms)> = cipher @@ -209,16 +198,14 @@ async fn a_one_value_plan_answers_from_its_indexes_by_type() { .unwrap(); let eq = cipher - .query("bob@example.com") - .using(&plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&plan) .await .unwrap(); assert_eq!(eq, stored.terms.0); let matches = cipher - .query("example") - .using(&plan) - .op(op::Matches::new()) + .query(op::Matches, "example") + .on(&plan) .await .unwrap(); assert!(stored.terms.1.matches(&matches)); @@ -233,9 +220,9 @@ async fn a_one_value_plan_answers_from_its_indexes_by_type() { .await .unwrap(); let query = cipher - .query("Ali") - .using(&exact_plan) - .op_via(op::Matches::::new(), Match::::new()) + .query(op::Matches.case_sensitive(), "Ali") + .via(Match::::new()) + .on(&exact_plan) .await .unwrap(); assert!(name.terms.matches(&query)); @@ -255,9 +242,8 @@ async fn a_target_answers_an_operator_through_the_index_it_names() { .await .unwrap(); let query = cipher - .query("bob@example.com") - .using(&eq_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&eq_plan) .await .unwrap(); assert_eq!(query, stored); @@ -273,9 +259,8 @@ async fn a_target_answers_an_operator_through_the_index_it_names() { .await .unwrap(); let query = cipher - .query("bob") - .using(&match_plan) - .op(op::Matches::new()) + .query(op::Matches, "bob") + .on(&match_plan) .await .unwrap(); assert!(stored.matches(&query)); @@ -295,16 +280,16 @@ async fn a_target_with_several_terms_names_the_index_with_op_via() { .await .unwrap(); let eq = cipher - .query("bob@example.com") - .using(&plan) - .op_via(op::Eq, Equality) + .query(op::Eq, "bob@example.com") + .via(Equality) + .on(&plan) .await .unwrap(); assert_eq!(eq, stored.terms.0); let matches = cipher - .query("bob") - .using(&plan) - .op_via(op::Matches::new(), Match::default()) + .query(op::Matches, "bob") + .via(Match) + .on(&plan) .await .unwrap(); assert!(stored.terms.1.matches(&matches)); @@ -318,9 +303,9 @@ async fn a_target_with_several_terms_names_the_index_with_op_via() { assert_eq!( plan_error( cipher - .query("bob") - .using(&one_term) - .op_via(op::Matches::new(), Match::default()) + .query(op::Matches, "bob") + .via(Match) + .on(&one_term) .await ), PlanError::IndexNotDeclared { @@ -328,7 +313,7 @@ async fn a_target_with_several_terms_names_the_index_with_op_via() { index: "match" } ); - let eq = cipher.query("bob").using(&one_term).op(op::Eq).await; + let eq = cipher.query(op::Eq, "bob").on(&one_term).await; assert!(eq.is_ok(), "one term answers what the term answers"); } @@ -347,14 +332,12 @@ async fn operator_queries_settle_together_and_extend_like_the_write() { let (eq, matches) = stack_encrypt::all(( cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .extend(7u64), cipher - .query("bob") - .using(&email_plan) - .op(op::Matches::new()) + .query(op::Matches, "bob") + .on(&email_plan) .extend(7u64), )) .await @@ -363,9 +346,8 @@ async fn operator_queries_settle_together_and_extend_like_the_write() { assert!(email.terms.1.matches(&matches)); let unextended = cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_ne!( @@ -380,7 +362,7 @@ async fn a_query_chain_debugs_without_its_value() { let plan = people_plan(); let email_plan = plan.field("email").unwrap(); let secret = "bob@example.com"; - let shown = format!("{:?}", cipher.query(secret).using(&email_plan).op(op::Eq)); + let shown = format!("{:?}", cipher.query(op::Eq, secret).on(&email_plan)); assert_eq!(shown, "QueryOp { .. }"); } @@ -396,7 +378,7 @@ impl MatchConfig for Mislabelled { } } impl MatchCase for Mislabelled { - type Case = op::Insensitive; + type Case = op::CaseInsensitive; } #[tokio::test] @@ -408,21 +390,21 @@ async fn a_match_case_that_disagrees_with_its_options_is_refused() { .unwrap(); let refused = plan_error( cipher - .query("Bob") - .using(&plan) - .op_via(op::Matches::new(), Match::::new()) + .query(op::Matches, "Bob") + .via(Match::::new()) + .on(&plan) .await, ); assert_eq!( refused, PlanError::MatchCase { - declared: "Insensitive", + declared: "CaseInsensitive", folds: false } ); assert_eq!( refused.to_string(), - "a match configuration declares the case Insensitive but its options keep case" + "a match configuration declares the case CaseInsensitive but its options keep case" ); } @@ -436,9 +418,8 @@ async fn a_refused_operator_query_loads_no_keyset() { .build() .unwrap(); let refused = cipher - .query("Alice") - .using(&plan.field("name").unwrap()) - .op(op::Eq) + .query(op::Eq, "Alice") + .on(&plan.field("name").unwrap()) .keyset("tenant") .await; assert_eq!( @@ -460,9 +441,8 @@ async fn a_match_query_debugs_as_its_positions() { let cipher = stack_cipher().await; let plan = people_plan(); let query = cipher - .query("bob") - .using(&plan.field("email").unwrap()) - .op(op::Matches::new()) + .query(op::Matches, "bob") + .on(&plan.field("email").unwrap()) .await .unwrap(); assert_eq!( @@ -485,17 +465,15 @@ async fn an_operator_query_runs_under_the_keyset_it_names() { .await .unwrap(); let under_tenant = cipher - .query("bob@example.com") - .using(&plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&plan) .keyset("tenant") .await .unwrap(); assert_eq!(under_tenant, stored.terms); let under_default = cipher - .query("bob@example.com") - .using(&plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&plan) .await .unwrap(); assert_ne!(under_default, stored.terms, "another keyset, another key"); diff --git a/packages/stack-encrypt/tests/plan.rs b/packages/stack-encrypt/tests/plan.rs index 76133e69d..d2f3ac879 100644 --- a/packages/stack-encrypt/tests/plan.rs +++ b/packages/stack-encrypt/tests/plan.rs @@ -102,7 +102,7 @@ type AgeTerms = (EqualityTerm, OreTerm); fn users_plan() -> Plan { Plan::context("users") .fields() - .encrypt_index::("email", (Equality, Match::default())) + .encrypt_index::("email", (Equality, Match)) .encrypt_index::("age", (Equality, Ore)) .encrypt::("notes") .passthrough::("id") @@ -134,7 +134,7 @@ type HandOut = ( u64, ); fn hand_composed<'s, K: 'static>() -> Encryption<'s, User, HandOut, K, DeclaredContext> { - indexed::((Equality, Match::default())) + indexed::((Equality, Match)) .under(nonempty!("users").with("email")) .project(|u: &User| &u.email) .zip( @@ -159,7 +159,7 @@ async fn the_chain_is_the_hand_composed_combinators_field_for_field() { .encrypt(&user) .context("users") .fields() - .encrypt_index::("email", (Equality, Match::default())) + .encrypt_index::("email", (Equality, Match)) .encrypt_index::("age", (Equality, Ore)) .encrypt::("notes") .passthrough::("id") @@ -255,7 +255,7 @@ async fn a_saved_plan_is_the_same_chain_and_decrypts_back() { .encrypt(&user) .context("users") .fields() - .encrypt_index::("email", (Equality, Match::default())) + .encrypt_index::("email", (Equality, Match)) .encrypt_index::("age", (Equality, Ore)) .encrypt::("notes") .passthrough::("id") @@ -290,9 +290,8 @@ async fn a_query_derives_under_the_label_the_write_used() { let email_plan = users_plan.field("email").unwrap(); let age_plan = users_plan.field("age").unwrap(); let eq: EqualityTerm = cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_eq!( @@ -300,25 +299,18 @@ async fn a_query_derives_under_the_label_the_write_used() { "equality query bytes equal the stored term" ); let matching: MatchTerms = cipher - .query("bob@example.com") - .using(&email_plan) - .index(Match::default()) + .query_index(Match, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_eq!(matching.to_bytes(), email.terms.1.to_bytes()); - let ore: OreTerm = cipher - .query(&34u32) - .using(&age_plan) - .index(Ore) - .await - .unwrap(); + let ore: OreTerm = cipher.query_index(Ore, &34u32).on(&age_plan).await.unwrap(); assert_eq!(ore.to_bytes(), age.terms.1.to_bytes()); // A different value is a different term: the query is not a constant. let other = cipher - .query("eve@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "eve@example.com") + .on(&email_plan) .await .unwrap(); assert_ne!(other, email.terms.0); @@ -337,12 +329,12 @@ async fn a_query_extended_like_the_write_matches_it() { let email: Encrypted = row.take("email").unwrap(); let email_plan = users_plan.field("email").unwrap(); let query = |extend: Option| { - let chain = cipher.query("bob@example.com").using(&email_plan); + let chain = cipher.query(op::Eq, "bob@example.com"); let chain = match extend { Some(parts) => chain.extend(parts), None => chain, }; - chain.op(op::Eq) + chain.on(&email_plan) }; assert_eq!(query(Some(7)).await.unwrap(), email.terms.0); assert_ne!(query(None).await.unwrap(), email.terms.0); @@ -357,21 +349,21 @@ async fn a_query_against_an_undeclared_index_or_type_is_refused() { let notes_plan = users_plan.field("notes").unwrap(); assert_eq!( - plan_error(cipher.query(&34u32).using(&age_plan).index(Ope).await), + plan_error(cipher.query_index(Ope, &34u32).on(&age_plan).await), PlanError::IndexNotDeclared { field: "age".into(), index: "ope" } ); assert_eq!( - plan_error(cipher.query("x").using(¬es_plan).op(op::Eq).await), + plan_error(cipher.query(op::Eq, "x").on(¬es_plan).await), PlanError::IndexNotDeclared { field: "notes".into(), index: "eq" } ); assert_eq!( - plan_error(cipher.query(&34u64).using(&age_plan).op(op::Eq).await), + plan_error(cipher.query(op::Eq, &34u64).on(&age_plan).await), PlanError::FieldType { field: "age".into(), expected: "u32" @@ -403,19 +395,9 @@ async fn a_one_value_plan_writes_queries_by_type_and_reads_back() { assert_eq!(chained.terms.0, out.terms.0); assert_eq!(chained.terms.1.to_bytes(), out.terms.1.to_bytes()); - let eq = cipher - .query(&34u32) - .using(&age_plan) - .op(op::Eq) - .await - .unwrap(); + let eq = cipher.query(op::Eq, &34u32).on(&age_plan).await.unwrap(); assert_eq!(eq, out.terms.0); - let ore = cipher - .query(&34u32) - .using(&age_plan) - .index::() - .await - .unwrap(); + let ore = cipher.query_index(Ore, &34u32).on(&age_plan).await.unwrap(); assert_eq!(ore.to_bytes(), out.terms.1.to_bytes()); // Byte-identical to the combinator, under the same label. @@ -511,7 +493,7 @@ async fn a_one_value_plan_extended_on_write_needs_the_extension_to_query_and_ope assert_eq!(sent.lock().unwrap().generated(), ["(users/age)/7u64"]); let query = |parts: Option| { - let chain = cipher.query(&34u32).using(&age_plan).op(op::Eq); + let chain = cipher.query(op::Eq, &34u32).on(&age_plan); match parts { Some(parts) => chain.extend(parts), None => chain, @@ -546,7 +528,7 @@ async fn an_index_only_field_matches_its_query_and_mints_no_data_key() { let (cipher, sent) = recording_cipher().await; let tokens_plan: Plan = Plan::context("users") .fields() - .index::("email", (Equality, Match::default())) + .index::("email", (Equality, Match)) .encrypt::("age") .encrypt::("notes") .passthrough::("id") @@ -562,9 +544,8 @@ async fn an_index_only_field_matches_its_query_and_mints_no_data_key() { let email_plan = tokens_plan.field("email").unwrap(); let query = cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_eq!(query, eq, "the query derives under the write's label"); @@ -862,9 +843,8 @@ async fn a_refused_chain_never_loads_the_keyset_it_names() { assert!(matches!( plan_error( cipher - .query(&34u32) - .using(&age_plan) - .index(Ope) + .query_index(Ope, &34u32) + .on(&age_plan) .keyset("tenant") .await ), @@ -964,13 +944,7 @@ async fn a_passthrough_name_need_not_be_a_label_segment() { let passthrough_plan = odd_plan.field("2fa_enabled").unwrap(); assert_eq!( - plan_error( - cipher - .query(&1u64) - .using(&passthrough_plan) - .op(op::Eq) - .await - ), + plan_error(cipher.query(op::Eq, &1u64).on(&passthrough_plan).await), PlanError::IndexNotDeclared { field: "2fa_enabled".into(), index: "eq" @@ -1230,9 +1204,8 @@ async fn match_options_must_agree_for_a_query_to_be_declared() { let email_plan = users_plan.field("email").unwrap(); let refused = plan_error( cipher - .query("bob") - .using(&email_plan) - .index(Match::::new()) + .query_index(Match::::new(), "bob") + .on(&email_plan) .await, ); assert_eq!( @@ -1260,10 +1233,7 @@ async fn all_settles_several_chains_in_one_request() { let (row, probe) = stack_encrypt::all(( cipher.encrypt(&user).using(&users_plan), - cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq), + cipher.query(op::Eq, "bob@example.com").on(&email_plan), )) .await .unwrap(); @@ -1748,9 +1718,14 @@ async fn a_chain_shows_no_value() { format!("{:?}", cipher.encrypt(&secret).context("x").with(Equality)), format!("{:?}", cipher.encrypt(&user()).context("x").fields()), format!("{:?}", cipher.encrypt(&user()).using(&users_plan)), - format!("{:?}", cipher.query(&secret)), - format!("{:?}", cipher.query(&secret).using(&email_plan)), - format!("{:?}", cipher.query(&secret).using(&email_plan).op(op::Eq)), + format!("{:?}", cipher.query(op::Eq, &secret)), + format!("{:?}", cipher.query(op::Eq, &secret).via(Equality)), + format!("{:?}", cipher.query(op::Eq, &secret).on(&email_plan)), + format!("{:?}", cipher.query_index(Equality, &secret)), + format!( + "{:?}", + cipher.query_index(Equality, &secret).on(&email_plan) + ), format!("{:?}", cipher.open(secret.clone())), format!("{:?}", cipher.open(FieldValues::new()).using(&users_plan)), format!( @@ -1873,16 +1848,14 @@ async fn every_chain_honours_its_named_keyset() { let email: Encrypted = row.take("email").unwrap(); let email_plan = users_plan.field("email").unwrap(); let under_tenant = cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .keyset("tenant") .await .unwrap(); let under_default = cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_eq!(under_tenant, email.terms.0); diff --git a/packages/stack-encrypt/tests/plan_grammar.rs b/packages/stack-encrypt/tests/plan_grammar.rs index 100c20f72..db9d94114 100644 --- a/packages/stack-encrypt/tests/plan_grammar.rs +++ b/packages/stack-encrypt/tests/plan_grammar.rs @@ -142,10 +142,9 @@ async fn a_plan_built_without_a_context_takes_the_calls() { let stored: Encrypted = record.take("email").unwrap(); let query_value = cipher - .query("bob@example.com") + .query(op::Eq, "bob@example.com") .context("tenants/acme") - .using(&email_plan) - .op(op::Eq) + .on(&email_plan) .await .unwrap(); assert_eq!( @@ -153,10 +152,9 @@ async fn a_plan_built_without_a_context_takes_the_calls() { "a query naming the same context matches" ); let elsewhere = cipher - .query("bob@example.com") + .query(op::Eq, "bob@example.com") .context("tenants/globex") - .using(&email_plan) - .op(op::Eq) + .on(&email_plan) .await .unwrap(); assert_ne!(elsewhere, stored.terms); @@ -186,9 +184,8 @@ async fn a_plan_run_with_no_context_is_refused_before_any_key_request() { assert_eq!( plan_error( cipher - .query("bob@example.com") - .using(&users_plan.field("email").unwrap()) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&users_plan.field("email").unwrap()) .await ), PlanError::NoContext @@ -211,7 +208,7 @@ async fn a_plan_run_with_no_context_is_refused_before_any_key_request() { PlanError::NoContext ); assert_eq!( - plan_error(cipher.query(&34u32).using(&age_plan).op(op::Eq).await), + plan_error(cipher.query(op::Eq, &34u32).on(&age_plan).await), PlanError::NoContext ); let age = cipher @@ -290,10 +287,9 @@ async fn a_context_given_twice_is_refused_at_the_call_before_any_key_request() { assert_eq!( plan_error( cipher - .query("bob@example.com") + .query(op::Eq, "bob@example.com") .context("users") - .using(&users_plan.field("email").unwrap()) - .op(op::Eq) + .on(&users_plan.field("email").unwrap()) .await ), two("the plan", "the call") @@ -340,10 +336,9 @@ async fn a_context_given_twice_is_refused_at_the_call_before_any_key_request() { assert_eq!( plan_error( cipher - .query(&34u32) + .query(op::Eq, &34u32) .context("users/age") - .using(&age_plan) - .op(op::Eq) + .on(&age_plan) .await ), two("the plan", "the call") @@ -366,10 +361,9 @@ async fn a_call_context_that_is_not_a_label_is_refused() { ))); assert!(bad(plan_error( cipher - .query("bob@example.com") + .query(op::Eq, "bob@example.com") .context("a//b") - .using(&users_plan.field("email").unwrap()) - .op(op::Eq) + .on(&users_plan.field("email").unwrap()) .await ))); assert!(bad(plan_error( @@ -837,10 +831,9 @@ async fn a_context_field_is_the_context_of_every_other_field() { // A query names the context field's value. let query_value = cipher - .query("bob@example.com") + .query(op::Eq, "bob@example.com") .context("tenants/acme") - .using(&records_plan.field("email").unwrap()) - .op(op::Eq) + .on(&records_plan.field("email").unwrap()) .await .unwrap(); assert_eq!(query_value, stored.terms); @@ -1091,7 +1084,7 @@ fn a_five_index_field_is_refused_by_both_spellings() { .fields::, FakeDataKeySource>() .encrypt_index( pick("v", |o: &One| &o.v), - (Equality, Match::default(), Ore, Ope, Match::::new()), + (Equality, Match, Ore, Ope, Match::::new()), ) .build(); assert_eq!(plan_error(data), refused()); @@ -1129,25 +1122,22 @@ async fn a_typed_field_answers_the_queries_its_target_declares_and_no_other() { let email_plan = users_plan.field("email").unwrap(); let query_value = cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_eq!(query_value, stored.terms.0); let matched = cipher - .query("bob@example.com") - .using(&email_plan) - .index(Match::default()) + .query_index(Match, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_eq!(matched, stored.terms.1); assert_eq!( plan_error( cipher - .query("bob@example.com") - .using(&email_plan) - .index(Ore) + .query_index(Ore, "bob@example.com") + .on(&email_plan) .await ), PlanError::IndexNotDeclared { @@ -1158,9 +1148,8 @@ async fn a_typed_field_answers_the_queries_its_target_declares_and_no_other() { assert_eq!( plan_error( cipher - .query(&34u32) - .using(&users_plan.field("age").unwrap()) - .op(op::Eq) + .query(op::Eq, &34u32) + .on(&users_plan.field("age").unwrap()) .await ), PlanError::IndexNotDeclared { @@ -1184,9 +1173,8 @@ async fn a_typed_field_of_terms_alone_does_not_come_back() { let mut record = cipher.encrypt(&user()).using(&users_plan).await.unwrap(); let term: EqualityTerm = record.take("email").unwrap(); let query_value = cipher - .query("bob@example.com") - .using(&users_plan.field("email").unwrap()) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&users_plan.field("email").unwrap()) .await .unwrap(); assert_eq!(query_value, term); @@ -1333,18 +1321,16 @@ async fn a_one_value_typed_plan_answers_its_targets_queries_and_no_other() { let email = String::from("bob@example.com"); let (_, hm) = cipher.encrypt(&email).using(&email_plan).await.unwrap(); let query_value = cipher - .query("bob@example.com") - .using(&email_plan) - .op(op::Eq) + .query(op::Eq, "bob@example.com") + .on(&email_plan) .await .unwrap(); assert_eq!(query_value, hm); assert_eq!( plan_error( cipher - .query("bob@example.com") - .using(&email_plan) - .index(Ore) + .query_index(Ore, "bob@example.com") + .on(&email_plan) .await ), PlanError::IndexNotDeclared { @@ -1368,10 +1354,9 @@ async fn a_one_value_plan_quotes_no_call_context_in_its_refusals() { let error = plan_error( cipher - .query("bob@example.com") + .query_index(Ore, "bob@example.com") .context(marker) - .using(&plan) - .index(Ore) + .on(&plan) .await, ); assert_eq!( @@ -1631,9 +1616,8 @@ async fn a_pinned_identity_rescues_a_field_name_that_is_not_plain() { ); let value: Encrypted = record.take("0").unwrap(); let query_value = cipher - .query(&21u32) - .using(&readings_plan.field("0").unwrap()) - .op(op::Eq) + .query(op::Eq, &21u32) + .on(&readings_plan.field("0").unwrap()) .await .unwrap(); assert_eq!(query_value, value.terms); @@ -1684,10 +1668,9 @@ async fn a_pinned_identity_keys_a_call_context_plan_for_query_and_open() { assert_eq!(sent.lock().unwrap().generated(), ["reading/value"]); let stored: Encrypted = record.take("0").unwrap(); let query_value = cipher - .query(&21u32) + .query(op::Eq, &21u32) .context("reading") - .using(&readings_plan.field("0").unwrap()) - .op(op::Eq) + .on(&readings_plan.field("0").unwrap()) .await .unwrap(); assert_eq!(query_value, stored.terms); diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs index 6b363cb74..e601f4dd2 100644 --- a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs @@ -4,5 +4,5 @@ use stack_encrypt::target::{indexed, Borrowed, Equality, Match}; use stack_kms::FakeDataKeySource; fn main() { - let _ = indexed::((Equality, Match::default())); + let _ = indexed::((Equality, Match)); } diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr index cb38e177b..f2afb651f 100644 --- a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr @@ -1,8 +1,8 @@ error[E0277]: the trait bound `u32: AsRef` is not satisfied --> tests/ui/match_in_a_tuple_on_integer.rs:7:13 | -7 | let _ = indexed::((Equality, Match::default())); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` +7 | let _ = indexed::((Equality, Match)); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` | help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` --> src/target/index.rs diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.rs b/packages/stack-encrypt/tests/ui/match_on_integer.rs index 37298c7ee..39da28605 100644 --- a/packages/stack-encrypt/tests/ui/match_on_integer.rs +++ b/packages/stack-encrypt/tests/ui/match_on_integer.rs @@ -4,5 +4,5 @@ use stack_encrypt::target::{indexed, Borrowed, Match}; use stack_kms::FakeDataKeySource; fn main() { - let _ = indexed::(Match::default()); + let _ = indexed::(Match); } diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_on_integer.stderr index 309411cc5..2843b7847 100644 --- a/packages/stack-encrypt/tests/ui/match_on_integer.stderr +++ b/packages/stack-encrypt/tests/ui/match_on_integer.stderr @@ -1,8 +1,8 @@ error[E0277]: the trait bound `u32: AsRef` is not satisfied --> tests/ui/match_on_integer.rs:7:13 | -7 | let _ = indexed::(Match::default()); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` +7 | let _ = indexed::(Match); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` | help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` --> src/target/index.rs diff --git a/packages/stack-encrypt/tests/ui/op_case_mismatch.rs b/packages/stack-encrypt/tests/ui/op_case_mismatch.rs index ab1c0c2e4..6e1d2e9a5 100644 --- a/packages/stack-encrypt/tests/ui/op_case_mismatch.rs +++ b/packages/stack-encrypt/tests/ui/op_case_mismatch.rs @@ -1,18 +1,18 @@ //! The default match index folds case, so it answers only a //! case-insensitive `Matches`: asking it for a case-sensitive one does not -//! compile, rather than quietly folding the needle. +//! compile, rather than quietly folding the query. use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::{op, Match, Plan, StackCipher}; async fn query(cipher: &StackCipher) { let name_plan = Plan::context("users/name") - .with::(Match::default()) + .with::(Match) .build() .unwrap(); let _ = cipher - .query("Ali") - .using(&name_plan) - .op_via(op::Matches::::new(), Match::default()); + .query(op::Matches.case_sensitive(), "Ali") + .via(Match) + .on(&name_plan); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_case_mismatch.stderr b/packages/stack-encrypt/tests/ui/op_case_mismatch.stderr index af453429e..fd7634477 100644 --- a/packages/stack-encrypt/tests/ui/op_case_mismatch.stderr +++ b/packages/stack-encrypt/tests/ui/op_case_mismatch.stderr @@ -1,26 +1,37 @@ -error[E0308]: mismatched types - --> tests/ui/op_case_mismatch.rs:15:17 +error[E0271]: type mismatch resolving `::Case == CaseSensitive` + --> tests/ui/op_case_mismatch.rs:14:14 | -15 | .op_via(op::Matches::::new(), Match::default()); - | ------ ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected `Matches`, found `Matches` +14 | .via(Match) + | --- ^^^^^ expected `CaseSensitive`, found `CaseInsensitive` | | - | arguments to this method are incorrect + | required by a bound introduced by this call | - = note: expected struct `stack_encrypt::op::Matches` - found struct `stack_encrypt::op::Matches` -help: the return type of this call is `stack_encrypt::op::Matches` due to the type of the argument passed - --> tests/ui/op_case_mismatch.rs:12:13 +note: required by a bound in `QueryBuilder::<'a, Op, F, K>::via` + --> src/plan/chain.rs + | + | pub fn via>(self, index: I) -> QueryVia<'a, Op, F, I, K> { + | ^^^^^^^^^^^^^^^ required by this bound in `QueryBuilder::<'a, Op, F, K>::via` + +error[E0599]: the method `on` exists for struct `QueryVia<'_, stack_encrypt::op::Matches, std::string::String, stack_encrypt::Match, FakeDataKeySource>`, but its trait bounds were not satisfied + --> tests/ui/op_case_mismatch.rs:15:10 | 12 | let _ = cipher - | _____________^ -13 | | .query("Ali") -14 | | .using(&name_plan) -15 | | .op_via(op::Matches::::new(), Match::default()); - | |_________________-----------------------------------__________________^ - | | - | this argument influences the return type of `op_via` -note: method defined here - --> src/plan/chain.rs + | _____________- +13 | | .query(op::Matches.case_sensitive(), "Ali") +14 | | .via(Match) +15 | | .on(&name_plan); + | |_________-^^ + | + ::: src/target/index.rs + | + | pub struct Match { + | ---------------------------------- doesn't satisfy `_: Supports, String>` + | + ::: src/sem/mod.rs + | + | pub struct DefaultMatch; + | ----------------------- doesn't satisfy `<_ as MatchCase>::Case = CaseSensitive` | - | pub fn op_via(self, _op: Op, _index: I) -> QueryOp<'a, F, Op, I, K> - | ^^^^^^ + = note: the following trait bounds were not satisfied: + `::Case = CaseSensitive` + which is required by `stack_encrypt::Match: Supports, std::string::String>` diff --git a/packages/stack-encrypt/tests/ui/op_matches_on_integer.rs b/packages/stack-encrypt/tests/ui/op_matches_on_integer.rs index b92c7917c..8408d9b3f 100644 --- a/packages/stack-encrypt/tests/ui/op_matches_on_integer.rs +++ b/packages/stack-encrypt/tests/ui/op_matches_on_integer.rs @@ -5,7 +5,7 @@ use stack_encrypt::{op, Equality, Plan, StackCipher}; async fn query(cipher: &StackCipher) { let age_plan = Plan::context("users/age").with::(Equality).build().unwrap(); - let _ = cipher.query(&34u32).using(&age_plan).op(op::Matches::new()); + let _ = cipher.query(op::Matches, &34u32).on(&age_plan); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr b/packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr index 966ebf8f9..5f15c0684 100644 --- a/packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr +++ b/packages/stack-encrypt/tests/ui/op_matches_on_integer.stderr @@ -1,21 +1,73 @@ -error[E0271]: type mismatch resolving `>::Via == Equality` - --> tests/ui/op_matches_on_integer.rs:8:51 +error[E0277]: the trait bound `u32: AsRef` is not satisfied + --> tests/ui/op_matches_on_integer.rs:8:47 + | +8 | let _ = cipher.query(op::Matches, &34u32).on(&age_plan); + | ^^ the trait `AsRef` is not implemented for `u32` | -8 | let _ = cipher.query(&34u32).using(&age_plan).op(op::Matches::new()); - | ^^ expected `Equality`, found `Match` +help: the following other types implement trait `Chooses` + --> src/plan/chain.rs | - = note: expected struct `stack_encrypt::Equality` - found struct `stack_encrypt::Match` + | / impl Chooses for ValuePlan> + | | where + | | Op: DefaultIndex, + | | Op::Via: Clone, + | | F: 'static, + | | X: Indexes + Select, + | |________________________________________^ `ValuePlan>` implements `Chooses` +... + | / impl Chooses for ValuePlan> + | | where + | | Op: Operator, + | | F: 'static, + | | T: Answers, + | | T::Via: Default, + | | Typed: ValueShape, + | |____________________________^ `ValuePlan>` implements `Chooses` + = note: required for `stack_encrypt::op::Matches` to implement `DefaultIndex` + = note: required for `ValuePlan>` to implement `Chooses` -error[E0277]: the trait bound `u32: AsRef` is not satisfied - --> tests/ui/op_matches_on_integer.rs:8:51 +error[E0277]: the trait bound `stack_encrypt::Equality: Select` is not satisfied + --> tests/ui/op_matches_on_integer.rs:8:47 | -8 | let _ = cipher.query(&34u32).using(&age_plan).op(op::Matches::new()); - | ^^ the trait `AsRef` is not implemented for `u32` +8 | let _ = cipher.query(op::Matches, &34u32).on(&age_plan); + | ^^ the trait `Select` is not implemented for `stack_encrypt::Equality` | -help: the trait `DefaultIndex` is implemented for `stack_encrypt::op::Matches` - --> src/op.rs + = help: the following other types implement trait `Select`: + `(A, B)` implements `Select>` + `(A, B)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + and $N others + = note: required for `ValuePlan>` to implement `Chooses` + +error[E0277]: the trait bound `stack_encrypt::Equality: Select` is not satisfied + --> tests/ui/op_matches_on_integer.rs:8:50 | - | impl> DefaultIndex for Matches<::Case> { - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ - = note: required for `stack_encrypt::op::Matches` to implement `DefaultIndex` +8 | let _ = cipher.query(op::Matches, &34u32).on(&age_plan); + | -- ^^^^^^^^^ the trait `Select` is not implemented for `stack_encrypt::Equality` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `Select`: + `(A, B)` implements `Select>` + `(A, B)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + and $N others + = note: required for `ValuePlan>` to implement `Chooses` +note: required by a bound in `QueryBuilder::<'a, Op, F, K>::on` + --> src/plan/chain.rs + | + | pub fn on(self, plan: &P) -> QueryOp<'a, F, Op, P::Via, K> + | -- required by a bound in this associated function + | where + | P: Chooses, + | ^^^^^^^^^^^^^^^^^^ required by this bound in `QueryBuilder::<'a, Op, F, K>::on` diff --git a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs index 0b0bdc6e4..3dee1da94 100644 --- a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs +++ b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.rs @@ -10,7 +10,7 @@ async fn query(cipher: &StackCipher) { .encrypt_into::() .build() .unwrap(); - let _ = cipher.query("bob").using(&email_plan).op(op::Matches::new()); + let _ = cipher.query(op::Matches, "bob").on(&email_plan); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr index 3a2dacda9..8fe0edf23 100644 --- a/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr +++ b/packages/stack-encrypt/tests/ui/op_target_does_not_answer.stderr @@ -1,22 +1,15 @@ -error[E0308]: mismatched types - --> tests/ui/op_target_does_not_answer.rs:13:55 +error[E0277]: `EqualityTerm` does not answer `stack_encrypt::op::Matches` over `std::string::String` + --> tests/ui/op_target_does_not_answer.rs:13:46 | -13 | let _ = cipher.query("bob").using(&email_plan).op(op::Matches::new()); - | -- ^^^^^^^^^^^^^^^^^^ expected `Eq`, found `Matches<_>` - | | - | arguments to this method are incorrect +13 | let _ = cipher.query(op::Matches, "bob").on(&email_plan); + | ^^ this target type does not answer the operator | - = note: expected struct `stack_encrypt::op::Eq` - found struct `stack_encrypt::op::Matches<_>` -help: the return type of this call is `stack_encrypt::op::Matches<_>` due to the type of the argument passed - --> tests/ui/op_target_does_not_answer.rs:13:13 + = note: a target answers the operators its type implements `Answers` for; `.via(index)` names an index the target declares +help: the trait `Answers` is not implemented for `EqualityTerm` + but trait `Answers` is implemented for it + --> src/op.rs | -13 | let _ = cipher.query("bob").using(&email_plan).op(op::Matches::new()); - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^------------------^ - | | - | this argument influences the return type of `op` -note: method defined here - --> src/plan/chain.rs - | - | pub fn op(self, op: Op) -> QueryOp<'a, F, Op, T::Via, K> - | ^^ + | impl Answers for EqualityTerm { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = help: for that trait implementation, expected `stack_encrypt::op::Eq`, found `stack_encrypt::op::Matches` + = note: required for `ValuePlan>` to implement `Chooses` diff --git a/packages/stack-encrypt/tests/ui/pass/indexes.rs b/packages/stack-encrypt/tests/ui/pass/indexes.rs index 4079dd413..eca5a09c6 100644 --- a/packages/stack-encrypt/tests/ui/pass/indexes.rs +++ b/packages/stack-encrypt/tests/ui/pass/indexes.rs @@ -16,7 +16,7 @@ fn main() { let _ = indexed::(Equality); let _ = indexed::((Equality, Ore)); let _ = indexed::((Equality, Ore, Ope)); - let _ = indexed::(Match::default()); + let _ = indexed::(Match); let _ = indexed::(( Equality, Match::::new(), @@ -25,7 +25,7 @@ fn main() { )); let _ = indexed::(( Equality, - Match::default(), + Match, Ore, Ope, Match::::new(), diff --git a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs index 4a51aa731..79347a38d 100644 --- a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs +++ b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs @@ -34,7 +34,7 @@ async fn chains(cipher: &StackCipher, person: &Person) -> Res .encrypt(person) .context("people") .fields() - .encrypt_index::("email", (Equality, Match::default())) + .encrypt_index::("email", (Equality, Match)) .encrypt::("age") .keyset("tenant") .extend(7u64) @@ -47,18 +47,18 @@ async fn chains(cipher: &StackCipher, person: &Person) -> Res let row = cipher.encrypt(person).using(&people_plan).await?; let _ = cipher.open(row).using(&people_plan).await?; let email_plan = people_plan.field("email")?; - let _ = cipher.query("bob@example.com").using(&email_plan).op(op::Eq).await?; + let _ = cipher.query(op::Eq, "bob@example.com").on(&email_plan).await?; let name_plan = Plan::context("people/name") - .with::(stack_encrypt::Match::default()) + .with::(stack_encrypt::Match) .build()?; - let _ = cipher.query("ali").using(&name_plan).op(op::Matches::new()).await?; + let _ = cipher.query(op::Matches, "ali").on(&name_plan).await?; let age_plan = Plan::context("people/age").with((Equality, Ore)).build()?; let ages = vec![1u32, 2]; let _ = cipher.encrypt(&ages).using(&age_plan).await?; - let _ = cipher.query(&3u32).using(&age_plan).index::().await?; + let _ = cipher.query_index(Ore, &3u32).on(&age_plan).await?; let _ = stack_encrypt::all(( cipher.encrypt(person).using(&people_plan), - cipher.query(&3u32).using(&age_plan).op(op::Eq), + cipher.query(op::Eq, &3u32).on(&age_plan), )) .await?; Ok(()) diff --git a/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs b/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs index ca5ba34f3..699d6163b 100644 --- a/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs +++ b/packages/stack-encrypt/tests/ui/plan_match_on_integer.rs @@ -21,5 +21,5 @@ impl Field for User { fn main() { let _ = Plan::context("users") .fields::() - .encrypt_index::("age", Match::default()); + .encrypt_index::("age", Match); } diff --git a/packages/stack-encrypt/tests/ui/plan_match_on_integer.stderr b/packages/stack-encrypt/tests/ui/plan_match_on_integer.stderr index af4212052..35d448a70 100644 --- a/packages/stack-encrypt/tests/ui/plan_match_on_integer.stderr +++ b/packages/stack-encrypt/tests/ui/plan_match_on_integer.stderr @@ -1,7 +1,7 @@ error[E0277]: the trait bound `u32: AsRef` is not satisfied --> tests/ui/plan_match_on_integer.rs:24:10 | -24 | .encrypt_index::("age", Match::default()); +24 | .encrypt_index::("age", Match); | ^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` | help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` diff --git a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs index ce1440102..26f9f5e92 100644 --- a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs +++ b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.rs @@ -6,7 +6,7 @@ use stack_encrypt::{Ore, Plan, StackCipher}; async fn query(cipher: &StackCipher) { let age_plan = Plan::context("users/age").with::(Ore).build().unwrap(); - let _ = cipher.query(&34u32).using(&age_plan).op(op::Eq); + let _ = cipher.query(op::Eq, &34u32).on(&age_plan); } fn main() {} diff --git a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr index 3a5c76063..a26c14f20 100644 --- a/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr +++ b/packages/stack-encrypt/tests/ui/plan_query_undeclared_index.stderr @@ -1,5 +1,45 @@ -error[E0271]: type mismatch resolving `>::Via == Ore` - --> tests/ui/plan_query_undeclared_index.rs:9:51 +error[E0277]: the trait bound `stack_encrypt::Ore: Select` is not satisfied + --> tests/ui/plan_query_undeclared_index.rs:9:42 | -9 | let _ = cipher.query(&34u32).using(&age_plan).op(op::Eq); - | ^^ expected `Ore`, found `Equality` +9 | let _ = cipher.query(op::Eq, &34u32).on(&age_plan); + | ^^ the trait `Select` is not implemented for `stack_encrypt::Ore` + | + = help: the following other types implement trait `Select`: + `(A, B)` implements `Select>` + `(A, B)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + and $N others + = note: required for `ValuePlan>` to implement `Chooses` + +error[E0277]: the trait bound `stack_encrypt::Ore: Select` is not satisfied + --> tests/ui/plan_query_undeclared_index.rs:9:45 + | +9 | let _ = cipher.query(op::Eq, &34u32).on(&age_plan); + | -- ^^^^^^^^^ the trait `Select` is not implemented for `stack_encrypt::Ore` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `Select`: + `(A, B)` implements `Select>` + `(A, B)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + `(A, B, C, D)` implements `Select>` + and $N others + = note: required for `ValuePlan>` to implement `Chooses` +note: required by a bound in `QueryBuilder::<'a, Op, F, K>::on` + --> src/plan/chain.rs + | + | pub fn on(self, plan: &P) -> QueryOp<'a, F, Op, P::Via, K> + | -- required by a bound in this associated function + | where + | P: Chooses, + | ^^^^^^^^^^^^^^^^^^ required by this bound in `QueryBuilder::<'a, Op, F, K>::on` From ce047679b66b7d811e80f2b59c869c9abe7b2e82 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sat, 17 Oct 2026 12:01:46 +1100 Subject: [PATCH 4/4] fix(stack-encrypt): address the second review of the operator-typed query - `query_index` takes only an index no operator answers yet: `Ore` and `Ope`, the sealed `plan::Unanswered` indexes. `query_index(Match, v)` handed back a stored `MatchTerms` built from a query value, skipping `MatchQuery` and the `MatchCase` check. It no longer compiles, and `tests/ui/query_index_answered.rs` pins the diagnostic. The tests that used it for `Match` or `Equality` now query through the operator. - A query keeps the plan's own refusal. `QueryOp::new` overwrote an `IndexNotDeclared`, `IndexOptions` or `NoContext` label with `PlanError::MatchCase`, so the more basic fault surfaced only after the case was fixed. - `QueryVia` takes `.context(..)`, so the context can be named before or after `.via(index)`, as every other chain allows. - `MatchTerms::contains` names its parameter `other` and says it compares two stored terms; `MatchQuery`'s doc names `matches` as the query check. - The CHANGELOG gives the new signature, `cipher.query(op, v)`. --- packages/stack-encrypt/CHANGELOG.md | 13 ++-- packages/stack-encrypt/src/plan/chain.rs | 47 ++++++++++-- packages/stack-encrypt/src/plan/mod.rs | 2 +- packages/stack-encrypt/src/sem/mod.rs | 13 ++-- packages/stack-encrypt/tests/op.rs | 75 +++++++++++++++++-- packages/stack-encrypt/tests/plan.rs | 17 +++-- packages/stack-encrypt/tests/plan_grammar.rs | 4 +- .../stack-encrypt/tests/ui/pass/plan_chain.rs | 6 ++ .../tests/ui/query_index_answered.rs | 11 +++ .../tests/ui/query_index_answered.stderr | 25 +++++++ 10 files changed, 176 insertions(+), 37 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/query_index_answered.rs create 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 d02d7bc31..ed1023898 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -75,16 +75,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking -- **The query chain starts with the operator.** `cipher.query(v)` takes the - operator first: `cipher.query(op::Eq, v).on(&plan)` replaces +- **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 queries only (a write still runs `using` a plan). An index's own term, for an index no operator answers yet (`Ore`, `Ope`), is `cipher.query_index(Ore, v).on(&plan)`, which goes when the ordering - operators arrive. `QueryUsing` is gone; the chain's types are - `QueryBuilder`, `QueryVia`, `QueryIndexBuilder`, `QueryIndex` and - `QueryOp`, and the plan shapes implement `plan::Chooses` and - `plan::Resolves`. + operators arrive. `query_index` does not compile for `Equality` or `Match` + (`plan::Unanswered`), since an operator answers both. `QueryUsing` is + gone; the chain's types are `QueryBuilder`, `QueryVia`, + `QueryIndexBuilder`, `QueryIndex` and `QueryOp`, and the plan shapes + implement `plan::Chooses` and `plan::Resolves`. - **`Match::default()` is `Match`.** The match index under the default configuration is the value `Match`, and `Default` is now implemented for every `Match`, so `Match::default()` no longer infers the configuration. diff --git a/packages/stack-encrypt/src/plan/chain.rs b/packages/stack-encrypt/src/plan/chain.rs index 1c7181829..1142a5e21 100644 --- a/packages/stack-encrypt/src/plan/chain.rs +++ b/packages/stack-encrypt/src/plan/chain.rs @@ -302,14 +302,16 @@ impl StackCipher { /// 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. For an index no operator answers yet ([`Ore`] and - /// [`Ope`]); a query for an operator is [`query`](Self::query), and this - /// goes when the ordering operators arrive. + /// 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 { @@ -678,6 +680,13 @@ pub struct QueryVia<'a, Op, F, I, K> { } impl<'a, Op: Operator, F: 'static, I: Supports, K: 'static> QueryVia<'a, Op, F, I, K> { + /// 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 { + self.common.context = Some(context.into_label()); + self + } + /// Query on `plan` through the named index, which the plan must /// declare ([`Resolves`]): a one-value plan without it does not /// compile; a field of a fields plan without it is refused when the @@ -702,6 +711,28 @@ impl<'a, Op: Operator, F: 'static, I: Supports, K: 'static> QueryVia<'a, } } +/// 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> { @@ -927,10 +958,14 @@ pub struct QueryOp<'a, F, Op, I, K> { impl<'a, F, Op: Operator, I: Supports, K: 'static> QueryOp<'a, F, Op, I, K> { /// The chain for `inner`'s index, refused when awaited if the index - /// cannot answer `Op` as it is configured. + /// cannot answer `Op` as it is configured. A refusal the plan already + /// gave (an index it does not declare, or no context) is kept: it is + /// the more basic fault, and the one to fix first. fn new(mut inner: QueryIndex<'a, F, I, K>, _op: Op) -> Self { - if let Err(error) = inner.index.check() { - inner.label = Err(error); + if inner.label.is_ok() { + if let Err(error) = inner.index.check() { + inner.label = Err(error); + } } Self { inner, diff --git a/packages/stack-encrypt/src/plan/mod.rs b/packages/stack-encrypt/src/plan/mod.rs index 7e58c504f..111d21e3d 100644 --- a/packages/stack-encrypt/src/plan/mod.rs +++ b/packages/stack-encrypt/src/plan/mod.rs @@ -321,7 +321,7 @@ pub use build::{FieldKind, FieldPlan, FieldsBuilder, IntoLabel, Opens, Plan, Pla pub use chain::{ all, All, Batch, Chooses, EncryptBuilder, EncryptFields, EncryptIndexed, EncryptUsing, EncryptWithContext, KeysetChoice, OpenBuilder, OpenUsing, Operation, PlanKms, QueryBuilder, - QueryIndex, QueryIndexBuilder, QueryOp, QueryVia, Resolves, + QueryIndex, QueryIndexBuilder, QueryOp, QueryVia, Resolves, Unanswered, }; pub use error::PlanError; pub use field_ref::{pick, FieldRef}; diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 2dba9df2c..53d65cb85 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -553,13 +553,14 @@ impl MatchTerms { } /// Whether this term's positions are a superset of `other`'s, for two - /// terms of the same kind. [`matches`](Self::matches) is the check for - /// a query. + /// stored terms: `other` is a filter over a whole stored value, not a + /// query. [`matches`](Self::matches) is the check for a query, and the + /// only one that takes a [`MatchQuery`]. /// /// An empty `other` returns `false`, as an empty query does. - pub fn contains(&self, query: &MatchTerms) -> bool { - !query.positions.is_empty() - && query + pub fn contains(&self, other: &MatchTerms) -> bool { + !other.positions.is_empty() + && other .positions .iter() .all(|p| self.positions.binary_search(p).is_ok()) @@ -645,7 +646,7 @@ impl Eq for MatchTerms {} /// The query term of a match index: a Bloom filter over the *query's* /// tokens, where the stored [`MatchTerms`] is a filter over the whole /// stored value's. A row matches when its stored filter holds every position of -/// the query's ([`MatchTerms::contains`]). +/// the query's ([`MatchTerms::matches`]). /// /// The two are different types because they are different things, even /// where their bytes share an encoding: a query is never stored, and a diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs index 487ffe056..f3931aa41 100644 --- a/packages/stack-encrypt/tests/op.rs +++ b/packages/stack-encrypt/tests/op.rs @@ -113,14 +113,16 @@ async fn a_matches_query_is_a_filter_over_the_query_value_that_the_stored_filter "the query is the query value's filter, not the stored value's" ); - // The operator derives the term the index does, under the same label. - let by_index: MatchTerms = cipher - .query_index(Match, "BOB") - .on(&email_plan) - .await - .unwrap(); - assert_eq!(query.to_bytes(), by_index.to_bytes()); - assert_eq!(query.positions(), by_index.positions()); + // The operator derives the filter the index writes, under the same + // label: the query for BOB is the stored filter of bob. + let bob = Person { + email: "bob".into(), + ..person() + }; + let mut bob_row = cipher.encrypt(&bob).using(&plan).await.unwrap(); + let bob_email: Encrypted<(EqualityTerm, MatchTerms)> = bob_row.take("email").unwrap(); + assert_eq!(query.to_bytes(), bob_email.terms.1.to_bytes()); + assert_eq!(query.positions(), bob_email.terms.1.positions()); assert_eq!(query.clone(), query); let elsewhere: MatchQuery = cipher @@ -408,6 +410,63 @@ async fn a_match_case_that_disagrees_with_its_options_is_refused() { ); } +#[tokio::test] +async fn an_undeclared_index_is_refused_before_its_case_is_checked() { + let cipher = stack_cipher().await; + let plan = people_plan(); + let refused = plan_error( + cipher + .query(op::Matches, "Bob") + .via(Match::::new()) + .on(&plan.field("email").unwrap()) + .await, + ); + assert!( + matches!(refused, PlanError::IndexOptions { ref field, .. } if field == "email"), + "the plan's refusal comes first, not the case: {refused:?}" + ); + + let no_context: Plan = Plan::fields() + .encrypt::("email") + .encrypt_index::("name", Match::::new()) + .build() + .unwrap(); + let refused = plan_error( + cipher + .query(op::Matches, "Bob") + .via(Match::::new()) + .on(&no_context.field("name").unwrap()) + .await, + ); + assert_eq!(refused, PlanError::NoContext); +} + +#[tokio::test] +async fn a_query_names_its_context_before_or_after_the_index() { + let cipher = stack_cipher().await; + let plan: Plan = Plan::fields() + .encrypt_index::("email", Match) + .encrypt::("name") + .build() + .unwrap(); + let email_plan = plan.field("email").unwrap(); + let before = cipher + .query(op::Matches, "bob") + .context("people") + .via(Match) + .on(&email_plan) + .await + .unwrap(); + let after = cipher + .query(op::Matches, "bob") + .via(Match) + .context("people") + .on(&email_plan) + .await + .unwrap(); + assert_eq!(before, after); +} + #[tokio::test] async fn a_refused_operator_query_loads_no_keyset() { let (cipher, loads) = loads_counting_cipher().await; diff --git a/packages/stack-encrypt/tests/plan.rs b/packages/stack-encrypt/tests/plan.rs index d2f3ac879..9cced6950 100644 --- a/packages/stack-encrypt/tests/plan.rs +++ b/packages/stack-encrypt/tests/plan.rs @@ -298,8 +298,8 @@ async fn a_query_derives_under_the_label_the_write_used() { eq, email.terms.0, "equality query bytes equal the stored term" ); - let matching: MatchTerms = cipher - .query_index(Match, "bob@example.com") + let matching = cipher + .query(op::Matches, "bob@example.com") .on(&email_plan) .await .unwrap(); @@ -1199,12 +1199,16 @@ async fn match_options_must_agree_for_a_query_to_be_declared() { } } } + impl op::MatchCase for Shingles { + type Case = op::CaseSensitive; + } let cipher = stack_cipher().await; let users_plan: Plan = users_plan(); let email_plan = users_plan.field("email").unwrap(); let refused = plan_error( cipher - .query_index(Match::::new(), "bob") + .query(op::Matches.case_sensitive(), "bob") + .via(Match::::new()) .on(&email_plan) .await, ); @@ -1721,11 +1725,8 @@ async fn a_chain_shows_no_value() { format!("{:?}", cipher.query(op::Eq, &secret)), format!("{:?}", cipher.query(op::Eq, &secret).via(Equality)), format!("{:?}", cipher.query(op::Eq, &secret).on(&email_plan)), - format!("{:?}", cipher.query_index(Equality, &secret)), - format!( - "{:?}", - cipher.query_index(Equality, &secret).on(&email_plan) - ), + format!("{:?}", cipher.query_index(Ore, &secret)), + format!("{:?}", cipher.query_index(Ore, &secret).on(&email_plan)), format!("{:?}", cipher.open(secret.clone())), format!("{:?}", cipher.open(FieldValues::new()).using(&users_plan)), format!( diff --git a/packages/stack-encrypt/tests/plan_grammar.rs b/packages/stack-encrypt/tests/plan_grammar.rs index db9d94114..6166cf7c9 100644 --- a/packages/stack-encrypt/tests/plan_grammar.rs +++ b/packages/stack-encrypt/tests/plan_grammar.rs @@ -1128,11 +1128,11 @@ async fn a_typed_field_answers_the_queries_its_target_declares_and_no_other() { .unwrap(); assert_eq!(query_value, stored.terms.0); let matched = cipher - .query_index(Match, "bob@example.com") + .query(op::Matches, "bob@example.com") .on(&email_plan) .await .unwrap(); - assert_eq!(matched, stored.terms.1); + assert_eq!(matched.to_bytes(), stored.terms.1.to_bytes()); assert_eq!( plan_error( cipher diff --git a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs index 79347a38d..4649cdba6 100644 --- a/packages/stack-encrypt/tests/ui/pass/plan_chain.rs +++ b/packages/stack-encrypt/tests/ui/pass/plan_chain.rs @@ -52,6 +52,12 @@ async fn chains(cipher: &StackCipher, person: &Person) -> Res .with::(stack_encrypt::Match) .build()?; let _ = cipher.query(op::Matches, "ali").on(&name_plan).await?; + let _ = cipher + .query(op::Matches, "ali") + .via(Match) + .context("people/name") + .on(&people_plan.field("email")?) + .await; let age_plan = Plan::context("people/age").with((Equality, Ore)).build()?; let ages = vec![1u32, 2]; let _ = cipher.encrypt(&ages).using(&age_plan).await?; diff --git a/packages/stack-encrypt/tests/ui/query_index_answered.rs b/packages/stack-encrypt/tests/ui/query_index_answered.rs new file mode 100644 index 000000000..02527bec2 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/query_index_answered.rs @@ -0,0 +1,11 @@ +//! An operator answers `Match`, so `query_index` cannot derive its term: a +//! match query is `cipher.query(op::Matches, v)`, whose term is a query, +//! never the stored filter `query_index` would hand back. +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::{Match, StackCipher}; + +fn query(cipher: &StackCipher) { + let _ = cipher.query_index(Match, "bob"); +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/query_index_answered.stderr b/packages/stack-encrypt/tests/ui/query_index_answered.stderr new file mode 100644 index 000000000..366544dec --- /dev/null +++ b/packages/stack-encrypt/tests/ui/query_index_answered.stderr @@ -0,0 +1,25 @@ +error[E0277]: `stack_encrypt::Match` is answered by an operator, so `query_index` cannot derive its term + --> tests/ui/query_index_answered.rs:8:32 + | +8 | let _ = cipher.query_index(Match, "bob"); + | ----------- ^^^^^ an operator answers this index + | | + | required by a bound introduced by this call + | + = help: the trait `Unanswered` is not implemented for `stack_encrypt::Match` + = 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 +help: the following other types implement trait `Unanswered` + --> src/plan/chain.rs + | + | impl Unanswered for crate::target::Ore {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `stack_encrypt::Ore` + | impl Unanswered for crate::target::Ope {} + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `stack_encrypt::Ope` +note: required by a bound in `plan::chain::>::query_index` + --> src/plan/chain.rs + | + | pub fn query_index<'a, I, Q>(&'a self, index: I, value: &Q) -> QueryIndexBuilder<'a, I, F, K> + | ----------- required by a bound in this associated function + | where + | I: Unanswered, + | ^^^^^^^^^^ required by this bound in `plan::chain::>::query_index`