Skip to content

Commit 72e8860

Browse files
committed
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.
1 parent f757b24 commit 72e8860

4 files changed

Lines changed: 93 additions & 34 deletions

File tree

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

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -760,9 +760,8 @@ take a declaration as a frame: `plan_with`, `encrypt_with`, `query`, and
760760
`decrypt_with`. `Planned` calls them. Each takes a `TargetResolver`. The
761761
resolver runs the EQL type that a field names.
762762

763-
On `next`, each program writes its own resolver over `eql-bindings`. In
764-
the target state, `eql-bindings` provides it. See [A ready-made EQL
765-
resolver](#a-ready-made-eql-resolver).
763+
`eql-bindings` provides the resolver: `eql_bindings::encryption::EqlTargets`.
764+
See [A ready-made EQL resolver](#a-ready-made-eql-resolver).
766765

767766
#### A query plan
768767

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

Lines changed: 89 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,6 @@
1-
//! The EQL types as Stack Encrypt's resolver.
2-
//!
3-
//! Stack Encrypt's dynamic interface runs a plan that arrives as data. A
4-
//! field of that plan may name an EQL type, such as `"TextEq"`. Stack
5-
//! Encrypt does not depend on this crate, so a program gives it the EQL
6-
//! types through a [`TargetResolver`]. [`EqlTargets`] is that resolver. It
7-
//! holds every type in [`targets`](super::targets::targets), and runs a
8-
//! named type through [`encrypt_with`], [`decrypt`] and [`query`].
9-
//!
10-
//! ```
11-
//! use eql_bindings::encryption::EqlTargets;
12-
//! use stack_encrypt::dynamic::TargetResolver;
13-
//!
14-
//! let text_eq = EqlTargets.resolve("TextEq")?;
15-
//! assert_eq!(text_eq.sql_domain, "public.eql_v4_text_eq");
16-
//! assert_eq!(text_eq.indexes, ["eq"]);
17-
//! # Ok::<(), stack_encrypt::dynamic::TargetError>(())
18-
//! ```
19-
//!
20-
//! Pass `&EqlTargets` wherever the dynamic interface takes a resolver, such
21-
//! as `stack_encrypt::dynamic::record::plan_with`.
1+
//! The EQL types as Stack Encrypt's resolver: [`EqlTargets`], and the two
2+
//! conversions it is built from. The module is private, so the type's own
3+
//! documentation is what a reader sees.
224
235
use stack_encrypt::dynamic::{self, TargetDescriptor, TargetOptions, TargetResolver, ValueFrame};
246
use stack_encrypt::target::IndexSpec;
@@ -28,6 +10,26 @@ use super::targets::{self, decrypt, encrypt_with, query, Target, TargetError};
2810

2911
/// Every EQL type this crate defines, as Stack Encrypt's resolver.
3012
///
13+
/// Stack Encrypt's dynamic interface runs a plan that arrives as data. A
14+
/// field of that plan may name an EQL type, such as `"TextEq"`. Stack
15+
/// Encrypt does not depend on this crate, so a program gives it the EQL
16+
/// types through a [`TargetResolver`]. `EqlTargets` is that resolver. It
17+
/// holds every type in [`targets`](super::targets::targets), and runs a
18+
/// named type through [`encrypt_with`], [`decrypt`] and [`query`].
19+
///
20+
/// Pass `&EqlTargets` wherever the dynamic interface takes a resolver, such
21+
/// as `stack_encrypt::dynamic::record::plan_with`.
22+
///
23+
/// ```
24+
/// use eql_bindings::encryption::EqlTargets;
25+
/// use stack_encrypt::dynamic::TargetResolver;
26+
///
27+
/// let text_eq = EqlTargets.resolve("TextEq")?;
28+
/// assert_eq!(text_eq.sql_domain, "public.eql_v4_text_eq");
29+
/// assert_eq!(text_eq.indexes, ["eq"]);
30+
/// # Ok::<(), stack_encrypt::dynamic::TargetError>(())
31+
/// ```
32+
///
3133
/// The resolver sees a type's name and a field's label. It never sees the
3234
/// field's name, so a refusal leaves the field name empty and the engine
3335
/// fills it in.
@@ -160,7 +162,15 @@ impl From<TargetError> for dynamic::TargetError {
160162
target: target.to_owned(),
161163
reason: stack_encrypt::diagnostic::describe_json_error(&source),
162164
},
163-
other => Self::Other(Box::new(other)),
165+
// The engine refuses each of these before it calls the
166+
// resolver: it derives a document's queries itself, asks a query
167+
// only through an index the type carries, and refuses options on
168+
// a type with no JSON index when it builds the plan. Reaching
169+
// one is the engine's failure, not the caller's input. Each is
170+
// named, so a variant added later must be placed by hand.
171+
error @ (TargetError::DocumentQuery { .. }
172+
| TargetError::NoIndex { .. }
173+
| TargetError::Options { .. }) => Self::Other(Box::new(error)),
164174
}
165175
}
166176
}
@@ -294,16 +304,66 @@ mod tests {
294304
dynamic::TargetError::from(TargetError::NoQuery { name: "Text" }),
295305
dynamic::TargetError::NoQuery { name } if name == "Text"
296306
));
297-
// The engine asks a type's query only through an index the type
298-
// carries. One it does not carry is the engine's failure, not the
299-
// caller's input.
300307
assert!(matches!(
301-
dynamic::TargetError::from(TargetError::NoIndex {
302-
name: "TextEq",
303-
index: "ope",
308+
dynamic::TargetError::from(TargetError::Unproducible {
309+
name: "Json",
310+
reason: "not yet",
304311
}),
305-
dynamic::TargetError::Other(_)
312+
dynamic::TargetError::Unproducible { name, reason }
313+
if name == "Json" && reason == "not yet"
314+
));
315+
// A value that is not a document keeps the engine's code, and no
316+
// byte of the value.
317+
let Err(source) = dynamic::read_json(&ValueFrame::of(r#"{"secret": "#).unwrap()) else {
318+
panic!("the text is not a JSON document");
319+
};
320+
let code = source.code();
321+
let error = dynamic::TargetError::from(TargetError::Json {
322+
target: "JsonSearch",
323+
source,
324+
});
325+
assert!(matches!(
326+
&error,
327+
dynamic::TargetError::Json { name, target, reason }
328+
if name.is_empty() && target == "JsonSearch" && *reason == code
306329
));
330+
let shown = format!("{error} {:?}", stack_encrypt::ErrorPayload::payload(&error));
331+
assert!(!shown.contains("secret"), "{shown}");
332+
// The engine refuses these three before it calls the resolver, so
333+
// reaching one is the engine's failure, not the caller's input.
334+
for refusal in [
335+
TargetError::NoIndex {
336+
name: "TextEq",
337+
index: "ope",
338+
},
339+
TargetError::DocumentQuery { name: "JsonSearch" },
340+
TargetError::Options { name: "TextEq" },
341+
] {
342+
assert!(matches!(
343+
dynamic::TargetError::from(refusal),
344+
dynamic::TargetError::Other(_)
345+
));
346+
}
347+
}
348+
349+
/// Why `Options` converts to `Other`: the engine refuses options on a
350+
/// type with no JSON index when it builds the plan, so the resolver is
351+
/// never asked to encrypt under them.
352+
#[test]
353+
fn the_engine_refuses_options_on_a_type_without_a_json_index() {
354+
let wire = serde_json::json!({
355+
"email": {
356+
"context": ["users", "email"],
357+
"target": {"name": "TextEq", "options": {"ordered": []}}
358+
}
359+
});
360+
let frame = dynamic::frame::serde::to_vec(&wire).expect("plan frame");
361+
let error = record::plan_with(frame.as_protected_ref(), &EqlTargets).unwrap_err();
362+
assert_eq!(
363+
error.reason(),
364+
Some(dynamic::Reason::TargetOptions),
365+
"{error:?}"
366+
);
307367
}
308368

309369
/// `serde_json` quotes the value it refused, and a stored EQL value

‎packages/eql/tests/encryption/tests/json_tree_data_plan.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ mod common;
2121
use stack_encrypt::AsProtectedRef as _;
2222
use std::collections::BTreeMap;
2323

24-
use eql_bindings::encryption::targets::{self as eql_targets};
24+
use eql_bindings::encryption::targets as eql_targets;
2525
use eql_bindings::encryption::EqlTargets;
2626
use eql_bindings::v4::json_tree::{JsonTree, JsonTreeDocument};
2727
use eql_bindings::Identifier;

‎packages/eql/tests/encryption/tests/typed_queries.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
mod common;
1212

1313
use eql_bindings::encryption::query::{MatchTwin, QueryLabelError};
14-
use eql_bindings::encryption::targets::{self as eql_targets};
14+
use eql_bindings::encryption::targets as eql_targets;
1515
use eql_bindings::encryption::EqlTargets;
1616
use eql_bindings::v4::answers::ANSWERS;
1717
use eql_bindings::v4::plaintext::{Date, Decimal, Timestamp};

0 commit comments

Comments
 (0)