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..f25221c51 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -321,22 +321,36 @@ 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 (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 -// 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 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. A value is passed by value; a string literal as it is. let by_email: TextEqQuery = EncryptedMember::EMAIL .eq("alice@example.com") .scope(tenant) @@ -344,6 +358,7 @@ let by_email: TextEqQuery = EncryptedMember::EMAIL .await?; let adults: IntegerOrdQuery = EncryptedMember::AGE .between(18, 65) + .scope(tenant) .encrypt(&keyset) .await?; @@ -690,13 +705,12 @@ 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 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 = 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,10 +774,10 @@ it declares about queries, so no declaration lists the queries a program might run. ```rust -// Not on `next` yet: query plans. +// 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?; ``` @@ -785,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 `IndexNotDeclared` 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 -// Not on `next` yet: the dynamic field handle. +// 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?; @@ -1550,10 +1567,15 @@ 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 #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 hand-built plan the same markers, through `Plan::::scope(..)` with `pick!`. That is withdrawn. No Rust program builds a plan by hand. @@ -1565,13 +1587,18 @@ 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; 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; + - 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`), 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. + context (`tests/markers.rs`, `tests/planned.rs`). #### `Planned` and its serialised form @@ -1603,11 +1630,17 @@ 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 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 + `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 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 946779033..5a3e86f77 100644 --- a/packages/stack-encrypt-derive/src/lib.rs +++ b/packages/stack-encrypt-derive/src/lib.rs @@ -121,6 +121,25 @@ //! 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` (`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. //! Query-only targets derive `EncryptFrom` alone. diff --git a/packages/stack-encrypt-derive/src/owned_encrypt.rs b/packages/stack-encrypt-derive/src/owned_encrypt.rs index b202847d4..188a78159 100644 --- a/packages/stack-encrypt-derive/src/owned_encrypt.rs +++ b/packages/stack-encrypt-derive/src/owned_encrypt.rs @@ -29,7 +29,69 @@ 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, 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 { + 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, .. }), + } = &field.kind + else { + return None; + }; + 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 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, private_interfaces)] + pub const #const_name: #krate::Marker<#ty, #scope_ty> = + #krate::Marker::new(#field_name, #prefix, #identity, Self::contexts); + }) + }); + quote! { + #[automatically_derived] + impl #impl_generics #name #ty_generics #where_clause { + #(#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..cd8f2c8c2 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -7,6 +7,57 @@ 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. 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. 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 + 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`, 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)` 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. + ### 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..547e3a01f 100644 --- a/packages/stack-encrypt/CONTEXT.md +++ b/packages/stack-encrypt/CONTEXT.md @@ -275,6 +275,59 @@ 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. 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)`. 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) + +**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)`, 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**: 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..0bf4bea6c --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/planned.rs @@ -0,0 +1,728 @@ +//! 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 ([`PlanError::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::borrow::Cow; +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, CallerShape, Contexts, LeafContext, Placeholder}; +use crate::target::{ + CallerContext, DeclaredContext, DecryptSource, Decryption, EncryptTarget, Encryption, + OptionalContext, Owned, Pending, +}; +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`], 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. +/// +/// 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, + 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, + /// 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 { + Ok(contexts::resolve( + 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, with a placeholder for the caller's scope: + /// [`QueryPlan::contexts`]. + /// + /// # Errors + /// + /// As [`QueryPlan::contexts`]. + pub fn contexts(&self) -> Result { + self.plan.contexts() + } +} + +/// 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() + } +} + +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(); + } +} + +/// 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 { + match error { + Error::Cipher(error) => error, + Error::Plan { + reason: Reason::BindInQuery, + .. + } => PlanError::BindInQuery.into(), + other => crate::Error::Other(Box::new(other)), + } +} + +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, 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(Cow::Borrowed(self)); + }; + let (scopes, bindings) = context.checked()?.pieces(); + let plan = self.clone().with_scopes(scopes)?; + if bindings.is_empty() { + return Ok(Cow::Owned(plan)); + } + let mut all = plan.bindings().to_vec(); + all.extend(bindings); + plan.with_bindings(all).map(Cow::Owned) + } + + /// 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.clone().with_scopes(pieces).map(Cow::Owned) + } + + /// 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, 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, + }) + } +} + +/// 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 the frame in place; the owned form consumes it. +impl<'p, R> EncryptTarget> for StackCipherText +where + R: TargetResolver + 'p, +{ + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, Plan, ValueFrame, R>, Self, K, Self::ContextInput> + where + Planned<'p, Plan, ValueFrame, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + move |source: &Planned<'p, Plan, ValueFrame, R>, + cipher, + context: Self::ContextInput| { + encrypt_record( + cipher, + source.plan, + &source.value, + &source.resolver, + context, + ) + }, + ) + } + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, Plan, ValueFrame, R>, Self, K, Self::ContextInput, Owned> + where + Planned<'p, Plan, ValueFrame, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + move |source: Planned<'p, Plan, ValueFrame, 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.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. 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, +{ + type ContextInput = OptionalContext; + fn decryption(self, context: Self::ContextInput) -> Decryption { + // 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 { + 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, 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 = 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()), + 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.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 ([`PlanError::BindInQuery`]). +impl<'p, R> EncryptTarget> for QueryTerm +where + R: TargetResolver + 'p, +{ + type ContextInput = OptionalContext; + fn encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, QueryPlan, ValueFrame, R>, Self, K, Self::ContextInput> + where + Planned<'p, QueryPlan, ValueFrame, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + 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, &scopes, &source.resolver) + }, + ) + } + fn owned_encryption<'s, K: 'static>( + ) -> Encryption<'s, Planned<'p, QueryPlan, ValueFrame, R>, Self, K, Self::ContextInput, Owned> + where + Planned<'p, QueryPlan, ValueFrame, R>: 's, + { + Encryption::from_build( + contexts::caller_leaf(), + move |source: Planned<'p, QueryPlan, ValueFrame, 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_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, + } + } +} + +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, R: TargetResolver + ?Sized> FieldHandle<'p, R> { + $(#[$doc])* + /// + /// # Errors + /// + /// As [`Plan::query_with`]. + pub fn $method(self, value: ValueFrame) -> Result, Error> { + self.op(Op::$op, value) + } + } + )+}; +} +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, 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 + } + + /// 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. The +/// value is a frame, a protected buffer wiped when the query is dropped. +pub struct DynamicQuery<'p, R: ?Sized = NoTargets> { + plan: QueryPlan, + value: ValueFrame, + scopes: DeclaredContext, + resolver: &'p R, +} + +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") + .field("field", &self.plan.field) + .field("operator", &self.plan.operator) + .finish_non_exhaustive() + } +} + +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 + } + + /// The query plan this query runs under. + pub fn plan(&self) -> &QueryPlan { + &self.plan + } +} + +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. + 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) + } +} + +#[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/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..7b34a3643 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 @@ -579,6 +580,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 +1029,19 @@ 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 { - use vitaminc_aead::IntoContext as _; - let lowered = self.lower::<()>()?.shapes_under(&self.declared_context()); + /// 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 { 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,88 @@ impl Plan { &self.bindings } + /// 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 + /// + /// [`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..16f6e2cbb 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::{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 new file mode 100644 index 000000000..edf1c3158 --- /dev/null +++ b/packages/stack-encrypt/src/marker.rs @@ -0,0 +1,563 @@ +//! 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), 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. 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; +//! 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(); +//! ``` +//! +//! 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, 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, 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 { + name: &'static str, + scope: &'static str, + identity: &'static str, + contexts: fn() -> Contexts, + target: PhantomData (T, C)>, +} + +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. 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, + scope: &'static str, + identity: &'static str, + contexts: fn() -> Contexts, + ) -> Self { + Self { + name, + scope, + identity, + contexts, + target: PhantomData, + } + } + + /// The field's name in the record, as `contexts()` names it. + 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, wipe: fn(In)) -> MarkedQuery + where + Op: Operator, + In: Input, + C: ScopeInput, + { + MarkedQuery { + label: self.label(), + 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(); +} + +/// 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 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 + where + Q: QueryInput, + T: Answers<$op, Q::Value> + LabelledTarget, + { + self.query(value.into_value(), wipe_one) + } + } + )+}; +} +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) -> BetweenQuery + where + Q: QueryInput, + T: Answers + LabelledTarget, + { + self.query((lo.into_value(), hi.into_value()), wipe_pair) + } +} + +/// 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: Option, + wipe: fn(In), + scopes: DeclaredContext, + marker: Tags, +} + +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") + .field("label", &self.label) + .finish_non_exhaustive() + } +} + +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, +{ + /// 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 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>( + mut self, + keyset: &'a KeysetCipher<'_, K>, + ) -> Pending<'a, >::Query, K> + where + >::Term: MaybeSend, + In::Terms<>::Term>: MaybeSend, + >::Query: MaybeSend + 'static, + { + 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(&value)) + { + Ok(verifier) => verifier, + Err(error) => { + (self.wipe)(value); + return Pending::failed(keyset, error); + } + }; + let token = QueryToken::new(self.label.clone(), extended); + 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)) + } +} + +#[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); + } + + /// 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/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..e36ab122a 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..dfd64c897 --- /dev/null +++ b/packages/stack-encrypt/tests/markers.rs @@ -0,0 +1,393 @@ +//! 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; 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::{DecryptSource, EncryptTarget, NoScope, Scope}; +use stack_encrypt::testing::{scope, stored_scope}; +use stack_encrypt::{ + assert_context, nonempty, DecryptFrom, EncryptInto, Encrypted, Error, Marker, 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, a field keyed under another identity, +/// and a field that is a ciphertext alone. +#[derive(EncryptTarget, DecryptSource)] +#[stash(struct = User, scope = "people")] +struct Renamed { + #[stash(identity = "mail")] + email: Encrypted, + age: Encrypted>, + 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::*; + + #[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_context!( + StoredTenant::EMAIL, + [["users", "email"], stored_scope::("tenant")] + ); + 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 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 = 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 supplies_a_scope_the_record_stores_in_the_row() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + 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(Scope::new(42u64)) + .encrypt(&keyset) + .await + .unwrap(); + assert_eq!(term, stored.email.terms); + } + + #[tokio::test] + 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!(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] + 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"); + } + + #[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 new file mode 100644 index 000000000..bdd8d51c5 --- /dev/null +++ b/packages/stack-encrypt/tests/planned.rs @@ -0,0 +1,736 @@ +//! 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 std::sync::atomic::Ordering; + +use common::frames::{frame, object}; +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, IndexSpec, Scope}; +use stack_encrypt::{ + nonempty, AsProtectedRef, DecryptFrom, EncryptInto, Encrypted, Error, KeysetCipher, Label, + Pending, PlanError, 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() +} + +/// 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 { + 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_with(declaration.as_protected_ref(), &OneType).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 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 plan = plan_of(serde_json::json!({ + "email": { "context": [["users", "email"], 5], "outputs": ["c", "eq"], "type": "string" }, + "bind": [7], + })); + let contexts = Planned::new(&plan, ada()).contexts().unwrap(); + assert_eq!( + contexts.to_string(), + "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() + ); + } + + #[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() { + // 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}"); + } +} + +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) / {scope?: CallerContext}\n" + ); + assert_eq!( + Planned::new(&by_email, frame("x")).contexts().unwrap(), + by_email.contexts().unwrap() + ); + } + + #[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, 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@example.com")) + .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!(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. + 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!(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 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(); + 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::Target(TargetError::Extended { name, .. }) if name == "email" + ), + "{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..491ceef26 --- /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..3dde8a38b --- /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::Value> + 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::Value> + 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..96b7ca89a --- /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 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; +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..53e71956b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/marker_query_has_no_bind.stderr @@ -0,0 +1,27 @@ +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[E0308]: mismatched types + --> tests/ui/marker_query_has_no_bind.rs:27:16 + | +27 | .scope(Bind::new(9u128)); + | ----- ^^^^^^^^^^^^^^^^ expected `Scope`, found `Bind` + | | + | arguments to this method are incorrect + | + = 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, 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; +}