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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .cargo/mutants.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +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 <impl IntoFuture for Pending<.*>>::into_future -> Self::IntoFuture with Default::default\(\)$',
# stack-encrypt — equivalent.
# 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 <impl Clone for Matches<C>>::clone -> Self with Default::default\(\)$',
'stack-encrypt/src/op(/mod)?\.rs:\d+:\d+: replace Matches::case_sensitive -> Matches<CaseSensitive> with Default::default\(\)$',
'stack-encrypt/src/target/index\.rs:\d+:\d+: replace <impl Clone for Match<O>>::clone -> Self with Default::default\(\)$',
'stack-encrypt/src/cipher\.rs:\d+:\d+: replace StackCipher<FromEnv>::builder -> StackCipherBuilder with Default::default\(\)$',
'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace <impl MatchConfig for DefaultMatch>::options -> MatchOptions with Default::default\(\)$',
# Both unit-context conversions explicitly return Self::default().
Expand Down
43 changes: 43 additions & 0 deletions packages/stack-encrypt/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,34 @@ 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(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 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::<MyConfig>::new()` is another configuration.

- **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
Expand Down Expand Up @@ -47,6 +75,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Breaking

- **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. `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<O>`, so `Match::default()` no longer infers the configuration.

- **Update pattern matches for `dynamic::Error`.** `Context`, `Plan`,
`Source`, and `Record` now carry
`{ field: Option<String>, reason: Reason }`; `Term` also gains
Expand Down
21 changes: 19 additions & 2 deletions packages/stack-encrypt/CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -223,12 +223,29 @@ _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 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 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)

**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
Expand Down
4 changes: 4 additions & 0 deletions packages/stack-encrypt/src/codes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ mod tests {
..MatchOptions::default()
}),
},
PlanError::MatchCase { .. } => PlanError::MatchCase {
declared: "CaseInsensitive",
folds: false,
},
PlanError::TwoContextSources { .. } => PlanError::TwoContextSources {
first: "the plan",
second: "the call",
Expand Down
17 changes: 14 additions & 3 deletions packages/stack-encrypt/src/dynamic/record.rs
Original file line number Diff line number Diff line change
Expand Up @@ -556,9 +556,11 @@ fn refusal(error: crate::Error) -> (Option<String>, 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),
}
}

Expand Down Expand Up @@ -6795,6 +6797,15 @@ mod tests {
Refused,
),
(P::NoContext.into(), None, Refused),
(
P::MatchCase {
declared: "CaseSensitive",
folds: true,
}
.into(),
None,
Refused,
),
];
for (error, field, reason) in cases {
let shown = format!("{error:?}");
Expand Down
4 changes: 2 additions & 2 deletions packages/stack-encrypt/src/dynamic/term.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1206,7 +1206,7 @@ mod wire_form {
fn every_typed_index_round_trips_through_the_wire_form() {
let specs = [
Index::<String>::spec(&Equality),
Index::<String>::spec(&Match::default()),
Index::<String>::spec(&Match),
Index::<String>::spec(&Match::<Words>::new()),
Index::<String>::spec(&Ore),
Index::<String>::spec(&Ope),
Expand All @@ -1222,7 +1222,7 @@ mod wire_form {

#[test]
fn a_default_match_index_is_the_bare_match_key() {
let spec = Index::<String>::spec(&Match::default());
let spec = Index::<String>::spec(&Match);
assert!(matches!(
spec.to_value(),
FfiValue::String(key) if super::utf8(&key) == Some("match")
Expand Down
1 change: 1 addition & 0 deletions packages/stack-encrypt/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading