Skip to content

Commit f757b24

Browse files
committed
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
1 parent 6b6119d commit f757b24

11 files changed

Lines changed: 463 additions & 537 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/eql": minor
3+
---
4+
5+
`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<targets::TargetError>` for Stack Encrypt's `TargetError`. Stored bytes and query operands do not change. The SQL bundle and the TypeScript types do not change.

‎docs/plans/2026-10-24-stack-encrypt-api-plan.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1521,7 +1521,8 @@ Base64.
15211521
- **Done already.** The functions it calls exist in `eql-bindings`:
15221522
`encrypt_with`, `decrypt`, and `query` in `v4::targets` and
15231523
`encryption::targets`.
1524-
- **Tracked in.** No issue.
1524+
- **Tracked in.** #1571. Done: the resolver is
1525+
`eql_bindings::encryption::EqlTargets`.
15251526
- **Finished when** the tests and the Go guest use the resolver from
15261527
`eql-bindings`, and their own copies are deleted.
15271528

‎languages/golang/encrypt/guest/src/targets.rs‎

Lines changed: 12 additions & 265 deletions
Original file line numberDiff line numberDiff line change
@@ -4,32 +4,26 @@
44
//! are code spans, not links: a doc build of either feature set resolves.
55
//!
66
//! Two builds of this crate, one resolver each (ADR-0007, amended
7-
//! 2026-10-06). With the `eql` feature, [`Resolver`] is `EqlTargets`,
8-
//! which installs `eql-bindings`' by-name dispatch
9-
//! (`eql_bindings::encryption::targets`): `se_targets` lists every EQL type
10-
//! the catalog has, and a plan field naming a producible one (`TextEq`) is
11-
//! run through that type's own `EncryptFrom` / `DecryptInto`, in the same
12-
//! ZeroKMS request as the rest of the record. Without it, [`Resolver`] is
13-
//! the engine's `NoTargets` (`stack_encrypt::dynamic::NoTargets`):
14-
//! `se_targets` lists nothing, and a plan that
15-
//! names a target is refused when it is parsed — at `se_plan_check`, before
16-
//! any value crosses.
7+
//! 2026-10-06). With the `eql` feature, [`Resolver`] is `eql-bindings`'
8+
//! own resolver (`eql_bindings::encryption::EqlTargets`): `se_targets`
9+
//! lists every EQL type the catalog has, and a plan field naming a
10+
//! producible one (`TextEq`) is run through that type's own plan, in the
11+
//! same ZeroKMS request as the rest of the record. Without it,
12+
//! [`Resolver`] is the engine's `NoTargets`
13+
//! (`stack_encrypt::dynamic::NoTargets`): `se_targets` lists nothing, and
14+
//! a plan that names a target is refused when it is parsed — at
15+
//! `se_plan_check`, before any value crosses.
1716
//!
1817
//! The Go module embeds both: `encrypt` the build without EQL types,
1918
//! `encrypt/eql` the build with them, registered on import, so a program
2019
//! that names an EQL type links the build that has it.
2120
22-
#[cfg(not(feature = "eql"))]
23-
use stack_encrypt::dynamic::NoTargets;
24-
#[cfg(feature = "eql")]
25-
use stack_encrypt::dynamic::{TargetDescriptor, TargetError, TargetResolver};
26-
2721
/// The resolver this build installs.
2822
#[cfg(feature = "eql")]
29-
pub type Resolver = EqlTargets;
23+
pub type Resolver = eql_bindings::encryption::EqlTargets;
3024
/// The resolver this build installs.
3125
#[cfg(not(feature = "eql"))]
32-
pub type Resolver = NoTargets;
26+
pub type Resolver = stack_encrypt::dynamic::NoTargets;
3327

