From 972e63583057d77add7bd77db2cada389deebb4e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 05:45:50 +1100 Subject: [PATCH 1/6] feat(stack-encrypt): field markers and a value under a plan that is data A query must derive under the context the stored term was derived under, or it matches nothing and gives no error. Until now a Rust program wrote a second plan by hand for its queries, which nothing tied to the record the derive wrote. The derive now emits one `Marker` per derived field of a `struct = ..` record, as an associated constant (`EncryptedUser::EMAIL`). A typed query starts from the marker: one method per operator the field's indexes answer, bounded by `Answers` so a missing one is a compile error; scopes with `.scope(..)` and no `bind`; `.encrypt(&keyset)` as the end. The query is derived under the marker's label, so it is the stored term. For a program whose declaration is data, `dynamic::Planned` pairs a plan with one value and is a target: `StackCipherText` encrypts a record under a `Plan`, `QueryTerm` a query value under a `QueryPlan`, and `Planned` decrypts to a `ValueFrame`, each through the owned entry points with the caller's scope and bindings as the one context argument. `Plan::query(field, op)` is refused where it is made when no index answers the operator; `Plan::field(name)` is the dynamic handle. A marker query and a `Planned` query derive the same bytes under the same scope, and a test holds them to it. A marker carries no plaintext type or accessor: the derive sees the record's field types, not the plaintext struct's. The serialised `Planned`, the Go module and the corpus rewrite are #1594. Closes #1486, #1590. --- .../2026-10-24-stack-encrypt-api-plan.md | 75 ++- packages/stack-encrypt-derive/src/lib.rs | 15 + .../stack-encrypt-derive/src/owned_encrypt.rs | 49 +- packages/stack-encrypt/CHANGELOG.md | 39 ++ packages/stack-encrypt/CONTEXT.md | 43 ++ ...-through-a-plan-never-a-second-executor.md | 3 +- packages/stack-encrypt/src/contexts.rs | 64 +- packages/stack-encrypt/src/dynamic/mod.rs | 2 + packages/stack-encrypt/src/dynamic/planned.rs | 586 ++++++++++++++++++ packages/stack-encrypt/src/dynamic/query.rs | 11 +- packages/stack-encrypt/src/dynamic/record.rs | 75 ++- .../stack-encrypt/src/dynamic/value_frame.rs | 5 + packages/stack-encrypt/src/lib.rs | 2 + packages/stack-encrypt/src/marker.rs | 275 ++++++++ packages/stack-encrypt/src/plan/build.rs | 13 + packages/stack-encrypt/src/target/context.rs | 14 +- packages/stack-encrypt/src/testing.rs | 7 + packages/stack-encrypt/tests/markers.rs | 276 +++++++++ packages/stack-encrypt/tests/planned.rs | 445 +++++++++++++ .../tests/ui/marker_operator_not_answered.rs | 27 + .../ui/marker_operator_not_answered.stderr | 71 +++ .../tests/ui/marker_query_has_no_bind.rs | 28 + .../tests/ui/marker_query_has_no_bind.stderr | 36 ++ 23 files changed, 2098 insertions(+), 63 deletions(-) create mode 100644 packages/stack-encrypt/src/dynamic/planned.rs create mode 100644 packages/stack-encrypt/src/marker.rs create mode 100644 packages/stack-encrypt/tests/markers.rs create mode 100644 packages/stack-encrypt/tests/planned.rs create mode 100644 packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs create mode 100644 packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr create mode 100644 packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs create mode 100644 packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index f79ec9873..994cea4ba 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -321,10 +321,15 @@ and checks each one. #### Query a record field The derive emits one marker for each field of a record, such as -`EncryptedMember::EMAIL`. A marker names the field, its plaintext type, -its accessor, and the field's index list. It is the value a query starts -from. The derive emits no plan: the marker is enough for a query, and a -binding sends a plan of its own. +`EncryptedMember::EMAIL`. A marker names the field, the context the +derive sealed it under (the record's scope, then the field's identity), +and the field's target type, whose `Answers` impls are the field's index +list. It is the value a query starts from. The derive emits no plan: the +marker is enough for a query, and a binding sends a plan of its own. +Built on 10 November 2026 (#1486): the marker carries no plaintext type +and no accessor, because the derive sees the record's field types and +not the plaintext struct's, and a query value converts from the borrowed +form a caller has (`&str` to `String`) through `ToOwned`. A typed query starts from the marker, and has one method for each operator the field's indexes answer. A missing operator is a compile @@ -335,8 +340,9 @@ The query ends with `encrypt(&keyset)`, and its output type is the EQL query type the marker fixes. Decided on 7 November 2026. ```rust -// Not on `next` yet: field markers and their query methods. Today a -// query names a plan written by hand: `cipher.query(op::Eq, value).on(&plan)`. +// On `next` since #1486, with the index's query term as the output +// type (`EqualityTerm`, `RangeQuery>`, `MatchQuery`); the +// EQL query types follow with the EQL owned implementations. let by_email: TextEqQuery = EncryptedMember::EMAIL .eq("alice@example.com") .scope(tenant) @@ -690,13 +696,11 @@ keyset as the argument. Made a target after the combinator spike of 9 November 2026 (ADR-0007, amended). ```rust -// Not on `next` yet: `Planned`. Today the chain does this: -// `cipher.encrypt(&user).using(&plan)` and `cipher.query(op, v).on(&plan)`. -// The value under a data plan is the dynamic interface's record, -// `FieldValues`, and the stored row is a `StackCipherText`. A typed -// struct uses the derive, not a plan. +// On `next` since #1486. The value under a data plan is the dynamic +// interface's record, a `ValueFrame` of its fields, and the stored row +// is a `StackCipherText`. A typed struct uses the derive, not a plan. let row: StackCipherText = Planned::new(&plan, values).encrypt_into(&keyset).await?; -let values = FieldValues::decrypt_from(Planned::new(&plan, row), &keyset).await?; +let values = ValueFrame::decrypt_from(Planned::new(&plan, row), &keyset).await?; let row: StackCipherText = Planned::new(&plan, values) .encrypt_into_with_context(&keyset, Scope::new(tenant).bind(row_id)) @@ -760,7 +764,7 @@ it declares about queries, so no declaration lists the queries a program might run. ```rust -// Not on `next` yet: query plans. +// On `next` since #1486. let by_email = plan.query("email", Op::Eq)?; // refused here if no index answers Eq let q: QueryTerm = Planned::new(&by_email, "alice@example.com") @@ -786,11 +790,11 @@ field](#query-a-record-field) shows the typed form. A program whose plan is data uses the same builder through a field handle. The handle has every operator method. Each one is checked at run -time against the plan, and returns `IndexNotDeclared` when no index +time against the plan, and returns `operator_not_answered` when no index answers it. The handle has no `bind` method either. ```rust -// Not on `next` yet: the dynamic field handle. +// On `next` since #1486. let email = plan.field("email")?; let q: QueryTerm = email .eq("alice@example.com")? // refused here if no index answers Eq @@ -1550,10 +1554,14 @@ review. The caller must not write a second plan by hand. The marker holds the declaration the derive wrote, so the query term and the stored term come from one source. -- **Done already.** `Answers` on a target type is today's compile-time - lookup of the index for an operator. The marker's index list extends - it to a record field. Until the markers land, a query works through a - plan written by hand: `cipher.query(op, value).on(&plan)`. +- **Done already.** `Answers` on a target type is the compile-time + lookup of the index for an operator; a marker's methods are bound by + it, so the marker's index list is its target type's `Answers` impls. + Pull request #1486's branch lands the markers, their query methods + and `assert_context!` over a marker (`src/marker.rs`, + `tests/markers.rs`, `tests/ui/marker_*.rs`). The chain query + `cipher.query(op, value).on(&plan)` still exists until #1488 removes + it. - **No hand-built plan.** The review of 7 November 2026 gave a hand-built plan the same markers, through `Plan::::scope(..)` with `pick!`. That is withdrawn. No Rust program builds a plan by hand. @@ -1565,13 +1573,17 @@ review. time too. Today the builder refuses it at run time, so that check lands with the builder's removal. The dynamic grammar keeps its run-time checks. -- **Tracked in.** Issue #1486 is open. +- **Tracked in.** Issue #1486 is open; its markers pull request is on + the stack above #1592. - **Finished when** these are all true: - - the [marker example](#query-a-record-field) compiles; + - the [marker example](#query-a-record-field) compiles, with the + index's query term as the output type; the EQL query types come + with the EQL owned implementations; - the compile-fail cases in `tests/ui` cover an operator the field's - indexes do not answer; + indexes do not answer (`marker_operator_not_answered`) and a + binding on a query (`marker_query_has_no_bind`); - tests show that a query cannot run without its plan or its required - context. + context (`tests/markers.rs`, `tests/planned.rs`). #### `Planned` and its serialised form @@ -1603,11 +1615,18 @@ Decided on 7 November 2026, as area one of the query design review. - **Rules.** A serialised `Planned` is a transport form, never a storage form, because its value is plaintext. It is never logged or written to disk. Its shape is not part of the stored format. -- **Done already.** The guest already sends the engine a plan frame, then - a value frame for each call, through `plan_with`, `encrypt_with`, - `query`, and `decrypt_with` in `dynamic::record`. The serialised `Planned` names and groups what those functions already -receive. -- **Tracked in.** Issue #1486 is open, in part. On the Go side, issue #1116 is open to hide or rename `record.Plan`. Issue #1307 is open for a Go plan-building API, for types `stashgen` cannot tag. It builds a data plan, so the Rust builder's removal does not affect it. +- **Done already.** `Planned`, the query plan, the field handle, + `QueryTerm` and `contexts()` on a `Plan` and a `Planned` are on the + markers pull request's branch (`src/dynamic/planned.rs`, + `tests/planned.rs`). `Planned` calls `encrypt_with`, `query_term` and + `decrypt_with` in `dynamic::record` and `dynamic::query`, under the + plan extended by the caller's parts. The guest still sends the engine + a plan frame, then a value frame for each call. The serialised + `Planned` names and groups what those functions already receive, and + is not built yet. +- **Tracked in.** Issue #1486 is open, in part. Issue #1594 is the + serialised `Planned`, the Go module sending it, and the corpus + rewrite, split from the markers pull request. On the Go side, issue #1116 is open to hide or rename `record.Plan`. Issue #1307 is open for a Go plan-building API, for types `stashgen` cannot tag. It builds a data plan, so the Rust builder's removal does not affect it. - **Finished when** these are all true: - every case in the conformance corpus is a serialised `Plan` or `Planned` with the engine's expected answer beside it; diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 946779033..88125bf3b 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -117,6 +117,21 @@ //! # }).unwrap(); //! ``` //! +//! # Field markers +//! +//! A `struct` record also emits one marker per derived field, as an +//! associated constant named after the field in upper case: +//! `EncryptedUser::EMAIL` for a field `email`. A marker is the value a +//! typed query of the field starts from. It has one method per operator the +//! field's indexes answer, checked at compile time, takes the caller's +//! scopes with `.scope(..)` and no binding, and ends with +//! `.encrypt(&keyset)`. The query derives under the context the derive +//! sealed the field under, so it is the stored term. See +//! `stack_encrypt::Marker`. A generic record emits no marker, since a +//! marker holds the record's `contexts` as a function pointer; a tuple +//! field has no name, so none either. The constant's name is the field's +//! in upper case, so a record cannot define another constant of that name. +//! //! The storage envelope itself adds no //! cryptographic map-entry context. Vitamin C still binds keys inside plaintext //! maps and preserves authenticated absence and empty-container markers. diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs index b202847d4..fca178fc6 100644 --- a/packages/stack-encrypt-derive/src/owned_encrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -29,7 +29,54 @@ pub(crate) fn derive(input: DeriveInput) -> Result { value_derive(&input, &record, &derived) }; let contexts = contexts_fn(&input, &record); - Ok(quote!(#emitted #decryptable #contexts)) + let markers = markers(&input, &record); + Ok(quote!(#emitted #decryptable #contexts #markers)) +} + +/// The field markers of a `struct = ..` record: one associated constant +/// per derived field, named after the field in upper case +/// (`EncryptedUser::EMAIL`), carrying the field's name, the record's scope, +/// the field's identity and the record's `contexts`. A generic record +/// emits none: a marker holds `contexts` as a function pointer, which a +/// generic function is not. A tuple field has no name, so none either. +fn markers(input: &DeriveInput, record: &Record) -> TokenStream { + if !record.by_field || !input.generics.params.is_empty() { + return TokenStream::new(); + } + let krate = &record.krate; + let name = &input.ident; + let consts = record.fields.iter().filter_map(|field| { + let Kind::Derived { + by_field: Some(ByField { context, .. }), + } = &field.kind + else { + return None; + }; + let syn::Member::Named(ident) = &field.member else { + return None; + }; + let field_name = ident.to_string(); + let field_name = field_name.strip_prefix("r#").unwrap_or(&field_name).to_owned(); + let const_name = Ident::new(&field_name.to_uppercase(), ident.span()); + let ty = &field.ty; + let OwnContext { prefix, field: identity } = context; + let doc = format!( + "The marker of the field `{field_name}`: what a typed query of it starts from. Emitted by `#[derive(EncryptTarget)]`; see [`Marker`]({}::Marker).", + quote!(#krate) + ); + Some(quote_spanned! {ty.span()=> + #[doc = #doc] + #[allow(dead_code)] + pub const #const_name: #krate::Marker<#ty> = + #krate::Marker::new(#field_name, #prefix, #identity, Self::contexts); + }) + }); + quote! { + #[automatically_derived] + impl #name { + #(#consts)* + } + } } /// The caller's input on the encrypt side. A record whose fields have their diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 3b427c041..a70aafc18 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -7,6 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Field markers** (#1486, #1590). `#[derive(EncryptTarget)]` on a + `struct = ..` record emits one `Marker` per derived field. It is an + associated constant named after the field in upper case: + `EncryptedUser::EMAIL`. A marker names the field, the context the derive + sealed it under, and the field's target type. Its methods are one per + operator the field's indexes answer, checked at compile time through + `Answers`: `eq`, `lt`, `lte`, `gt`, `gte`, `between`, and `matches`. The + query takes the caller's scopes with `.scope(..)`, has no `bind` method, + and ends with `.encrypt(&keyset)`. The output is the index's query term. + A query from a marker derives under the declaration the derive wrote, so + it is the stored term. No plan is written by hand beside the record. + `assert_context!` takes a marker. `Marker::contexts()` is the record's + contexts narrowed to the field. A generic record and a tuple field emit + no marker. +- **`dynamic::Planned`, a value under a plan that is data** (#1486). A + record under a `Plan`, or a query value under a `QueryPlan`, is a target + in the owned API's sense. `StackCipherText` implements + `EncryptTarget>`. `QueryTerm` implements + `EncryptTarget>`. `Planned` + implements `DecryptSource`. So `encrypt_into`, + `decrypt_from`, and their `_with_context` forms run them. The caller's + scope and bindings are the one context argument, after the plan's own. A + query takes scopes alone, and refuses a binding before any key is + requested. `Planned::contexts()` shows every field's context with the + caller's placeholder, before any value is encrypted. +- **Query plans and the dynamic field handle** (#1486). `Plan::query(field, + op)` narrows a record plan to one field and one operator. It is refused + where it is made when none of the field's indexes answers the operator + (`operator_not_answered`), or when the plan has no such field. + `Plan::field(name)` is the handle a program whose plan is data queries + through. It has one method per operator, each checked against the plan + where it is called, then `.scope(..)` and `.encrypt(&keyset)`. Neither + has a `bind` method. +- `ValueFrame` implements `Zeroize`. `dynamic::query_term` takes any + `K: 'static`, as `record::encrypt_with` does. The key source's bounds + apply when the pending is awaited, not when the term is described. + ### Breaking - **Every operator is declared once, and `op::Supports` has one method** diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index 861681078..cdade5270 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -275,6 +275,49 @@ Case belongs to the match index's configuration, not to the operator. _Avoid_: query type, index (that is what answers the operator), `OpSpec` (the dynamic enum's old name) +**Field marker**: +The value a typed query of a derived record's field starts from. The +derive emits one per field, as an associated constant named after the field +in upper case (`EncryptedUser::EMAIL`). A marker names the field, the +context the derive sealed it under, and the field's target type. The target +type's `Answers` impls give the marker one method per operator the field's +indexes answer, checked at compile time. + +A query from a marker takes scopes and no binding. It derives under the +declaration the derive wrote, so it is the stored term. No plan is +written by hand beside the record. `Marker::contexts()` is the record's +contexts narrowed to the field, and `assert_context!` takes a marker. +_Avoid_: field handle (that is the dynamic form), column, accessor + +**Planned**: +A value under a plan that is data, `Planned::new(&plan, value)`: a record +under a record plan, or a query value under a query plan. A target in the +owned API's sense, so `encrypt_into`, `decrypt_from`, and their +`_with_context` forms run it. The caller's scope and bindings are the one +context argument, after the plan's own. It prints neither its value nor its +plan, and wipes the value as every source is wiped. + +A serialised form, what a binding sends for an operation, is to come +(#1594). The Go module still sends a plan frame and a value frame. +_Avoid_: request, envelope (that is the storage format), record (a +`Planned` can contain a query value) + +**Query plan**: +A record plan narrowed to one field and one operator, `plan.query(field, +op)`. It is refused where it is made when none of the field's indexes +answers the operator. So a query plan in hand is one the engine derives a term +for. A query value under it is a `Planned`, and encrypts to the query +term. +_Avoid_: query, index (that is what answers the operator) + +**Field handle**: +One field of a plan that is data, for a query: `plan.field(name)`. It has +one method per operator, each checked against the plan where it is +called, then `.scope(..)` and `.encrypt(&keyset)`. The dynamic +counterpart of a field marker. Neither has a `bind` method: a binding is +in no query context. +_Avoid_: field marker (that is the typed form), column + **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 diff --git a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md index 8604e3fe7..6e66c5950 100644 --- a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md +++ b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md @@ -400,7 +400,8 @@ type's plan. The equivalence suite uses data plans written by hand. 2. **The builder's typed surface had one Rust author.** In the engine's own source, nothing outside `plan/` and `dynamic/` builds a plan. The one real use is a typed query, `cipher.query(op, value).on(&plan)`. - That use exists only because field markers are not built yet. + That use existed only because field markers were not built; they + are, since #1486's markers pull request. `eql-bindings` and the tests are the other users. A plan is the dynamic interface's declaration. A Rust program that learns its declaration at run time uses the dynamic interface through `Planned`, diff --git a/packages/stack-encrypt/src/contexts.rs b/packages/stack-encrypt/src/contexts.rs index 8c8b6e495..e81849aae 100644 --- a/packages/stack-encrypt/src/contexts.rs +++ b/packages/stack-encrypt/src/contexts.rs @@ -534,6 +534,30 @@ impl Contexts { pub fn is_empty(&self) -> bool { self.fields.is_empty() } + + /// The fields of the one named `name`: the field itself, and for a + /// field that is a record, each of its fields (`name.inner`), with the + /// prefix taken off. + pub(crate) fn narrowed(&self, name: &str) -> Contexts { + let prefix = format!("{name}."); + let fields = self + .fields + .iter() + .filter_map(|field| { + let own = field.name.as_deref()?; + let inner = if own == name { + None + } else { + Some(Arc::from(own.strip_prefix(&prefix)?)) + }; + Some(FieldContext { + name: inner, + context: field.context.clone(), + }) + }) + .collect(); + Contexts { fields } + } } impl fmt::Display for Contexts { @@ -1141,19 +1165,7 @@ pub(crate) fn known_leaf(name: &str, piece: ContextPiece<'static>) -> LeafShape /// placed among them first. #[cfg_attr(not(feature = "dynamic"), allow(dead_code))] pub(crate) fn under_declared(shapes: Shapes, declared: &DeclaredContext) -> Shapes { - let chain = Shape::Chain( - declared - .parts() - .iter() - .map(|part| { - let (scopes, bindings) = part.pieces(); - let mut parts: Vec = scopes.into_iter().map(Shape::Known).collect(); - parts.extend(bindings.into_iter().map(Shape::KnownBind)); - Shape::Chain(parts) - }) - .collect(), - ); - substitute(shapes, &chain) + substitute(shapes, &Shape::Chain(declared_chain(declared))) } /// One leaf that runs under the context it is handed. @@ -1215,6 +1227,32 @@ pub(crate) fn resolve_under(shapes: Shapes, declared: &DeclaredContext) -> Conte resolve(under_declared(shapes, declared), CallerShape::none()) } +/// The shapes of a declaration run under `declared` and then the context +/// it is handed: what a data plan's record is run under when a caller adds +/// scopes and bindings to the plan's own. +#[cfg_attr(not(feature = "dynamic"), allow(dead_code))] +pub(crate) fn under_declared_then_caller(shapes: Shapes, declared: &DeclaredContext) -> Shapes { + let mut chain = declared_chain(declared); + chain.push(Shape::Caller); + substitute(shapes, &Shape::Chain(chain)) +} + +/// The known parts of `declared`, one chain per part: its scope, then its +/// bindings. +#[cfg_attr(not(feature = "dynamic"), allow(dead_code))] +fn declared_chain(declared: &DeclaredContext) -> Vec { + declared + .parts() + .iter() + .map(|part| { + let (scopes, bindings) = part.pieces(); + let mut parts: Vec = scopes.into_iter().map(Shape::Known).collect(); + parts.extend(bindings.into_iter().map(Shape::KnownBind)); + Shape::Chain(parts) + }) + .collect() +} + #[cfg(test)] mod tests { use super::*; diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 7cba18aa8..6aaf9b03a 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -92,6 +92,7 @@ mod json; mod json_predicate; mod json_query; mod kind; +pub mod planned; mod query; pub mod record; mod target; @@ -108,6 +109,7 @@ pub use json::{json_frame, read_json, JsonPlaintext, JsonValueError}; /// kind's names are frozen wire format; see [`admits`] and [`read`] for what /// a kind means to this engine. pub use kind::{admits, not_admitted, read, ParseValueKindError, ValueKind}; +pub use planned::{DynamicQuery, FieldHandle, Planned, QueryPlan}; #[allow(deprecated)] pub use query::OpSpec; pub use query::{ diff --git a/packages/stack-encrypt/src/dynamic/planned.rs b/packages/stack-encrypt/src/dynamic/planned.rs new file mode 100644 index 000000000..b1f0c8ab0 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/planned.rs @@ -0,0 +1,586 @@ +//! A value under a plan that is data: [`Planned`], a target in the owned +//! API's sense, and the query plans and field handles a program whose +//! declaration is data queries through. +//! +//! A [`Plan`] says how a record is stored. [`Planned::new`] pairs it with +//! one record, or one query value, so that the owned entry points run it: +//! `encrypt_into` for a record, `decrypt_from` for a stored row, and the +//! same for a query value under a [`QueryPlan`]. The caller's scopes and +//! bindings come as the one context argument, built from +//! [`Scope`](crate::target::Scope) and [`Bind`](crate::target::Bind), and +//! a query takes scopes only: a binding on a query is refused before any +//! key is requested ([`Reason::BindInQuery`]). +//! +//! ``` +//! use stack_encrypt::dynamic::{record, Op, Planned, QueryTerm, ValueFrame}; +//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::target::Scope; +//! use stack_encrypt::{AsProtectedRef, DecryptFrom, EncryptInto, StackCipher, StackCipherText}; +//! use vitaminc_aead_frame::serde::to_vec; +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let declaration = to_vec(&serde_json::json!({ +//! "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, +//! "age": { "context": ["users", "age"], "outputs": ["c", "ore"], "type": "uint64" }, +//! }))?; +//! let plan = record::plan(declaration.as_protected_ref())?; +//! let values = ValueFrame::from_bytes( +//! to_vec(&serde_json::json!({"email": "ada@example.com", "age": 36}))?.as_protected_ref(), +//! )?; +//! +//! // A record under the plan, bound to its row. +//! let row: StackCipherText = Planned::new(&plan, values.clone()) +//! .encrypt_into_with_context(&keyset, Scope::new(42u64).bind(9u128)) +//! .await?; +//! let opened = ValueFrame::decrypt_from_with_context( +//! Planned::new(&plan, row), +//! &keyset, +//! Scope::new(42u64).bind(9u128), +//! ) +//! .await?; +//! assert_eq!(opened, values); +//! +//! // A query value under a query plan, by the scope alone. +//! let by_email = plan.query("email", Op::Eq)?; +//! let probe = ValueFrame::from_bytes(to_vec("ada@example.com")?.as_protected_ref())?; +//! let term: QueryTerm = Planned::new(&by_email, probe.clone()) +//! .encrypt_into_with_context(&keyset, Scope::new(42u64)) +//! .await?; +//! assert_eq!(term.operator(), Op::Eq); +//! +//! // Or through the field handle, checked against the plan where it is made. +//! let same: QueryTerm = plan +//! .field("email")? +//! .eq(probe)? +//! .scope(Scope::new(42u64)) +//! .encrypt(&keyset) +//! .await?; +//! assert_eq!(same, term); +//! # Ok::<(), Box>(()) +//! # }).unwrap(); +//! ``` +use std::fmt; + +use zeroize::Zeroize; + +use super::query::{field_operators, find, query_term, QueryTerm}; +use super::record::{self, Plan}; +use super::target::{NoTargets, TargetResolver}; +use super::value_frame::ValueFrame; +use super::{Error, Op, Reason, Scope}; +use crate::contexts::{self, Contexts, LeafContext}; +use crate::target::{ + CallerContext, DeclaredContext, DecryptSource, Decryption, EncryptTarget, Encryption, + OptionalContext, Owned, Pending, +}; +use crate::{Encrypt, KeysetCipher, StackCipherText}; + +/// One value under one plan: a record under a [`Plan`], or a query value +/// under a [`QueryPlan`]. A target in the owned API's sense: the stored row +/// implements `EncryptTarget>`, a query term implements +/// `EncryptTarget>`, and `Planned` implements `DecryptSource`. The resolver +/// `R` runs the EQL type a field names; [`new`](Self::new) takes none, +/// [`with_resolver`](Self::with_resolver) one. +/// +/// The value is plaintext, so `Planned` wipes it when it is dropped through +/// the owned entry points, as every source is, and prints neither it nor +/// the plan. +pub struct Planned<'p, P, V, R = NoTargets> { + plan: &'p P, + value: V, + resolver: R, +} + +impl<'p, P, V> Planned<'p, P, V> { + /// `value` under `plan`, with no EQL types: a plan whose field names + /// one is refused when it runs. + pub fn new(plan: &'p P, value: V) -> Self { + Self { + plan, + value, + resolver: NoTargets, + } + } +} + +impl<'p, P, V, R> Planned<'p, P, V, R> { + /// `value` under `plan`, with `resolver` for the EQL types its fields + /// name. + pub fn with_resolver(plan: &'p P, value: V, resolver: R) -> Self { + Self { + plan, + value, + resolver, + } + } + + /// The plan. + pub fn plan(&self) -> &'p P { + self.plan + } +} + +impl<'p, V, R> Planned<'p, Plan, V, R> { + /// The contexts every field of the plan is sealed and indexed under, + /// with a placeholder for the parts the caller supplies, before the + /// value is encrypted. No key is requested. + /// + /// # Errors + /// + /// As [`Plan::contexts`]. + pub fn contexts(&self) -> Result { + let shapes = self.plan.caller_shapes()?; + Ok(contexts::resolve( + shapes, + as crate::SuppliedContext>::supplies(), + )) + } +} + +impl<'p, V, R> Planned<'p, QueryPlan, V, R> { + /// The query context and the stored context of the field the query + /// plan names: [`QueryPlan::contexts`]. + /// + /// # Errors + /// + /// As [`QueryPlan::contexts`]. + pub fn contexts(&self) -> Result { + self.plan.contexts() + } +} + +impl<'p, P, V, R> fmt::Debug for Planned<'p, P, V, R> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Planned").finish_non_exhaustive() + } +} + +impl<'p, P, V: Zeroize, R> Zeroize for Planned<'p, P, V, R> { + fn zeroize(&mut self) { + self.value.zeroize(); + } +} + +/// Replace the frame's bytes with none. The protected buffer that held +/// them wipes them as it is dropped. +impl Zeroize for ValueFrame { + fn zeroize(&mut self) { + *self = ValueFrame::empty(); + } +} + +fn wrap(error: Error) -> crate::Error { + crate::Error::Other(Box::new(error)) +} + +impl Plan { + /// This plan under the caller's context: its scope as one more part of + /// every field's context, then its bindings after the plan's own. + /// `None` leaves the plan as it is. + fn under_caller(self, context: Option) -> Result { + let Some(context) = context else { + return Ok(self); + }; + let (scopes, bindings) = context.checked()?.pieces(); + let plan = self.with_scopes(scopes)?; + if bindings.is_empty() { + return Ok(plan); + } + let mut all = plan.bindings().to_vec(); + all.extend(bindings); + plan.with_bindings(all) + } + + /// This plan under a query's scopes, which hold no binding. + fn under_scopes(self, scopes: &DeclaredContext, field: &str) -> Result { + if scopes.has_bindings() { + return Err(Error::Plan { + field: Some(field.to_owned()), + reason: Reason::BindInQuery, + }); + } + let pieces = scopes.parts().iter().filter_map(|part| part.pieces().0); + self.with_scopes(pieces) + } + + /// A query plan for `field` and `operator`, with no EQL types: refused + /// here, before any value exists, when no index of the field answers + /// the operator. + /// + /// # Errors + /// + /// As [`query_with`](Self::query_with). + pub fn query(&self, field: &str, operator: Op) -> Result { + self.query_with(field, operator, &NoTargets) + } + + /// A query plan for `field` and `operator`, with `resolver` for an EQL + /// type the field names. + /// + /// # Errors + /// + /// [`Error::Plan`] with [`Reason::NoSuchField`] for a field the plan + /// does not have; [`Error::Source`] with [`Reason::OperatorNotAnswered`] + /// when no index of the field answers the operator; [`Error::Target`] + /// for a type the resolver refuses. + pub fn query_with( + &self, + field: &str, + operator: Op, + resolver: &(impl TargetResolver + ?Sized), + ) -> Result { + let answered = field_operators(self, field, resolver)? + .into_iter() + .any(|(op, _)| op == operator); + if !answered { + return Err(Error::Source { + field: Some(field.to_owned()), + reason: Reason::OperatorNotAnswered, + }); + } + Ok(QueryPlan { + plan: self.clone(), + field: field.to_owned(), + operator, + }) + } + + /// The handle of `field`, whose operator methods are each checked + /// against this plan where they are called. + /// + /// # Errors + /// + /// [`Error::Plan`] with [`Reason::NoSuchField`]. + pub fn field<'p>(&'p self, name: &str) -> Result, Error> { + let _ = find(self, name)?; + Ok(FieldHandle { + plan: self, + name: name.to_owned(), + }) + } +} + +/// A record under a plan encrypts to the stored row, as +/// [`record::encrypt_with`] produces it: every field under its own context, +/// the caller's scope and then its bindings after the plan's own. The +/// borrowed form reads a copy of the value; the owned form consumes it. +impl<'p, V, R> EncryptTarget> for StackCipherText +where + V: Encrypt + Clone + Zeroize + 'p, + R: TargetResolver + 'p, +{ + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, Plan, V, R>, Self, K, Self::ContextInput> + where + Planned<'p, Plan, V, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + move |source: &Planned<'p, Plan, V, R>, cipher, context: Self::ContextInput| { + encrypt_record( + cipher, + source.plan, + source.value.clone(), + &source.resolver, + context, + ) + }, + ) + } + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, Plan, V, R>, Self, K, Self::ContextInput, Owned> + where + Planned<'p, Plan, V, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + move |source: Planned<'p, Plan, V, R>, cipher, context: Self::ContextInput| { + let Planned { + plan, + value, + resolver, + } = source; + encrypt_record(cipher, plan, value, &resolver, context) + }, + ) + } +} + +/// [`record::encrypt_with`] under the plan extended by the caller's +/// context, or the refusal. +fn encrypt_record<'a, K: 'static>( + cipher: &'a KeysetCipher<'_, K>, + plan: &Plan, + value: impl Encrypt, + resolver: &(impl TargetResolver + ?Sized), + context: OptionalContext, +) -> Pending<'a, StackCipherText, K> { + let plan = match plan.clone().under_caller(context.into_inner()) { + Ok(plan) => plan, + Err(error) => return Pending::failed(cipher, wrap(error)), + }; + match record::encrypt_with(cipher, value, &plan, resolver) { + Ok(pending) => pending, + Err(error) => Pending::failed(cipher, wrap(error)), + } +} + +/// A stored row under a plan decrypts to its values, as +/// [`record::decrypt_with`] opens it, under the caller's scope and +/// bindings. A row from another keyset than the one the call names is +/// refused before any key is retrieved. +impl<'p, R> DecryptSource for Planned<'p, Plan, StackCipherText, R> +where + R: TargetResolver + Send + Sync + 'static, +{ + type ContextInput = OptionalContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + let plan = match self.plan.clone().under_caller(context.into_inner()) { + Ok(plan) => plan, + Err(error) => return Decryption::failed(wrap(error)), + }; + let Planned { + value, resolver, .. + } = self; + Decryption::from_open(move |cipher| { + match record::decrypt_with(Scope::Client(cipher), value, &plan, None, &resolver) { + Ok(pending) => pending, + Err(error) => Pending::failed(cipher, wrap(error)), + } + }) + } +} + +/// A record plan narrowed to one field and one operator: what a query +/// value runs under. Made by [`Plan::query`], which refuses a field no +/// index of which answers the operator, so a query plan in hand is one the +/// engine derives a term for. +#[derive(Clone, Debug)] +pub struct QueryPlan { + plan: Plan, + field: String, + operator: Op, +} + +impl QueryPlan { + /// The record plan the query runs under. + pub fn plan(&self) -> &Plan { + &self.plan + } + + /// The field the query is about. + pub fn field(&self) -> &str { + &self.field + } + + /// The operator. + pub fn operator(&self) -> Op { + self.operator + } + + /// The query context and the stored context of the field, before any + /// scope the caller adds: the record plan's entry for it. + /// + /// # Errors + /// + /// As [`Plan::contexts`]. + pub fn contexts(&self) -> Result { + let contexts = self.plan.contexts()?; + contexts.get(&self.field).cloned().ok_or_else(|| { + wrap(Error::Plan { + field: Some(self.field.clone()), + reason: Reason::NoSuchField, + }) + }) + } + + /// [`query_term`] under the plan extended by `scopes`, or the refusal. + fn term<'a, K: 'static>( + &self, + cipher: &'a KeysetCipher<'_, K>, + value: impl Encrypt, + scopes: &DeclaredContext, + resolver: &(impl TargetResolver + ?Sized), + ) -> Pending<'a, QueryTerm, K> { + let plan = match self.plan.clone().under_scopes(scopes, &self.field) { + Ok(plan) => plan, + Err(error) => return Pending::failed(cipher, wrap(error)), + }; + match query_term(cipher, &plan, &self.field, self.operator, value, resolver) { + Ok(pending) => pending, + Err(error) => Pending::failed(cipher, wrap(error)), + } + } +} + +/// A query value under a query plan encrypts to the query term, as +/// [`query_term`] derives it: the operand bytes and the SQL type they bind +/// as. The caller's context is its scope alone; a binding is refused +/// before any key is requested ([`Reason::BindInQuery`]). +impl<'p, V, R> EncryptTarget> for QueryTerm +where + V: Encrypt + Clone + Zeroize + 'p, + R: TargetResolver + 'p, +{ + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, QueryPlan, V, R>, Self, K, Self::ContextInput> + where + Planned<'p, QueryPlan, V, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + move |source: &Planned<'p, QueryPlan, V, R>, cipher, context: Self::ContextInput| { + let scopes = context + .into_inner() + .map(DeclaredContext::from) + .unwrap_or_default(); + source + .plan + .term(cipher, source.value.clone(), &scopes, &source.resolver) + }, + ) + } + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, QueryPlan, V, R>, Self, K, Self::ContextInput, Owned> + where + Planned<'p, QueryPlan, V, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + move |source: Planned<'p, QueryPlan, V, R>, cipher, context: Self::ContextInput| { + let scopes = context + .into_inner() + .map(DeclaredContext::from) + .unwrap_or_default(); + let Planned { + plan, + value, + resolver, + } = source; + plan.term(cipher, value, &scopes, &resolver) + }, + ) + } +} + +/// One field of a plan that is data, for a query: one method per operator, +/// each checked against the plan where it is called ([`Plan::query`]), so +/// a field no index of which answers the operator is refused there. The +/// handle has no `bind` method: a binding is in no query context. +#[derive(Clone, Debug)] +pub struct FieldHandle<'p> { + plan: &'p Plan, + name: String, +} + +/// One method per operator, each a [`Plan::query`] with the value. +macro_rules! handle_methods { + ($($(#[$doc:meta])* $method:ident => $op:ident;)+) => {$( + impl<'p> FieldHandle<'p> { + $(#[$doc])* + /// + /// # Errors + /// + /// As [`Plan::query`]. + pub fn $method(self, value: V) -> Result, Error> { + Ok(DynamicQuery { + plan: self.plan.query(&self.name, Op::$op)?, + value, + scopes: DeclaredContext::default(), + resolver: NoTargets, + }) + } + } + )+}; +} +handle_methods! { + /// Rows whose value equals `value` (`eq`). + eq => Eq; + /// Rows whose value is below `value` (`lt`). + lt => Lt; + /// Rows whose value is at or below `value` (`lte`). + lte => Lte; + /// Rows whose value is above `value` (`gt`). + gt => Gt; + /// Rows whose value is at or above `value` (`gte`). + gte => Gte; + /// Rows whose text holds `value` (`matches`), a probabilistic answer. + matches => Matches; + /// Rows whose value is between the two bounds of `value`, a list of + /// two values, low then high (`between`). + between => Between; +} + +impl<'p> FieldHandle<'p> { + /// The field's name. + pub fn name(&self) -> &str { + &self.name + } + + /// The plan the field belongs to. + pub fn plan(&self) -> &'p Plan { + self.plan + } +} + +/// A query from a [`FieldHandle`], ready for the caller's scopes and the +/// keyset. It has no `bind` method: a binding is in no query context. +pub struct DynamicQuery { + plan: QueryPlan, + value: V, + scopes: DeclaredContext, + resolver: R, +} + +impl fmt::Debug for DynamicQuery { + /// Names the field and the operator; the value is plaintext. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DynamicQuery") + .field("field", &self.plan.field) + .field("operator", &self.plan.operator) + .finish_non_exhaustive() + } +} + +impl DynamicQuery { + /// Add a scope the stored values were written under. A `Scope` or a + /// `NonEmpty` converts; a `Bind` does not, and a context with a binding + /// inside it is refused when the query runs ([`Reason::BindInQuery`]). + pub fn scope(mut self, part: impl Into) -> Self { + self.scopes.append(part.into()); + self + } + + /// Run the EQL type the field names through `resolver`. + pub fn with_resolver(self, resolver: R2) -> DynamicQuery { + DynamicQuery { + plan: self.plan, + value: self.value, + scopes: self.scopes, + resolver, + } + } + + /// The query plan this query runs under. + pub fn plan(&self) -> &QueryPlan { + &self.plan + } +} + +impl DynamicQuery { + /// Derive the query term under `keyset`: [`query_term`] for the plan, + /// extended by the scopes. Nothing is requested until the pending is + /// awaited. + pub fn encrypt<'a, K: 'static>( + self, + keyset: &'a KeysetCipher<'_, K>, + ) -> Pending<'a, QueryTerm, K> { + self.plan + .term(keyset, self.value, &self.scopes, &self.resolver) + } +} diff --git a/packages/stack-encrypt/src/dynamic/query.rs b/packages/stack-encrypt/src/dynamic/query.rs index c745eb3cb..d67fa2e51 100644 --- a/packages/stack-encrypt/src/dynamic/query.rs +++ b/packages/stack-encrypt/src/dynamic/query.rs @@ -76,7 +76,7 @@ use serde::{Serialize, Serializer}; use super::WireBytes; use std::fmt; -use stack_kms::{DataKeySource, IndexKeySource}; +use stack_kms::DataKeySource; use super::record::{FieldPlan, Plan}; use super::term::derive; @@ -482,7 +482,7 @@ fn answer<'p>( /// The field named `field`, or [`Error::Plan`] with /// [`Reason::NoSuchField`]. -fn find<'p>(plan: &'p Plan, field: &str) -> Result<&'p FieldPlan, Error> { +pub(super) fn find<'p>(plan: &'p Plan, field: &str) -> Result<&'p FieldPlan, Error> { plan.fields() .iter() .find(|candidate| candidate.name() == field) @@ -1056,17 +1056,14 @@ where /// bound is above its high bound is not refused: as SQL's `BETWEEN`, it /// matches nothing. /// [`Error::Target`] for what the resolver refuses. -pub fn query_term<'a, K>( +pub fn query_term<'a, K: 'static>( cipher: &'a KeysetCipher<'_, K>, plan: &Plan, field: &str, operator: Op, input: impl Encrypt, resolver: &(impl TargetResolver + ?Sized), -) -> Result, Error> -where - K: DataKeySource + IndexKeySource + Sync + 'static, -{ +) -> Result, Error> { let answer = answer(plan, field, operator, resolver)?; has_context(plan, field)?; if operator.is_json() { diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index dd3da38b8..8bc0db91f 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -579,6 +579,17 @@ impl FieldPlan { fn is_target(&self) -> bool { self.target.is_some() } + + /// Extend the field's context by `part`, nested to the left as a + /// binding spells it (`[[..label], part]`), and record it in the + /// extension: what [`Plan::with_scopes`] does to every field. + fn extend(&mut self, part: ContextPiece<'static>) { + let own = self.context.get().clone(); + let nested = ContextPiece::List(vec![own, part.clone()]); + self.context = NonEmpty::new(nested) + .unwrap_or_else(|_| unreachable!("a list of two parts is not empty")); + self.extension.push(part); + } } /// The text of a text part. @@ -1017,20 +1028,20 @@ impl Plan { /// /// The plan does not lower: the same errors as running it. pub fn contexts(&self) -> Result { + let lowered = self.lower::<()>()?.shapes_under(&self.declared_context()); Ok(crate::contexts::resolve( - self.caller_shapes()?, + self.placed(lowered), crate::contexts::CallerShape::none(), )) } - /// The shape of every field's context, in plan order, with the plan's - /// extension applied: the lowered fields' shapes, and each target field - /// under its label alone. An EQL value is sealed under its table and - /// column by the resolver, and takes no part from the caller: the plan - /// refuses an extension for it before it runs. - fn caller_shapes(&self) -> Result { + /// The shape of every field's context, in plan order: the `lowered` + /// fields' shapes, and each target field under its label alone. An EQL + /// value is sealed under its table and column by the resolver, and + /// takes no part from the caller: the plan refuses an extension for it + /// before it runs. + fn placed(&self, lowered: crate::contexts::Shapes) -> crate::contexts::Shapes { use vitaminc_aead::IntoContext as _; - let lowered = self.lower::<()>()?.shapes_under(&self.declared_context()); let mut lowered = lowered.into_iter().peekable(); let mut shapes = crate::contexts::Shapes::with_capacity(self.fields.len()); for field in &self.fields { @@ -1049,7 +1060,7 @@ impl Plan { } } shapes.extend(lowered); - Ok(shapes) + shapes } /// The fields plan this declaration lowers to, for a cipher over `K`. @@ -1128,6 +1139,52 @@ impl Plan { &self.bindings } + /// This plan with `parts` after its extension: a caller's scopes, each + /// one more part of every field's context, as a plan's `"context"` key + /// carries them. + /// + /// # Errors + /// + /// [`Error::Plan`] with [`Reason::EmptyContext`] for an empty part, and + /// [`Error::Target`] if a field names an EQL type + /// ([`TargetError::Extended`]), as [`with_bindings`](Self::with_bindings). + pub(super) fn with_scopes( + mut self, + parts: impl IntoIterator>, + ) -> Result { + let parts: Vec<_> = parts.into_iter().collect(); + if parts.iter().any(crate::MaybeEmpty::is_empty) { + return Err(Error::bad_plan(Reason::EmptyContext)); + } + if let Some(target) = self.fields.iter().find(|field| field.is_target()) { + if !parts.is_empty() { + return Err(TargetError::Extended { + name: target.name.clone(), + label: target.label.to_string(), + } + .into()); + } + } + for field in &mut self.fields { + for part in &parts { + field.extend(part.clone()); + } + } + self.extension.extend(parts); + Ok(self) + } + + /// The shapes of every field's leaf, run under this plan's context and + /// then the context the caller hands a [`Planned`](super::Planned) + /// record. + pub(super) fn caller_shapes(&self) -> Result { + let plan = self.lower::<()>()?; + Ok(self.placed(crate::contexts::under_declared_then_caller( + plan.shapes(), + &self.declared_context(), + ))) + } + /// The context every field's label is run under: the extension, as the /// chain's `.extend(..)` would carry it, then the bindings. fn declared_context(&self) -> DeclaredContext { diff --git a/packages/stack-encrypt/src/dynamic/value_frame.rs b/packages/stack-encrypt/src/dynamic/value_frame.rs index 1ea40687a..e3c471edd 100644 --- a/packages/stack-encrypt/src/dynamic/value_frame.rs +++ b/packages/stack-encrypt/src/dynamic/value_frame.rs @@ -113,6 +113,11 @@ impl ValueFrame { write(|writer| value.visit(Modeled::new(writer))) } + /// A frame of no bytes: what a wiped frame is left as. + pub(crate) fn empty() -> Self { + Self(Equatable::new(Vec::new())) + } + /// Read one value from frame bytes a host handed over. /// /// A typed null in the bytes reads as a plain null: the value this diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index e4792d1e7..64b822650 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -567,6 +567,7 @@ pub mod descriptor; pub mod dynamic; pub mod json; pub mod keyset; +pub mod marker; pub mod op; pub mod plan; pub mod sem; @@ -585,6 +586,7 @@ pub use contexts::{ }; pub use descriptor::{Describe, Description, Descriptor, Label, LabelError}; pub use keyset::KeysetCipher; +pub use marker::{MarkedQuery, Marker}; pub use plan::{all, Plan, PlanError}; // stack-kms is a public dependency: `StackCipher` is generic over its // `DataKeySource`, and `StackCipherBuilder::kms` takes its `StackKms`. It is diff --git a/packages/stack-encrypt/src/marker.rs b/packages/stack-encrypt/src/marker.rs new file mode 100644 index 000000000..f59e976d3 --- /dev/null +++ b/packages/stack-encrypt/src/marker.rs @@ -0,0 +1,275 @@ +//! Field markers: the value a typed query of a record field starts from. +//! +//! `#[derive(EncryptTarget)]` on a `struct = ..` record emits one marker +//! per derived field, as an associated constant named after the field in +//! upper case: `EncryptedUser::EMAIL` for a field `email`. A marker names +//! the field, the context the derive sealed it under (the record's scope, +//! then the field's identity), and the field's target type, whose +//! [`Answers`] impls say which operators the field's indexes answer. So a +//! query from a marker uses the declaration the derive wrote, and never a +//! plan written by hand beside it: the query term and the stored term come +//! from one source. +//! +//! A marker has one method per operator the field's indexes answer, checked +//! at compile time through [`Answers`]: a field of `Encrypted` +//! has [`eq`](Marker::eq) and no `gt`, and the error names the index. The +//! query takes the caller's scopes with [`scope`](MarkedQuery::scope), and +//! no binding: a binding is in no query context, and the query type has no +//! `bind` method. It ends with [`encrypt`](MarkedQuery::encrypt), whose +//! output is the query term the field's index sends for the operator +//! ([`Supports::Query`]). +//! +//! ``` +//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::sem::{EqualityTerm, OreTerm}; +//! use stack_encrypt::target::{EncryptTarget, Scope}; +//! use stack_encrypt::{EncryptInto, Encrypted, StackCipher}; +//! use zeroize::Zeroize; +//! +//! #[derive(Clone, Zeroize)] +//! struct User { +//! email: String, +//! age: u32, +//! } +//! +//! #[derive(EncryptTarget)] +//! #[stash(struct = User, scope = "users", scope(tenant: u64))] +//! struct EncryptedUser { +//! email: Encrypted, +//! age: Encrypted>, +//! } +//! +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! let user = User { email: "ada@example.com".into(), age: 36 }; +//! let stored: EncryptedUser = user.encrypt_into_with_context(&keyset, Scope::new(42u64)).await?; +//! +//! // The query from the marker, under the same scope, is the stored term. +//! let by_email = EncryptedUser::EMAIL +//! .eq("ada@example.com") +//! .scope(Scope::new(42u64)) +//! .encrypt(&keyset) +//! .await?; +//! assert_eq!(by_email, stored.email.terms); +//! let adults = EncryptedUser::AGE +//! .between(&18u32, &36) +//! .scope(Scope::new(42u64)) +//! .encrypt(&keyset) +//! .await?; +//! assert_eq!(adults.hi(), &stored.age.terms); +//! # Ok::<(), Box>(()) +//! # }).unwrap(); +//! ``` +//! +//! `EncryptedUser::EMAIL.gt("x")` does not compile: `Encrypted` +//! answers no ordering (`tests/ui/marker_operator_not_answered.rs`). Nor +//! does `.bind(..)` on the query (`tests/ui/marker_query_has_no_bind.rs`). +use std::marker::PhantomData; + +use stack_kms::MaybeSend; + +use crate::contexts::Contexts; +use crate::op::{ + Answers, Between, Eq, Gt, Gte, Input, LabelledTarget, Lt, Lte, Matches, Operator, QueryToken, + Supports, +}; +use crate::target::{DeclaredContext, Pending}; +use crate::{Error, KeysetCipher, Label, PlanError}; + +/// A field of a derived record, as a value: the field's name, the context +/// the derive sealed it under, and its target type `T`. See the [module +/// docs](self). +pub struct Marker { + name: &'static str, + scope: &'static str, + identity: &'static str, + contexts: fn() -> Contexts, + target: PhantomData T>, +} + +impl Clone for Marker { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Marker {} +impl std::fmt::Debug for Marker { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Marker") + .field("name", &self.name) + .field("scope", &self.scope) + .field("identity", &self.identity) + .finish_non_exhaustive() + } +} + +impl Marker { + /// A marker for the field `name`, sealed under `scope` then `identity` + /// by the record whose `contexts` function this is. The derive emits + /// the call; a record's markers are its associated constants. + #[doc(hidden)] + pub const fn new( + name: &'static str, + scope: &'static str, + identity: &'static str, + contexts: fn() -> Contexts, + ) -> Self { + Self { + name, + scope, + identity, + contexts, + target: PhantomData, + } + } + + /// The field's name in the record. + pub fn name(&self) -> &'static str { + self.name + } + + /// The context the field is sealed and indexed under before any part + /// the caller supplies: the record's scope, then the field's identity, + /// `users/email`. + pub fn label(&self) -> Label { + Label::new([self.scope, self.identity]).unwrap_or_else(|_| { + unreachable!("the derive refused a scope or identity that is not a plain segment") + }) + } + + /// The contexts the field is sealed and indexed under, with a + /// placeholder for each part the caller supplies: the record's + /// [`contexts()`](Contexts), narrowed to this field. One unnamed + /// context for a field that is a value; one per inner field for a + /// field that is itself a record. No key is requested. + pub fn contexts(&self) -> Contexts { + (self.contexts)().narrowed(self.name) + } + + fn query>(self, value: In) -> MarkedQuery { + MarkedQuery { + label: self.label(), + value, + scopes: DeclaredContext::default(), + marker: PhantomData, + } + } +} + +/// One method per operator. Each exists only where the field's target type +/// answers the operator ([`Answers`]), so a missing one is a compile error +/// that names the field's indexes. +macro_rules! operator_methods { + ($($(#[$doc:meta])* $method:ident => $op:ident;)+) => {$( + impl Marker { + $(#[$doc])* + pub fn $method(self, value: &Q) -> MarkedQuery + where + Q: ?Sized + ToOwned, + Q::Owned: 'static, + T: Answers<$op, Q::Owned> + LabelledTarget, + { + self.query(value.to_owned()) + } + } + )+}; +} +operator_methods! { + /// Rows whose value equals `value` ([`op::Eq`](struct@Eq)). + eq => Eq; + /// Rows whose value is below `value` ([`op::Lt`](Lt)). + lt => Lt; + /// Rows whose value is at or below `value` ([`op::Lte`](Lte)). + lte => Lte; + /// Rows whose value is above `value` ([`op::Gt`](Gt)). + gt => Gt; + /// Rows whose value is at or above `value` ([`op::Gte`](Gte)). + gte => Gte; + /// Rows whose text holds `value`, as the field's match index cuts both + /// ([`op::Matches`](struct@Matches)). The query term is probabilistic: + /// verify each decrypted row with it. + matches => Matches; +} + +impl Marker { + /// Rows whose value is between `lo` and `hi`, inclusive + /// ([`op::Between`](Between)): one term carrying both bounds, derived + /// under one context. + pub fn between(self, lo: &Q, hi: &Q) -> MarkedQuery + where + Q: ?Sized + ToOwned, + Q::Owned: 'static, + T: Answers + LabelledTarget, + { + self.query((lo.to_owned(), hi.to_owned())) + } +} + +/// A query from a [`Marker`], for the operator `Op` with the input `In`, +/// ready for the caller's scopes and the keyset. It has no `bind` method: +/// a binding is in no query context. +pub struct MarkedQuery { + label: Label, + value: In, + scopes: DeclaredContext, + marker: PhantomData (T, Op)>, +} + +impl std::fmt::Debug for MarkedQuery { + /// Names the query and its label; the value is plaintext. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("MarkedQuery") + .field("label", &self.label) + .finish_non_exhaustive() + } +} + +impl MarkedQuery +where + Op: Operator, + In: Input, + T: Answers, +{ + /// Add a scope the stored values were written under: the tenant a + /// record was encrypted with `Scope::new(tenant)`. Further scopes + /// nest after it, in order. A `Scope` or a `NonEmpty` converts; a + /// `Bind` does not, and a context that reaches the query with a + /// binding inside it is refused when the query runs + /// ([`PlanError::BindInQuery`]). + pub fn scope(mut self, part: impl Into) -> Self { + self.scopes.append(part.into()); + self + } + + /// Derive the query term under `keyset`: the term the field's index + /// sends for the operator, derived under the marker's label extended + /// by the scopes. The query is prepared ([`Supports::prepare`]) before + /// any key is requested, so a refused query costs no request. + pub fn encrypt<'a, K: 'static>( + self, + keyset: &'a KeysetCipher<'_, K>, + ) -> Pending<'a, >::Query, K> + where + >::Term: MaybeSend, + In::Terms<>::Term>: MaybeSend, + >::Query: MaybeSend + 'static, + { + if self.scopes.has_bindings() { + return Pending::failed(keyset, Error::from(PlanError::BindInQuery)); + } + let index = T::Via::default(); + let extended = self.scopes.is_extended(); + let verifier = match index + .check_label(&self.label, extended) + .and_then(|()| index.prepare(&self.value)) + { + Ok(verifier) => verifier, + Err(error) => return Pending::failed(keyset, error), + }; + let token = QueryToken::new(self.label.clone(), extended); + self.value + .derive(&index, keyset, self.label, self.scopes) + .map(move |terms| T::Via::query(verifier, terms, token)) + } +} diff --git a/packages/stack-encrypt/src/plan/build.rs b/packages/stack-encrypt/src/plan/build.rs index d40f6fce5..12a41d3f1 100644 --- a/packages/stack-encrypt/src/plan/build.rs +++ b/packages/stack-encrypt/src/plan/build.rs @@ -1437,6 +1437,19 @@ impl Plan { crate::contexts::under_declared(self.lowering(base).shapes(), extension) } + /// The shapes of this plan's leaves, in terms of the extension a run + /// adds. Crate-internal: a data plan's record target runs them under + /// the plan's own context and then the caller's. + #[cfg(feature = "dynamic")] + pub(crate) fn shapes(&self) -> crate::contexts::Shapes { + let base = match &self.inner.source { + Source::Plan(label) => Base::Label(label.clone()), + Source::Field(field) => Base::Field(Arc::clone(&field.of_value)), + Source::Call => Base::Call, + }; + self.lowering(base).shapes() + } + /// [`encryption`](Self::encryption), once the base context is known. fn lowering<'s>(&self, base: Base) -> Encryption<'s, S, FieldValues, K, DeclaredContext> { let names = Arc::clone(&self.inner.names); diff --git a/packages/stack-encrypt/src/target/context.rs b/packages/stack-encrypt/src/target/context.rs index 33a07490a..888d99fa2 100644 --- a/packages/stack-encrypt/src/target/context.rs +++ b/packages/stack-encrypt/src/target/context.rs @@ -134,13 +134,19 @@ impl CallerContext { empty_part: false, } } - /// Prove the query context nonempty, and refuse an empty part, before - /// a term is derived. - pub(crate) fn validated(self) -> Result, Error> { + /// Refuse an empty part. A context with no scope passes: a record + /// whose fields have contexts of their own takes a binding alone. + pub(crate) fn checked(self) -> Result { if self.empty_part { return Err(refuse_empty_part()); } - nonempty(self) + Ok(self) + } + /// Prove the query context nonempty, and refuse an empty part, before + /// a term is derived. + pub(crate) fn validated(self) -> Result, Error> { + let this = self.checked()?; + nonempty(this) } /// The query context: the scopes, as one tree. The unit context when /// the caller passed no scope. diff --git a/packages/stack-encrypt/src/testing.rs b/packages/stack-encrypt/src/testing.rs index a3a7b0faa..6440a0f4f 100644 --- a/packages/stack-encrypt/src/testing.rs +++ b/packages/stack-encrypt/src/testing.rs @@ -246,6 +246,13 @@ impl ContextSubject for Contexts { }) } } +/// A field marker states its field's context: the record's contexts, +/// narrowed to that field. +impl ContextSubject for crate::marker::Marker { + fn single_context(&self) -> Result { + self.contexts().single_context() + } +} impl ContextSubject for CallContexts { fn single_context(&self) -> Result { if self.has_failed() { diff --git a/packages/stack-encrypt/tests/markers.rs b/packages/stack-encrypt/tests/markers.rs new file mode 100644 index 000000000..4702f9512 --- /dev/null +++ b/packages/stack-encrypt/tests/markers.rs @@ -0,0 +1,276 @@ +//! Field markers (#1486): the value a typed query of a derived record's +//! field starts from. `#[derive(EncryptTarget)]` on a `struct = ..` record +//! emits `EncryptedUser::EMAIL` for a field `email`. A query from the +//! marker derives under the context the derive sealed the field under, so +//! the query term is the stored term, and the marker's operator methods +//! exist only where the field's indexes answer the operator. +#![cfg(feature = "testing")] + +mod common; + +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; +use stack_encrypt::target::{DeclaredContext, DecryptSource, EncryptTarget, Scope}; +use stack_encrypt::testing::scope; +use stack_encrypt::{ + assert_context, nonempty, DecryptFrom, EncryptInto, Encrypted, Error, Marker, PlanError, + StackCipher, StackCipherText, +}; +use zeroize::Zeroize; + +async fn cipher() -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .unwrap() +} + +#[derive(Clone, Zeroize)] +struct User { + email: String, + age: u32, + bio: String, +} + +fn ada() -> User { + User { + email: "ada@example.com".into(), + age: 36, + bio: "Analytical engines and their programs".into(), + } +} + +/// A record whose caller supplies a tenant scope on every call. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users", scope(tenant: u64))] +struct EncryptedUser { + email: Encrypted, + age: Encrypted>, + bio: Encrypted, +} + +/// A record with no caller part, and a field keyed under another identity. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "people")] +struct Renamed { + #[stash(identity = "mail")] + email: Encrypted, + age: Encrypted>, + bio: StackCipherText, +} + +mod a_marker { + use super::*; + + #[test] + fn names_its_field_and_the_context_the_derive_sealed_it_under() { + assert_eq!(EncryptedUser::EMAIL.name(), "email"); + assert_eq!(EncryptedUser::EMAIL.label().to_string(), "users/email"); + assert_eq!(EncryptedUser::AGE.label().to_string(), "users/age"); + // An identity replaces the segment, and the name stays the field's. + assert_eq!(Renamed::EMAIL.name(), "email"); + assert_eq!(Renamed::EMAIL.label().to_string(), "people/mail"); + } + + #[test] + fn shows_the_fields_context_with_the_callers_placeholder() { + assert_context!( + EncryptedUser::EMAIL, + [["users", "email"], scope::("tenant")] + ); + assert_context!(Renamed::EMAIL, ["people", "mail"]); + assert_eq!( + EncryptedUser::AGE.contexts().to_string(), + "context: (users / age) / {tenant: u64}\n" + ); + // The marker's context is the record's entry for the field. + assert_eq!( + EncryptedUser::contexts().get("email"), + EncryptedUser::EMAIL.contexts().single() + ); + } + + #[test] + #[should_panic(expected = "the context differs at part 1: expected `members`, found `people`")] + fn is_pinned_with_assert_context() { + assert_context!(Renamed::EMAIL, ["members", "mail"]); + } + + #[test] + fn is_a_plain_value() { + let marker: Marker> = EncryptedUser::EMAIL; + let copy = marker; + assert_eq!(copy.name(), marker.name()); + let shown = format!("{marker:?}"); + assert!( + shown.contains("\"email\"") && shown.contains("\"users\""), + "{shown}" + ); + } +} + +mod a_marker_query { + use super::*; + + #[tokio::test] + async fn derives_the_stored_equality_term_under_the_scope() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedUser = ada() + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + + let term = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(term, stored.email.terms); + + // Another tenant, or another value, is another term. + let other_tenant = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new(43u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_ne!(other_tenant, stored.email.terms); + let other_value = EncryptedUser::EMAIL + .eq("bob@example.com") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_ne!(other_value, stored.email.terms); + } + + #[tokio::test] + async fn derives_the_stored_order_term_at_each_bound() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedUser = ada() + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + + // A bound equal to the stored value derives the stored term. + let at_36 = EncryptedUser::AGE + .gte(&36u32) + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(at_36, stored.age.terms); + + // A range carries one term per bound, each as the stored term of + // that value would be. + let adults = EncryptedUser::AGE + .between(&18u32, &36u32) + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(adults.hi(), &stored.age.terms); + let at_18: Encrypted> = 18u32 + .encrypt_into_with_context(&keyset, nonempty!("users").with("age").with(42u64)) + .await + .unwrap(); + assert_eq!(adults.lo(), &at_18.terms); + } + + #[tokio::test] + async fn matches_the_stored_text_under_the_scope() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedUser = ada() + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + let query = EncryptedUser::BIO + .matches("engines") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert!(stored.bio.terms.matches(&query)); + let elsewhere = EncryptedUser::BIO + .matches("engines") + .scope(Scope::new(43u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert!(!stored.bio.terms.matches(&elsewhere)); + } + + #[tokio::test] + async fn takes_no_scope_for_a_record_with_no_caller_part() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let stored: Renamed = ada().encrypt_into(&keyset).await.unwrap(); + let term = Renamed::EMAIL + .eq("ada@example.com") + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(term, stored.email.terms); + let direct: Encrypted = String::from("ada@example.com") + .encrypt_into_with_context(&keyset, nonempty!("people").with("mail")) + .await + .unwrap(); + assert_eq!(term, direct.terms); + } + + #[tokio::test] + async fn refuses_a_binding_before_any_key_is_requested() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + // The query type has no `bind` method (`tests/ui/marker_query_has_no_bind.rs`). + // A binding smuggled in through a declared context is refused. + let bound = DeclaredContext::default().with(Scope::new(42u64).bind(9u128)); + let refused = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(bound) + .encrypt(&keyset) + .await; + assert!( + matches!(refused, Err(Error::Plan(PlanError::BindInQuery))), + "{refused:?}" + ); + } + + #[tokio::test] + async fn refuses_text_with_no_normal_form_before_any_key_is_requested() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let refused = EncryptedUser::EMAIL + .eq("ada\u{0378}@example.com") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await; + assert!(refused.is_err()); + } + + #[tokio::test] + async fn is_the_query_the_record_opens_for() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let stored: EncryptedUser = ada() + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + let term = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(term, stored.email.terms); + let back = User::decrypt_from_with_context(stored, &keyset, Scope::new(42u64)) + .await + .unwrap(); + assert_eq!(back.email, "ada@example.com"); + } +} diff --git a/packages/stack-encrypt/tests/planned.rs b/packages/stack-encrypt/tests/planned.rs new file mode 100644 index 000000000..6f087e139 --- /dev/null +++ b/packages/stack-encrypt/tests/planned.rs @@ -0,0 +1,445 @@ +//! A value under a plan that is data (#1486): `Planned` is a target, so +//! the owned entry points encrypt a record under a `Plan`, decrypt a stored +//! row, and derive a query term under a `QueryPlan`, each with the caller's +//! context as the one context argument. The dynamic field handle checks +//! each operator against the plan where it is called. A query from a plan +//! that is data derives the same term a typed query from a field marker +//! does, under the same scope. +#![cfg(all(feature = "dynamic", feature = "testing"))] + +mod common; + +use common::frames::{frame, object}; +use stack_encrypt::dynamic::{self, record, Op, Operand, Planned, QueryTerm, ValueFrame}; +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{DeclaredContext, DecryptSource, EncryptTarget, Scope}; +use stack_encrypt::{ + AsProtectedRef, DecryptFrom, EncryptInto, Encrypted, Error, StackCipher, StackCipherText, +}; +use zeroize::Zeroize; + +/// The plaintext row, as a decrypted frame reads back. Plan order is the +/// field names' order, so the frame is compared by value, not by bytes. +#[derive(Debug, PartialEq, serde::Deserialize)] +struct Row { + email: String, + age: u32, +} + +fn row_of(frame: &ValueFrame) -> Row { + vitaminc_aead_frame::serde::from_slice(frame.risky_ref()).expect("an object of the two fields") +} + +fn ada_row() -> Row { + Row { + email: "ada@example.com".into(), + age: 36, + } +} + +async fn cipher() -> StackCipher { + StackCipher::builder() + .kms(FakeDataKeySource::new()) + .init() + .await + .unwrap() +} + +/// The data plan of a record with a searchable email and an ordered age, +/// each field under its own context. +fn plan() -> record::Plan { + let declaration = vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ + "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, + "age": { "context": ["users", "age"], "outputs": ["c", "ore"], "type": "uint32" }, + })) + .unwrap(); + record::plan(declaration.as_protected_ref()).unwrap() +} + +fn ada() -> ValueFrame { + object(vec![ + ("email", frame("ada@example.com")), + ("age", frame(36u32)), + ]) +} + +/// The dynamic error inside an owned-path failure. +fn dynamic_error(error: &Error) -> &dynamic::Error { + match error { + Error::Other(inner) => inner + .downcast_ref::() + .unwrap_or_else(|| panic!("not a dynamic error: {inner}")), + other => panic!("not an owned-path failure: {other}"), + } +} + +// The typed record the data plan above describes, for the equivalence +// checks: the derive seals `email` under `("users", "email")` extended by +// the caller's tenant, as the plan's field is extended by the caller's +// scope. +#[derive(Clone, Zeroize)] +struct User { + email: String, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "users", scope(tenant: u64))] +struct EncryptedUser { + email: Encrypted, +} + +mod a_record_under_a_plan { + use super::*; + + #[tokio::test] + async fn round_trips_with_no_caller_context() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + let row: StackCipherText = Planned::new(&plan, ada()) + .encrypt_into(&keyset) + .await + .unwrap(); + let back = ValueFrame::decrypt_from(Planned::new(&plan, row), &keyset) + .await + .unwrap(); + assert_eq!(row_of(&back), ada_row()); + } + + /// A row written under the tenant and bound to its row ID. + async fn bound_row( + keyset: &stack_encrypt::KeysetCipher<'_, FakeDataKeySource>, + ) -> StackCipherText { + Planned::new(&plan(), ada()) + .encrypt_into_with_context(keyset, Scope::new(42u64).bind(9u128)) + .await + .unwrap() + } + + #[tokio::test] + async fn round_trips_under_a_scope_and_a_binding() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + let row = bound_row(&keyset).await; + let back = ValueFrame::decrypt_from_with_context( + Planned::new(&plan, row), + &keyset, + Scope::new(42u64).bind(9u128), + ) + .await + .unwrap(); + assert_eq!(row_of(&back), ada_row()); + } + + #[tokio::test] + async fn does_not_open_under_another_context() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + // Another row's binding, another tenant, or no context: the row + // does not open. + for wrong in [ + Scope::new(42u64).bind(10u128), + Scope::new(43u64).bind(9u128), + ] { + let row = bound_row(&keyset).await; + let refused = + ValueFrame::decrypt_from_with_context(Planned::new(&plan, row), &keyset, wrong) + .await; + assert!(refused.is_err()); + } + let row = bound_row(&keyset).await; + assert!(ValueFrame::decrypt_from(Planned::new(&plan, row), &keyset) + .await + .is_err()); + } + + #[tokio::test] + async fn takes_a_binding_alone() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + let row: StackCipherText = Planned::new(&plan, ada()) + .encrypt_into_with_context(&keyset, stack_encrypt::target::Bind::new(9u128)) + .await + .unwrap(); + let back = ValueFrame::decrypt_from_with_context( + Planned::new(&plan, row), + &keyset, + stack_encrypt::target::Bind::new(9u128), + ) + .await + .unwrap(); + assert_eq!(row_of(&back), ada_row()); + } + + #[tokio::test] + async fn refuses_an_empty_part_before_any_key_is_requested() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + let refused = Planned::new(&plan, ada()) + .encrypt_into_with_context::(&keyset, Scope::new("")) + .await; + assert!(refused.is_err(), "{refused:?}"); + } + + #[test] + fn shows_every_fields_context_with_the_callers_placeholder() { + let plan = plan(); + let contexts = Planned::new(&plan, ada()).contexts().unwrap(); + assert_eq!( + contexts.to_string(), + "age\n context: (users / age) / {context?: CallerContext}\n\ + email\n context: (users / email) / {context?: CallerContext}\n" + ); + } + + #[test] + fn shows_the_plans_own_parts_before_the_callers() { + let declaration = vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ + "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, + "bind": [7], + })) + .unwrap(); + let plan = record::plan(declaration.as_protected_ref()).unwrap(); + let contexts = Planned::new(&plan, ada()).contexts().unwrap(); + assert_eq!( + contexts.to_string(), + "email\n query context: (users / email) / {context?: CallerContext}\n \ + stored context: ((users / email) / {context?: CallerContext}) / 7u64\n" + ); + } + + #[test] + fn prints_neither_the_value_nor_the_plan() { + let plan = plan(); + let shown = format!("{:?}", Planned::new(&plan, frame("a secret"))); + assert!(!shown.contains("secret"), "{shown}"); + assert!(!shown.contains("users"), "{shown}"); + } + + #[test] + fn wipes_its_value() { + let mut value = frame("a secret"); + value.zeroize(); + assert!(value.risky_ref().is_empty()); + let plan = plan(); + let mut planned = Planned::new(&plan, frame("a secret")); + planned.zeroize(); + } +} + +mod a_query_plan { + use super::*; + + #[test] + fn is_refused_where_no_index_answers_the_operator() { + let plan = plan(); + assert!(plan.query("email", Op::Eq).is_ok()); + assert!(plan.query("age", Op::Between).is_ok()); + let refused = plan.query("email", Op::Gt).unwrap_err(); + assert!( + matches!( + &refused, + dynamic::Error::Source { field: Some(field), reason: dynamic::Reason::OperatorNotAnswered } if field == "email" + ), + "{refused}" + ); + let missing = plan.query("name", Op::Eq).unwrap_err(); + assert!( + matches!( + &missing, + dynamic::Error::Plan { + reason: dynamic::Reason::NoSuchField, + .. + } + ), + "{missing}" + ); + } + + #[test] + fn names_its_field_and_operator_and_shows_the_fields_context() { + let plan = plan(); + let by_email = plan.query("email", Op::Eq).unwrap(); + assert_eq!(by_email.field(), "email"); + assert_eq!(by_email.operator(), Op::Eq); + assert_eq!(by_email.plan().fields().len(), 2); + assert_eq!( + by_email.contexts().unwrap().to_string(), + "context: users / email\n" + ); + } + + #[tokio::test] + async fn derives_the_term_a_typed_marker_query_derives() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + + // The typed record, written under the tenant. + let stored: EncryptedUser = User { + email: "ada@example.com".into(), + } + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + let typed = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(typed, stored.email.terms); + + // The query value under the query plan, by the same scope. + let by_email = plan.query("email", Op::Eq).unwrap(); + let term: QueryTerm = Planned::new(&by_email, frame("ada@example.com")) + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + assert_eq!(term.operator(), Op::Eq); + assert_eq!(term.operand(), &Operand::One(typed.as_bytes().to_vec())); + + // And through the field handle. + let same: QueryTerm = plan + .field("email") + .unwrap() + .eq(frame("ada@example.com")) + .unwrap() + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(same, term); + + // Another tenant is another term. + let other: QueryTerm = Planned::new(&by_email, frame("ada@example.com")) + .encrypt_into_with_context(&keyset, Scope::new(43u64)) + .await + .unwrap(); + assert_ne!(other, term); + } + + #[tokio::test] + async fn derives_a_range_with_one_term_per_bound() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + let range: QueryTerm = plan + .field("age") + .unwrap() + .between(frame(vec![18u32, 65])) + .unwrap() + .encrypt(&keyset) + .await + .unwrap(); + let lo: QueryTerm = Planned::new(&plan.query("age", Op::Gte).unwrap(), frame(18u32)) + .encrypt_into(&keyset) + .await + .unwrap(); + match (range.operand(), lo.operand()) { + (Operand::Range { lo: low, .. }, Operand::One(bound)) => assert_eq!(low, bound), + other => panic!("{other:?}"), + } + } + + #[tokio::test] + async fn refuses_a_binding_before_any_key_is_requested() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + let by_email = plan.query("email", Op::Eq).unwrap(); + let refused = Planned::new(&by_email, frame("ada@example.com")) + .encrypt_into_with_context::(&keyset, Scope::new(42u64).bind(9u128)) + .await + .unwrap_err(); + assert!( + matches!( + dynamic_error(&refused), + dynamic::Error::Plan { + reason: dynamic::Reason::BindInQuery, + .. + } + ), + "{refused}" + ); + + // The handle has no `bind` method; a binding smuggled in through a + // declared context is refused the same way. + let bound = DeclaredContext::default().with(Scope::new(42u64).bind(9u128)); + let refused = plan + .field("email") + .unwrap() + .eq(frame("ada@example.com")) + .unwrap() + .scope(bound) + .encrypt(&keyset) + .await + .unwrap_err(); + assert!( + matches!( + dynamic_error(&refused), + dynamic::Error::Plan { + reason: dynamic::Reason::BindInQuery, + .. + } + ), + "{refused}" + ); + } +} + +mod a_field_handle { + use super::*; + + #[test] + fn is_refused_for_a_field_the_plan_does_not_have() { + let plan = plan(); + let missing = plan.field("name").unwrap_err(); + assert!( + matches!( + &missing, + dynamic::Error::Plan { + reason: dynamic::Reason::NoSuchField, + .. + } + ), + "{missing}" + ); + assert_eq!(plan.field("email").unwrap().name(), "email"); + } + + #[test] + fn checks_each_operator_where_it_is_called() { + let plan = plan(); + let email = plan.field("email").unwrap(); + assert!(email.clone().eq(frame("x")).is_ok()); + let refused = email.gt(frame("x")).unwrap_err(); + assert!( + matches!( + &refused, + dynamic::Error::Source { + reason: dynamic::Reason::OperatorNotAnswered, + .. + } + ), + "{refused}" + ); + let age = plan.field("age").unwrap(); + assert!(age.clone().lt(frame(1u32)).is_ok()); + assert!(age.matches(frame("x")).is_err()); + } + + #[test] + fn prints_the_field_and_operator_and_not_the_value() { + let plan = plan(); + let query = plan.field("email").unwrap().eq(frame("a secret")).unwrap(); + let shown = format!("{query:?}"); + assert!(shown.contains("email") && shown.contains("Eq"), "{shown}"); + assert!(!shown.contains("secret"), "{shown}"); + } +} diff --git a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs new file mode 100644 index 000000000..f8dd02e25 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs @@ -0,0 +1,27 @@ +//! A field marker has one method per operator the field's indexes answer. +//! `Encrypted` answers `eq` alone, so its marker has no `gt`; +//! `Encrypted>` answers the orderings and no `matches`. +use stack_encrypt::sem::{EqualityTerm, OreTerm}; +use stack_encrypt::target::EncryptTarget; +use stack_encrypt::Encrypted; +use zeroize::Zeroize; + +#[derive(Clone, Zeroize)] +struct User { + email: String, + age: u32, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, scope = "users")] +struct EncryptedUser { + email: Encrypted, + age: Encrypted>, +} + +fn main() { + let _ = EncryptedUser::EMAIL.eq("ada@example.com"); + let _ = EncryptedUser::EMAIL.gt("ada@example.com"); + let _ = EncryptedUser::AGE.between(&18u32, &65); + let _ = EncryptedUser::AGE.matches("36"); +} diff --git a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr new file mode 100644 index 000000000..d47a0d410 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr @@ -0,0 +1,71 @@ +error[E0277]: `EqualityTerm` does not answer `stack_encrypt::op::Gt` given `_` + --> tests/ui/marker_operator_not_answered.rs:24:37 + | +24 | let _ = EncryptedUser::EMAIL.gt("ada@example.com"); + | -- ^^^^^^^^^^^^^^^^^ this target type does not answer the operator + | | + | required by a bound introduced by this call + | + = 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/mod.rs + | + | / impl Answers for EqualityTerm + | | where + | | S: crate::Encrypt + Clone + 'static, + | |________________________________________^ + = help: for that trait implementation, expected `stack_encrypt::op::Eq`, found `stack_encrypt::op::Gt` + = note: required for `Encrypted` to implement `Answers` +note: required by a bound in `Marker::::gt` + --> src/marker.rs + | + | T: Answers<$op, Q::Owned> + LabelledTarget, + | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::gt` +... + | / operator_methods! { + | | /// Rows whose value equals `value` ([`op::Eq`](struct@Eq)). + | | eq => Eq; + | | /// Rows whose value is below `value` ([`op::Lt`](Lt)). +... | + | | gt => Gt; + | | -- required by a bound in this associated function +... | + | | matches => Matches; + | | } + | |_- in this macro invocation + = note: this error originates in the macro `operator_methods` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `OreTerm` does not answer `stack_encrypt::op::Matches` given `_` + --> tests/ui/marker_operator_not_answered.rs:26:40 + | +26 | let _ = EncryptedUser::AGE.matches("36"); + | ------- ^^^^ this target type does not answer the operator + | | + | required by a bound introduced by this call + | + = help: the trait `Answers` is not implemented for `OreTerm` + = note: a target answers the operators its type implements `Answers` for; `.via(index)` names an index the target declares + = help: the following other types implement trait `Answers`: + `OreTerm` implements `Answers` + `OreTerm` implements `Answers` + `OreTerm` implements `Answers` + `OreTerm` implements `Answers` + `OreTerm` implements `Answers` + = note: required for `Encrypted>` to implement `Answers` +note: required by a bound in `Marker::::matches` + --> src/marker.rs + | + | T: Answers<$op, Q::Owned> + LabelledTarget, + | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::matches` +... + | / operator_methods! { + | | /// Rows whose value equals `value` ([`op::Eq`](struct@Eq)). + | | eq => Eq; + | | /// Rows whose value is below `value` ([`op::Lt`](Lt)). +... | + | | matches => Matches; + | | ------- required by a bound in this associated function + | | } + | |_- in this macro invocation + = note: this error originates in the macro `operator_methods` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs new file mode 100644 index 000000000..fbe3f2bda --- /dev/null +++ b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs @@ -0,0 +1,28 @@ +//! A query from a field marker takes scopes and no binding: a binding is in +//! no query context, so the query type has no `bind` method and a bare +//! `Bind` does not convert into its `scope`. +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{Bind, EncryptTarget, Scope}; +use stack_encrypt::Encrypted; +use zeroize::Zeroize; + +#[derive(Clone, Zeroize)] +struct User { + email: String, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, scope = "users", scope(tenant: u64), bind(row: u128))] +struct EncryptedUser { + email: Encrypted, +} + +fn main() { + let _ = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new(42u64)) + .bind(9u128); + let _ = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Bind::new(9u128)); +} diff --git a/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr new file mode 100644 index 000000000..b35ac6937 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr @@ -0,0 +1,36 @@ +error[E0599]: no method named `bind` found for struct `MarkedQuery` in the current scope + --> tests/ui/marker_query_has_no_bind.rs:24:10 + | +21 | let _ = EncryptedUser::EMAIL + | _____________- +22 | | .eq("ada@example.com") +23 | | .scope(Scope::new(42u64)) +24 | | .bind(9u128); + | | -^^^^ method not found in `MarkedQuery, stack_encrypt::op::Eq, std::string::String>` + | |_________| + | + +error[E0277]: the trait bound `DeclaredContext: From>` is not satisfied + --> tests/ui/marker_query_has_no_bind.rs:27:16 + | +27 | .scope(Bind::new(9u128)); + | ----- ^^^^^^^^^^^^^^^^ the trait `From>` is not implemented for `DeclaredContext` + | | + | required by a bound introduced by this call + | + = help: the following other types implement trait `From`: + `DeclaredContext` implements `From<&NonEmpty>` + `DeclaredContext` implements `From<()>` + `DeclaredContext` implements `From` + `DeclaredContext` implements `From` + `DeclaredContext` implements `From` + `DeclaredContext` implements `From>` + `DeclaredContext` implements `From>>` + `DeclaredContext` implements `From` + and $N others + = note: required for `stack_encrypt::target::Bind` to implement `Into` +note: required by a bound in `MarkedQuery::::scope` + --> src/marker.rs + | + | pub fn scope(mut self, part: impl Into) -> Self { + | ^^^^^^^^^^^^^^^^^^^^^ required by this bound in `MarkedQuery::::scope` From a95a0296793e1fc52542666096e348ef07b13be9 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 06:38:39 +1100 Subject: [PATCH 2/6] fix(stack-encrypt): address the review of the field markers A marker carries the type of the scope the record's caller supplies, and a query from it has no `encrypt` until that scope is given, once: a missing, mistyped or repeated scope no longer compiles. The query's copy of the value is wiped on refusal and on drop. `Planned` and the field handle take a `ValueFrame`, the dynamic interface's value, so the borrowed form reads it in place and nothing is left unwiped. A dynamic refusal on the owned path keeps its kind, and a binding on a query is `PlanError::BindInQuery` with its code. The handle takes its resolver at `Plan::field_with`, so a field that names an EQL type is checked and queried through it. `contexts()` shows the caller's scope and binding as two placeholders, after the plan's own parts, and tests pin the run against that order. The derive keeps a raw identifier's name, allows `private_interfaces` on the constant, and emits markers for generic records. The operator methods a marker still lacks are #1596. Refs #1486. --- .../2026-10-24-stack-encrypt-api-plan.md | 80 +++-- packages/stack-encrypt-derive/src/lib.rs | 32 +- .../stack-encrypt-derive/src/owned_encrypt.rs | 33 +- packages/stack-encrypt/CHANGELOG.md | 65 ++-- packages/stack-encrypt/CONTEXT.md | 54 +-- packages/stack-encrypt/src/dynamic/planned.rs | 242 ++++++++----- packages/stack-encrypt/src/lib.rs | 2 +- packages/stack-encrypt/src/marker.rs | 242 +++++++++---- packages/stack-encrypt/src/testing.rs | 2 +- packages/stack-encrypt/tests/markers.rs | 161 +++++++-- packages/stack-encrypt/tests/planned.rs | 326 ++++++++++++++++-- .../ui/marker_operator_not_answered.stderr | 8 +- .../tests/ui/marker_query_has_no_bind.rs | 6 +- .../tests/ui/marker_query_has_no_bind.stderr | 27 +- .../tests/ui/marker_query_needs_its_scope.rs | 40 +++ .../ui/marker_query_needs_its_scope.stderr | 39 +++ .../stack-encrypt/tests/ui/pass/markers.rs | 52 +++ 17 files changed, 1083 insertions(+), 328 deletions(-) create mode 100644 packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.rs create mode 100644 packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/markers.rs diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index 994cea4ba..acc7eca47 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -326,30 +326,39 @@ derive sealed it under (the record's scope, then the field's identity), and the field's target type, whose `Answers` impls are the field's index list. It is the value a query starts from. The derive emits no plan: the marker is enough for a query, and a binding sends a plan of its own. -Built on 10 November 2026 (#1486): the marker carries no plaintext type -and no accessor, because the derive sees the record's field types and -not the plaintext struct's, and a query value converts from the borrowed -form a caller has (`&str` to `String`) through `ToOwned`. +Built on 10 November 2026 (pull request #1595): the marker has no +plaintext type and no accessor, because the derive sees the record's +field types and not the plaintext struct's, and a query value converts +from the borrowed form a caller has (`&str` to `String`) through +`ToOwned`. So an equality marker takes a value of any type its index +accepts, and a field whose type is several indexes, a nullable field, +or a record field has a marker with no operator method yet. Both gaps +are #1596. A typed query starts from the marker, and has one method for each operator the field's indexes answer. A missing operator is a compile error: "no method named `gt`", with a message that names the field's -indexes. Scopes chain on the query. A binding cannot, because the query -builder has no `bind` method, so a query accepts scopes only, by type. -The query ends with `encrypt(&keyset)`, and its output type is the EQL -query type the marker fixes. Decided on 7 November 2026. +indexes. A record that lists a caller scope gives its markers the +scope's type, and the query has no `encrypt` until `.scope(..)` is given +once, with a value of that type; a record with no caller scope gives a +query that is ready at once. A binding cannot be given, because the +query type has no `bind` method, so a query accepts its scope only, by +type. The query ends with `encrypt(&keyset)`, and its output type is the +EQL query type the marker fixes. Decided on 7 November 2026. ```rust -// On `next` since #1486, with the index's query term as the output -// type (`EqualityTerm`, `RangeQuery>`, `MatchQuery`); the -// EQL query types follow with the EQL owned implementations. +// On `next` since pull request #1595, with the index's query term as +// the output type (`EqualityTerm`, `RangeQuery>`, +// `MatchQuery`); the EQL query types follow with the EQL owned +// implementations. The bounds are passed by reference. let by_email: TextEqQuery = EncryptedMember::EMAIL .eq("alice@example.com") .scope(tenant) .encrypt(&keyset) .await?; let adults: IntegerOrdQuery = EncryptedMember::AGE - .between(18, 65) + .between(&18, &65) + .scope(tenant) .encrypt(&keyset) .await?; @@ -696,9 +705,10 @@ keyset as the argument. Made a target after the combinator spike of 9 November 2026 (ADR-0007, amended). ```rust -// On `next` since #1486. The value under a data plan is the dynamic -// interface's record, a `ValueFrame` of its fields, and the stored row -// is a `StackCipherText`. A typed struct uses the derive, not a plan. +// On `next` since pull request #1595. The value under a data plan is +// the dynamic interface's value, a `ValueFrame` of the record's fields, +// and the stored row is a `StackCipherText`. A typed struct uses the +// derive, not a plan. let row: StackCipherText = Planned::new(&plan, values).encrypt_into(&keyset).await?; let values = ValueFrame::decrypt_from(Planned::new(&plan, row), &keyset).await?; @@ -764,10 +774,10 @@ it declares about queries, so no declaration lists the queries a program might run. ```rust -// On `next` since #1486. +// On `next` since pull request #1595. A query value is a `ValueFrame`. let by_email = plan.query("email", Op::Eq)?; // refused here if no index answers Eq -let q: QueryTerm = Planned::new(&by_email, "alice@example.com") +let q: QueryTerm = Planned::new(&by_email, ValueFrame::of("alice@example.com")?) .encrypt_into_with_context(&keyset, Scope::new(tenant)) .await?; ``` @@ -789,15 +799,18 @@ field](#query-a-record-field) shows the typed form. #### The dynamic field handle A program whose plan is data uses the same builder through a field -handle. The handle has every operator method. Each one is checked at run -time against the plan, and returns `operator_not_answered` when no index -answers it. The handle has no `bind` method either. +handle. The handle has one method per scalar operator, and `op(..)` for +any operator, the JSON ones included. Each one is checked at run time +against the plan, and returns `operator_not_answered` when no index +answers it. A field that names an EQL type takes its resolver at the +handle, `plan.field_with(name, &resolver)`. The handle has no `bind` +method either. ```rust -// On `next` since #1486. +// On `next` since pull request #1595. A query value is a `ValueFrame`. let email = plan.field("email")?; let q: QueryTerm = email - .eq("alice@example.com")? // refused here if no index answers Eq + .eq(ValueFrame::of("alice@example.com")?)? // refused here if no index answers Eq .scope(tenant) .encrypt(&keyset) .await?; @@ -1557,9 +1570,10 @@ review. - **Done already.** `Answers` on a target type is the compile-time lookup of the index for an operator; a marker's methods are bound by it, so the marker's index list is its target type's `Answers` impls. - Pull request #1486's branch lands the markers, their query methods - and `assert_context!` over a marker (`src/marker.rs`, - `tests/markers.rs`, `tests/ui/marker_*.rs`). The chain query + Pull request #1595 lands the markers, their query methods and + `assert_context!` over a marker (`src/marker.rs`, `tests/markers.rs`, + `tests/ui/marker_*.rs`). A field of several indexes, a nullable field + and a record field have markers with no operator method yet (#1596). The chain query `cipher.query(op, value).on(&plan)` still exists until #1488 removes it. - **No hand-built plan.** The review of 7 November 2026 gave a @@ -1573,15 +1587,16 @@ review. time too. Today the builder refuses it at run time, so that check lands with the builder's removal. The dynamic grammar keeps its run-time checks. -- **Tracked in.** Issue #1486 is open; its markers pull request is on - the stack above #1592. +- **Tracked in.** Issue #1486 is open; pull request #1595 closes it. + Issue #1596 is the operator methods the markers still lack. - **Finished when** these are all true: - the [marker example](#query-a-record-field) compiles, with the index's query term as the output type; the EQL query types come with the EQL owned implementations; - the compile-fail cases in `tests/ui` cover an operator the field's - indexes do not answer (`marker_operator_not_answered`) and a - binding on a query (`marker_query_has_no_bind`); + indexes do not answer (`marker_operator_not_answered`), a binding + on a query (`marker_query_has_no_bind`), and a missing, mistyped or + repeated scope (`marker_query_needs_its_scope`); - tests show that a query cannot run without its plan or its required context (`tests/markers.rs`, `tests/planned.rs`). @@ -1616,9 +1631,8 @@ Decided on 7 November 2026, as area one of the query design review. form, because its value is plaintext. It is never logged or written to disk. Its shape is not part of the stored format. - **Done already.** `Planned`, the query plan, the field handle, - `QueryTerm` and `contexts()` on a `Plan` and a `Planned` are on the - markers pull request's branch (`src/dynamic/planned.rs`, - `tests/planned.rs`). `Planned` calls `encrypt_with`, `query_term` and + `QueryTerm` and `contexts()` on a `Plan` and a `Planned` are on pull + request #1595 (`src/dynamic/planned.rs`, `tests/planned.rs`). `Planned` calls `encrypt_with`, `query_term` and `decrypt_with` in `dynamic::record` and `dynamic::query`, under the plan extended by the caller's parts. The guest still sends the engine a plan frame, then a value frame for each call. The serialised @@ -1626,7 +1640,7 @@ Decided on 7 November 2026, as area one of the query design review. is not built yet. - **Tracked in.** Issue #1486 is open, in part. Issue #1594 is the serialised `Planned`, the Go module sending it, and the corpus - rewrite, split from the markers pull request. On the Go side, issue #1116 is open to hide or rename `record.Plan`. Issue #1307 is open for a Go plan-building API, for types `stashgen` cannot tag. It builds a data plan, so the Rust builder's removal does not affect it. + rewrite, split from pull request #1595. On the Go side, issue #1116 is open to hide or rename `record.Plan`. Issue #1307 is open for a Go plan-building API, for types `stashgen` cannot tag. It builds a data plan, so the Rust builder's removal does not affect it. - **Finished when** these are all true: - every case in the conformance corpus is a serialised `Plan` or `Planned` with the engine's expected answer beside it; diff --git a/packages/stack-encrypt-derive/src/lib.rs b/packages/stack-encrypt-derive/src/lib.rs index 88125bf3b..5a3e86f77 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -117,24 +117,28 @@ //! # }).unwrap(); //! ``` //! +//! The storage envelope itself adds no +//! cryptographic map-entry context. Vitamin C still binds keys inside plaintext +//! maps and preserves authenticated absence and empty-container markers. +//! //! # Field markers //! //! A `struct` record also emits one marker per derived field, as an //! associated constant named after the field in upper case: -//! `EncryptedUser::EMAIL` for a field `email`. A marker is the value a -//! typed query of the field starts from. It has one method per operator the -//! field's indexes answer, checked at compile time, takes the caller's -//! scopes with `.scope(..)` and no binding, and ends with -//! `.encrypt(&keyset)`. The query derives under the context the derive -//! sealed the field under, so it is the stored term. See -//! `stack_encrypt::Marker`. A generic record emits no marker, since a -//! marker holds the record's `contexts` as a function pointer; a tuple -//! field has no name, so none either. The constant's name is the field's -//! in upper case, so a record cannot define another constant of that name. -//! -//! The storage envelope itself adds no -//! cryptographic map-entry context. Vitamin C still binds keys inside plaintext -//! maps and preserves authenticated absence and empty-container markers. +//! `EncryptedUser::EMAIL` for a field `email` (`EncryptedDoc::TYPE` for +//! `r#type`). A marker is the value a typed query of the field starts +//! from. It has one method per operator the field's indexes answer, +//! checked at compile time, where the field's type is one index +//! (`Encrypted`, `Encrypted>`, `MatchTerms`); +//! a field of several indexes, a nullable field or a record field has a +//! marker with no operator method yet. A record that lists a caller scope +//! gives its markers that scope's type, and the query takes it with +//! `.scope(..)`, once; a record with none gives a query that is ready at +//! once. No binding can be given. The query ends with `.encrypt(&keyset)` +//! and derives under the context the derive sealed the field under, so it +//! is the stored term. See `stack_encrypt::Marker`. The constant's name is +//! the field's in upper case, so a record cannot define another constant +//! of that name; a tuple field has no name, so no marker. //! //! Ciphertext and term operations compose before awaiting, preserving batched //! key requests. Terms alone need only their respective PRF or ordering trait. diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs index fca178fc6..188a78159 100644 --- a/packages/stack-encrypt-derive/src/owned_encrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -36,15 +36,25 @@ pub(crate) fn derive(input: DeriveInput) -> Result { /// The field markers of a `struct = ..` record: one associated constant /// per derived field, named after the field in upper case /// (`EncryptedUser::EMAIL`), carrying the field's name, the record's scope, -/// the field's identity and the record's `contexts`. A generic record -/// emits none: a marker holds `contexts` as a function pointer, which a -/// generic function is not. A tuple field has no name, so none either. +/// the field's identity, the record's `contexts`, and the type of the +/// scope the caller supplies (`Scope` for `scope(tenant: u64)` or a +/// stored `scope(field = ..)` of that type; `NoScope` for none). A tuple +/// field has no name, so it has no marker. fn markers(input: &DeriveInput, record: &Record) -> TokenStream { - if !record.by_field || !input.generics.params.is_empty() { + if !record.by_field { return TokenStream::new(); } let krate = &record.krate; let name = &input.ident; + let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl(); + let scope_ty = match (&record.parts.caller_scope, &record.parts.stored_scope) { + (Some((_, ty)), _) => quote!(#krate::target::Scope<#ty>), + (None, Some(stored)) => { + let ty = &stored.ty; + quote!(#krate::target::Scope<#ty>) + } + (None, None) => quote!(#krate::target::NoScope), + }; let consts = record.fields.iter().filter_map(|field| { let Kind::Derived { by_field: Some(ByField { context, .. }), @@ -55,25 +65,30 @@ fn markers(input: &DeriveInput, record: &Record) -> TokenStream { let syn::Member::Named(ident) = &field.member else { return None; }; + // The marker's name is the field's as `contexts()` and the label + // spell it, `r#` included; the constant's name cannot carry it. let field_name = ident.to_string(); - let field_name = field_name.strip_prefix("r#").unwrap_or(&field_name).to_owned(); - let const_name = Ident::new(&field_name.to_uppercase(), ident.span()); + let plain = field_name.strip_prefix("r#").unwrap_or(&field_name); + let const_name = Ident::new(&plain.to_uppercase(), ident.span()); let ty = &field.ty; let OwnContext { prefix, field: identity } = context; let doc = format!( "The marker of the field `{field_name}`: what a typed query of it starts from. Emitted by `#[derive(EncryptTarget)]`; see [`Marker`]({}::Marker).", quote!(#krate) ); + // A public record may hold a private field type (a nested record); + // the constant names that type, which `private_interfaces` would + // warn about in the user's crate. Some(quote_spanned! {ty.span()=> #[doc = #doc] - #[allow(dead_code)] - pub const #const_name: #krate::Marker<#ty> = + #[allow(dead_code, private_interfaces)] + pub const #const_name: #krate::Marker<#ty, #scope_ty> = #krate::Marker::new(#field_name, #prefix, #identity, Self::contexts); }) }); quote! { #[automatically_derived] - impl #name { + impl #impl_generics #name #ty_generics #where_clause { #(#consts)* } } diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index a70aafc18..b08ef647d 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -13,35 +13,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `struct = ..` record emits one `Marker` per derived field. It is an associated constant named after the field in upper case: `EncryptedUser::EMAIL`. A marker names the field, the context the derive - sealed it under, and the field's target type. Its methods are one per - operator the field's indexes answer, checked at compile time through - `Answers`: `eq`, `lt`, `lte`, `gt`, `gte`, `between`, and `matches`. The - query takes the caller's scopes with `.scope(..)`, has no `bind` method, - and ends with `.encrypt(&keyset)`. The output is the index's query term. - A query from a marker derives under the declaration the derive wrote, so - it is the stored term. No plan is written by hand beside the record. - `assert_context!` takes a marker. `Marker::contexts()` is the record's - contexts narrowed to the field. A generic record and a tuple field emit - no marker. + sealed it under, and the field's target type. It also names the type of + the scope the record's caller supplies. Its methods are one per operator the field's + index answers, checked at compile time through `Answers`: `eq`, `lt`, + `lte`, `gt`, `gte`, `between`, and `matches`. A record that lists a + caller scope, `scope(tenant: u64)`, gives a query with no `encrypt` + until `.scope(..)` is given once, with a `Scope`. A record with no + caller scope gives a query that is ready at once. The query has no + `bind` method, and ends with `.encrypt(&keyset)`. The output is the + index's query term. A query from a marker derives under the declaration + the derive wrote, so it is the stored term. No plan is written by hand + beside the record. The query's copy of the value is wiped when it is + derived, refused, or dropped. `assert_context!` takes a marker, and + `Marker::contexts()` is the record's contexts narrowed to the field. + Not yet (#1596): a field of several indexes, a nullable field, and a + record field have markers with no operator method. An equality marker + takes a value of any type its index accepts. A tuple field has no + marker. - **`dynamic::Planned`, a value under a plan that is data** (#1486). A - record under a `Plan`, or a query value under a `QueryPlan`, is a target - in the owned API's sense. `StackCipherText` implements - `EncryptTarget>`. `QueryTerm` implements - `EncryptTarget>`. `Planned` - implements `DecryptSource`. So `encrypt_into`, - `decrypt_from`, and their `_with_context` forms run them. The caller's - scope and bindings are the one context argument, after the plan's own. A - query takes scopes alone, and refuses a binding before any key is - requested. `Planned::contexts()` shows every field's context with the - caller's placeholder, before any value is encrypted. + record under a `Plan`, or a query value under a `QueryPlan`, each a + `ValueFrame`, is a target in the owned API's sense. `StackCipherText` + implements `EncryptTarget>`. `QueryTerm` + implements `EncryptTarget>`. + `Planned` implements `DecryptSource`. + So `encrypt_into`, `decrypt_from`, and their `_with_context` forms run + them. The caller's scope and bindings are the one context argument, + after the plan's own. A query takes scopes alone, and refuses a binding + before any key is requested, as `PlanError::BindInQuery`. A refused + term keeps its kind (`Error::Term`); other dynamic refusals are + `Error::Other`. `Planned::contexts()` shows every field's context with + a placeholder for the caller's scope and one for the caller's binding, + before any value is encrypted. - **Query plans and the dynamic field handle** (#1486). `Plan::query(field, - op)` narrows a record plan to one field and one operator. It is refused - where it is made when none of the field's indexes answers the operator - (`operator_not_answered`), or when the plan has no such field. - `Plan::field(name)` is the handle a program whose plan is data queries - through. It has one method per operator, each checked against the plan - where it is called, then `.scope(..)` and `.encrypt(&keyset)`. Neither - has a `bind` method. + op)` and `Plan::query_with(.., &resolver)` narrow a record plan to one + field and one operator. They are refused where they are made when none + of the field's indexes answers the operator (`operator_not_answered`), + or when the plan has no such field. `Plan::field(name)` and + `Plan::field_with(name, &resolver)` are the handle a program whose plan + is data queries through. It has one method per scalar operator and + `op(..)` for any, each checked against the plan where it is called, then + `.scope(..)` and `.encrypt(&keyset)`. Neither has a `bind` method. - `ValueFrame` implements `Zeroize`. `dynamic::query_term` takes any `K: 'static`, as `record::encrypt_with` does. The key source's bounds apply when the pending is awaited, not when the term is described. diff --git a/packages/stack-encrypt/CONTEXT.md b/packages/stack-encrypt/CONTEXT.md index cdade5270..547e3a01f 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -279,26 +279,33 @@ _Avoid_: query type, index (that is what answers the operator), `OpSpec` The value a typed query of a derived record's field starts from. The derive emits one per field, as an associated constant named after the field in upper case (`EncryptedUser::EMAIL`). A marker names the field, the -context the derive sealed it under, and the field's target type. The target -type's `Answers` impls give the marker one method per operator the field's -indexes answer, checked at compile time. - -A query from a marker takes scopes and no binding. It derives under the -declaration the derive wrote, so it is the stored term. No plan is -written by hand beside the record. `Marker::contexts()` is the record's -contexts narrowed to the field, and `assert_context!` takes a marker. +context the derive sealed it under, and the field's target type. It also +names the type of the scope the record's caller supplies. The target +type's `Answers` impls give the marker one method per operator the +field's index answers, checked at compile time. + +A query from a marker takes the record's scope once, by type. It takes no +binding. It derives under the declaration the derive wrote, so it is the +stored term. No plan is written by hand beside the record. + +`Marker::contexts()` is the record's contexts narrowed to the field, and +`assert_context!` takes a marker. A field of several indexes, a nullable +field, and a record field have markers with no operator method yet +(#1596). _Avoid_: field handle (that is the dynamic form), column, accessor **Planned**: -A value under a plan that is data, `Planned::new(&plan, value)`: a record -under a record plan, or a query value under a query plan. A target in the -owned API's sense, so `encrypt_into`, `decrypt_from`, and their -`_with_context` forms run it. The caller's scope and bindings are the one -context argument, after the plan's own. It prints neither its value nor its -plan, and wipes the value as every source is wiped. - -A serialised form, what a binding sends for an operation, is to come -(#1594). The Go module still sends a plan frame and a value frame. +A value under a plan that is data, `Planned::new(&plan, value)`. The +value is a `ValueFrame` under a record plan or a query plan, or a stored +row under a record plan. A target in the owned API's sense, so +`encrypt_into`, `decrypt_from`, and their `_with_context` forms run it. +The caller's scope and bindings are the one context argument, after the +plan's own. + +It prints neither its value nor its plan. The frame is a protected +buffer, wiped as it is dropped. A serialised form, what a binding sends +for an operation, is to come (#1594). The Go module still sends a plan +frame and a value frame. _Avoid_: request, envelope (that is the storage format), record (a `Planned` can contain a query value) @@ -311,11 +318,14 @@ term. _Avoid_: query, index (that is what answers the operator) **Field handle**: -One field of a plan that is data, for a query: `plan.field(name)`. It has -one method per operator, each checked against the plan where it is -called, then `.scope(..)` and `.encrypt(&keyset)`. The dynamic -counterpart of a field marker. Neither has a `bind` method: a binding is -in no query context. +One field of a plan that is data, for a query: `plan.field(name)`, or +`plan.field_with(name, &resolver)` for a field that names an EQL type. It +has one method per scalar operator and `op(..)` for any operator. Each is +checked against the plan where it is called, then `.scope(..)` and +`.encrypt(&keyset)` follow. + +The dynamic counterpart of a field marker. Neither has a `bind` method: a +binding is in no query context. _Avoid_: field marker (that is the typed form), column **Query term**: diff --git a/packages/stack-encrypt/src/dynamic/planned.rs b/packages/stack-encrypt/src/dynamic/planned.rs index b1f0c8ab0..eb4655284 100644 --- a/packages/stack-encrypt/src/dynamic/planned.rs +++ b/packages/stack-encrypt/src/dynamic/planned.rs @@ -9,7 +9,7 @@ //! bindings come as the one context argument, built from //! [`Scope`](crate::target::Scope) and [`Bind`](crate::target::Bind), and //! a query takes scopes only: a binding on a query is refused before any -//! key is requested ([`Reason::BindInQuery`]). +//! key is requested ([`PlanError::BindInQuery`]). //! //! ``` //! use stack_encrypt::dynamic::{record, Op, Planned, QueryTerm, ValueFrame}; @@ -70,24 +70,29 @@ use super::record::{self, Plan}; use super::target::{NoTargets, TargetResolver}; use super::value_frame::ValueFrame; use super::{Error, Op, Reason, Scope}; -use crate::contexts::{self, Contexts, LeafContext}; +use crate::contexts::{self, CallerShape, Contexts, LeafContext, Placeholder}; use crate::target::{ CallerContext, DeclaredContext, DecryptSource, Decryption, EncryptTarget, Encryption, OptionalContext, Owned, Pending, }; -use crate::{Encrypt, KeysetCipher, StackCipherText}; +use crate::{Encrypt, KeysetCipher, PlanError, StackCipherText}; + +/// The resolver of a build without EQL types, for [`Plan::field`]. +static NO_TARGETS: NoTargets = NoTargets; /// One value under one plan: a record under a [`Plan`], or a query value -/// under a [`QueryPlan`]. A target in the owned API's sense: the stored row -/// implements `EncryptTarget>`, a query term implements -/// `EncryptTarget>`, and `Planned` implements `DecryptSource`. The resolver -/// `R` runs the EQL type a field names; [`new`](Self::new) takes none, -/// [`with_resolver`](Self::with_resolver) one. +/// under a [`QueryPlan`], each a [`ValueFrame`], the dynamic interface's +/// value; or a stored row under a [`Plan`]. A target in the owned API's +/// sense: the stored row implements `EncryptTarget>`, a query term implements `EncryptTarget>`, and `Planned` implements +/// `DecryptSource`. The resolver `R` runs the EQL type a field +/// names; [`new`](Self::new) takes none, [`with_resolver`](Self::with_resolver) +/// one. /// -/// The value is plaintext, so `Planned` wipes it when it is dropped through -/// the owned entry points, as every source is, and prints neither it nor -/// the plan. +/// A `ValueFrame` is plaintext in a protected buffer: the borrowed form +/// reads it in place, the owned form consumes it, and the buffer is wiped +/// as it is dropped. `Planned` prints neither the value nor the plan. pub struct Planned<'p, P, V, R = NoTargets> { plan: &'p P, value: V, @@ -125,24 +130,25 @@ impl<'p, P, V, R> Planned<'p, P, V, R> { impl<'p, V, R> Planned<'p, Plan, V, R> { /// The contexts every field of the plan is sealed and indexed under, - /// with a placeholder for the parts the caller supplies, before the - /// value is encrypted. No key is requested. + /// after the plan's own parts and with a placeholder for the caller's + /// scope and for the caller's binding, before the value is encrypted. + /// No key is requested. /// /// # Errors /// /// As [`Plan::contexts`]. pub fn contexts(&self) -> Result { - let shapes = self.plan.caller_shapes()?; Ok(contexts::resolve( - shapes, - as crate::SuppliedContext>::supplies(), + self.plan.caller_shapes()?, + CallerShape::both(caller_scope(), caller_bind()), )) } } impl<'p, V, R> Planned<'p, QueryPlan, V, R> { /// The query context and the stored context of the field the query - /// plan names: [`QueryPlan::contexts`]. + /// plan names, with a placeholder for the caller's scope: + /// [`QueryPlan::contexts`]. /// /// # Errors /// @@ -152,6 +158,17 @@ impl<'p, V, R> Planned<'p, QueryPlan, V, R> { } } +/// The placeholder for the scope a `Planned` record's or query's caller +/// may supply. +fn caller_scope() -> Placeholder { + Placeholder::optional("scope", "CallerContext") +} + +/// The placeholder for the binding a `Planned` record's caller may supply. +fn caller_bind() -> Placeholder { + Placeholder::optional("bind", "CallerContext").as_bind() +} + impl<'p, P, V, R> fmt::Debug for Planned<'p, P, V, R> { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("Planned").finish_non_exhaustive() @@ -172,8 +189,20 @@ impl Zeroize for ValueFrame { } } +/// A dynamic refusal as the owned path reports it. A cipher error is +/// already the crate's and passes through, so a refused term keeps its +/// kind. A binding on a query is the crate's own refusal, +/// [`PlanError::BindInQuery`], as the typed query reports it. Every other +/// dynamic refusal is boxed as [`crate::Error::Other`], with its message. fn wrap(error: Error) -> crate::Error { - crate::Error::Other(Box::new(error)) + match error { + Error::Cipher(error) => error, + Error::Plan { + reason: Reason::BindInQuery, + .. + } => PlanError::BindInQuery.into(), + other => crate::Error::Other(Box::new(other)), + } } impl Plan { @@ -249,16 +278,33 @@ impl Plan { } /// The handle of `field`, whose operator methods are each checked - /// against this plan where they are called. + /// against this plan where they are called, with no EQL types: a field + /// that names one is refused at the operator. /// /// # Errors /// /// [`Error::Plan`] with [`Reason::NoSuchField`]. pub fn field<'p>(&'p self, name: &str) -> Result, Error> { + self.field_with(name, &NO_TARGETS) + } + + /// The handle of `field`, with `resolver` for an EQL type the field + /// names: each operator is checked through it, and the query runs the + /// type through it. + /// + /// # Errors + /// + /// [`Error::Plan`] with [`Reason::NoSuchField`]. + pub fn field_with<'p, R: TargetResolver + ?Sized>( + &'p self, + name: &str, + resolver: &'p R, + ) -> Result, Error> { let _ = find(self, name)?; Ok(FieldHandle { plan: self, name: name.to_owned(), + resolver, }) } } @@ -266,25 +312,26 @@ impl Plan { /// A record under a plan encrypts to the stored row, as /// [`record::encrypt_with`] produces it: every field under its own context, /// the caller's scope and then its bindings after the plan's own. The -/// borrowed form reads a copy of the value; the owned form consumes it. -impl<'p, V, R> EncryptTarget> for StackCipherText +/// borrowed form reads the frame in place; the owned form consumes it. +impl<'p, R> EncryptTarget> for StackCipherText where - V: Encrypt + Clone + Zeroize + 'p, R: TargetResolver + 'p, { type ContextInput = OptionalContext; fn encryption<'s, K: 'static>( - ) -> Encryption<'s, Planned<'p, Plan, V, R>, Self, K, Self::ContextInput> + ) -> Encryption<'s, Planned<'p, Plan, ValueFrame, R>, Self, K, Self::ContextInput> where - Planned<'p, Plan, V, R>: 's, + Planned<'p, Plan, ValueFrame, R>: 's, { Encryption::from_build( contexts::caller_leaf(), - move |source: &Planned<'p, Plan, V, R>, cipher, context: Self::ContextInput| { + move |source: &Planned<'p, Plan, ValueFrame, R>, + cipher, + context: Self::ContextInput| { encrypt_record( cipher, source.plan, - source.value.clone(), + &source.value, &source.resolver, context, ) @@ -292,13 +339,13 @@ where ) } fn owned_encryption<'s, K: 'static>( - ) -> Encryption<'s, Planned<'p, Plan, V, R>, Self, K, Self::ContextInput, Owned> + ) -> Encryption<'s, Planned<'p, Plan, ValueFrame, R>, Self, K, Self::ContextInput, Owned> where - Planned<'p, Plan, V, R>: 's, + Planned<'p, Plan, ValueFrame, R>: 's, { Encryption::from_build( contexts::caller_leaf(), - move |source: Planned<'p, Plan, V, R>, cipher, context: Self::ContextInput| { + move |source: Planned<'p, Plan, ValueFrame, R>, cipher, context: Self::ContextInput| { let Planned { plan, value, @@ -332,7 +379,10 @@ fn encrypt_record<'a, K: 'static>( /// A stored row under a plan decrypts to its values, as /// [`record::decrypt_with`] opens it, under the caller's scope and /// bindings. A row from another keyset than the one the call names is -/// refused before any key is retrieved. +/// refused before any key is retrieved. The opening is a closure the +/// cipher runs later, which owns the plan, the row and the resolver, so +/// the resolver is `Send + Sync + 'static` here where the encrypt side +/// borrows it. impl<'p, R> DecryptSource for Planned<'p, Plan, StackCipherText, R> where R: TargetResolver + Send + Sync + 'static, @@ -382,14 +432,18 @@ impl QueryPlan { self.operator } - /// The query context and the stored context of the field, before any - /// scope the caller adds: the record plan's entry for it. + /// The query context and the stored context of the field, after the + /// record plan's own parts and with a placeholder for the scope the + /// caller adds. /// /// # Errors /// /// As [`Plan::contexts`]. pub fn contexts(&self) -> Result { - let contexts = self.plan.contexts()?; + let contexts = contexts::resolve( + self.plan.caller_shapes()?, + CallerShape::scope(caller_scope()), + ); contexts.get(&self.field).cloned().ok_or_else(|| { wrap(Error::Plan { field: Some(self.field.clone()), @@ -420,39 +474,42 @@ impl QueryPlan { /// A query value under a query plan encrypts to the query term, as /// [`query_term`] derives it: the operand bytes and the SQL type they bind /// as. The caller's context is its scope alone; a binding is refused -/// before any key is requested ([`Reason::BindInQuery`]). -impl<'p, V, R> EncryptTarget> for QueryTerm +/// before any key is requested ([`PlanError::BindInQuery`]). +impl<'p, R> EncryptTarget> for QueryTerm where - V: Encrypt + Clone + Zeroize + 'p, R: TargetResolver + 'p, { type ContextInput = OptionalContext; fn encryption<'s, K: 'static>( - ) -> Encryption<'s, Planned<'p, QueryPlan, V, R>, Self, K, Self::ContextInput> + ) -> Encryption<'s, Planned<'p, QueryPlan, ValueFrame, R>, Self, K, Self::ContextInput> where - Planned<'p, QueryPlan, V, R>: 's, + Planned<'p, QueryPlan, ValueFrame, R>: 's, { Encryption::from_build( contexts::caller_leaf(), - move |source: &Planned<'p, QueryPlan, V, R>, cipher, context: Self::ContextInput| { + move |source: &Planned<'p, QueryPlan, ValueFrame, R>, + cipher, + context: Self::ContextInput| { let scopes = context .into_inner() .map(DeclaredContext::from) .unwrap_or_default(); source .plan - .term(cipher, source.value.clone(), &scopes, &source.resolver) + .term(cipher, &source.value, &scopes, &source.resolver) }, ) } fn owned_encryption<'s, K: 'static>( - ) -> Encryption<'s, Planned<'p, QueryPlan, V, R>, Self, K, Self::ContextInput, Owned> + ) -> Encryption<'s, Planned<'p, QueryPlan, ValueFrame, R>, Self, K, Self::ContextInput, Owned> where - Planned<'p, QueryPlan, V, R>: 's, + Planned<'p, QueryPlan, ValueFrame, R>: 's, { Encryption::from_build( contexts::caller_leaf(), - move |source: Planned<'p, QueryPlan, V, R>, cipher, context: Self::ContextInput| { + move |source: Planned<'p, QueryPlan, ValueFrame, R>, + cipher, + context: Self::ContextInput| { let scopes = context .into_inner() .map(DeclaredContext::from) @@ -469,31 +526,46 @@ where } /// One field of a plan that is data, for a query: one method per operator, -/// each checked against the plan where it is called ([`Plan::query`]), so -/// a field no index of which answers the operator is refused there. The -/// handle has no `bind` method: a binding is in no query context. -#[derive(Clone, Debug)] -pub struct FieldHandle<'p> { +/// each checked against the plan where it is called ([`Plan::query_with`]), +/// so a field none of whose indexes answers the operator is refused there. +/// The handle has no `bind` method: a binding is in no query context. Made +/// by [`Plan::field`], with no EQL types, or [`Plan::field_with`], with +/// the resolver that runs the type the field names. +pub struct FieldHandle<'p, R: ?Sized = NoTargets> { plan: &'p Plan, name: String, + resolver: &'p R, +} + +impl<'p, R: ?Sized> Clone for FieldHandle<'p, R> { + fn clone(&self) -> Self { + Self { + plan: self.plan, + name: self.name.clone(), + resolver: self.resolver, + } + } } -/// One method per operator, each a [`Plan::query`] with the value. +impl<'p, R: ?Sized> fmt::Debug for FieldHandle<'p, R> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("FieldHandle") + .field("name", &self.name) + .finish_non_exhaustive() + } +} + +/// One method per scalar operator, each [`op`](FieldHandle::op) with it. macro_rules! handle_methods { ($($(#[$doc:meta])* $method:ident => $op:ident;)+) => {$( - impl<'p> FieldHandle<'p> { + impl<'p, R: TargetResolver + ?Sized> FieldHandle<'p, R> { $(#[$doc])* /// /// # Errors /// - /// As [`Plan::query`]. - pub fn $method(self, value: V) -> Result, Error> { - Ok(DynamicQuery { - plan: self.plan.query(&self.name, Op::$op)?, - value, - scopes: DeclaredContext::default(), - resolver: NoTargets, - }) + /// As [`Plan::query_with`]. + pub fn $method(self, value: ValueFrame) -> Result, Error> { + self.op(Op::$op, value) } } )+}; @@ -516,7 +588,23 @@ handle_methods! { between => Between; } -impl<'p> FieldHandle<'p> { +impl<'p, R: TargetResolver + ?Sized> FieldHandle<'p, R> { + /// The query for any operator, the JSON ones included, with `value` as + /// [`query_term`] reads the operator's input: what the named methods + /// are each one of. + /// + /// # Errors + /// + /// As [`Plan::query_with`]. + pub fn op(self, operator: Op, value: ValueFrame) -> Result, Error> { + Ok(DynamicQuery { + plan: self.plan.query_with(&self.name, operator, self.resolver)?, + value, + scopes: DeclaredContext::default(), + resolver: self.resolver, + }) + } + /// The field's name. pub fn name(&self) -> &str { &self.name @@ -529,15 +617,16 @@ impl<'p> FieldHandle<'p> { } /// A query from a [`FieldHandle`], ready for the caller's scopes and the -/// keyset. It has no `bind` method: a binding is in no query context. -pub struct DynamicQuery { +/// keyset. It has no `bind` method: a binding is in no query context. The +/// value is a frame, a protected buffer wiped when the query is dropped. +pub struct DynamicQuery<'p, R: ?Sized = NoTargets> { plan: QueryPlan, - value: V, + value: ValueFrame, scopes: DeclaredContext, - resolver: R, + resolver: &'p R, } -impl fmt::Debug for DynamicQuery { +impl<'p, R: ?Sized> fmt::Debug for DynamicQuery<'p, R> { /// Names the field and the operator; the value is plaintext. fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("DynamicQuery") @@ -547,32 +636,23 @@ impl fmt::Debug for DynamicQuery { } } -impl DynamicQuery { - /// Add a scope the stored values were written under. A `Scope` or a - /// `NonEmpty` converts; a `Bind` does not, and a context with a binding - /// inside it is refused when the query runs ([`Reason::BindInQuery`]). +impl<'p, R: ?Sized> DynamicQuery<'p, R> { + /// Add a scope the stored values were written under. A `Scope`, a + /// `NonEmpty` or a bare integer converts; a `Bind` does not, and a + /// context with a binding inside it is refused when the query runs + /// ([`PlanError::BindInQuery`]). pub fn scope(mut self, part: impl Into) -> Self { self.scopes.append(part.into()); self } - /// Run the EQL type the field names through `resolver`. - pub fn with_resolver(self, resolver: R2) -> DynamicQuery { - DynamicQuery { - plan: self.plan, - value: self.value, - scopes: self.scopes, - resolver, - } - } - /// The query plan this query runs under. pub fn plan(&self) -> &QueryPlan { &self.plan } } -impl DynamicQuery { +impl<'p, R: TargetResolver + ?Sized> DynamicQuery<'p, R> { /// Derive the query term under `keyset`: [`query_term`] for the plan, /// extended by the scopes. Nothing is requested until the pending is /// awaited. @@ -581,6 +661,6 @@ impl DynamicQuery { keyset: &'a KeysetCipher<'_, K>, ) -> Pending<'a, QueryTerm, K> { self.plan - .term(keyset, self.value, &self.scopes, &self.resolver) + .term(keyset, self.value, &self.scopes, self.resolver) } } diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 64b822650..81ff94a71 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -586,7 +586,7 @@ pub use contexts::{ }; pub use descriptor::{Describe, Description, Descriptor, Label, LabelError}; pub use keyset::KeysetCipher; -pub use marker::{MarkedQuery, Marker}; +pub use marker::{BetweenQuery, MarkedQuery, Marker, Needs, Ready, ScopeInput}; pub use plan::{all, Plan, PlanError}; // stack-kms is a public dependency: `StackCipher` is generic over its // `DataKeySource`, and `StackCipherBuilder::kms` takes its `StackKms`. It is diff --git a/packages/stack-encrypt/src/marker.rs b/packages/stack-encrypt/src/marker.rs index f59e976d3..06e60bb10 100644 --- a/packages/stack-encrypt/src/marker.rs +++ b/packages/stack-encrypt/src/marker.rs @@ -4,20 +4,23 @@ //! per derived field, as an associated constant named after the field in //! upper case: `EncryptedUser::EMAIL` for a field `email`. A marker names //! the field, the context the derive sealed it under (the record's scope, -//! then the field's identity), and the field's target type, whose -//! [`Answers`] impls say which operators the field's indexes answer. So a -//! query from a marker uses the declaration the derive wrote, and never a -//! plan written by hand beside it: the query term and the stored term come -//! from one source. +//! then the field's identity), the field's target type, whose [`Answers`] +//! impls say which operators the field's indexes answer, and the scope the +//! record's caller supplies, if it lists one. So a query from a marker uses +//! the declaration the derive wrote, and never a plan written by hand +//! beside it: the query term and the stored term come from one source. //! //! A marker has one method per operator the field's indexes answer, checked //! at compile time through [`Answers`]: a field of `Encrypted` -//! has [`eq`](Marker::eq) and no `gt`, and the error names the index. The -//! query takes the caller's scopes with [`scope`](MarkedQuery::scope), and -//! no binding: a binding is in no query context, and the query type has no -//! `bind` method. It ends with [`encrypt`](MarkedQuery::encrypt), whose -//! output is the query term the field's index sends for the operator -//! ([`Supports::Query`]). +//! has [`eq`](Marker::eq) and no `gt`, and the error names the index. A +//! record that lists a caller scope, `scope(tenant: u64)`, gives its +//! markers that scope's type, and the query has no +//! [`encrypt`](MarkedQuery::encrypt) until [`scope`](MarkedQuery::scope) is +//! given exactly once, with a value of that type. A record that lists none +//! gives a query that is ready at once and takes no scope. A binding is in +//! no query context, so the query type has no `bind` method. The query ends +//! with `encrypt`, whose output is the query term the field's index sends +//! for the operator ([`Supports::Query`]). //! //! ``` //! use stack_encrypt::kms::FakeDataKeySource; @@ -62,39 +65,62 @@ //! # }).unwrap(); //! ``` //! -//! `EncryptedUser::EMAIL.gt("x")` does not compile: `Encrypted` -//! answers no ordering (`tests/ui/marker_operator_not_answered.rs`). Nor -//! does `.bind(..)` on the query (`tests/ui/marker_query_has_no_bind.rs`). +//! These do not compile: `EncryptedUser::EMAIL.gt("x")`, since +//! `Encrypted` answers no ordering +//! (`tests/ui/marker_operator_not_answered.rs`); `.encrypt(&keyset)` with +//! no scope, `.scope(Scope::new("42"))` for a `u64` tenant, and a second +//! `.scope(..)` (`tests/ui/marker_query_needs_its_scope.rs`); and +//! `.bind(..)` on the query (`tests/ui/marker_query_has_no_bind.rs`). +//! +//! # What a marker does not check +//! +//! An equality marker takes a value of any type that the equality index +//! accepts: `EMAIL.eq(&42u64)` compiles on a text field and matches +//! nothing, because the derive sees the record's field types and not the +//! plaintext struct's. An order marker fixes the type, since `OreTerm` +//! names it. A field whose type is several indexes, such as +//! `Encrypted<(EqualityTerm, MatchTerms)>`, or an `Option<..>` field, has a +//! marker with no operator method yet, since [`Answers`] is implemented for +//! one index (#1596); the query chain still queries them. +//! +//! A marker on a field that is itself a record (`EncryptedAccount::ADDRESS`) +//! names the record's context, and [`contexts`](Marker::contexts) lists the +//! inner fields under it. It has no operator methods, and does not reach +//! the inner record's markers: the inner record's own markers derive under +//! its own scope, not the outer field's. use std::marker::PhantomData; use stack_kms::MaybeSend; +use zeroize::Zeroize; use crate::contexts::Contexts; use crate::op::{ Answers, Between, Eq, Gt, Gte, Input, LabelledTarget, Lt, Lte, Matches, Operator, QueryToken, Supports, }; -use crate::target::{DeclaredContext, Pending}; -use crate::{Error, KeysetCipher, Label, PlanError}; +use crate::target::{DeclaredContext, NoScope, Pending, Scope}; +use crate::{KeysetCipher, Label}; /// A field of a derived record, as a value: the field's name, the context -/// the derive sealed it under, and its target type `T`. See the [module +/// the derive sealed it under, its target type `T`, and the type `C` of the +/// scope the record's caller supplies: `Scope` for `scope(tenant: +/// u64)`, [`NoScope`] for a record that lists none. See the [module /// docs](self). -pub struct Marker { +pub struct Marker { name: &'static str, scope: &'static str, identity: &'static str, contexts: fn() -> Contexts, - target: PhantomData T>, + target: PhantomData (T, C)>, } -impl Clone for Marker { +impl Clone for Marker { fn clone(&self) -> Self { *self } } -impl Copy for Marker {} -impl std::fmt::Debug for Marker { +impl Copy for Marker {} +impl std::fmt::Debug for Marker { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("Marker") .field("name", &self.name) @@ -104,10 +130,13 @@ impl std::fmt::Debug for Marker { } } -impl Marker { +impl Marker { /// A marker for the field `name`, sealed under `scope` then `identity` - /// by the record whose `contexts` function this is. The derive emits - /// the call; a record's markers are its associated constants. + /// by the record whose `contexts` function this is. For generated code: + /// the derive emits the call with the segments it has checked to be + /// plain, and a record's markers are its associated constants. A marker + /// built by hand states nothing about any stored value, and + /// [`label`](Self::label) panics on a segment that is not plain. #[doc(hidden)] pub const fn new( name: &'static str, @@ -124,7 +153,7 @@ impl Marker { } } - /// The field's name in the record. + /// The field's name in the record, as `contexts()` names it. pub fn name(&self) -> &'static str { self.name } @@ -147,30 +176,49 @@ impl Marker { (self.contexts)().narrowed(self.name) } - fn query>(self, value: In) -> MarkedQuery { + fn query(self, value: In, wipe: fn(In)) -> MarkedQuery + where + Op: Operator, + In: Input, + C: ScopeInput, + { MarkedQuery { label: self.label(), - value, + value: Some(value), + wipe, scopes: DeclaredContext::default(), marker: PhantomData, } } } +/// Wipe one owned query value. +fn wipe_one(mut value: S) { + value.zeroize(); +} + +/// Wipe both bounds of a range. +fn wipe_pair((mut lo, mut hi): (S, S)) { + lo.zeroize(); + hi.zeroize(); +} + /// One method per operator. Each exists only where the field's target type /// answers the operator ([`Answers`]), so a missing one is a compile error -/// that names the field's indexes. +/// that names the field's indexes. The value is copied into the query +/// (`ToOwned`), and the copy is wiped when the query is derived, refused or +/// dropped. macro_rules! operator_methods { ($($(#[$doc:meta])* $method:ident => $op:ident;)+) => {$( - impl Marker { + impl Marker { $(#[$doc])* - pub fn $method(self, value: &Q) -> MarkedQuery + pub fn $method(self, value: &Q) -> MarkedQuery where Q: ?Sized + ToOwned, - Q::Owned: 'static, + Q::Owned: Zeroize + 'static, T: Answers<$op, Q::Owned> + LabelledTarget, { - self.query(value.to_owned()) + self.query(value.to_owned(), wipe_one) } } )+}; @@ -192,31 +240,78 @@ operator_methods! { matches => Matches; } -impl Marker { +impl Marker { /// Rows whose value is between `lo` and `hi`, inclusive /// ([`op::Between`](Between)): one term carrying both bounds, derived /// under one context. - pub fn between(self, lo: &Q, hi: &Q) -> MarkedQuery + pub fn between(self, lo: &Q, hi: &Q) -> BetweenQuery where Q: ?Sized + ToOwned, - Q::Owned: 'static, + Q::Owned: Zeroize + 'static, T: Answers + LabelledTarget, { - self.query((lo.to_owned(), hi.to_owned())) + self.query((lo.to_owned(), hi.to_owned()), wipe_pair) } } -/// A query from a [`Marker`], for the operator `Op` with the input `In`, -/// ready for the caller's scopes and the keyset. It has no `bind` method: -/// a binding is in no query context. -pub struct MarkedQuery { +/// A query for [`Between`] from a [`Marker`]: the input is the two bounds, +/// low then high, of one type. +pub type BetweenQuery = MarkedQuery; + +/// The types a query is tagged with, and holds none of: its target, its +/// operator and its state. +type Tags = PhantomData (T, Op, S)>; + +/// The scope a record's caller supplies, as a marker's type parameter: +/// what state a query from the marker starts in. Sealed: the two inputs +/// are [`NoScope`] and [`Scope`]. +pub trait ScopeInput: scope_sealed::Sealed { + /// [`Ready`] for a record with no caller scope; [`Needs`] for a + /// record whose caller supplies one. + type Start; +} +mod scope_sealed { + pub trait Sealed {} +} +impl scope_sealed::Sealed for NoScope {} +impl ScopeInput for NoScope { + type Start = Ready; +} +impl scope_sealed::Sealed for Scope {} +impl ScopeInput for Scope { + type Start = Needs>; +} + +/// The state of a query whose record lists a caller scope of type `C` +/// that has not been given yet: [`scope`](MarkedQuery::scope) takes it and +/// makes the query [`Ready`]. +pub struct Needs(PhantomData C>); + +/// The state of a query that can be encrypted: its record lists no caller +/// scope, or the scope has been given. +pub struct Ready; + +/// A query from a [`Marker`], for the operator `Op` with the input `In`, in +/// the state `S`: [`Needs`] its scope, or [`Ready`] for the keyset. It has +/// no `bind` method: a binding is in no query context. The value is wiped +/// when the query is derived, refused or dropped. +pub struct MarkedQuery { label: Label, - value: In, + value: Option, + wipe: fn(In), scopes: DeclaredContext, - marker: PhantomData (T, Op)>, + marker: Tags, } -impl std::fmt::Debug for MarkedQuery { +impl Drop for MarkedQuery { + fn drop(&mut self) { + if let Some(value) = self.value.take() { + (self.wipe)(value); + } + } +} + +impl std::fmt::Debug for MarkedQuery { /// Names the query and its label; the value is plaintext. fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.debug_struct("MarkedQuery") @@ -225,29 +320,40 @@ impl std::fmt::Debug for MarkedQuery { } } -impl MarkedQuery +impl MarkedQuery> +where + C: Into, +{ + /// Give the scope the record's caller supplies: the tenant a record + /// was encrypted with `Scope::new(tenant)`, of the type the record + /// lists. An empty scope is refused when the query runs. The query is + /// then [`Ready`]; there is no second `scope`. + pub fn scope(mut self, scope: C) -> MarkedQuery { + let mut scopes = std::mem::take(&mut self.scopes); + scopes.append(scope.into()); + MarkedQuery { + label: self.label.clone(), + value: self.value.take(), + wipe: self.wipe, + scopes, + marker: PhantomData, + } + } +} + +impl MarkedQuery where Op: Operator, In: Input, T: Answers, { - /// Add a scope the stored values were written under: the tenant a - /// record was encrypted with `Scope::new(tenant)`. Further scopes - /// nest after it, in order. A `Scope` or a `NonEmpty` converts; a - /// `Bind` does not, and a context that reaches the query with a - /// binding inside it is refused when the query runs - /// ([`PlanError::BindInQuery`]). - pub fn scope(mut self, part: impl Into) -> Self { - self.scopes.append(part.into()); - self - } - /// Derive the query term under `keyset`: the term the field's index /// sends for the operator, derived under the marker's label extended - /// by the scopes. The query is prepared ([`Supports::prepare`]) before - /// any key is requested, so a refused query costs no request. + /// by the scope. The query is prepared ([`Supports::prepare`]) before + /// any key is requested, so a refused query costs no request, and its + /// value is wiped. pub fn encrypt<'a, K: 'static>( - self, + mut self, keyset: &'a KeysetCipher<'_, K>, ) -> Pending<'a, >::Query, K> where @@ -255,21 +361,25 @@ where In::Terms<>::Term>: MaybeSend, >::Query: MaybeSend + 'static, { - if self.scopes.has_bindings() { - return Pending::failed(keyset, Error::from(PlanError::BindInQuery)); - } + let Some(value) = self.value.take() else { + unreachable!("a query holds its value until it is encrypted or dropped") + }; let index = T::Via::default(); let extended = self.scopes.is_extended(); let verifier = match index .check_label(&self.label, extended) - .and_then(|()| index.prepare(&self.value)) + .and_then(|()| index.prepare(&value)) { Ok(verifier) => verifier, - Err(error) => return Pending::failed(keyset, error), + Err(error) => { + (self.wipe)(value); + return Pending::failed(keyset, error); + } }; let token = QueryToken::new(self.label.clone(), extended); - self.value - .derive(&index, keyset, self.label, self.scopes) + let scopes = std::mem::take(&mut self.scopes); + value + .derive(&index, keyset, self.label.clone(), scopes) .map(move |terms| T::Via::query(verifier, terms, token)) } } diff --git a/packages/stack-encrypt/src/testing.rs b/packages/stack-encrypt/src/testing.rs index 6440a0f4f..e36ab122a 100644 --- a/packages/stack-encrypt/src/testing.rs +++ b/packages/stack-encrypt/src/testing.rs @@ -248,7 +248,7 @@ impl ContextSubject for Contexts { } /// A field marker states its field's context: the record's contexts, /// narrowed to that field. -impl ContextSubject for crate::marker::Marker { +impl ContextSubject for crate::marker::Marker { fn single_context(&self) -> Result { self.contexts().single_context() } diff --git a/packages/stack-encrypt/tests/markers.rs b/packages/stack-encrypt/tests/markers.rs index 4702f9512..dc48410dd 100644 --- a/packages/stack-encrypt/tests/markers.rs +++ b/packages/stack-encrypt/tests/markers.rs @@ -2,19 +2,23 @@ //! field starts from. `#[derive(EncryptTarget)]` on a `struct = ..` record //! emits `EncryptedUser::EMAIL` for a field `email`. A query from the //! marker derives under the context the derive sealed the field under, so -//! the query term is the stored term, and the marker's operator methods -//! exist only where the field's indexes answer the operator. +//! the query term is the stored term; the marker's operator methods exist +//! only where the field's indexes answer the operator; and a record that +//! lists a caller scope gives its markers that scope's type, so the query +//! cannot run without it. #![cfg(feature = "testing")] mod common; +use std::sync::atomic::Ordering; + use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; -use stack_encrypt::target::{DeclaredContext, DecryptSource, EncryptTarget, Scope}; -use stack_encrypt::testing::scope; +use stack_encrypt::target::{DecryptSource, EncryptTarget, NoScope, Scope}; +use stack_encrypt::testing::{scope, stored_scope}; use stack_encrypt::{ - assert_context, nonempty, DecryptFrom, EncryptInto, Encrypted, Error, Marker, PlanError, - StackCipher, StackCipherText, + assert_context, nonempty, DecryptFrom, EncryptInto, Encrypted, Error, Marker, StackCipher, + StackCipherText, }; use zeroize::Zeroize; @@ -50,7 +54,8 @@ struct EncryptedUser { bio: Encrypted, } -/// A record with no caller part, and a field keyed under another identity. +/// A record with no caller part, a field keyed under another identity, +/// and a field that is a ciphertext alone. #[derive(EncryptTarget, DecryptSource)] #[stash(struct = User, scope = "people")] struct Renamed { @@ -60,6 +65,49 @@ struct Renamed { bio: StackCipherText, } +/// A record whose scope is stored in the row: the plaintext carries it, +/// and a query supplies it. +#[derive(Clone, Zeroize)] +struct TenantUser { + tenant: u64, + email: String, +} + +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = TenantUser, scope = "users", scope(field = "tenant"))] +struct StoredTenant { + #[stash(passthrough)] + tenant: u64, + email: Encrypted, +} + +/// A record with a raw-identifier field and a field that is a record. +#[derive(Clone, Zeroize)] +struct Address { + street: String, +} + +#[derive(Clone, Zeroize)] +struct Account { + r#type: String, + address: Address, +} + +#[derive(EncryptTarget)] +#[stash(struct = Address, scope = "addresses")] +#[allow(dead_code)] +struct EncryptedAddress { + street: Encrypted, +} + +#[derive(EncryptTarget)] +#[stash(struct = Account, scope = "accounts")] +#[allow(dead_code)] +struct EncryptedAccount { + r#type: Encrypted, + address: EncryptedAddress, +} + mod a_marker { use super::*; @@ -80,6 +128,10 @@ mod a_marker { [["users", "email"], scope::("tenant")] ); assert_context!(Renamed::EMAIL, ["people", "mail"]); + assert_context!( + StoredTenant::EMAIL, + [["users", "email"], stored_scope::("tenant")] + ); assert_eq!( EncryptedUser::AGE.contexts().to_string(), "context: (users / age) / {tenant: u64}\n" @@ -97,9 +149,42 @@ mod a_marker { assert_context!(Renamed::EMAIL, ["members", "mail"]); } + #[test] + fn carries_the_type_of_the_scope_its_caller_supplies() { + let scoped: Marker, Scope> = EncryptedUser::EMAIL; + let stored: Marker, Scope> = StoredTenant::EMAIL; + let unscoped: Marker, NoScope> = Renamed::EMAIL; + assert_eq!(scoped.name(), stored.name()); + assert_eq!(unscoped.name(), "email"); + } + + #[test] + fn keeps_a_raw_identifiers_name() { + // The constant drops the `r#`; the name and the context keep it, + // as `contexts()` and the label spell the field. + assert_eq!(EncryptedAccount::TYPE.name(), "r#type"); + assert_eq!( + EncryptedAccount::TYPE.label().to_string(), + "accounts/r#type" + ); + assert_context!(EncryptedAccount::TYPE, ["accounts", "r#type"]); + } + + #[test] + fn on_a_record_field_lists_the_inner_fields() { + let address = EncryptedAccount::ADDRESS; + assert_eq!(address.label().to_string(), "accounts/address"); + assert_eq!( + address.contexts().to_string(), + "street\n context: (addresses / street) / (accounts / address)\n" + ); + assert_eq!(address.contexts().fields().len(), 1); + assert!(address.contexts().single().is_none()); + } + #[test] fn is_a_plain_value() { - let marker: Marker> = EncryptedUser::EMAIL; + let marker = EncryptedUser::EMAIL; let copy = marker; assert_eq!(copy.name(), marker.name()); let shown = format!("{marker:?}"); @@ -224,33 +309,52 @@ mod a_marker_query { } #[tokio::test] - async fn refuses_a_binding_before_any_key_is_requested() { + async fn supplies_a_scope_the_record_stores_in_the_row() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - // The query type has no `bind` method (`tests/ui/marker_query_has_no_bind.rs`). - // A binding smuggled in through a declared context is refused. - let bound = DeclaredContext::default().with(Scope::new(42u64).bind(9u128)); - let refused = EncryptedUser::EMAIL + let stored: StoredTenant = TenantUser { + tenant: 42, + email: "ada@example.com".into(), + } + .encrypt_into(&keyset) + .await + .unwrap(); + assert_eq!(stored.tenant, 42); + let term = StoredTenant::EMAIL .eq("ada@example.com") - .scope(bound) + .scope(Scope::new(42u64)) .encrypt(&keyset) - .await; - assert!( - matches!(refused, Err(Error::Plan(PlanError::BindInQuery))), - "{refused:?}" - ); + .await + .unwrap(); + assert_eq!(term, stored.email.terms); } #[tokio::test] - async fn refuses_text_with_no_normal_form_before_any_key_is_requested() { - let cipher = cipher().await; + async fn refuses_text_with_no_normal_form_and_requests_no_key() { + let (cipher, generates, retrieves) = common::counting_cipher().await; let keyset = cipher.default_keyset(); let refused = EncryptedUser::EMAIL .eq("ada\u{0378}@example.com") .scope(Scope::new(42u64)) .encrypt(&keyset) .await; - assert!(refused.is_err()); + assert!(matches!(refused, Err(Error::Term(_))), "{refused:?}"); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn refuses_an_empty_scope_and_requests_no_key() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let refused = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new(0u64)) + .encrypt(&keyset) + .await; + assert!(refused.is_ok(), "zero is a value, not an empty part"); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); } #[tokio::test] @@ -273,4 +377,17 @@ mod a_marker_query { .unwrap(); assert_eq!(back.email, "ada@example.com"); } + + #[test] + fn prints_its_label_and_not_the_value() { + let query = EncryptedUser::EMAIL.eq("a secret"); + let shown = format!("{query:?}"); + assert!( + shown.contains("users") && shown.contains("email"), + "{shown}" + ); + assert!(!shown.contains("secret"), "{shown}"); + // Dropped before it is encrypted: the copy is wiped, not leaked. + drop(query); + } } diff --git a/packages/stack-encrypt/tests/planned.rs b/packages/stack-encrypt/tests/planned.rs index 6f087e139..0fb1ef08a 100644 --- a/packages/stack-encrypt/tests/planned.rs +++ b/packages/stack-encrypt/tests/planned.rs @@ -9,13 +9,20 @@ mod common; +use std::sync::atomic::Ordering; + use common::frames::{frame, object}; -use stack_encrypt::dynamic::{self, record, Op, Operand, Planned, QueryTerm, ValueFrame}; +use miette::Diagnostic as _; +use stack_encrypt::dynamic::{ + self, record, Op, Operand, Planned, QueryTerm, TargetDescriptor, TargetError, TargetResolver, + ValueFrame, ValueKind, +}; use stack_encrypt::kms::FakeDataKeySource; use stack_encrypt::sem::EqualityTerm; -use stack_encrypt::target::{DeclaredContext, DecryptSource, EncryptTarget, Scope}; +use stack_encrypt::target::{DeclaredContext, DecryptSource, EncryptTarget, IndexSpec, Scope}; use stack_encrypt::{ - AsProtectedRef, DecryptFrom, EncryptInto, Encrypted, Error, StackCipher, StackCipherText, + nonempty, AsProtectedRef, DecryptFrom, EncryptInto, Encrypted, Error, KeysetCipher, Label, + Pending, PlanError, StackCipher, StackCipherText, }; use zeroize::Zeroize; @@ -46,15 +53,78 @@ async fn cipher() -> StackCipher { .unwrap() } +/// A data plan read from its JSON declaration, with no EQL types. +fn plan_of(declaration: serde_json::Value) -> record::Plan { + let declaration = vitaminc_aead_frame::serde::to_vec(&declaration).unwrap(); + record::plan(declaration.as_protected_ref()).unwrap() +} + /// The data plan of a record with a searchable email and an ordered age, /// each field under its own context. fn plan() -> record::Plan { - let declaration = vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ + plan_of(serde_json::json!({ "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, "age": { "context": ["users", "age"], "outputs": ["c", "ore"], "type": "uint32" }, })) +} + +/// A resolver holding one EQL type, `Some`, that answers `eq`, whose query +/// is a fixed JSON value: what a target field's query term carries. +struct OneType; + +impl TargetResolver for OneType { + fn targets(&self) -> Vec { + vec![TargetDescriptor::new( + "Some", + "some", + "", + Some(ValueKind::String), + "public.some", + vec!["eq".to_owned()], + Some("SomeQuery".to_owned()), + Some("eql_v3.query_some".to_owned()), + true, + None, + )] + } + fn encrypt<'a, K: 'static>( + &self, + _: &str, + _: Option<&dynamic::TargetOptions>, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: ValueFrame, + ) -> Result, K>, TargetError> { + unreachable!("these tests query, and encrypt nothing") + } + fn decrypt<'a, K: 'static>( + &self, + _: &str, + _: &'a StackCipher, + _: &Label, + _: &[u8], + ) -> Result, TargetError> { + unreachable!("these tests query, and decrypt nothing") + } + fn query<'a, K: 'static>( + &self, + _: &str, + _: &IndexSpec, + keyset: &'a KeysetCipher<'_, K>, + _: &Label, + _: ValueFrame, + ) -> Result, K>, TargetError> { + Ok(Pending::ready(keyset, Ok(br#"{"hm":"00"}"#.to_vec()))) + } +} + +/// The data plan of a record whose email names the EQL type `Some`. +fn target_plan() -> record::Plan { + let declaration = vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ + "email": { "context": ["users", "email"], "target": "Some" }, + })) .unwrap(); - record::plan(declaration.as_protected_ref()).unwrap() + record::plan_with(declaration.as_protected_ref(), &OneType).unwrap() } fn ada() -> ValueFrame { @@ -192,24 +262,105 @@ mod a_record_under_a_plan { let contexts = Planned::new(&plan, ada()).contexts().unwrap(); assert_eq!( contexts.to_string(), - "age\n context: (users / age) / {context?: CallerContext}\n\ - email\n context: (users / email) / {context?: CallerContext}\n" + "age\n query context: (users / age) / {scope?: CallerContext}\n \ + stored context: ((users / age) / {scope?: CallerContext}) / {bind?: CallerContext}\n\ + email\n query context: (users / email) / {scope?: CallerContext}\n \ + stored context: ((users / email) / {scope?: CallerContext}) / {bind?: CallerContext}\n" ); } #[test] fn shows_the_plans_own_parts_before_the_callers() { - let declaration = vitaminc_aead_frame::serde::to_vec(&serde_json::json!({ - "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, + let plan = plan_of(serde_json::json!({ + "email": { "context": [["users", "email"], 5], "outputs": ["c", "eq"], "type": "string" }, "bind": [7], - })) - .unwrap(); - let plan = record::plan(declaration.as_protected_ref()).unwrap(); + })); let contexts = Planned::new(&plan, ada()).contexts().unwrap(); assert_eq!( contexts.to_string(), - "email\n query context: (users / email) / {context?: CallerContext}\n \ - stored context: ((users / email) / {context?: CallerContext}) / 7u64\n" + "email\n query context: ((users / email) / 5u64) / {scope?: CallerContext}\n \ + stored context: ((((users / email) / 5u64) / {scope?: CallerContext}) / 7u64) / {bind?: CallerContext}\n" + ); + } + + #[tokio::test] + async fn runs_the_plans_own_scope_before_the_callers() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan_of(serde_json::json!({ + "email": { "context": [["users", "email"], 5], "outputs": ["c", "eq"], "type": "string" }, + })); + let by_email = plan.query("email", Op::Eq).unwrap(); + let term: QueryTerm = Planned::new(&by_email, frame("ada@example.com")) + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + // The plan's part, then the caller's: `users/email/5/42`. + let direct: Encrypted = String::from("ada@example.com") + .encrypt_into_with_context( + &keyset, + nonempty!("users").with("email").with(5u64).with(42u64), + ) + .await + .unwrap(); + assert_eq!( + term.operand(), + &Operand::One(direct.terms.as_bytes().to_vec()) + ); + // And a row written with the caller's scope opens under a plan + // that spells both parts. + let row: StackCipherText = + Planned::new(&plan, object(vec![("email", frame("ada@example.com"))])) + .encrypt_into_with_context(&keyset, Scope::new(42u64)) + .await + .unwrap(); + let spelled = plan_of(serde_json::json!({ + "email": { "context": [[["users", "email"], 5], 42], "outputs": ["c", "eq"], "type": "string" }, + })); + let back = ValueFrame::decrypt_from(Planned::new(&spelled, row), &keyset) + .await + .unwrap(); + assert_eq!( + vitaminc_aead_frame::serde::from_slice::(back.risky_ref()).unwrap(), + serde_json::json!({"email": "ada@example.com"}) + ); + } + + #[tokio::test] + async fn runs_the_plans_own_binding_before_the_callers() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan_of(serde_json::json!({ + "email": { "context": ["users", "email"], "outputs": ["c", "eq"], "type": "string" }, + "bind": [7], + })); + let row = || async { + let row: StackCipherText = + Planned::new(&plan, object(vec![("email", frame("ada@example.com"))])) + .encrypt_into_with_context(&keyset, Scope::new(42u64).bind(9u64)) + .await + .unwrap(); + row + }; + // The row opens under a plan whose bindings are the plan's, then + // the caller's, and not the other way round. + let in_order = plan_of(serde_json::json!({ + "email": { "context": [["users", "email"], 42], "outputs": ["c", "eq"], "type": "string" }, + "bind": [7, 9], + })); + assert!( + ValueFrame::decrypt_from(Planned::new(&in_order, row().await), &keyset) + .await + .is_ok() + ); + let reversed = plan_of(serde_json::json!({ + "email": { "context": [["users", "email"], 42], "outputs": ["c", "eq"], "type": "string" }, + "bind": [9, 7], + })); + assert!( + ValueFrame::decrypt_from(Planned::new(&reversed, row().await), &keyset) + .await + .is_err() ); } @@ -223,12 +374,17 @@ mod a_record_under_a_plan { #[test] fn wipes_its_value() { + // A frame wipes to no bytes; a `Planned` wipes its frame the same + // way, and the owned entry points consume or read the frame in + // place, so its buffer is wiped as it is dropped. let mut value = frame("a secret"); value.zeroize(); assert!(value.risky_ref().is_empty()); let plan = plan(); let mut planned = Planned::new(&plan, frame("a secret")); planned.zeroize(); + let shown = format!("{planned:?}"); + assert!(!shown.contains("secret"), "{shown}"); } } @@ -270,7 +426,11 @@ mod a_query_plan { assert_eq!(by_email.plan().fields().len(), 2); assert_eq!( by_email.contexts().unwrap().to_string(), - "context: users / email\n" + "context: (users / email) / {scope?: CallerContext}\n" + ); + assert_eq!( + Planned::new(&by_email, frame("x")).contexts().unwrap(), + by_email.contexts().unwrap() ); } @@ -349,7 +509,7 @@ mod a_query_plan { #[tokio::test] async fn refuses_a_binding_before_any_key_is_requested() { - let cipher = cipher().await; + let (cipher, generates, retrieves) = common::counting_cipher().await; let keyset = cipher.default_keyset(); let plan = plan(); let by_email = plan.query("email", Op::Eq).unwrap(); @@ -357,16 +517,16 @@ mod a_query_plan { .encrypt_into_with_context::(&keyset, Scope::new(42u64).bind(9u128)) .await .unwrap_err(); + // The crate's own refusal, with its code, as the typed query + // reports it. assert!( - matches!( - dynamic_error(&refused), - dynamic::Error::Plan { - reason: dynamic::Reason::BindInQuery, - .. - } - ), + matches!(refused, Error::Plan(PlanError::BindInQuery)), "{refused}" ); + assert_eq!( + refused.code().unwrap().to_string(), + "stack_encrypt::plan_bind_in_query" + ); // The handle has no `bind` method; a binding smuggled in through a // declared context is refused the same way. @@ -380,13 +540,125 @@ mod a_query_plan { .encrypt(&keyset) .await .unwrap_err(); + assert!( + matches!(refused, Error::Plan(PlanError::BindInQuery)), + "{refused}" + ); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn keeps_a_refused_terms_kind_and_requests_no_key() { + let (cipher, generates, retrieves) = common::counting_cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(); + let by_email = plan.query("email", Op::Eq).unwrap(); + let refused = Planned::new(&by_email, frame("ada\u{0378}@example.com")) + .encrypt_into::(&keyset) + .await + .unwrap_err(); + assert!(matches!(refused, Error::Term(_)), "{refused}"); + let refused = plan + .field("email") + .unwrap() + .eq(frame("ada\u{0378}@example.com")) + .unwrap() + .encrypt(&keyset) + .await + .unwrap_err(); + assert!(matches!(refused, Error::Term(_)), "{refused}"); + // An empty scope is refused as the typed path refuses it. + let refused = Planned::new(&by_email, frame("ada@example.com")) + .encrypt_into_with_context::(&keyset, Scope::new("")) + .await + .unwrap_err(); + assert!(matches!(refused, Error::Other(_)), "{refused}"); + assert_eq!(generates.load(Ordering::SeqCst), 0); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn derives_a_match_query_the_typed_path_derives() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan_of(serde_json::json!({ + "bio": { "context": ["users", "bio"], "outputs": ["c", "match"], "type": "string" }, + })); + let term: QueryTerm = plan + .field("bio") + .unwrap() + .matches(frame("engines")) + .unwrap() + .scope(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + let typed: Encrypted = String::from("engines") + .encrypt_into_with_context(&keyset, nonempty!("users").with("bio").with(42u64)) + .await + .unwrap(); + // A match query term is the filter of the query text under the + // same context, which is what is stored for that text. + assert_eq!(term.operator(), Op::Matches); + assert_eq!(term.operand(), &Operand::One(typed.terms.to_bytes())); + } +} + +mod a_target_field { + use super::*; + + #[tokio::test] + async fn queries_through_the_handle_and_through_planned_with_its_resolver() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = target_plan(); + let through_handle: QueryTerm = plan + .field_with("email", &OneType) + .unwrap() + .eq(frame("a")) + .unwrap() + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!( + through_handle.operand(), + &Operand::One(br#"{"hm":"00"}"#.to_vec()) + ); + assert_eq!(through_handle.domain(), Some("eql_v3.query_some")); + let by_email = plan.query_with("email", Op::Eq, &OneType).unwrap(); + let through_planned: QueryTerm = Planned::with_resolver(&by_email, frame("a"), OneType) + .encrypt_into(&keyset) + .await + .unwrap(); + assert_eq!(through_planned, through_handle); + } + + #[test] + fn is_refused_without_its_resolver() { + let plan = target_plan(); + let refused = plan.field("email").unwrap().eq(frame("a")).unwrap_err(); + assert!(matches!(refused, dynamic::Error::Target(_)), "{refused}"); + assert!(matches!( + plan.query("email", Op::Eq).unwrap_err(), + dynamic::Error::Target(_) + )); + } + + #[tokio::test] + async fn refuses_a_scope_it_cannot_be_stored_under() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = target_plan(); + let by_email = plan.query_with("email", Op::Eq, &OneType).unwrap(); + let refused = Planned::with_resolver(&by_email, frame("a"), OneType) + .encrypt_into_with_context::(&keyset, Scope::new(1u64)) + .await + .unwrap_err(); assert!( matches!( dynamic_error(&refused), - dynamic::Error::Plan { - reason: dynamic::Reason::BindInQuery, - .. - } + dynamic::Error::Target(TargetError::Extended { name, .. }) if name == "email" ), "{refused}" ); diff --git a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr index d47a0d410..8c537fd67 100644 --- a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr +++ b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr @@ -17,11 +17,11 @@ help: the trait `Answers` is not implemented for `Equa | |________________________________________^ = help: for that trait implementation, expected `stack_encrypt::op::Eq`, found `stack_encrypt::op::Gt` = note: required for `Encrypted` to implement `Answers` -note: required by a bound in `Marker::::gt` +note: required by a bound in `Marker::::gt` --> src/marker.rs | | T: Answers<$op, Q::Owned> + LabelledTarget, - | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::gt` + | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::gt` ... | / operator_methods! { | | /// Rows whose value equals `value` ([`op::Eq`](struct@Eq)). @@ -53,11 +53,11 @@ error[E0277]: `OreTerm` does not answer `stack_encrypt::op::Matches` given `OreTerm` implements `Answers` `OreTerm` implements `Answers` = note: required for `Encrypted>` to implement `Answers` -note: required by a bound in `Marker::::matches` +note: required by a bound in `Marker::::matches` --> src/marker.rs | | T: Answers<$op, Q::Owned> + LabelledTarget, - | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::matches` + | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::matches` ... | / operator_methods! { | | /// Rows whose value equals `value` ([`op::Eq`](struct@Eq)). diff --git a/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs index fbe3f2bda..96b7ca89a 100644 --- a/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs +++ b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.rs @@ -1,6 +1,6 @@ -//! A query from a field marker takes scopes and no binding: a binding is in -//! no query context, so the query type has no `bind` method and a bare -//! `Bind` does not convert into its `scope`. +//! A query from a field marker takes the record's scope and no binding: a +//! binding is in no query context, so the query type has no `bind` method, +//! and `scope` takes the scope's type, which a `Bind` is not. use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::{Bind, EncryptTarget, Scope}; use stack_encrypt::Encrypted; diff --git a/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr index b35ac6937..53e71956b 100644 --- a/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr +++ b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr @@ -1,4 +1,4 @@ -error[E0599]: no method named `bind` found for struct `MarkedQuery` in the current scope +error[E0599]: no method named `bind` found for struct `MarkedQuery` in the current scope --> tests/ui/marker_query_has_no_bind.rs:24:10 | 21 | let _ = EncryptedUser::EMAIL @@ -10,27 +10,18 @@ error[E0599]: no method named `bind` found for struct `MarkedQuery` i | |_________| | -error[E0277]: the trait bound `DeclaredContext: From>` is not satisfied +error[E0308]: mismatched types --> tests/ui/marker_query_has_no_bind.rs:27:16 | 27 | .scope(Bind::new(9u128)); - | ----- ^^^^^^^^^^^^^^^^ the trait `From>` is not implemented for `DeclaredContext` + | ----- ^^^^^^^^^^^^^^^^ expected `Scope`, found `Bind` | | - | required by a bound introduced by this call + | arguments to this method are incorrect | - = help: the following other types implement trait `From`: - `DeclaredContext` implements `From<&NonEmpty>` - `DeclaredContext` implements `From<()>` - `DeclaredContext` implements `From` - `DeclaredContext` implements `From` - `DeclaredContext` implements `From` - `DeclaredContext` implements `From>` - `DeclaredContext` implements `From>>` - `DeclaredContext` implements `From` - and $N others - = note: required for `stack_encrypt::target::Bind` to implement `Into` -note: required by a bound in `MarkedQuery::::scope` + = note: expected struct `stack_encrypt::target::Scope` + found struct `stack_encrypt::target::Bind` +note: method defined here --> src/marker.rs | - | pub fn scope(mut self, part: impl Into) -> Self { - | ^^^^^^^^^^^^^^^^^^^^^ required by this bound in `MarkedQuery::::scope` + | pub fn scope(mut self, scope: C) -> MarkedQuery { + | ^^^^^ diff --git a/packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.rs b/packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.rs new file mode 100644 index 000000000..86f871d3c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.rs @@ -0,0 +1,40 @@ +//! A record that lists a caller scope gives its markers that scope's type: +//! a query from one has no `encrypt` until the scope is given, takes the +//! scope's type and no other, and takes it once. +use stack_encrypt::kms::FakeDataKeySource; +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::{EncryptTarget, Scope}; +use stack_encrypt::{Encrypted, StackCipher}; +use zeroize::Zeroize; + +#[derive(Clone, Zeroize)] +struct User { + email: String, +} + +#[derive(EncryptTarget)] +#[stash(struct = User, scope = "users", scope(tenant: u64))] +struct EncryptedUser { + email: Encrypted, +} + +async fn query(cipher: &StackCipher) { + let keyset = cipher.default_keyset(); + // The scope is missing. + let _ = EncryptedUser::EMAIL.eq("ada@example.com").encrypt(&keyset).await; + // The scope has the wrong type. + let _ = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new("42")) + .encrypt(&keyset) + .await; + // The scope is given twice. + let _ = EncryptedUser::EMAIL + .eq("ada@example.com") + .scope(Scope::new(42u64)) + .scope(Scope::new(1u64)) + .encrypt(&keyset) + .await; +} + +fn main() {} diff --git a/packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.stderr b/packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.stderr new file mode 100644 index 000000000..19c94ee5d --- /dev/null +++ b/packages/stack-encrypt/tests/ui/marker_query_needs_its_scope.stderr @@ -0,0 +1,39 @@ +error[E0599]: no method named `encrypt` found for struct `MarkedQuery, stack_encrypt::op::Eq, std::string::String, Needs>>` in the current scope + --> tests/ui/marker_query_needs_its_scope.rs:24:56 + | +24 | let _ = EncryptedUser::EMAIL.eq("ada@example.com").encrypt(&keyset).await; + | ^^^^^^^ + | +help: there is a method `encrypt_into` with a similar name + | +24 | let _ = EncryptedUser::EMAIL.eq("ada@example.com").encrypt_into(&keyset).await; + | +++++ + +error[E0308]: mismatched types + --> tests/ui/marker_query_needs_its_scope.rs:28:27 + | +28 | .scope(Scope::new("42")) + | ---------- ^^^^ expected `u64`, found `&str` + | | + | arguments to this function are incorrect + | +note: associated function defined here + --> src/target/context.rs + | + | pub fn new(value: T) -> Self { + | ^^^ + +error[E0599]: no method named `scope` found for struct `MarkedQuery, stack_encrypt::op::Eq, std::string::String>` in the current scope + --> tests/ui/marker_query_needs_its_scope.rs:35:10 + | +32 | let _ = EncryptedUser::EMAIL + | _____________- +33 | | .eq("ada@example.com") +34 | | .scope(Scope::new(42u64)) +35 | | .scope(Scope::new(1u64)) + | | -^^^^^ method not found in `MarkedQuery, stack_encrypt::op::Eq, std::string::String>` + | |_________| + | + | + = note: the method was found for + - `MarkedQuery>` diff --git a/packages/stack-encrypt/tests/ui/pass/markers.rs b/packages/stack-encrypt/tests/ui/pass/markers.rs new file mode 100644 index 000000000..eda2ab42c --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/markers.rs @@ -0,0 +1,52 @@ +//! Shapes a record's markers must compile in: a public record whose field +//! is a private nested record (the marker's constant names that type), a +//! raw-identifier field, and a generic record. +#![deny(warnings)] +use stack_encrypt::sem::EqualityTerm; +use stack_encrypt::target::EncryptTarget; +use stack_encrypt::{Encrypted, StackCipherText}; +use zeroize::Zeroize; + +#[derive(Clone, Zeroize)] +pub struct Address { + street: String, +} + +#[derive(Clone, Zeroize)] +pub struct Account { + r#type: String, + address: Address, +} + +#[derive(EncryptTarget)] +#[stash(struct = Address, scope = "addresses")] +struct EncryptedAddress { + street: StackCipherText, +} + +#[derive(EncryptTarget)] +#[stash(struct = Account, scope = "accounts")] +pub struct EncryptedAccount { + r#type: Encrypted, + address: EncryptedAddress, +} + +#[derive(Zeroize)] +pub struct Tagged { + tag: String, + extra: T, +} + +#[derive(EncryptTarget)] +#[stash(struct = Tagged, scope = "tags")] +pub struct EncryptedTagged { + tag: Encrypted, + #[stash(passthrough)] + extra: T, +} + +fn main() { + let _ = EncryptedAccount::TYPE; + let _ = EncryptedAccount::ADDRESS; + let _ = EncryptedTagged::::TAG; +} From e348a25fe326cb8482f31a65b782e48a57b9c7a0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 07:13:56 +1100 Subject: [PATCH 3/6] fix(stack-encrypt): show EQL fields in a planned context, and copy the plan only when the caller adds to it `caller_shapes` skipped the fields the resolver runs, so `contexts()` on a `Planned` and on a `QueryPlan` left an EQL field out, and the query plan reported no such field. Each is now a leaf under its label alone, which is the context the resolver seals it under. `under_caller` and `under_scopes` borrow the plan when the caller adds nothing and copy it once when they do, where every call copied it before. Refs #1486. --- packages/stack-encrypt/src/dynamic/planned.rs | 38 ++++++++++++------- packages/stack-encrypt/src/dynamic/record.rs | 2 +- packages/stack-encrypt/tests/planned.rs | 19 ++++++++++ 3 files changed, 45 insertions(+), 14 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/planned.rs b/packages/stack-encrypt/src/dynamic/planned.rs index eb4655284..88b6ab491 100644 --- a/packages/stack-encrypt/src/dynamic/planned.rs +++ b/packages/stack-encrypt/src/dynamic/planned.rs @@ -61,6 +61,7 @@ //! # Ok::<(), Box>(()) //! # }).unwrap(); //! ``` +use std::borrow::Cow; use std::fmt; use zeroize::Zeroize; @@ -208,31 +209,40 @@ fn wrap(error: Error) -> crate::Error { impl Plan { /// This plan under the caller's context: its scope as one more part of /// every field's context, then its bindings after the plan's own. - /// `None` leaves the plan as it is. - fn under_caller(self, context: Option) -> Result { + /// `None` leaves the plan as it is, borrowed: nothing is copied for a + /// call with no caller context. + fn under_caller(&self, context: Option) -> Result, Error> { let Some(context) = context else { - return Ok(self); + return Ok(Cow::Borrowed(self)); }; let (scopes, bindings) = context.checked()?.pieces(); - let plan = self.with_scopes(scopes)?; + let plan = self.clone().with_scopes(scopes)?; if bindings.is_empty() { - return Ok(plan); + return Ok(Cow::Owned(plan)); } let mut all = plan.bindings().to_vec(); all.extend(bindings); - plan.with_bindings(all) + plan.with_bindings(all).map(Cow::Owned) } - /// This plan under a query's scopes, which hold no binding. - fn under_scopes(self, scopes: &DeclaredContext, field: &str) -> Result { + /// This plan under a query's scopes, which hold no binding. No scope + /// leaves the plan as it is, borrowed. + fn under_scopes<'p>( + &'p self, + scopes: &DeclaredContext, + field: &str, + ) -> Result, Error> { if scopes.has_bindings() { return Err(Error::Plan { field: Some(field.to_owned()), reason: Reason::BindInQuery, }); } + if !scopes.is_extended() { + return Ok(Cow::Borrowed(self)); + } let pieces = scopes.parts().iter().filter_map(|part| part.pieces().0); - self.with_scopes(pieces) + self.clone().with_scopes(pieces).map(Cow::Owned) } /// A query plan for `field` and `operator`, with no EQL types: refused @@ -366,7 +376,7 @@ fn encrypt_record<'a, K: 'static>( resolver: &(impl TargetResolver + ?Sized), context: OptionalContext, ) -> Pending<'a, StackCipherText, K> { - let plan = match plan.clone().under_caller(context.into_inner()) { + let plan = match plan.under_caller(context.into_inner()) { Ok(plan) => plan, Err(error) => return Pending::failed(cipher, wrap(error)), }; @@ -389,8 +399,10 @@ where { type ContextInput = OptionalContext; fn decryption(self, context: Self::ContextInput) -> Decryption { - let plan = match self.plan.clone().under_caller(context.into_inner()) { - Ok(plan) => plan, + // The opening is a closure the cipher runs later, so it owns its + // plan: one copy, with the caller's parts applied. + let plan = match self.plan.under_caller(context.into_inner()) { + Ok(plan) => plan.into_owned(), Err(error) => return Decryption::failed(wrap(error)), }; let Planned { @@ -460,7 +472,7 @@ impl QueryPlan { scopes: &DeclaredContext, resolver: &(impl TargetResolver + ?Sized), ) -> Pending<'a, QueryTerm, K> { - let plan = match self.plan.clone().under_scopes(scopes, &self.field) { + let plan = match self.plan.under_scopes(scopes, &self.field) { Ok(plan) => plan, Err(error) => return Pending::failed(cipher, wrap(error)), }; diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 8bc0db91f..82d30bff7 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -162,6 +162,7 @@ use crate::{ CipherText, ContextPiece, KeysetCipher, Label, NonEmpty, PassthroughFrame, Pending, StackCipherText, }; +use vitaminc_aead::IntoContext as _; /// The map key a target field's EQL value rides under in a stored record: /// wire format, like the output keys (see the @@ -1041,7 +1042,6 @@ impl Plan { /// takes no part from the caller: the plan refuses an extension for it /// before it runs. fn placed(&self, lowered: crate::contexts::Shapes) -> crate::contexts::Shapes { - use vitaminc_aead::IntoContext as _; let mut lowered = lowered.into_iter().peekable(); let mut shapes = crate::contexts::Shapes::with_capacity(self.fields.len()); for field in &self.fields { diff --git a/packages/stack-encrypt/tests/planned.rs b/packages/stack-encrypt/tests/planned.rs index 0fb1ef08a..bdd8d51c5 100644 --- a/packages/stack-encrypt/tests/planned.rs +++ b/packages/stack-encrypt/tests/planned.rs @@ -634,6 +634,25 @@ mod a_target_field { assert_eq!(through_planned, through_handle); } + #[test] + fn shows_its_context_under_its_label_alone() { + let plan = target_plan(); + // An EQL value is sealed under its label by the resolver, and takes + // no caller part, so the view shows the label and no placeholder. + assert_eq!( + Planned::new(&plan, frame("a")) + .contexts() + .unwrap() + .to_string(), + "email\n context: users / email\n" + ); + let by_email = plan.query_with("email", Op::Eq, &OneType).unwrap(); + assert_eq!( + by_email.contexts().unwrap().to_string(), + "context: users / email\n" + ); + } + #[test] fn is_refused_without_its_resolver() { let plan = target_plan(); From 7c0e449231582f939b616e6b4f6dc6b314648e7b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 08:43:14 +1100 Subject: [PATCH 4/6] test(stack-encrypt): pin the wipe on drop and the Debug of a planned value The mutants gate on #1595 found six surviving mutants: the two wipe functions and the Drop of a marked query, the Zeroize and Debug of a planned value, and the Debug of a field handle. Each is now pinned by a unit test: a dropped query wipes its value once (both bounds of a range), a value taken out of it is not wiped a second time, zeroizing a planned value empties its frame, and Debug names the wrapper and the field but shows no value. Refs #1486. --- packages/stack-encrypt/src/dynamic/planned.rs | 50 +++++++++++++++ packages/stack-encrypt/src/marker.rs | 62 +++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/packages/stack-encrypt/src/dynamic/planned.rs b/packages/stack-encrypt/src/dynamic/planned.rs index 88b6ab491..0bf4bea6c 100644 --- a/packages/stack-encrypt/src/dynamic/planned.rs +++ b/packages/stack-encrypt/src/dynamic/planned.rs @@ -676,3 +676,53 @@ impl<'p, R: TargetResolver + ?Sized> DynamicQuery<'p, R> { .term(keyset, self.value, &self.scopes, self.resolver) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::test_frame::{frame_of, Tree}; + use vitaminc_protected::AsProtectedRef as _; + + fn s(value: &str) -> Tree { + Tree::String(value.into()) + } + + fn the_plan() -> Plan { + let spec = Tree::Object(vec![ + ("context".into(), Tree::Array(vec![s("users"), s("age")])), + ("outputs".into(), Tree::Array(vec![s("c")])), + ("type".into(), s("uint32")), + ]); + let value = Tree::Object(vec![("age".into(), spec)]); + record::plan(frame_of(&value).as_protected_ref().as_deref()).expect("parses") + } + + fn a_row() -> ValueFrame { + let value = Tree::Object(vec![("age".into(), Tree::UInt32(34))]); + ValueFrame::from_bytes(frame_of(&value).as_protected_ref().as_deref()).expect("a frame") + } + + /// Zeroizing a planned value wipes the value it holds: the frame is + /// replaced by the empty one, whose dropped buffer wipes the bytes. + #[test] + fn zeroizing_a_planned_value_empties_its_frame() { + let plan = the_plan(); + let mut planned = Planned::new(&plan, a_row()); + assert_ne!(planned.value, ValueFrame::empty()); + planned.zeroize(); + assert_eq!(planned.value, ValueFrame::empty()); + } + + /// `Debug` names the wrapper and the handle's field, and shows no value. + #[test] + fn debug_names_the_wrapper_and_the_field_but_no_value() { + let plan = the_plan(); + let shown = format!("{:?}", Planned::new(&plan, a_row())); + assert!(shown.starts_with("Planned"), "{shown}"); + assert!(!shown.contains("34"), "{shown}"); + let handle = plan.field("age").expect("the field"); + let shown = format!("{handle:?}"); + assert!(shown.starts_with("FieldHandle"), "{shown}"); + assert!(shown.contains("age"), "{shown}"); + } +} diff --git a/packages/stack-encrypt/src/marker.rs b/packages/stack-encrypt/src/marker.rs index 06e60bb10..cb555dbef 100644 --- a/packages/stack-encrypt/src/marker.rs +++ b/packages/stack-encrypt/src/marker.rs @@ -383,3 +383,65 @@ where .map(move |terms| T::Via::query(verifier, terms, token)) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + /// A query value that counts how many times it was wiped. + struct Spy(Arc); + + impl Zeroize for Spy { + fn zeroize(&mut self) { + let _ = self.0.fetch_add(1, Ordering::SeqCst); + } + } + + fn query(value: In, wipe: fn(In)) -> MarkedQuery<(), (), In> { + MarkedQuery { + label: Label::parse("users/age").expect("a label"), + value: Some(value), + wipe, + scopes: DeclaredContext::default(), + marker: PhantomData, + } + } + + /// A query dropped before it is derived wipes the value it copied. + #[test] + fn a_dropped_query_wipes_its_value() { + let wipes = Arc::new(AtomicUsize::new(0)); + let query = query(Spy(Arc::clone(&wipes)), wipe_one::); + assert_eq!(wipes.load(Ordering::SeqCst), 0, "held, not wiped"); + drop(query); + assert_eq!(wipes.load(Ordering::SeqCst), 1, "wiped once on drop"); + } + + /// A range query wipes both of its bounds. + #[test] + fn a_dropped_range_query_wipes_both_bounds() { + let lo = Arc::new(AtomicUsize::new(0)); + let hi = Arc::new(AtomicUsize::new(0)); + let query = query( + (Spy(Arc::clone(&lo)), Spy(Arc::clone(&hi))), + wipe_pair::, + ); + drop(query); + assert_eq!(lo.load(Ordering::SeqCst), 1); + assert_eq!(hi.load(Ordering::SeqCst), 1); + } + + /// A query whose value was taken, as deriving it does, wipes nothing + /// twice: the taken value is the derivation's to wipe. + #[test] + fn a_taken_value_is_not_wiped_by_the_drop() { + let wipes = Arc::new(AtomicUsize::new(0)); + let mut query = query(Spy(Arc::clone(&wipes)), wipe_one::); + let taken = query.value.take().expect("the value"); + drop(query); + assert_eq!(wipes.load(Ordering::SeqCst), 0, "nothing left to wipe"); + drop(taken); + } +} From 4438430a70a6fc0f585684e137f2d4542b0d58df Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 13:00:24 +1100 Subject: [PATCH 5/6] fix(stack-encrypt): a marker's operator methods take the value by value A marker query took every value by reference, `AGE.between(&18, &65)`, so that a string literal could be copied in through `ToOwned`. A number by reference reads oddly for a `Copy` type, as the review of #1595 said. The methods now take the value by value through `QueryInput`: an owned scalar is moved into the query, and a borrowed text or byte string is copied into an owned one. The scalars are listed, since a blanket over `Zeroize` would overlap the borrowed impls under coherence. The query wipes the owned value as before. The trybuild snapshot follows the bound's new name. Refs #1486, #1590. --- .../2026-10-24-stack-encrypt-api-plan.md | 4 +- packages/stack-encrypt/CHANGELOG.md | 3 +- packages/stack-encrypt/src/lib.rs | 2 +- packages/stack-encrypt/src/marker.rs | 144 ++++++++++++++++-- packages/stack-encrypt/tests/markers.rs | 4 +- .../tests/ui/marker_operator_not_answered.rs | 2 +- .../ui/marker_operator_not_answered.stderr | 4 +- 7 files changed, 140 insertions(+), 23 deletions(-) diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index acc7eca47..f25221c51 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -350,14 +350,14 @@ EQL query type the marker fixes. Decided on 7 November 2026. // On `next` since pull request #1595, with the index's query term as // the output type (`EqualityTerm`, `RangeQuery>`, // `MatchQuery`); the EQL query types follow with the EQL owned -// implementations. The bounds are passed by reference. +// implementations. A value is passed by value; a string literal as it is. let by_email: TextEqQuery = EncryptedMember::EMAIL .eq("alice@example.com") .scope(tenant) .encrypt(&keyset) .await?; let adults: IntegerOrdQuery = EncryptedMember::AGE - .between(&18, &65) + .between(18, 65) .scope(tenant) .encrypt(&keyset) .await?; diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index b08ef647d..cd8f2c8c2 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -23,7 +23,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `bind` method, and ends with `.encrypt(&keyset)`. The output is the index's query term. A query from a marker derives under the declaration the derive wrote, so it is the stored term. No plan is written by hand - beside the record. The query's copy of the value is wiped when it is + beside the record. A method takes its value by value, and a string + literal as it is (`QueryInput`). The query's value is wiped when it is derived, refused, or dropped. `assert_context!` takes a marker, and `Marker::contexts()` is the record's contexts narrowed to the field. Not yet (#1596): a field of several indexes, a nullable field, and a diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 81ff94a71..16f6e2cbb 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -586,7 +586,7 @@ pub use contexts::{ }; pub use descriptor::{Describe, Description, Descriptor, Label, LabelError}; pub use keyset::KeysetCipher; -pub use marker::{BetweenQuery, MarkedQuery, Marker, Needs, Ready, ScopeInput}; +pub use marker::{BetweenQuery, MarkedQuery, Marker, Needs, QueryInput, Ready, ScopeInput}; pub use plan::{all, Plan, PlanError}; // stack-kms is a public dependency: `StackCipher` is generic over its // `DataKeySource`, and `StackCipherBuilder::kms` takes its `StackKms`. It is diff --git a/packages/stack-encrypt/src/marker.rs b/packages/stack-encrypt/src/marker.rs index cb555dbef..edf1c3158 100644 --- a/packages/stack-encrypt/src/marker.rs +++ b/packages/stack-encrypt/src/marker.rs @@ -56,7 +56,7 @@ //! .await?; //! assert_eq!(by_email, stored.email.terms); //! let adults = EncryptedUser::AGE -//! .between(&18u32, &36) +//! .between(18u32, 36) //! .scope(Scope::new(42u64)) //! .encrypt(&keyset) //! .await?; @@ -75,7 +75,7 @@ //! # What a marker does not check //! //! An equality marker takes a value of any type that the equality index -//! accepts: `EMAIL.eq(&42u64)` compiles on a text field and matches +//! accepts: `EMAIL.eq(42u64)` compiles on a text field and matches //! nothing, because the derive sees the record's field types and not the //! plaintext struct's. An order marker fixes the type, since `OreTerm` //! names it. A field whose type is several indexes, such as @@ -203,22 +203,95 @@ fn wipe_pair((mut lo, mut hi): (S, S)) { hi.zeroize(); } +/// What a marker's operator methods take: an owned scalar, moved into the +/// query, or a borrowed text or byte string, copied into it. A number is +/// given by value, `AGE.between(18, 65)`, and a string literal as it is, +/// `EMAIL.eq("ada@example.com")`. The query holds the owned value and +/// wipes it when it is derived, refused or dropped. The scalars are the +/// ones the indexes accept: the booleans, the integers, the floats, text, +/// bytes, the [`value`](crate::value) types, and an `Option` of each. +pub trait QueryInput { + /// The owned value the query holds. + type Value: Zeroize + 'static; + /// The value, owned. + fn into_value(self) -> Self::Value; +} +macro_rules! owned_query_inputs { + ($($ty:ty),+ $(,)?) => {$( + impl QueryInput for $ty { + type Value = $ty; + fn into_value(self) -> $ty { + self + } + } + )+}; +} +owned_query_inputs!( + bool, + u8, + u16, + u32, + u64, + u128, + i8, + i16, + i32, + i64, + i128, + f32, + f64, + String, + Vec, + crate::value::Date, + crate::value::Timestamp, + crate::value::Decimal, +); +impl QueryInput for Option { + type Value = Option; + fn into_value(self) -> Self::Value { + self.map(V::into_value) + } +} +impl QueryInput for &str { + type Value = String; + fn into_value(self) -> String { + self.to_owned() + } +} +impl QueryInput for &String { + type Value = String; + fn into_value(self) -> String { + self.clone() + } +} +impl QueryInput for &[u8] { + type Value = Vec; + fn into_value(self) -> Vec { + self.to_vec() + } +} +impl QueryInput for &Vec { + type Value = Vec; + fn into_value(self) -> Vec { + self.clone() + } +} + /// One method per operator. Each exists only where the field's target type /// answers the operator ([`Answers`]), so a missing one is a compile error -/// that names the field's indexes. The value is copied into the query -/// (`ToOwned`), and the copy is wiped when the query is derived, refused or +/// that names the field's indexes. The value is moved or copied into the +/// query ([`QueryInput`]), and wiped when the query is derived, refused or /// dropped. macro_rules! operator_methods { ($($(#[$doc:meta])* $method:ident => $op:ident;)+) => {$( impl Marker { $(#[$doc])* - pub fn $method(self, value: &Q) -> MarkedQuery + pub fn $method(self, value: Q) -> MarkedQuery where - Q: ?Sized + ToOwned, - Q::Owned: Zeroize + 'static, - T: Answers<$op, Q::Owned> + LabelledTarget, + Q: QueryInput, + T: Answers<$op, Q::Value> + LabelledTarget, { - self.query(value.to_owned(), wipe_one) + self.query(value.into_value(), wipe_one) } } )+}; @@ -244,13 +317,12 @@ impl Marker { /// Rows whose value is between `lo` and `hi`, inclusive /// ([`op::Between`](Between)): one term carrying both bounds, derived /// under one context. - pub fn between(self, lo: &Q, hi: &Q) -> BetweenQuery + pub fn between(self, lo: Q, hi: Q) -> BetweenQuery where - Q: ?Sized + ToOwned, - Q::Owned: Zeroize + 'static, - T: Answers + LabelledTarget, + Q: QueryInput, + T: Answers + LabelledTarget, { - self.query((lo.to_owned(), hi.to_owned()), wipe_pair) + self.query((lo.into_value(), hi.into_value()), wipe_pair) } } @@ -444,4 +516,48 @@ mod tests { assert_eq!(wipes.load(Ordering::SeqCst), 0, "nothing left to wipe"); drop(taken); } + + /// An owned scalar is the query's as it is, for each scalar the + /// indexes accept; a borrowed text or byte string is copied into an + /// owned one. + #[test] + fn a_query_input_is_owned_or_copied_in() { + use crate::value::{Date, Decimal, Timestamp}; + macro_rules! owned_as_it_is { + ($($value:expr),+ $(,)?) => {$( + assert_eq!($value.into_value(), $value); + assert_eq!(Some($value).into_value(), Some($value)); + )+}; + } + owned_as_it_is!( + true, + 7u8, + 7u16, + 7u32, + 7u64, + 7u128, + -7i8, + -7i16, + -7i32, + -7i64, + -7i128, + 0.5f32, + 0.5f64, + String::from("ada"), + b"ada".to_vec(), + ); + let date = Date::from_days_since_epoch(7).unwrap(); + assert_eq!(date.into_value().days_since_epoch(), 7); + let instant = Timestamp::new(7, 9).unwrap(); + let instant = instant.into_value(); + assert_eq!((instant.seconds(), instant.nanoseconds()), (7, 9)); + let decimal = Decimal::new(75, 1).unwrap(); + let decimal = decimal.into_value(); + assert_eq!((decimal.mantissa(), decimal.scale()), (75, 1)); + + assert_eq!("ada".into_value(), String::from("ada")); + assert_eq!((&String::from("ada")).into_value(), String::from("ada")); + assert_eq!(b"ada"[..].into_value(), b"ada".to_vec()); + assert_eq!((&b"ada".to_vec()).into_value(), b"ada".to_vec()); + } } diff --git a/packages/stack-encrypt/tests/markers.rs b/packages/stack-encrypt/tests/markers.rs index dc48410dd..dfd64c897 100644 --- a/packages/stack-encrypt/tests/markers.rs +++ b/packages/stack-encrypt/tests/markers.rs @@ -243,7 +243,7 @@ mod a_marker_query { // A bound equal to the stored value derives the stored term. let at_36 = EncryptedUser::AGE - .gte(&36u32) + .gte(36u32) .scope(Scope::new(42u64)) .encrypt(&keyset) .await @@ -253,7 +253,7 @@ mod a_marker_query { // A range carries one term per bound, each as the stored term of // that value would be. let adults = EncryptedUser::AGE - .between(&18u32, &36u32) + .between(18u32, 36u32) .scope(Scope::new(42u64)) .encrypt(&keyset) .await diff --git a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs index f8dd02e25..491ceef26 100644 --- a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs +++ b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.rs @@ -22,6 +22,6 @@ struct EncryptedUser { fn main() { let _ = EncryptedUser::EMAIL.eq("ada@example.com"); let _ = EncryptedUser::EMAIL.gt("ada@example.com"); - let _ = EncryptedUser::AGE.between(&18u32, &65); + let _ = EncryptedUser::AGE.between(18u32, 65); let _ = EncryptedUser::AGE.matches("36"); } diff --git a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr index 8c537fd67..3dde8a38b 100644 --- a/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr +++ b/packages/stack-encrypt/tests/ui/marker_operator_not_answered.stderr @@ -20,7 +20,7 @@ help: the trait `Answers` is not implemented for `Equa note: required by a bound in `Marker::::gt` --> src/marker.rs | - | T: Answers<$op, Q::Owned> + LabelledTarget, + | T: Answers<$op, Q::Value> + LabelledTarget, | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::gt` ... | / operator_methods! { @@ -56,7 +56,7 @@ error[E0277]: `OreTerm` does not answer `stack_encrypt::op::Matches` given note: required by a bound in `Marker::::matches` --> src/marker.rs | - | T: Answers<$op, Q::Owned> + LabelledTarget, + | T: Answers<$op, Q::Value> + LabelledTarget, | ^^^^^^^^^^^^^^^^^^^^^^ required by this bound in `Marker::::matches` ... | / operator_methods! { From ce7e195138d4d17e8b470eed389ace0082f5ef0a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 14:13:57 +1100 Subject: [PATCH 6/6] docs(stack-encrypt): say what a plan with scopes is, with a doc test The doc of `Plan::with_scopes` said in one sentence what a scope does to a field's context, and it was hard to follow, as the review of #1595 said. It now says it in plain words, and a doc test shows the scope at the end of the field's context through `Pending::context()`. Refs #1486. --- packages/stack-encrypt/src/dynamic/record.rs | 42 ++++++++++++++++++-- 1 file changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 82d30bff7..7b34a3643 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -1139,9 +1139,45 @@ impl Plan { &self.bindings } - /// This plan with `parts` after its extension: a caller's scopes, each - /// one more part of every field's context, as a plan's `"context"` key - /// carries them. + /// Add a caller's scopes to this plan. Each scope becomes one more part + /// at the end of every field's context, after the parts the plan + /// already has, in the order given. A scoped call runs the plan this + /// returns. So a field is sealed and indexed under its label, then the + /// plan's own extension, then the scopes. A call with no scope borrows + /// the plan instead, and copies nothing. + /// + /// A plan with a field that names an EQL type takes no scope. An EQL + /// value is stored under its table and column alone. + /// + /// The scope shows in the context of the call. Here a record under a + /// one-field plan is encrypted under the scope `42u64`: + /// + /// ``` + /// use stack_encrypt::dynamic::{record, Planned, ValueFrame}; + /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::target::Scope; + /// use stack_encrypt::{AsProtectedRef, EncryptInto, Pending, StackCipher, StackCipherText}; + /// use vitaminc_aead_frame::serde::to_vec; + /// + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async { + /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let keyset = cipher.default_keyset(); + /// let declaration = to_vec(&serde_json::json!({ + /// "email": { "context": ["users", "email"], "outputs": ["c"], "type": "string" }, + /// }))?; + /// let plan = record::plan(declaration.as_protected_ref())?; + /// let values = ValueFrame::from_bytes( + /// to_vec(&serde_json::json!({"email": "ada@example.com"}))?.as_protected_ref(), + /// )?; + /// let call: Pending<'_, StackCipherText, FakeDataKeySource> = + /// Planned::new(&plan, values).encrypt_into_with_context(&keyset, Scope::new(42u64)); + /// // The field's context is its label, then the scope. + /// # #[cfg(feature = "testing")] + /// stack_encrypt::assert_context!(call.context(), [["users", "email"], 42u64]); + /// let _row = call.await?; + /// # Ok::<(), Box>(()) + /// # }).unwrap(); + /// ``` /// /// # Errors ///