From 783894dd7c1d2b72c4ff3106520a3543b8f3bbd6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 16:59:02 +1000 Subject: [PATCH 1/3] feat(eql-bindings): ship one TargetResolver for the EQL types Stack Encrypt's dynamic interface runs a plan that arrives as data. A field of that plan can name an EQL type, and a program gives the engine the EQL types through the TargetResolver trait. Nobody shipped one, so the Go guest and three EQL encryption tests each wrote the same resolver over encryption::targets, and the copies had drifted: two test copies named fewer of a JSON document's query domains than the guest. encryption::EqlTargets is the guest's resolver, moved into eql-bindings behind the targets feature. The row-to-descriptor and refusal conversions are From impls beside it. The guest and the tests use it and their copies are gone, so a change to the trait's signature is made once. Closes #1571 --- .changeset/eql-bindings-shared-resolver.md | 5 + .../2026-10-24-stack-encrypt-api-plan.md | 3 +- languages/golang/encrypt/guest/src/targets.rs | 277 +-------------- packages/eql/crates/eql-bindings/README.md | 6 +- .../eql/crates/eql-bindings/src/encryption.rs | 6 + .../eql-bindings/src/encryption/resolver.rs | 326 ++++++++++++++++++ .../encryption/tests/json_tree/elements.rs | 93 +---- .../encryption/tests/json_tree/predicate.rs | 13 +- .../encryption/tests/json_tree_data_plan.rs | 129 ++----- .../eql/tests/encryption/tests/targets.rs | 32 ++ .../tests/encryption/tests/typed_queries.rs | 110 ++---- 11 files changed, 463 insertions(+), 537 deletions(-) create mode 100644 .changeset/eql-bindings-shared-resolver.md create mode 100644 packages/eql/crates/eql-bindings/src/encryption/resolver.rs diff --git a/.changeset/eql-bindings-shared-resolver.md b/.changeset/eql-bindings-shared-resolver.md new file mode 100644 index 000000000..2cb41458b --- /dev/null +++ b/.changeset/eql-bindings-shared-resolver.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/eql": minor +--- + +`eql-bindings` (the Rust crate released with this package) now ships a resolver for Stack Encrypt's dynamic interface: `eql_bindings::encryption::EqlTargets`, behind the `targets` feature. The dynamic interface runs a plan that arrives as data, and a field of that plan can name an EQL type, such as `"TextEq"`. A program gives Stack Encrypt the EQL types through its `TargetResolver` trait. Until now each program wrote its own resolver over `encryption::targets`. `EqlTargets` holds every type in `targets::targets()` and runs a named type's `encrypt_with`, `decrypt` and `query`. Two conversions come with it: `From<&Target>` for Stack Encrypt's `TargetDescriptor`, and `From` for Stack Encrypt's `TargetError`. Stored bytes and query operands do not change. The SQL bundle and the TypeScript types do not change. 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 8b1105465..d9d4dddfe 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -1521,7 +1521,8 @@ Base64. - **Done already.** The functions it calls exist in `eql-bindings`: `encrypt_with`, `decrypt`, and `query` in `v4::targets` and `encryption::targets`. -- **Tracked in.** No issue. +- **Tracked in.** #1571. Done: the resolver is + `eql_bindings::encryption::EqlTargets`. - **Finished when** the tests and the Go guest use the resolver from `eql-bindings`, and their own copies are deleted. diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs index a6b706143..e4a587c09 100644 --- a/languages/golang/encrypt/guest/src/targets.rs +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -4,32 +4,26 @@ //! are code spans, not links: a doc build of either feature set resolves. //! //! Two builds of this crate, one resolver each (ADR-0007, amended -//! 2026-10-06). With the `eql` feature, [`Resolver`] is `EqlTargets`, -//! which installs `eql-bindings`' by-name dispatch -//! (`eql_bindings::encryption::targets`): `se_targets` lists every EQL type -//! the catalog has, and a plan field naming a producible one (`TextEq`) is -//! run through that type's own `EncryptFrom` / `DecryptInto`, in the same -//! ZeroKMS request as the rest of the record. Without it, [`Resolver`] is -//! the engine's `NoTargets` (`stack_encrypt::dynamic::NoTargets`): -//! `se_targets` lists nothing, and a plan that -//! names a target is refused when it is parsed — at `se_plan_check`, before -//! any value crosses. +//! 2026-10-06). With the `eql` feature, [`Resolver`] is `eql-bindings`' +//! own resolver (`eql_bindings::encryption::EqlTargets`): `se_targets` +//! lists every EQL type the catalog has, and a plan field naming a +//! producible one (`TextEq`) is run through that type's own plan, in the +//! same ZeroKMS request as the rest of the record. Without it, +//! [`Resolver`] is the engine's `NoTargets` +//! (`stack_encrypt::dynamic::NoTargets`): `se_targets` lists nothing, and +//! a plan that names a target is refused when it is parsed — at +//! `se_plan_check`, before any value crosses. //! //! The Go module embeds both: `encrypt` the build without EQL types, //! `encrypt/eql` the build with them, registered on import, so a program //! that names an EQL type links the build that has it. -#[cfg(not(feature = "eql"))] -use stack_encrypt::dynamic::NoTargets; -#[cfg(feature = "eql")] -use stack_encrypt::dynamic::{TargetDescriptor, TargetError, TargetResolver}; - /// The resolver this build installs. #[cfg(feature = "eql")] -pub type Resolver = EqlTargets; +pub type Resolver = eql_bindings::encryption::EqlTargets; /// The resolver this build installs. #[cfg(not(feature = "eql"))] -pub type Resolver = NoTargets; +pub type Resolver = stack_encrypt::dynamic::NoTargets; /// This build's resolver, for the record operations. pub fn resolver() -> Resolver { @@ -39,150 +33,11 @@ pub fn resolver() -> Resolver { /// Whether this build holds the EQL types. pub const HOLDS_EQL: bool = cfg!(feature = "eql"); -/// `eql-bindings`' by-name dispatch as the engine's resolver. The resolver -/// sees a type name and a field's label; the field's name is the -/// lowering's to add to a refusal. -#[cfg(feature = "eql")] -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct EqlTargets; - -#[cfg(feature = "eql")] -impl TargetResolver for EqlTargets { - fn targets(&self) -> Vec { - eql_bindings::encryption::targets::targets() - .iter() - .map(describe) - .collect() - } - - fn encrypt<'a, K: 'static>( - &self, - name: &str, - options: Option<&stack_encrypt::dynamic::TargetOptions>, - keyset: &'a stack_encrypt::KeysetCipher<'_, K>, - label: &stack_encrypt::Label, - plaintext: stack_encrypt::dynamic::ValueFrame, - ) -> Result, K>, TargetError> { - eql_bindings::encryption::targets::encrypt_with(name, options, keyset, label, plaintext) - .map_err(convert) - } - - fn decrypt<'a, K: 'static>( - &self, - name: &str, - cipher: &'a stack_encrypt::StackCipher, - label: &stack_encrypt::Label, - stored: &[u8], - ) -> Result, TargetError> - { - eql_bindings::encryption::targets::decrypt(name, cipher, label, stored).map_err(convert) - } - - fn query<'a, K: 'static>( - &self, - name: &str, - index: &stack_encrypt::target::IndexSpec, - keyset: &'a stack_encrypt::KeysetCipher<'_, K>, - label: &stack_encrypt::Label, - plaintext: stack_encrypt::dynamic::ValueFrame, - ) -> Result, K>, TargetError> { - eql_bindings::encryption::targets::query(name, index, keyset, label, plaintext) - .map_err(convert) - } -} - -/// An `eql-bindings` table row as the engine's descriptor. The two spell -/// one wire format; `eql-bindings` tests its serde form against this -/// crate's `to_value`. -#[cfg(feature = "eql")] -fn describe(target: &eql_bindings::encryption::targets::Target) -> TargetDescriptor { - let descriptor = TargetDescriptor::new( - target.name, - target.family, - target.suffix, - target.plaintext_kind(), - target.sql_domain, - target.indexes.iter().map(|key| (*key).to_owned()).collect(), - target.query.map(str::to_owned), - target.query_sql_domain.map(str::to_owned), - target.producible, - target.reason.map(str::to_owned), - ); - // A type with a match index answers `matches` through its twin only - // when the engine knows the options its filter is built under. - let descriptor = match target.match_options() { - Some(options) => descriptor.with_match_options(options), - None => descriptor, - }; - // A document's orderings at a path bind as its leaf operand, its - // element predicate (`element_match`) as its elements operand, and its - // compiled predicate (`match`) as its predicate operand. - let descriptor = match target.leaf_query_sql_domain() { - Some(domain) => descriptor.with_leaf_query_sql_domain(domain), - None => descriptor, - }; - let descriptor = match target.elements_query_sql_domain() { - Some(domain) => descriptor.with_elements_query_sql_domain(domain), - None => descriptor, - }; - match target.predicate_query_sql_domain() { - Some(domain) => descriptor.with_predicate_query_sql_domain(domain), - None => descriptor, - } -} - -/// An `eql-bindings` refusal as the engine's. The field name is left empty: -/// the lowering fills it in, since the resolver never sees it. -#[cfg(feature = "eql")] -fn convert(error: eql_bindings::encryption::targets::TargetError) -> TargetError { - use eql_bindings::encryption::targets::TargetError as Eql; - match error { - Eql::Unknown { name } => TargetError::Unknown { name }, - Eql::Unproducible { name, reason } => TargetError::Unproducible { - name: name.to_owned(), - reason: reason.to_owned(), - }, - Eql::NoQuery { name } => TargetError::NoQuery { - name: name.to_owned(), - }, - Eql::Context { label } => TargetError::Column { - name: String::new(), - label, - reason: "an EQL column is a two-segment label, table and column".to_owned(), - }, - Eql::Plaintext { - target, - expected, - found, - } => TargetError::Plaintext { - name: String::new(), - target: target.to_owned(), - expected: Some(expected), - found, - }, - // The engine's code for what was wrong with the document, never - // the document. - Eql::Json { target, source } => TargetError::Json { - name: String::new(), - target: target.to_owned(), - reason: source.code(), - }, - // The parser's kind and position, never its message: serde_json - // quotes the input it refused, and the input is stored ciphertext. - Eql::Stored { target, source } => TargetError::Stored { - name: String::new(), - target: target.to_owned(), - reason: stack_encrypt::diagnostic::describe_json_error(&source), - }, - other => TargetError::Other(Box::new(other)), - } -} - #[cfg(test)] mod tests { use super::*; #[cfg(feature = "eql")] - use stack_encrypt::AsProtectedRef as _; + use stack_encrypt::dynamic::{TargetError, TargetResolver as _}; #[cfg(not(feature = "eql"))] #[test] @@ -279,112 +134,4 @@ mod tests { Err(TargetError::Unknown { .. }) )); } - - /// The JSON document's query domains this guest names, and so the JSON - /// operators the Go SDK is offered: the leaf operand, for the orderings - /// at a path; the element predicate, for `element_match`; and the - /// compiled predicate, for `match`. Every operator a field lists is one - /// the Go SDK's operator list (`parseOperatorName`) and stashgen read. - #[cfg(feature = "eql")] - #[test] - fn a_document_names_the_leaf_element_and_predicate_domains() { - use stack_encrypt::dynamic::{self, record, Op}; - let targets = resolver().targets(); - let document = targets - .iter() - .find(|t| t.name == "JsonSearch") - .expect("JsonSearch"); - assert_eq!( - document.leaf_query_sql_domain.as_deref(), - Some("eql_v4.query_json_leaf") - ); - assert_eq!( - document.elements_query_sql_domain.as_deref(), - Some("eql_v4.query_json_elements") - ); - assert_eq!( - document.predicate_query_sql_domain.as_deref(), - Some("eql_v4.query_json_predicate") - ); - assert!( - document.operators().contains(&Op::Match), - "the type lists match: {:?}", - document.operators() - ); - // The operators a field answers: the orderings and `element_match` - // only where it orders a path, and `match` on every field. - let field_operators = |ordered: &[&str]| { - let wire = serde_json::json!({ - "profile": { - "context": ["users", "profile"], - "type": "json", - "null": "sql", - "target": {"name": "JsonSearch", "options": {"ordered": ordered}} - } - }); - let frame = dynamic::frame::serde::to_vec(&wire).expect("plan frame"); - let plan = record::plan_with(frame.as_protected_ref(), &resolver()).expect("plan"); - dynamic::field_operators(&plan, "profile", &resolver()) - .expect("operators") - .into_iter() - .map(|(op, _)| op) - .collect::>() - }; - let ordered = field_operators(&["$.contacts[*].relationship"]); - for present in [Op::ElementMatch, Op::Match] { - assert!(ordered.contains(&present), "{present:?}: {ordered:?}"); - } - let unordered = field_operators(&[]); - assert!(unordered.contains(&Op::Match), "{unordered:?}"); - assert!(!unordered.contains(&Op::ElementMatch), "{unordered:?}"); - } - - #[cfg(feature = "eql")] - #[test] - fn eql_refusals_convert_to_the_engines_with_the_field_left_to_the_lowering() { - use eql_bindings::encryption::targets::TargetError as Eql; - assert!(matches!( - convert(Eql::Context { - label: "tenant/users/email".into() - }), - TargetError::Column { name, label, .. } if name.is_empty() && label == "tenant/users/email" - )); - assert!(matches!( - convert(Eql::Plaintext { - target: "TextEq", - expected: stack_encrypt::dynamic::ValueKind::String, - found: Some(stack_encrypt::dynamic::ValueKind::UInt64), - }), - TargetError::Plaintext { target, expected: Some(stack_encrypt::dynamic::ValueKind::String), .. } if target == "TextEq" - )); - // The engine asks a type's query only through an index the type - // carries, so one it does not is the engine's failure, not the - // caller's input. - assert!(matches!( - convert(Eql::NoIndex { - name: "TextEq", - index: "ope", - }), - TargetError::Other(_) - )); - } - - /// serde_json quotes the value it refused, and a stored EQL value holds - /// ciphertext and index terms: only its kind and position cross. - #[cfg(feature = "eql")] - #[test] - fn a_stored_value_that_does_not_parse_quotes_none_of_it() { - use eql_bindings::encryption::targets::TargetError as Eql; - let source = serde_json::from_str::(r#""marker-ciphertext""#).unwrap_err(); - let error = convert(Eql::Stored { - target: "TextEq", - source, - }); - let TargetError::Stored { reason, .. } = &error else { - panic!("{error:?}"); - }; - assert_eq!(reason, "unexpected data at line 1 column 19"); - let shown = format!("{error} {:?}", stack_encrypt::ErrorPayload::payload(&error)); - assert!(!shown.contains("marker"), "{shown}"); - } } diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index a229f9119..7b9631f8e 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -405,9 +405,9 @@ feature, and drift-gated by the same parity tests. The same generator writes the Go package `languages/golang/encrypt/eql` from the same rows (`eql-codegen go-eql`, also under `types:generate` / `types:check`). The engine's `TargetResolver` (stack-encrypt's `dynamic` module) is implemented -over this module by the Go guest's `eql` build, not here: a resolver in this -crate would need stack-encrypt API newer than the crates.io release the -published crate names, and the guest already depends on both. A type is producible exactly +over this module by `encryption::EqlTargets`. A program that runs data plans +with EQL types passes `&EqlTargets` wherever the dynamic interface takes a +resolver, as the Go guest's `eql` build does. A type is producible exactly when its generated struct carries the `stack-encrypt` derives (`ENCRYPTION_DOMAINS` in `eql-codegen`), because the dispatch runs the derived plan; `targets::targets()` lists them, each with `producible` and, when it is diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index 3891de3fb..98452b07b 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -101,6 +101,12 @@ pub mod query; #[cfg(feature = "targets")] pub mod targets; +// The EQL types as Stack Encrypt's resolver (`targets` feature). +#[cfg(feature = "targets")] +mod resolver; +#[cfg(feature = "targets")] +pub use resolver::EqlTargets; + use base64::{engine::general_purpose::STANDARD, Engine as _}; use stack_encrypt::sem::{ EqualityTerm, MatchConfig, MatchOptions, MatchTerms, TermError, Tokenizer, diff --git a/packages/eql/crates/eql-bindings/src/encryption/resolver.rs b/packages/eql/crates/eql-bindings/src/encryption/resolver.rs new file mode 100644 index 000000000..eac7a26fc --- /dev/null +++ b/packages/eql/crates/eql-bindings/src/encryption/resolver.rs @@ -0,0 +1,326 @@ +//! The EQL types as Stack Encrypt's resolver. +//! +//! Stack Encrypt's dynamic interface runs a plan that arrives as data. A +//! field of that plan may name an EQL type, such as `"TextEq"`. Stack +//! Encrypt does not depend on this crate, so a program gives it the EQL +//! types through a [`TargetResolver`]. [`EqlTargets`] is that resolver. It +//! holds every type in [`targets`](super::targets::targets), and runs a +//! named type through [`encrypt_with`], [`decrypt`] and [`query`]. +//! +//! ``` +//! use eql_bindings::encryption::EqlTargets; +//! use stack_encrypt::dynamic::TargetResolver; +//! +//! let text_eq = EqlTargets.resolve("TextEq")?; +//! assert_eq!(text_eq.sql_domain, "public.eql_v4_text_eq"); +//! assert_eq!(text_eq.indexes, ["eq"]); +//! # Ok::<(), stack_encrypt::dynamic::TargetError>(()) +//! ``` +//! +//! Pass `&EqlTargets` wherever the dynamic interface takes a resolver, such +//! as `stack_encrypt::dynamic::record::plan_with`. + +use stack_encrypt::dynamic::{self, TargetDescriptor, TargetOptions, TargetResolver, ValueFrame}; +use stack_encrypt::target::IndexSpec; +use stack_encrypt::{KeysetCipher, Label, Pending, StackCipher}; + +use super::targets::{self, decrypt, encrypt_with, query, Target, TargetError}; + +/// Every EQL type this crate defines, as Stack Encrypt's resolver. +/// +/// The resolver sees a type's name and a field's label. It never sees the +/// field's name, so a refusal leaves the field name empty and the engine +/// fills it in. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct EqlTargets; + +impl TargetResolver for EqlTargets { + fn targets(&self) -> Vec { + targets::targets() + .iter() + .map(TargetDescriptor::from) + .collect() + } + + fn encrypt<'a, K: 'static>( + &self, + name: &str, + options: Option<&TargetOptions>, + keyset: &'a KeysetCipher<'_, K>, + label: &Label, + plaintext: ValueFrame, + ) -> Result, K>, dynamic::TargetError> { + Ok(encrypt_with(name, options, keyset, label, plaintext)?) + } + + fn decrypt<'a, K: 'static>( + &self, + name: &str, + cipher: &'a StackCipher, + label: &Label, + stored: &[u8], + ) -> Result, dynamic::TargetError> { + Ok(decrypt(name, cipher, label, stored)?) + } + + fn query<'a, K: 'static>( + &self, + name: &str, + index: &IndexSpec, + keyset: &'a KeysetCipher<'_, K>, + label: &Label, + plaintext: ValueFrame, + ) -> Result, K>, dynamic::TargetError> { + Ok(query(name, index, keyset, label, plaintext)?) + } +} + +/// A row of the target table as the engine's descriptor. The two have one +/// wire format. +/// +/// The descriptor also carries what the engine needs and the wire does +/// not: +/// +/// - the match options, for a type with a match index. The engine answers +/// `matches` through the type only when it knows them; +/// - the three query domains of a type that stores a JSON document. An +/// ordering at a path binds as the leaf operand, `element_match` as the +/// elements operand, and `match` as the predicate operand. +impl From<&Target> for TargetDescriptor { + fn from(target: &Target) -> Self { + let mut descriptor = TargetDescriptor::new( + target.name, + target.family, + target.suffix, + target.plaintext_kind(), + target.sql_domain, + target.indexes.iter().map(|key| (*key).to_owned()).collect(), + target.query.map(str::to_owned), + target.query_sql_domain.map(str::to_owned), + target.producible, + target.reason.map(str::to_owned), + ); + if let Some(options) = target.match_options() { + descriptor = descriptor.with_match_options(options); + } + if let Some(domain) = target.leaf_query_sql_domain() { + descriptor = descriptor.with_leaf_query_sql_domain(domain); + } + if let Some(domain) = target.elements_query_sql_domain() { + descriptor = descriptor.with_elements_query_sql_domain(domain); + } + if let Some(domain) = target.predicate_query_sql_domain() { + descriptor = descriptor.with_predicate_query_sql_domain(domain); + } + descriptor + } +} + +/// A refusal of this crate as the engine's. The field name is left empty: +/// the engine fills it in, since the resolver never sees it. +/// +/// No variant carries a byte of a plaintext or of a stored value. A value +/// that is not a JSON document keeps the engine's code for what was wrong. +/// A stored value that does not parse keeps the parser's kind and position +/// and not its message, because `serde_json` quotes the input it refused, +/// and that input is stored ciphertext. +impl From for dynamic::TargetError { + fn from(error: TargetError) -> Self { + match error { + TargetError::Unknown { name } => Self::Unknown { name }, + TargetError::Unproducible { name, reason } => Self::Unproducible { + name: name.to_owned(), + reason: reason.to_owned(), + }, + TargetError::NoQuery { name } => Self::NoQuery { + name: name.to_owned(), + }, + TargetError::Context { label } => Self::Column { + name: String::new(), + label, + reason: "an EQL column is a two-segment label, table and column".to_owned(), + }, + TargetError::Plaintext { + target, + expected, + found, + } => Self::Plaintext { + name: String::new(), + target: target.to_owned(), + expected: Some(expected), + found, + }, + TargetError::Json { target, source } => Self::Json { + name: String::new(), + target: target.to_owned(), + reason: source.code(), + }, + TargetError::Stored { target, source } => Self::Stored { + name: String::new(), + target: target.to_owned(), + reason: stack_encrypt::diagnostic::describe_json_error(&source), + }, + other => Self::Other(Box::new(other)), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use stack_encrypt::dynamic::{record, Op, ValueKind}; + use stack_encrypt::AsProtectedRef as _; + + /// The resolver lists one type for each row of the target table, in + /// the table's order. The table is generated from the catalog, and + /// `tests/catalog_parity.rs` holds it to the catalog. + #[test] + fn the_resolver_lists_the_target_table() { + let listed: Vec = EqlTargets.targets().into_iter().map(|t| t.name).collect(); + let table: Vec<&str> = targets::targets().iter().map(|t| t.name).collect(); + assert!(!table.is_empty(), "the catalog has types"); + assert_eq!(listed, table); + } + + /// Each descriptor has its row's values, and serializes to the row's + /// wire form with the engine's `operators` key added. + #[test] + fn a_descriptor_has_its_rows_values() { + for (row, descriptor) in targets::targets().iter().zip(EqlTargets.targets()) { + assert_eq!(descriptor.plaintext, row.plaintext_kind(), "{}", row.name); + assert_eq!(descriptor.producible, row.producible, "{}", row.name); + assert_eq!( + descriptor.match_options.is_some(), + row.indexes.contains(&"match"), + "{}: match options exactly where there is a match index", + row.name + ); + let mut wire = serde_json::to_value(&descriptor).unwrap(); + let operators = wire.as_object_mut().unwrap().remove("operators"); + assert!(operators.is_some(), "{}", row.name); + assert_eq!(wire, serde_json::to_value(row).unwrap(), "{}", row.name); + } + } + + #[test] + fn the_resolver_resolves_a_producible_type_and_refuses_the_rest() { + let text_eq = EqlTargets.resolve("TextEq").unwrap(); + assert_eq!(text_eq.plaintext, Some(ValueKind::String)); + assert_eq!(text_eq.indexes, ["eq"]); + assert_eq!(text_eq.query.as_deref(), Some("TextEqQuery")); + assert!(matches!( + EqlTargets.resolve("Json"), + Err(dynamic::TargetError::Unproducible { .. }) + )); + assert!(matches!( + EqlTargets.resolve("Nope"), + Err(dynamic::TargetError::Unknown { .. }) + )); + } + + /// The JSON document names its three query domains, so the engine + /// offers the orderings at a path, `element_match` and `match`. + #[test] + fn a_document_names_the_leaf_element_and_predicate_domains() { + let document = EqlTargets.resolve("JsonSearch").unwrap(); + assert_eq!( + document.leaf_query_sql_domain.as_deref(), + Some("eql_v4.query_json_leaf") + ); + assert_eq!( + document.elements_query_sql_domain.as_deref(), + Some("eql_v4.query_json_elements") + ); + assert_eq!( + document.predicate_query_sql_domain.as_deref(), + Some("eql_v4.query_json_predicate") + ); + assert!(document.operators().contains(&Op::Match)); + // The operators a field answers: the orderings and `element_match` + // only where it orders a path, and `match` on every field. + let field_operators = |ordered: &[&str]| { + let wire = serde_json::json!({ + "profile": { + "context": ["users", "profile"], + "type": "json", + "null": "sql", + "target": {"name": "JsonSearch", "options": {"ordered": ordered}} + } + }); + let frame = dynamic::frame::serde::to_vec(&wire).expect("plan frame"); + let plan = record::plan_with(frame.as_protected_ref(), &EqlTargets).expect("plan"); + dynamic::field_operators(&plan, "profile", &EqlTargets) + .expect("operators") + .into_iter() + .map(|(op, _)| op) + .collect::>() + }; + let ordered = field_operators(&["$.contacts[*].relationship"]); + for present in [Op::ElementMatch, Op::Match] { + assert!(ordered.contains(&present), "{present:?}: {ordered:?}"); + } + let unordered = field_operators(&[]); + assert!(unordered.contains(&Op::Match), "{unordered:?}"); + assert!(!unordered.contains(&Op::ElementMatch), "{unordered:?}"); + } + + #[test] + fn a_refusal_converts_with_the_field_name_left_to_the_engine() { + assert!(matches!( + dynamic::TargetError::from(TargetError::Context { + label: "tenant/users/email".into() + }), + dynamic::TargetError::Column { name, label, .. } + if name.is_empty() && label == "tenant/users/email" + )); + assert!(matches!( + dynamic::TargetError::from(TargetError::Plaintext { + target: "TextEq", + expected: ValueKind::String, + found: Some(ValueKind::UInt64), + }), + dynamic::TargetError::Plaintext { + target, + expected: Some(ValueKind::String), + found: Some(ValueKind::UInt64), + .. + } if target == "TextEq" + )); + assert!(matches!( + dynamic::TargetError::from(TargetError::Unknown { name: "Nope".into() }), + dynamic::TargetError::Unknown { name } if name == "Nope" + )); + assert!(matches!( + dynamic::TargetError::from(TargetError::NoQuery { name: "Text" }), + dynamic::TargetError::NoQuery { name } if name == "Text" + )); + // The engine asks a type's query only through an index the type + // carries. One it does not carry is the engine's failure, not the + // caller's input. + assert!(matches!( + dynamic::TargetError::from(TargetError::NoIndex { + name: "TextEq", + index: "ope", + }), + dynamic::TargetError::Other(_) + )); + } + + /// `serde_json` quotes the value it refused, and a stored EQL value + /// holds ciphertext and index terms. Only the error's kind and position + /// cross. + #[test] + fn a_stored_value_that_does_not_parse_quotes_none_of_it() { + let source = serde_json::from_str::(r#""marker-ciphertext""#).unwrap_err(); + let error = dynamic::TargetError::from(TargetError::Stored { + target: "TextEq", + source, + }); + let dynamic::TargetError::Stored { reason, .. } = &error else { + panic!("{error:?}"); + }; + assert_eq!(reason, "unexpected data at line 1 column 19"); + let shown = format!("{error} {:?}", stack_encrypt::ErrorPayload::payload(&error)); + assert!(!shown.contains("marker"), "{shown}"); + } +} diff --git a/packages/eql/tests/encryption/tests/json_tree/elements.rs b/packages/eql/tests/encryption/tests/json_tree/elements.rs index 9ae1e2bd6..b373802e5 100644 --- a/packages/eql/tests/encryption/tests/json_tree/elements.rs +++ b/packages/eql/tests/encryption/tests/json_tree/elements.rs @@ -2,11 +2,9 @@ //! Encrypt's JSON index, the `element_match` operand derived through a data //! plan over eql-bindings' target table, and //! `eql_v4.json_tree_element_match` over the stored column. The resolver -//! here ([`Eql`]) names the document's leaf, element-scoped and compiled -//! predicate domains, so the engine answers `element_match` and `match` -//! through it. The Go guest's resolver names the leaf domain alone (its -//! `targets` tests pin that), so the Go SDK is not offered either operator -//! until the Go side takes them. +//! is eql-bindings' own ([`EqlTargets`]). It names the document's leaf, +//! element-scoped and compiled predicate domains, so the engine answers +//! `element_match` and `match` through it. //! //! These tests pin: //! @@ -26,80 +24,9 @@ use super::*; use stack_encrypt::AsProtectedRef as _; -use eql_bindings::encryption::targets::{self as eql_targets, Target}; -use stack_encrypt::dynamic::{ - self, record, Op, Operand, TargetDescriptor, TargetError, TargetResolver, ValueFrame, -}; -use stack_encrypt::{KeysetCipher, Label, Pending, StackCipher}; - -/// eql-bindings' table as the engine's resolver, naming each type's leaf, -/// element predicate and compiled predicate domains: a host that answers -/// `element_match` and `match`. Not the Go guest's resolver, which names -/// the leaf domain alone. -pub(super) struct Eql; - -fn describe(target: &Target) -> TargetDescriptor { - let mut descriptor = TargetDescriptor::new( - target.name, - target.family, - target.suffix, - target.plaintext_kind(), - target.sql_domain, - target.indexes.iter().map(|key| (*key).to_owned()).collect(), - target.query.map(str::to_owned), - target.query_sql_domain.map(str::to_owned), - target.producible, - target.reason.map(str::to_owned), - ); - if let Some(domain) = target.leaf_query_sql_domain() { - descriptor = descriptor.with_leaf_query_sql_domain(domain); - } - if let Some(domain) = target.elements_query_sql_domain() { - descriptor = descriptor.with_elements_query_sql_domain(domain); - } - if let Some(domain) = target.predicate_query_sql_domain() { - descriptor = descriptor.with_predicate_query_sql_domain(domain); - } - descriptor -} - -impl TargetResolver for Eql { - fn targets(&self) -> Vec { - eql_targets::targets().iter().map(describe).collect() - } - fn encrypt<'a, K: 'static>( - &self, - name: &str, - options: Option<&dynamic::TargetOptions>, - keyset: &'a KeysetCipher<'_, K>, - label: &Label, - plaintext: ValueFrame, - ) -> Result, K>, TargetError> { - eql_targets::encrypt_with(name, options, keyset, label, plaintext) - .map_err(|error| TargetError::Other(Box::new(error))) - } - fn decrypt<'a, K: 'static>( - &self, - name: &str, - cipher: &'a StackCipher, - label: &Label, - stored: &[u8], - ) -> Result, TargetError> { - eql_targets::decrypt(name, cipher, label, stored) - .map_err(|error| TargetError::Other(Box::new(error))) - } - fn query<'a, K: 'static>( - &self, - name: &str, - index: &stack_encrypt::target::IndexSpec, - keyset: &'a KeysetCipher<'_, K>, - label: &Label, - plaintext: ValueFrame, - ) -> Result, K>, TargetError> { - eql_targets::query(name, index, keyset, label, plaintext) - .map_err(|error| TargetError::Other(Box::new(error))) - } -} +use eql_bindings::encryption::EqlTargets; +use stack_encrypt::dynamic::{self, record, Op, Operand, ValueFrame}; +use stack_encrypt::KeysetCipher; const RELATIONSHIP: &str = "$.extra.emergency_contacts[*].relationship"; const CALLS: &str = "$.extra.emergency_contacts[*].previous_call_count"; @@ -127,7 +54,7 @@ fn contact_plan() -> record::Plan { dynamic::frame::serde::to_vec(&wire) .unwrap() .as_protected_ref(), - &Eql, + &EqlTargets, ) .unwrap() } @@ -158,7 +85,7 @@ pub(super) fn derive( "profile", Op::parse(operator).unwrap(), frame_of(&input), - &Eql, + &EqlTargets, ) .unwrap(), ) @@ -205,7 +132,7 @@ fn layout_plan(sealed: bool) -> record::Plan { dynamic::frame::serde::to_vec(&wire) .unwrap() .as_protected_ref(), - &Eql, + &EqlTargets, ) .unwrap() } @@ -510,7 +437,7 @@ fn postgres_an_element_predicate_holds_of_one_element() { {"member": "name", "op": "eq", "json": "\"x\""} ]}), ), - &Eql, + &EqlTargets, ) .unwrap_err(); assert!( diff --git a/packages/eql/tests/encryption/tests/json_tree/predicate.rs b/packages/eql/tests/encryption/tests/json_tree/predicate.rs index 7f2375230..2d7b0e43d 100644 --- a/packages/eql/tests/encryption/tests/json_tree/predicate.rs +++ b/packages/eql/tests/encryption/tests/json_tree/predicate.rs @@ -1,13 +1,14 @@ //! The compiled predicate end to end: documents sealed by Stack Encrypt's //! JSON index, jsonpath predicates compiled by the `match` operator through -//! a data plan over eql-bindings' target table, with a resolver that names -//! the compiled predicate domain ([`Eql`]; the Go guest's does not yet), +//! a data plan over eql-bindings' target table, with eql-bindings' own +//! resolver ([`EqlTargets`]), which names the compiled predicate domain, //! and `eql_v4.json_tree_match` and `@@` over the stored column, //! against PostgreSQL's own `jsonb_path_match` over the plaintexts and //! against the WHERE a hand writes from the operators the leaves stand for. -use super::elements::{derive, eql_text, frame_of, Eql}; +use super::elements::{derive, eql_text, frame_of}; use super::*; +use eql_bindings::encryption::EqlTargets; use stack_encrypt::AsProtectedRef as _; use stack_encrypt::dynamic::{self, record, Op, Operand}; @@ -40,7 +41,7 @@ fn people_plan() -> record::Plan { dynamic::frame::serde::to_vec(&wire) .unwrap() .as_protected_ref(), - &Eql, + &EqlTargets, ) .unwrap() } @@ -264,7 +265,7 @@ fn postgres_a_compiled_predicate_answers_as_its_operators() { "profile", Op::Match, frame_of(&json!({"predicate": text})), - &Eql, + &EqlTargets, ) .unwrap_err(); let payload = stack_encrypt::ErrorPayload::payload(&error); @@ -276,7 +277,7 @@ fn postgres_a_compiled_predicate_answers_as_its_operators() { "profile", Op::Match, frame_of(&json!({"predicate": "$.height > 1"})), - &Eql, + &EqlTargets, ) .unwrap_err(); assert!( diff --git a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs index 71386e1a9..b29ad7d35 100644 --- a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs +++ b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs @@ -21,86 +21,15 @@ mod common; use stack_encrypt::AsProtectedRef as _; use std::collections::BTreeMap; -use eql_bindings::encryption::targets::{self as eql_targets, Target}; +use eql_bindings::encryption::targets::{self as eql_targets}; +use eql_bindings::encryption::EqlTargets; use eql_bindings::v4::json_tree::{JsonTree, JsonTreeDocument}; use eql_bindings::Identifier; use serde_json::{json, Value}; -use stack_encrypt::dynamic::{ - self, record, Scope, TargetDescriptor, TargetError, TargetResolver, ValueFrame, -}; +use stack_encrypt::dynamic::{self, record, Scope, TargetError, ValueFrame}; use stack_encrypt::json::{JsonDocument, JsonOptions, JsonQuery}; use stack_encrypt::target::{ExpectedContext, Layout}; -use stack_encrypt::{CipherText, Encrypt, KeysetCipher, Label, NonEmpty, Pending, StackCipher}; - -/// eql-bindings' table as the engine's resolver, as the Go guest installs -/// it (`languages/golang/encrypt/guest/src/targets.rs`). -struct Eql; - -fn describe(target: &Target) -> TargetDescriptor { - let descriptor = TargetDescriptor::new( - target.name, - target.family, - target.suffix, - target.plaintext_kind(), - target.sql_domain, - target.indexes.iter().map(|key| (*key).to_owned()).collect(), - target.query.map(str::to_owned), - target.query_sql_domain.map(str::to_owned), - target.producible, - target.reason.map(str::to_owned), - ); - match target.leaf_query_sql_domain() { - Some(domain) => descriptor.with_leaf_query_sql_domain(domain), - None => descriptor, - } -} - -/// As the guest converts: a document refusal keeps its code. -fn convert(error: eql_targets::TargetError) -> TargetError { - match error { - eql_targets::TargetError::Json { target, source } => TargetError::Json { - name: String::new(), - target: target.to_owned(), - reason: source.code(), - }, - other => TargetError::Other(Box::new(other)), - } -} - -impl TargetResolver for Eql { - fn targets(&self) -> Vec { - eql_targets::targets().iter().map(describe).collect() - } - fn encrypt<'a, K: 'static>( - &self, - name: &str, - options: Option<&dynamic::TargetOptions>, - keyset: &'a KeysetCipher<'_, K>, - label: &Label, - plaintext: ValueFrame, - ) -> Result, K>, TargetError> { - eql_targets::encrypt_with(name, options, keyset, label, plaintext).map_err(convert) - } - fn decrypt<'a, K: 'static>( - &self, - name: &str, - cipher: &'a StackCipher, - label: &Label, - stored: &[u8], - ) -> Result, TargetError> { - eql_targets::decrypt(name, cipher, label, stored).map_err(convert) - } - fn query<'a, K: 'static>( - &self, - name: &str, - index: &stack_encrypt::target::IndexSpec, - keyset: &'a KeysetCipher<'_, K>, - label: &Label, - plaintext: ValueFrame, - ) -> Result, K>, TargetError> { - eql_targets::query(name, index, keyset, label, plaintext).map_err(convert) - } -} +use stack_encrypt::{CipherText, Encrypt, KeysetCipher, Label, NonEmpty}; /// The options the documents are sealed under on both paths. fn options() -> JsonOptions { @@ -127,7 +56,7 @@ fn plan() -> record::Plan { dynamic::frame::serde::to_vec(&wire) .unwrap() .as_protected_ref(), - &Eql, + &EqlTargets, ) .unwrap() } @@ -237,7 +166,7 @@ async fn a_batch_of_documents_round_trips_in_one_request() { .iter() .map(|text| BTreeMap::from([("profile", text.as_str())])) .collect(); - let sealed = record::encrypt_with(&keyset, frame(&source), &plan, &Eql) + let sealed = record::encrypt_with(&keyset, frame(&source), &plan, &EqlTargets) .unwrap() .await .unwrap(); @@ -246,7 +175,7 @@ async fn a_batch_of_documents_round_trips_in_one_request() { 1, "every document's key in one request" ); - let opened = record::decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &Eql) + let opened = record::decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &EqlTargets) .unwrap() .await .unwrap(); @@ -274,7 +203,7 @@ async fn the_data_plan_and_the_typed_layout_agree() { for document in documents() { let text = document.to_string(); let source = BTreeMap::from([("profile", text.as_str())]); - let sealed = record::encrypt_with(&keyset, frame(&source), &plan, &Eql) + let sealed = record::encrypt_with(&keyset, frame(&source), &plan, &EqlTargets) .unwrap() .await .unwrap(); @@ -320,7 +249,7 @@ async fn the_data_plan_and_the_typed_layout_agree() { )), )]), )]); - let opened = record::decrypt_with(Scope::Client(&cipher), record, &plan, None, &Eql) + let opened = record::decrypt_with(Scope::Client(&cipher), record, &plan, None, &EqlTargets) .unwrap() .await .unwrap(); @@ -347,7 +276,7 @@ async fn a_sql_null_stores_no_document() { BTreeMap::new(), BTreeMap::from([("profile", Some("null"))]), ]; - let sealed = record::encrypt_with(&keyset, frame(&rows), &plan, &Eql) + let sealed = record::encrypt_with(&keyset, frame(&rows), &plan, &EqlTargets) .unwrap() .await .unwrap(); @@ -364,7 +293,7 @@ async fn a_sql_null_stores_no_document() { }) .collect(); assert_eq!(stores_profile, [true, false, false, true]); - let opened = record::decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &Eql) + let opened = record::decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &EqlTargets) .unwrap() .await .unwrap(); @@ -401,7 +330,7 @@ async fn a_document_does_not_open_under_another_column() { &keyset, frame(BTreeMap::from([("profile", text.as_str())])), &plan(), - &Eql, + &EqlTargets, ) .unwrap() .await @@ -420,11 +349,11 @@ async fn a_document_does_not_open_under_another_column() { })) .unwrap()) .as_protected_ref(), - &Eql, + &EqlTargets, ) .unwrap(); calls.lock().unwrap().retrieve.clear(); - let refused = record::decrypt_with(Scope::Client(&cipher), sealed, &other, None, &Eql) + let refused = record::decrypt_with(Scope::Client(&cipher), sealed, &other, None, &EqlTargets) .unwrap() .await; assert!( @@ -449,7 +378,7 @@ async fn text_that_is_not_a_document_is_refused_before_any_key() { let text = case["text"].as_str().unwrap(); let code = case["reason"].as_str().unwrap(); let source = BTreeMap::from([("profile", text)]); - let error = match record::encrypt_with(&keyset, frame(&source), &plan, &Eql) { + let error = match record::encrypt_with(&keyset, frame(&source), &plan, &EqlTargets) { Err(error) => error, Ok(_) => panic!("{text:?} was accepted"), }; @@ -465,7 +394,7 @@ async fn text_that_is_not_a_document_is_refused_before_any_key() { } // A value of another kind is not a document either. let source = BTreeMap::from([("profile", 7u32)]); - assert!(record::encrypt_with(&keyset, frame(&source), &plan, &Eql).is_err()); + assert!(record::encrypt_with(&keyset, frame(&source), &plan, &EqlTargets).is_err()); assert!( calls.lock().unwrap().generate.is_empty(), "no key was requested" @@ -517,7 +446,7 @@ async fn data_term( "profile", dynamic::Op::parse(operator).unwrap(), input(entries), - &Eql, + &EqlTargets, ) .unwrap() .await @@ -539,11 +468,12 @@ fn containment(operand: Value) -> JsonQuery { } /// The operators a document field answers, with the input each takes: -/// the orderings at a path only when the field orders a path. +/// the orderings at a path and `element_match` only when the field orders +/// a path, and `match` on every field. #[test] fn a_document_field_answers_the_json_operators() { let names = |plan: &record::Plan| -> Vec<(&'static str, &'static str)> { - dynamic::field_operators(plan, "profile", &Eql) + dynamic::field_operators(plan, "profile", &EqlTargets) .unwrap() .into_iter() .map(|(op, index)| { @@ -567,6 +497,8 @@ fn a_document_field_answers_the_json_operators() { ("subtree", "path_bound"), ("children", "path"), ("child_count", "path"), + ("element_match", "path_predicates"), + ("match", "predicate"), ] ); let unordered = record::plan_with( @@ -575,7 +507,7 @@ fn a_document_field_answers_the_json_operators() { })) .unwrap()) .as_protected_ref(), - &Eql, + &EqlTargets, ) .unwrap(); assert_eq!( @@ -590,9 +522,10 @@ fn a_document_field_answers_the_json_operators() { "selector", "subtree", "children", - "child_count" + "child_count", + "match" ], - "no ordering without an ordered path" + "no ordering and no `element_match` without an ordered path" ); } @@ -787,7 +720,7 @@ fn a_json_operator_refuses_what_it_cannot_answer() { "profile", dynamic::Op::parse(operator).unwrap(), input(entries), - &Eql, + &EqlTargets, ) }; let reason = |operator: &str, entries: &[(&str, Value)]| { @@ -849,8 +782,8 @@ fn a_json_operator_refuses_what_it_cannot_answer() { assert_eq!(reason(operator, &entries), "engine", "{operator}"); } // `eq` and friends are not a document's operators. - let error = - dynamic::check_query(&plan, "profile", dynamic::Op::Eq, input(&[]), &Eql).unwrap_err(); + let error = dynamic::check_query(&plan, "profile", dynamic::Op::Eq, input(&[]), &EqlTargets) + .unwrap_err(); assert_eq!(error.reason(), Some(Reason::OperatorNotAnswered)); } @@ -925,7 +858,7 @@ async fn open( ) .unwrap(); let rows = frame(&groups); - let opened = dynamic::json_open(keyset, plan, "profile", request, rows, &Eql) + let opened = dynamic::json_open(keyset, plan, "profile", request, rows, &EqlTargets) .unwrap() .await?; let groups: Vec> = @@ -959,7 +892,7 @@ async fn reads_open_against_the_request_that_prepared_them() { &keyset, frame(BTreeMap::from([("profile", text.as_str())])), &plan, - &Eql, + &EqlTargets, ) .unwrap() .await diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs index d94259937..434e01cbf 100644 --- a/packages/eql/tests/encryption/tests/targets.rs +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -476,6 +476,38 @@ mod the_table { } } + /// The shared resolver lists the catalog's types and no other: each + /// scalar domain as its struct name, then each document family's + /// stored type, in catalog order. A program that installs it holds + /// every EQL type the catalog has. + #[test] + fn the_resolver_lists_the_catalogs_types_in_order() { + use stack_encrypt::dynamic::TargetResolver as _; + let catalog: Vec = stored_domains() + .iter() + .map(|(family, domain)| domain.rust_struct_name(family.name)) + .chain(eql_domains::DOCUMENT_FAMILIES.iter().map(|family| { + family + .domain(eql_domains::DocumentRole::Stored) + .unwrap() + .name + .to_owned() + })) + .collect(); + let resolver = eql_bindings::encryption::EqlTargets; + let listed: Vec = resolver.targets().into_iter().map(|t| t.name).collect(); + assert_eq!(listed, catalog); + // A plan may name exactly the types the table marks producible. + for row in targets::targets() { + assert_eq!( + resolver.resolve(row.name).is_ok(), + row.producible, + "{}", + row.name + ); + } + } + #[test] fn names_every_stored_catalog_domain_in_order() { let all = targets::targets(); diff --git a/packages/eql/tests/encryption/tests/typed_queries.rs b/packages/eql/tests/encryption/tests/typed_queries.rs index c76bbbad2..38f08709d 100644 --- a/packages/eql/tests/encryption/tests/typed_queries.rs +++ b/packages/eql/tests/encryption/tests/typed_queries.rs @@ -11,7 +11,8 @@ mod common; use eql_bindings::encryption::query::{MatchTwin, QueryLabelError}; -use eql_bindings::encryption::targets::{self as eql_targets, Target}; +use eql_bindings::encryption::targets::{self as eql_targets}; +use eql_bindings::encryption::EqlTargets; use eql_bindings::v4::answers::ANSWERS; use eql_bindings::v4::plaintext::{Date, Decimal, Timestamp}; use eql_bindings::v4::text::{TextEq, TextMatch, TextMatchQuery}; @@ -20,13 +21,9 @@ use eql_bindings::v4::{ }; use eql_bindings::Identifier; use serde::Serialize; -use stack_encrypt::dynamic::{ - self, record, FieldPlan, Op, Operand, TargetDescriptor, TargetError, TargetResolver, ValueFrame, -}; +use stack_encrypt::dynamic::{self, record, FieldPlan, Op, Operand, TargetDescriptor, ValueFrame}; use stack_encrypt::op::{self, Probabilistic, Stats}; -use stack_encrypt::{ - ContextPiece, Encrypt, Error, KeysetCipher, Label, NonEmpty, Pending, Plan, StackCipher, -}; +use stack_encrypt::{ContextPiece, Encrypt, Error, NonEmpty, Plan, StackCipher}; type Cipher = StackCipher; @@ -35,70 +32,6 @@ fn frame(value: T) -> ValueFrame { ValueFrame::of(value).unwrap() } -/// eql-bindings' table as the engine's resolver, as the Go guest installs -/// it (`languages/golang/encrypt/guest/src/targets.rs`). -struct Eql; - -/// A table row as the engine's descriptor, with the match options of a type -/// that carries a match index, as the guest describes it. -fn describe(target: &Target) -> TargetDescriptor { - let descriptor = TargetDescriptor::new( - target.name, - target.family, - target.suffix, - target.plaintext_kind(), - target.sql_domain, - target.indexes.iter().map(|key| (*key).to_owned()).collect(), - target.query.map(str::to_owned), - target.query_sql_domain.map(str::to_owned), - target.producible, - target.reason.map(str::to_owned), - ); - match target.match_options() { - Some(options) => descriptor.with_match_options(options), - None => descriptor, - } -} - -fn convert(error: eql_targets::TargetError) -> TargetError { - TargetError::Other(Box::new(error)) -} - -impl TargetResolver for Eql { - fn targets(&self) -> Vec { - eql_targets::targets().iter().map(describe).collect() - } - fn encrypt<'a, K: 'static>( - &self, - name: &str, - options: Option<&dynamic::TargetOptions>, - keyset: &'a KeysetCipher<'_, K>, - label: &Label, - plaintext: ValueFrame, - ) -> Result, K>, TargetError> { - eql_targets::encrypt_with(name, options, keyset, label, plaintext).map_err(convert) - } - fn decrypt<'a, K: 'static>( - &self, - name: &str, - cipher: &'a StackCipher, - label: &Label, - stored: &[u8], - ) -> Result, TargetError> { - eql_targets::decrypt(name, cipher, label, stored).map_err(convert) - } - fn query<'a, K: 'static>( - &self, - name: &str, - index: &stack_encrypt::target::IndexSpec, - keyset: &'a KeysetCipher<'_, K>, - label: &Label, - plaintext: ValueFrame, - ) -> Result, K>, TargetError> { - eql_targets::query(name, index, keyset, label, plaintext).map_err(convert) - } -} - /// A data plan with one field, `value`, under `users/`, naming the /// EQL type `name`. fn data_plan(name: &str, column: &str) -> record::Plan { @@ -108,7 +41,7 @@ fn data_plan(name: &str, column: &str) -> record::Plan { ])) .unwrap(); let field = FieldPlan::with_target("value", context, name).unwrap(); - record::Plan::new_with(vec![field], &Eql).unwrap() + record::Plan::new_with(vec![field], &EqlTargets).unwrap() } /// The data path's term for the query, and the domain it is cast to. @@ -126,7 +59,7 @@ async fn data_term( "value", operator, input, - &Eql, + &EqlTargets, ) .unwrap() .await @@ -296,7 +229,7 @@ fn the_typed_chain_answers_what_the_data_path_answers() { .iter() .filter(|t| t.plaintext != Some("json")) { - let data: Vec<&str> = describe(target) + let data: Vec<&str> = TargetDescriptor::from(target) .operators() .into_iter() .map(Op::name) @@ -420,7 +353,15 @@ async fn a_typed_match_query_verifies_and_sizes_as_the_data_path_does() { let input = frame(needle); let page = ["Alice Smith", "alicia", "Bob", "malice", "ALICE", "al", ""]; - let data = dynamic::verify(&plan, "value", Op::Matches, input.clone(), page, &Eql).unwrap(); + let data = dynamic::verify( + &plan, + "value", + Op::Matches, + input.clone(), + page, + &EqlTargets, + ) + .unwrap(); let typed: Vec = page.iter().map(|v| query.verify(v)).collect(); assert_eq!(typed, data, "{needle}"); assert!(query.verify("Alice Smith"), "{needle}"); @@ -438,9 +379,16 @@ async fn a_typed_match_query_verifies_and_sizes_as_the_data_path_does() { Stats::declared(1_000_000), ] { for want in [0, 1, 10, 1_000] { - let data = - dynamic::target_limit(&plan, "value", Op::Matches, &term, want, &stats, &Eql) - .unwrap(); + let data = dynamic::target_limit( + &plan, + "value", + Op::Matches, + &term, + want, + &stats, + &EqlTargets, + ) + .unwrap(); assert_eq!(query.target_limit(want, &stats), data, "{needle} {want}"); } } @@ -576,13 +524,13 @@ async fn a_record_stores_a_null_in_an_eql_field_as_its_plan_says() { .unwrap() .with_null(NullMode::Sql), ], - &Eql, + &EqlTargets, ) .unwrap(); let day = chrono::NaiveDate::from_ymd_opt(1990, 5, 17); let rows = |dob, born| BTreeMap::from([("a_dob", dob), ("b_born", born)]); let source = vec![rows(day, day), rows(None, None)]; - let sealed = record::encrypt_with(&keyset, frame(&source), &plan, &Eql) + let sealed = record::encrypt_with(&keyset, frame(&source), &plan, &EqlTargets) .unwrap() .await .unwrap(); @@ -615,7 +563,7 @@ async fn a_record_stores_a_null_in_an_eql_field_as_its_plan_says() { "a sql null stores nothing" ); - let opened = record::decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &Eql) + let opened = record::decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &EqlTargets) .unwrap() .await .unwrap(); From a7218607046958fd6fa74bbf014c203ce21110ca Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 10 Nov 2026 17:17:59 +1000 Subject: [PATCH 2/3] fix(eql-bindings): address the review of the shared resolver - Name the three refusals that convert to `Other` (`DocumentQuery`, `NoIndex`, `Options`) instead of a wildcard, so a variant added later must be placed by hand. A test shows the engine refuses options on a type with no JSON index at plan time, before the resolver is called. - Move the explanation and the example onto `EqlTargets`. The `resolver` module is private, so rustdoc did not show its module doc. - Test the `Unproducible` and `Json` conversions and the `Other` arms. - Drop the braces around a single `self as eql_targets` import. - The API plan no longer says each program writes its own resolver. --- .../2026-10-24-stack-encrypt-api-plan.md | 5 +- .../eql-bindings/src/encryption/resolver.rs | 118 +++++++++++++----- .../encryption/tests/json_tree_data_plan.rs | 2 +- .../tests/encryption/tests/typed_queries.rs | 2 +- 4 files changed, 93 insertions(+), 34 deletions(-) diff --git a/docs/plans/2026-10-24-stack-encrypt-api-plan.md b/docs/plans/2026-10-24-stack-encrypt-api-plan.md index d9d4dddfe..9918efa2a 100644 --- a/docs/plans/2026-10-24-stack-encrypt-api-plan.md +++ b/docs/plans/2026-10-24-stack-encrypt-api-plan.md @@ -760,9 +760,8 @@ take a declaration as a frame: `plan_with`, `encrypt_with`, `query`, and `decrypt_with`. `Planned` calls them. Each takes a `TargetResolver`. The resolver runs the EQL type that a field names. -On `next`, each program writes its own resolver over `eql-bindings`. In -the target state, `eql-bindings` provides it. See [A ready-made EQL -resolver](#a-ready-made-eql-resolver). +`eql-bindings` provides the resolver: `eql_bindings::encryption::EqlTargets`. +See [A ready-made EQL resolver](#a-ready-made-eql-resolver). #### A query plan diff --git a/packages/eql/crates/eql-bindings/src/encryption/resolver.rs b/packages/eql/crates/eql-bindings/src/encryption/resolver.rs index eac7a26fc..80c8d4eec 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/resolver.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/resolver.rs @@ -1,24 +1,6 @@ -//! The EQL types as Stack Encrypt's resolver. -//! -//! Stack Encrypt's dynamic interface runs a plan that arrives as data. A -//! field of that plan may name an EQL type, such as `"TextEq"`. Stack -//! Encrypt does not depend on this crate, so a program gives it the EQL -//! types through a [`TargetResolver`]. [`EqlTargets`] is that resolver. It -//! holds every type in [`targets`](super::targets::targets), and runs a -//! named type through [`encrypt_with`], [`decrypt`] and [`query`]. -//! -//! ``` -//! use eql_bindings::encryption::EqlTargets; -//! use stack_encrypt::dynamic::TargetResolver; -//! -//! let text_eq = EqlTargets.resolve("TextEq")?; -//! assert_eq!(text_eq.sql_domain, "public.eql_v4_text_eq"); -//! assert_eq!(text_eq.indexes, ["eq"]); -//! # Ok::<(), stack_encrypt::dynamic::TargetError>(()) -//! ``` -//! -//! Pass `&EqlTargets` wherever the dynamic interface takes a resolver, such -//! as `stack_encrypt::dynamic::record::plan_with`. +//! The EQL types as Stack Encrypt's resolver: [`EqlTargets`], and the two +//! conversions it is built from. The module is private, so the type's own +//! documentation is what a reader sees. use stack_encrypt::dynamic::{self, TargetDescriptor, TargetOptions, TargetResolver, ValueFrame}; use stack_encrypt::target::IndexSpec; @@ -28,6 +10,26 @@ use super::targets::{self, decrypt, encrypt_with, query, Target, TargetError}; /// Every EQL type this crate defines, as Stack Encrypt's resolver. /// +/// Stack Encrypt's dynamic interface runs a plan that arrives as data. A +/// field of that plan may name an EQL type, such as `"TextEq"`. Stack +/// Encrypt does not depend on this crate, so a program gives it the EQL +/// types through a [`TargetResolver`]. `EqlTargets` is that resolver. It +/// holds every type in [`targets`](super::targets::targets), and runs a +/// named type through [`encrypt_with`], [`decrypt`] and [`query`]. +/// +/// Pass `&EqlTargets` wherever the dynamic interface takes a resolver, such +/// as `stack_encrypt::dynamic::record::plan_with`. +/// +/// ``` +/// use eql_bindings::encryption::EqlTargets; +/// use stack_encrypt::dynamic::TargetResolver; +/// +/// let text_eq = EqlTargets.resolve("TextEq")?; +/// assert_eq!(text_eq.sql_domain, "public.eql_v4_text_eq"); +/// assert_eq!(text_eq.indexes, ["eq"]); +/// # Ok::<(), stack_encrypt::dynamic::TargetError>(()) +/// ``` +/// /// The resolver sees a type's name and a field's label. It never sees the /// field's name, so a refusal leaves the field name empty and the engine /// fills it in. @@ -160,7 +162,15 @@ impl From for dynamic::TargetError { target: target.to_owned(), reason: stack_encrypt::diagnostic::describe_json_error(&source), }, - other => Self::Other(Box::new(other)), + // The engine refuses each of these before it calls the + // resolver: it derives a document's queries itself, asks a query + // only through an index the type carries, and refuses options on + // a type with no JSON index when it builds the plan. Reaching + // one is the engine's failure, not the caller's input. Each is + // named, so a variant added later must be placed by hand. + error @ (TargetError::DocumentQuery { .. } + | TargetError::NoIndex { .. } + | TargetError::Options { .. }) => Self::Other(Box::new(error)), } } } @@ -294,16 +304,66 @@ mod tests { dynamic::TargetError::from(TargetError::NoQuery { name: "Text" }), dynamic::TargetError::NoQuery { name } if name == "Text" )); - // The engine asks a type's query only through an index the type - // carries. One it does not carry is the engine's failure, not the - // caller's input. assert!(matches!( - dynamic::TargetError::from(TargetError::NoIndex { - name: "TextEq", - index: "ope", + dynamic::TargetError::from(TargetError::Unproducible { + name: "Json", + reason: "not yet", }), - dynamic::TargetError::Other(_) + dynamic::TargetError::Unproducible { name, reason } + if name == "Json" && reason == "not yet" + )); + // A value that is not a document keeps the engine's code, and no + // byte of the value. + let Err(source) = dynamic::read_json(&ValueFrame::of(r#"{"secret": "#).unwrap()) else { + panic!("the text is not a JSON document"); + }; + let code = source.code(); + let error = dynamic::TargetError::from(TargetError::Json { + target: "JsonSearch", + source, + }); + assert!(matches!( + &error, + dynamic::TargetError::Json { name, target, reason } + if name.is_empty() && target == "JsonSearch" && *reason == code )); + let shown = format!("{error} {:?}", stack_encrypt::ErrorPayload::payload(&error)); + assert!(!shown.contains("secret"), "{shown}"); + // The engine refuses these three before it calls the resolver, so + // reaching one is the engine's failure, not the caller's input. + for refusal in [ + TargetError::NoIndex { + name: "TextEq", + index: "ope", + }, + TargetError::DocumentQuery { name: "JsonSearch" }, + TargetError::Options { name: "TextEq" }, + ] { + assert!(matches!( + dynamic::TargetError::from(refusal), + dynamic::TargetError::Other(_) + )); + } + } + + /// Why `Options` converts to `Other`: the engine refuses options on a + /// type with no JSON index when it builds the plan, so the resolver is + /// never asked to encrypt under them. + #[test] + fn the_engine_refuses_options_on_a_type_without_a_json_index() { + let wire = serde_json::json!({ + "email": { + "context": ["users", "email"], + "target": {"name": "TextEq", "options": {"ordered": []}} + } + }); + let frame = dynamic::frame::serde::to_vec(&wire).expect("plan frame"); + let error = record::plan_with(frame.as_protected_ref(), &EqlTargets).unwrap_err(); + assert_eq!( + error.reason(), + Some(dynamic::Reason::TargetOptions), + "{error:?}" + ); } /// `serde_json` quotes the value it refused, and a stored EQL value diff --git a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs index b29ad7d35..821c75279 100644 --- a/packages/eql/tests/encryption/tests/json_tree_data_plan.rs +++ b/packages/eql/tests/encryption/tests/json_tree_data_plan.rs @@ -21,7 +21,7 @@ mod common; use stack_encrypt::AsProtectedRef as _; use std::collections::BTreeMap; -use eql_bindings::encryption::targets::{self as eql_targets}; +use eql_bindings::encryption::targets as eql_targets; use eql_bindings::encryption::EqlTargets; use eql_bindings::v4::json_tree::{JsonTree, JsonTreeDocument}; use eql_bindings::Identifier; diff --git a/packages/eql/tests/encryption/tests/typed_queries.rs b/packages/eql/tests/encryption/tests/typed_queries.rs index 38f08709d..d8f5eee2b 100644 --- a/packages/eql/tests/encryption/tests/typed_queries.rs +++ b/packages/eql/tests/encryption/tests/typed_queries.rs @@ -11,7 +11,7 @@ mod common; use eql_bindings::encryption::query::{MatchTwin, QueryLabelError}; -use eql_bindings::encryption::targets::{self as eql_targets}; +use eql_bindings::encryption::targets as eql_targets; use eql_bindings::encryption::EqlTargets; use eql_bindings::v4::answers::ANSWERS; use eql_bindings::v4::plaintext::{Date, Decimal, Timestamp}; From edc4107e9549d349428cd148d4ae6457af0dc7e4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 11 Nov 2026 18:16:46 +1100 Subject: [PATCH 3/3] docs(eql-bindings): rewrite the shared resolver docs in plain language The docs for EqlTargets and its two conversions, and the module docs of the Go guest's resolver, now use short sentences that name who does what. They say Stack Encrypt where they said the engine. --- languages/golang/encrypt/guest/src/targets.rs | 23 +++-- .../eql-bindings/src/encryption/resolver.rs | 91 +++++++++++-------- 2 files changed, 67 insertions(+), 47 deletions(-) diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs index e4a587c09..50590564f 100644 --- a/languages/golang/encrypt/guest/src/targets.rs +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -3,16 +3,19 @@ //! target fields through. The two names below are cfg-dependent, so they //! are code spans, not links: a doc build of either feature set resolves. //! -//! Two builds of this crate, one resolver each (ADR-0007, amended -//! 2026-10-06). With the `eql` feature, [`Resolver`] is `eql-bindings`' -//! own resolver (`eql_bindings::encryption::EqlTargets`): `se_targets` -//! lists every EQL type the catalog has, and a plan field naming a -//! producible one (`TextEq`) is run through that type's own plan, in the -//! same ZeroKMS request as the rest of the record. Without it, -//! [`Resolver`] is the engine's `NoTargets` -//! (`stack_encrypt::dynamic::NoTargets`): `se_targets` lists nothing, and -//! a plan that names a target is refused when it is parsed — at -//! `se_plan_check`, before any value crosses. +//! This crate has two builds. Each build has one resolver (ADR-0007, +//! amended 2026-10-06). +//! +//! - With the `eql` feature, [`Resolver`] is the resolver from +//! `eql-bindings`, `eql_bindings::encryption::EqlTargets`. `se_targets` +//! lists every EQL type in the catalog. A plan field can name any type +//! that the build can produce, such as `TextEq`. The build encrypts that +//! field with the type's own plan, in the same ZeroKMS request as the +//! rest of the record. +//! - Without the feature, [`Resolver`] is `NoTargets` from Stack Encrypt, +//! `stack_encrypt::dynamic::NoTargets`. `se_targets` lists nothing. +//! `se_plan_check` refuses a plan that names a type when it parses the +//! plan, before the guest receives any value. //! //! The Go module embeds both: `encrypt` the build without EQL types, //! `encrypt/eql` the build with them, registered on import, so a program diff --git a/packages/eql/crates/eql-bindings/src/encryption/resolver.rs b/packages/eql/crates/eql-bindings/src/encryption/resolver.rs index 80c8d4eec..f1da91ead 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/resolver.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/resolver.rs @@ -1,6 +1,8 @@ -//! The EQL types as Stack Encrypt's resolver: [`EqlTargets`], and the two -//! conversions it is built from. The module is private, so the type's own -//! documentation is what a reader sees. +//! [`EqlTargets`] gives the EQL types to Stack Encrypt. This module also +//! has the two conversions that `EqlTargets` uses. +//! +//! This module is private. Readers of the published docs only read the +//! documentation on `EqlTargets`. use stack_encrypt::dynamic::{self, TargetDescriptor, TargetOptions, TargetResolver, ValueFrame}; use stack_encrypt::target::IndexSpec; @@ -8,17 +10,20 @@ use stack_encrypt::{KeysetCipher, Label, Pending, StackCipher}; use super::targets::{self, decrypt, encrypt_with, query, Target, TargetError}; -/// Every EQL type this crate defines, as Stack Encrypt's resolver. +/// Gives Stack Encrypt every EQL type that this crate defines. /// -/// Stack Encrypt's dynamic interface runs a plan that arrives as data. A -/// field of that plan may name an EQL type, such as `"TextEq"`. Stack -/// Encrypt does not depend on this crate, so a program gives it the EQL -/// types through a [`TargetResolver`]. `EqlTargets` is that resolver. It -/// holds every type in [`targets`](super::targets::targets), and runs a -/// named type through [`encrypt_with`], [`decrypt`] and [`query`]. +/// The dynamic interface of Stack Encrypt runs a plan that arrives as +/// data. A field of that plan can name an EQL type, such as `"TextEq"`. +/// Stack Encrypt does not depend on this crate, so your program passes the +/// EQL types to Stack Encrypt through a [`TargetResolver`]. `EqlTargets` +/// is that resolver. /// -/// Pass `&EqlTargets` wherever the dynamic interface takes a resolver, such -/// as `stack_encrypt::dynamic::record::plan_with`. +/// `EqlTargets` lists every type in [`targets`](super::targets::targets). +/// For a type that a plan names, `EqlTargets` calls [`encrypt_with`], +/// [`decrypt`], or [`query`]. +/// +/// Pass `&EqlTargets` wherever the dynamic interface accepts a resolver, +/// such as `stack_encrypt::dynamic::record::plan_with`. /// /// ``` /// use eql_bindings::encryption::EqlTargets; @@ -30,9 +35,9 @@ use super::targets::{self, decrypt, encrypt_with, query, Target, TargetError}; /// # Ok::<(), stack_encrypt::dynamic::TargetError>(()) /// ``` /// -/// The resolver sees a type's name and a field's label. It never sees the -/// field's name, so a refusal leaves the field name empty and the engine -/// fills it in. +/// Stack Encrypt passes the resolver a type's name and a field's label, +/// and never the field's name. An error from the resolver therefore has +/// an empty field name, and Stack Encrypt adds the name. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct EqlTargets; @@ -77,17 +82,17 @@ impl TargetResolver for EqlTargets { } } -/// A row of the target table as the engine's descriptor. The two have one -/// wire format. +/// Converts a row of the target table to the descriptor that Stack +/// Encrypt uses. The row and the descriptor have the same wire format. /// -/// The descriptor also carries what the engine needs and the wire does -/// not: +/// The descriptor also contains two things that Stack Encrypt needs and +/// the wire format leaves out: /// -/// - the match options, for a type with a match index. The engine answers -/// `matches` through the type only when it knows them; -/// - the three query domains of a type that stores a JSON document. An -/// ordering at a path binds as the leaf operand, `element_match` as the -/// elements operand, and `match` as the predicate operand. +/// - The match options, for a type with a match index. Stack Encrypt runs +/// `matches` through the type only when the descriptor has them. +/// - The three query domains, for a type that stores a JSON document. An +/// ordering at a path uses the leaf domain. `element_match` uses the +/// elements domain. `match` uses the predicate domain. impl From<&Target> for TargetDescriptor { fn from(target: &Target) -> Self { let mut descriptor = TargetDescriptor::new( @@ -118,14 +123,19 @@ impl From<&Target> for TargetDescriptor { } } -/// A refusal of this crate as the engine's. The field name is left empty: -/// the engine fills it in, since the resolver never sees it. +/// Converts an error of this crate to the error type of Stack Encrypt. +/// +/// The field name is left empty. Stack Encrypt adds the name, because the +/// resolver never receives it. +/// +/// No variant contains any byte of a plaintext or of a stored value: /// -/// No variant carries a byte of a plaintext or of a stored value. A value -/// that is not a JSON document keeps the engine's code for what was wrong. -/// A stored value that does not parse keeps the parser's kind and position -/// and not its message, because `serde_json` quotes the input it refused, -/// and that input is stored ciphertext. +/// - For a value that is not a JSON document, the error keeps the code +/// that Stack Encrypt gave for the fault. +/// - For a stored value that does not parse, the error keeps the kind and +/// position that the parser reported, and drops the parser's message. +/// `serde_json` quotes the input it refused in that message, and the +/// input is stored ciphertext. impl From for dynamic::TargetError { fn from(error: TargetError) -> Self { match error { @@ -162,12 +172,19 @@ impl From for dynamic::TargetError { target: target.to_owned(), reason: stack_encrypt::diagnostic::describe_json_error(&source), }, - // The engine refuses each of these before it calls the - // resolver: it derives a document's queries itself, asks a query - // only through an index the type carries, and refuses options on - // a type with no JSON index when it builds the plan. Reaching - // one is the engine's failure, not the caller's input. Each is - // named, so a variant added later must be placed by hand. + // Stack Encrypt refuses each of these before it calls the + // resolver: + // + // - Stack Encrypt derives the queries of a document itself. + // - Stack Encrypt only asks for a query through an index that + // the type has. + // - Stack Encrypt refuses options on a type with no JSON index + // when it builds the plan. + // + // If the resolver returns one of these, the fault is in Stack + // Encrypt, not in the caller's input. The match names each + // variant, so that a variant added later does not compile + // until someone decides where it goes. error @ (TargetError::DocumentQuery { .. } | TargetError::NoIndex { .. } | TargetError::Options { .. }) => Self::Other(Box::new(error)),