3428
/// This build's resolver, for the record operations.
3529
pub fn resolver() -> Resolver {
@@ -39,150 +33,11 @@ pub fn resolver() -> Resolver {
3933
/// Whether this build holds the EQL types.
4034
pub const HOLDS_EQL: bool = cfg!(feature = "eql");
4135

42-
/// `eql-bindings`' by-name dispatch as the engine's resolver. The resolver
43-
/// sees a type name and a field's label; the field's name is the
44-
/// lowering's to add to a refusal.
45-
#[cfg(feature = "eql")]
46-
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
47-
pub struct EqlTargets;
48-
49-
#[cfg(feature = "eql")]
50-
impl TargetResolver for EqlTargets {
51-
fn targets(&self) -> Vec<TargetDescriptor> {
52-
eql_bindings::encryption::targets::targets()
53-
.iter()
54-
.map(describe)
55-
.collect()
56-
}
57-
58-
fn encrypt<'a, K: 'static>(
59-
&self,
60-
name: &str,
61-
options: Option<&stack_encrypt::dynamic::TargetOptions>,
62-
keyset: &'a stack_encrypt::KeysetCipher<'_, K>,
63-
label: &stack_encrypt::Label,
64-
plaintext: stack_encrypt::dynamic::ValueFrame,
65-
) -> Result<stack_encrypt::Pending<'a, Vec<u8>, K>, TargetError> {
66-
eql_bindings::encryption::targets::encrypt_with(name, options, keyset, label, plaintext)
67-
.map_err(convert)
68-
}
69-
70-
fn decrypt<'a, K: 'static>(
71-
&self,
72-
name: &str,
73-
cipher: &'a stack_encrypt::StackCipher<K>,
74-
label: &stack_encrypt::Label,
75-
stored: &[u8],
76-
) -> Result<stack_encrypt::Pending<'a, stack_encrypt::dynamic::ValueFrame, K>, TargetError>
77-
{
78-
eql_bindings::encryption::targets::decrypt(name, cipher, label, stored).map_err(convert)
79-
}
80-
81-
fn query<'a, K: 'static>(
82-
&self,
83-
name: &str,
84-
index: &stack_encrypt::target::IndexSpec,
85-
keyset: &'a stack_encrypt::KeysetCipher<'_, K>,
86-
label: &stack_encrypt::Label,
87-
plaintext: stack_encrypt::dynamic::ValueFrame,
88-
) -> Result<stack_encrypt::Pending<'a, Vec<u8>, K>, TargetError> {
89-
eql_bindings::encryption::targets::query(name, index, keyset, label, plaintext)
90-
.map_err(convert)
91-
}
92-
}
93-
94-
/// An `eql-bindings` table row as the engine's descriptor. The two spell
95-
/// one wire format; `eql-bindings` tests its serde form against this
96-
/// crate's `to_value`.
97-
#[cfg(feature = "eql")]
98-
fn describe(target: &eql_bindings::encryption::targets::Target) -> TargetDescriptor {
99-
let descriptor = TargetDescriptor::new(
100-
target.name,
101-
target.family,
102-
target.suffix,
103-
target.plaintext_kind(),
104-
target.sql_domain,
105-
target.indexes.iter().map(|key| (*key).to_owned()).collect(),
106-
target.query.map(str::to_owned),
107-
target.query_sql_domain.map(str::to_owned),
108-
target.producible,
109-
target.reason.map(str::to_owned),
110-
);
111-
// A type with a match index answers `matches` through its twin only
112-
// when the engine knows the options its filter is built under.
113-
let descriptor = match target.match_options() {
114-
Some(options) => descriptor.with_match_options(options),
115-
None => descriptor,
116-
};
117-
// A document's orderings at a path bind as its leaf operand, its
118-
// element predicate (`element_match`) as its elements operand, and its
119-
// compiled predicate (`match`) as its predicate operand.
120-
let descriptor = match target.leaf_query_sql_domain() {
121-
Some(domain) => descriptor.with_leaf_query_sql_domain(domain),
122-
None => descriptor,
123-
};
124-
let descriptor = match target.elements_query_sql_domain() {
125-
Some(domain) => descriptor.with_elements_query_sql_domain(domain),
126-
None => descriptor,
127-
};
128-
match target.predicate_query_sql_domain() {
129-
Some(domain) => descriptor.with_predicate_query_sql_domain(domain),
130-
None => descriptor,
131-
}
132-
}
133-
134-
/// An `eql-bindings` refusal as the engine's. The field name is left empty:
135-
/// the lowering fills it in, since the resolver never sees it.
136-
#[cfg(feature = "eql")]
137-
fn convert(error: eql_bindings::encryption::targets::TargetError) -> TargetError {
138-
use eql_bindings::encryption::targets::TargetError as Eql;
139-
match error {
140-
Eql::Unknown { name } => TargetError::Unknown { name },
141-
Eql::Unproducible { name, reason } => TargetError::Unproducible {
142-
name: name.to_owned(),
143-
reason: reason.to_owned(),
144-
},
145-
Eql::NoQuery { name } => TargetError::NoQuery {
146-
name: name.to_owned(),
147-
},
148-
Eql::Context { label } => TargetError::Column {
149-
name: String::new(),
150-
label,
151-
reason: "an EQL column is a two-segment label, table and column".to_owned(),
152-
},
153-
Eql::Plaintext {
154-
target,
155-
expected,
156-
found,
157-
} => TargetError::Plaintext {
158-
name: String::new(),
159-
target: target.to_owned(),
160-
expected: Some(expected),
161-
found,
162-
},
163-
// The engine's code for what was wrong with the document, never
164-
// the document.
165-
Eql::Json { target, source } => TargetError::Json {
166-
name: String::new(),
167-
target: target.to_owned(),
168-
reason: source.code(),
169-
},
170-
// The parser's kind and position, never its message: serde_json
171-
// quotes the input it refused, and the input is stored ciphertext.
172-
Eql::Stored { target, source } => TargetError::Stored {
173-
name: String::new(),
174-
target: target.to_owned(),
175-
reason: stack_encrypt::diagnostic::describe_json_error(&source),
176-
},
177-
other => TargetError::Other(Box::new(other)),
178-
}
179-
}
180-
18136
#[cfg(test)]
18237
mod tests {
18338
use super::*;
18439
#[cfg(feature = "eql")]
185-
use stack_encrypt::AsProtectedRef as _;
40+
use stack_encrypt::dynamic::{TargetError, TargetResolver as _};
18641

18742
#[cfg(not(feature = "eql"))]
18843
#[test]
@@ -279,112 +134,4 @@ mod tests {
279134
Err(TargetError::Unknown { .. })
280135
));
281136
}
282-
283-
/// The JSON document's query domains this guest names, and so the JSON
284-
/// operators the Go SDK is offered: the leaf operand, for the orderings
285-
/// at a path; the element predicate, for `element_match`; and the
286-
/// compiled predicate, for `match`. Every operator a field lists is one
287-
/// the Go SDK's operator list (`parseOperatorName`) and stashgen read.
288-
#[cfg(feature = "eql")]
289-
#[test]
290-
fn a_document_names_the_leaf_element_and_predicate_domains() {
291-
use stack_encrypt::dynamic::{self, record, Op};
292-
let targets = resolver().targets();
293-
let document = targets
294-
.iter()
295-
.find(|t| t.name == "JsonSearch")
296-
.expect("JsonSearch");
297-
assert_eq!(
298-
document.leaf_query_sql_domain.as_deref(),
299-
Some("eql_v4.query_json_leaf")
300-
);
301-
assert_eq!(
302-
document.elements_query_sql_domain.as_deref(),
303-
Some("eql_v4.query_json_elements")
304-
);
305-
assert_eq!(
306-
document.predicate_query_sql_domain.as_deref(),
307-
Some("eql_v4.query_json_predicate")
308-
);
309-
assert!(
310-
document.operators().contains(&Op::Match),
311-
"the type lists match: {:?}",
312-
document.operators()
313-
);
314-
// The operators a field answers: the orderings and `element_match`
315-
// only where it orders a path, and `match` on every field.
316-
let field_operators = |ordered: &[&str]| {
317-
let wire = serde_json::json!({
318-
"profile": {
319-
"context": ["users", "profile"],
320-
"type": "json",
321-
"null": "sql",
322-
"target": {"name": "JsonSearch", "options": {"ordered": ordered}}
323-
}
324-
});
325-
let frame = dynamic::frame::serde::to_vec(&wire).expect("plan frame");
326-
let plan = record::plan_with(frame.as_protected_ref(), &resolver()).expect("plan");
327-
dynamic::field_operators(&plan, "profile", &resolver())
328-
.expect("operators")
329-
.into_iter()
330-
.map(|(op, _)| op)
331-
.collect::<Vec<_>>()
332-
};
333-
let ordered = field_operators(&["$.contacts[*].relationship"]);
334-
for present in [Op::ElementMatch, Op::Match] {
335-
assert!(ordered.contains(&present), "{present:?}: {ordered:?}");
336-
}
337-
let unordered = field_operators(&[]);
338-
assert!(unordered.contains(&Op::Match), "{unordered:?}");
339-
assert!(!unordered.contains(&Op::ElementMatch), "{unordered:?}");
340-
}
341-
342-
#[cfg(feature = "eql")]
343-
#[test]
344-
fn eql_refusals_convert_to_the_engines_with_the_field_left_to_the_lowering() {
345-
use eql_bindings::encryption::targets::TargetError as Eql;
346-
assert!(matches!(
347-
convert(Eql::Context {
348-
label: "tenant/users/email".into()
349-
}),
350-
TargetError::Column { name, label, .. } if name.is_empty() && label == "tenant/users/email"
351-
));
352-
assert!(matches!(
353-
convert(Eql::Plaintext {
354-
target: "TextEq",
355-
expected: stack_encrypt::dynamic::ValueKind::String,
356-
found: Some(stack_encrypt::dynamic::ValueKind::UInt64),
357-
}),
358-
TargetError::Plaintext { target, expected: Some(stack_encrypt::dynamic::ValueKind::String), .. } if target == "TextEq"
359-
));
360-
// The engine asks a type's query only through an index the type
361-
// carries, so one it does not is the engine's failure, not the
362-
// caller's input.
363-
assert!(matches!(
364-
convert(Eql::NoIndex {
365-
name: "TextEq",
366-
index: "ope",
367-
}),
368-
TargetError::Other(_)
369-
));
370-
}
371-
372-
/// serde_json quotes the value it refused, and a stored EQL value holds
373-
/// ciphertext and index terms: only its kind and position cross.
374-
#[cfg(feature = "eql")]
375-
#[test]
376-
fn a_stored_value_that_does_not_parse_quotes_none_of_it() {
377-
use eql_bindings::encryption::targets::TargetError as Eql;
378-
let source = serde_json::from_str::<u8>(r#""marker-ciphertext""#).unwrap_err();
379-
let error = convert(Eql::Stored {
380-
target: "TextEq",
381-
source,
382-
});
383-
let TargetError::Stored { reason, .. } = &error else {
384-
panic!("{error:?}");
385-
};
386-
assert_eq!(reason, "unexpected data at line 1 column 19");
387-
let shown = format!("{error} {:?}", stack_encrypt::ErrorPayload::payload(&error));
388-
assert!(!shown.contains("marker"), "{shown}");
389-
}
390137
}

‎packages/eql/crates/eql-bindings/README.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -405,9 +405,9 @@ feature, and drift-gated by the same parity tests. The same generator writes
405405
the Go package `languages/golang/encrypt/eql` from the same rows
406406
(`eql-codegen go-eql`, also under `types:generate` / `types:check`). The
407407
engine's `TargetResolver` (stack-encrypt's `dynamic` module) is implemented
408-
over this module by the Go guest's `eql` build, not here: a resolver in this
409-
crate would need stack-encrypt API newer than the crates.io release the
410-
published crate names, and the guest already depends on both. A type is producible exactly
408+
over this module by `encryption::EqlTargets`. A program that runs data plans
409+
with EQL types passes `&EqlTargets` wherever the dynamic interface takes a
410+
resolver, as the Go guest's `eql` build does. A type is producible exactly
411411
when its generated struct carries the `stack-encrypt` derives
412412
(`ENCRYPTION_DOMAINS` in `eql-codegen`), because the dispatch runs the derived
413413
plan; `targets::targets()` lists them, each with `producible` and, when it is

‎packages/eql/crates/eql-bindings/src/encryption.rs‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,12 @@ pub mod query;
101101
#[cfg(feature = "targets")]
102102
pub mod targets;
103103

104+
// The EQL types as Stack Encrypt's resolver (`targets` feature).
105+
#[cfg(feature = "targets")]
106+
mod resolver;
107+
#[cfg(feature = "targets")]
108+
pub use resolver::EqlTargets;
109+
104110
use base64::{engine::general_purpose::STANDARD, Engine as _};
105111
use stack_encrypt::sem::{
106112
EqualityTerm, MatchConfig, MatchOptions, MatchTerms, TermError, Tokenizer,

0 commit comments

Comments
 (0)