Skip to content

Commit c0e186b

Browse files
committed
fix(eql-codegen): a producible type without a query twin gets no query arm
Review of #1095: render_targets_rs expected every producible type to have a query twin, so the day the storage-only `Text` joins ENCRYPTION_DOMAINS the generator would have panicked instead of writing the table. The query arms now skip a type without a twin, and the query dispatch's fall-through refuses it as answering no query: eql-bindings gains TargetError::NoQuery and refuse_query (used by the generated fall-through and by the public `query`), stack-encrypt's TargetError gains the matching variant, and the guest maps it (STATUS_ENCODING, pinned in the status table test). Rendering is factored over a row slice so a test can flip `Text` to producible and assert two arms and no query arm. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
1 parent b456715 commit c0e186b

7 files changed

Lines changed: 165 additions & 29 deletions

File tree

‎languages/golang/encrypt/guest/Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,9 @@ mod tests {
359359
name: "email".into(),
360360
label: "users/email".into(),
361361
},
362+
TargetError::NoQuery {
363+
name: "Text".into(),
364+
},
362365
TargetError::Kind {
363366
name: "email".into(),
364367
target: "TextEq".into(),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,9 @@ fn convert(error: eql_bindings::encryption::targets::TargetError) -> TargetError
116116
name: name.to_owned(),
117117
reason: reason.to_owned(),
118118
},
119+
Eql::NoQuery { name } => TargetError::NoQuery {
120+
name: name.to_owned(),
121+
},
119122
Eql::Context { label } => TargetError::Column {
120123
name: String::new(),
121124
label,

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,13 @@ pub enum TargetError {
170170
/// The table's reason.
171171
reason: &'static str,
172172
},
173+
/// The type is produced, and answers no query: a storage-only type has
174+
/// no query twin, so there is no operand to derive.
175+
#[error("{name} answers no query: it is a storage-only type")]
176+
NoQuery {
177+
/// The type's name.
178+
name: &'static str,
179+
},
173180
/// The field's context is not an EQL column: an [`Identifier`] is a
174181
/// two-segment label, table then column, and this label is not one.
175182
#[error("{label:?} is not an EQL column identifier: expected two segments, table and column")]
@@ -324,6 +331,9 @@ pub fn query<'a, K: 'static>(
324331
plaintext: FfiValue,
325332
) -> Result<Pending<'a, Vec<u8>, K>, TargetError> {
326333
producible(name)?;
334+
if let Some(error) = no_query(target(name)) {
335+
return Err(error);
336+
}
327337
let column = Identifier::from_label(context)?;
328338
query_named(name, keyset, column, plaintext)
329339
}
@@ -338,6 +348,24 @@ fn producible(name: &str) -> Result<(), TargetError> {
338348

339349
/// The error for a name without a dispatch arm: unproducible when the table
340350
/// has it, unknown otherwise. Called by the generated dispatch's fall-through.
351+
/// The error for a query on a name the query dispatch has no arm for: a
352+
/// producible type with no query twin answers no query; otherwise what
353+
/// [`refuse`] says. Called by the generated query dispatch's fall-through.
354+
pub(crate) fn refuse_query(name: &str) -> TargetError {
355+
no_query(target(name)).unwrap_or_else(|| refuse(name))
356+
}
357+
358+
/// `NoQuery` for a producible type without a query twin, else `None`: the
359+
/// one case the query dispatch refuses that the others do not.
360+
fn no_query(target: Option<&'static Target>) -> Option<TargetError> {
361+
match target {
362+
Some(target) if target.producible && target.query.is_none() => {
363+
Some(TargetError::NoQuery { name: target.name })
364+
}
365+
_ => None,
366+
}
367+
}
368+
341369
pub(crate) fn refuse(name: &str) -> TargetError {
342370
match target(name) {
343371
Some(target) => TargetError::Unproducible {
@@ -511,6 +539,47 @@ mod tests {
511539
}
512540
}
513541

542+
#[test]
543+
fn a_producible_type_without_a_query_twin_answers_no_query() {
544+
// No such type in the catalog today (`Text` is not producible), so
545+
// the rule is pinned on a synthetic row; `refuse_query` on the live
546+
// table falls through to `refuse`.
547+
let storage_only = Target {
548+
name: "Text",
549+
family: "text",
550+
suffix: "",
551+
plaintext: Some("string"),
552+
sql_domain: "public.eql_v3_text",
553+
indexes: &[],
554+
query: None,
555+
query_sql_domain: None,
556+
producible: true,
557+
reason: None,
558+
};
559+
let leaked: &'static Target = Box::leak(Box::new(storage_only));
560+
assert!(matches!(
561+
no_query(Some(leaked)),
562+
Some(TargetError::NoQuery { name: "Text" })
563+
));
564+
assert_eq!(
565+
no_query(Some(leaked)).unwrap().to_string(),
566+
"Text answers no query: it is a storage-only type"
567+
);
568+
assert!(
569+
no_query(target("TextEq")).is_none(),
570+
"TextEq answers a query"
571+
);
572+
assert!(
573+
no_query(target("Text")).is_none(),
574+
"an unproducible type is refused as that"
575+
);
576+
assert!(matches!(
577+
refuse_query("Text"),
578+
TargetError::Unproducible { .. }
579+
));
580+
assert!(matches!(refuse_query("Nope"), TargetError::Unknown { .. }));
581+
}
582+
514583
#[test]
515584
fn refusals_name_the_type_and_its_reason() {
516585
assert!(matches!(refuse("Nope"), TargetError::Unknown { name } if name == "Nope"));

‎packages/eql/crates/eql-bindings/src/v3/targets.rs‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
// @generated by eql-codegen from the eql-domains catalog — do not edit
22
//! The EQL types a Stack Encrypt data plan may name as a field target — every stored v3 domain type in eql-domains::CATALOG order, as data a guest serializes (`TARGETS`), with the by-name dispatch that runs a producible type's own Rust plan. Generated from the catalog; the descriptor type, the errors and the public entry points stay hand-written in `crate::encryption::targets`, which documents the wire format.
3-
use crate::encryption::targets::{open_target, refuse, run_target, Opener, Target, TargetError};
3+
use crate::encryption::targets::{
4+
open_target, refuse, refuse_query, run_target, Opener, Target, TargetError,
5+
};
46
use crate::Identifier;
57
use stack_encrypt::{KeysetCipher, NonEmpty, Pending};
68
use vitaminc_aead_value::FfiValue;
@@ -750,7 +752,8 @@ pub(crate) fn decrypt_named<'a, K: 'static>(
750752
}
751753
}
752754
/// Run the named type's query twin for one plaintext, or refuse the
753-
/// name as `encrypt_named` does.
755+
/// name: as `encrypt_named` does, or as answering no query
756+
/// (`TargetError::NoQuery`) for a producible type with no twin.
754757
pub(crate) fn query_named<'a, K: 'static>(
755758
name: &str,
756759
keyset: &'a KeysetCipher<'_, K>,
@@ -761,6 +764,6 @@ pub(crate) fn query_named<'a, K: 'static>(
761764
"TextEq" => {
762765
run_target::<super::text::TextEqQuery, String, K>("TextEq", keyset, column, plaintext)
763766
}
764-
_ => Err(refuse(name)),
767+
_ => Err(refuse_query(name)),
765768
}
766769
}

‎packages/eql/crates/eql-codegen/src/targets.rs‎

Lines changed: 75 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ const JSON_INDEX_KEY: &str = "json";
5454
/// encoding for the stack-encrypt producer profile is unspecified, which is
5555
/// every family but text today.
5656
pub fn plaintext(family: &DomainFamily) -> Option<(&'static str, &'static str)> {
57-
(family.name == "text").then_some(("string", "String"))
57+
plaintext_of(family.name)
58+
}
59+
60+
/// [`plaintext`], by family name: what a rendered row carries.
61+
fn plaintext_of(family: &str) -> Option<(&'static str, &'static str)> {
62+
(family == "text").then_some(("string", "String"))
5863
}
5964

6065
/// Why the engine cannot produce a stored domain's EQL type today, or
@@ -175,8 +180,13 @@ fn option_str(value: Option<&str>) -> TokenStream {
175180

176181
/// Render the generated `crates/eql-bindings/src/v3/targets.rs`.
177182
pub fn render_targets_rs() -> String {
178-
let rows = rows();
183+
render_targets_from(&rows())
184+
}
179185

186+
/// Render the target table and dispatch from the given rows: the catalog's
187+
/// in [`render_targets_rs`], a synthetic set in tests (a producible type
188+
/// with no query twin is not in the catalog today).
189+
fn render_targets_from(rows: &[Row]) -> String {
180190
let entries: TokenStream = rows
181191
.iter()
182192
.map(|r| {
@@ -209,43 +219,38 @@ pub fn render_targets_rs() -> String {
209219

210220
// One arm per producible type. The plaintext Rust type is the family's;
211221
// a producible family always has one, since a derive names it.
212-
let producible: Vec<(&Row, &'static DomainFamily)> = stored_payload_domains()
213-
.zip(rows.iter())
214-
.filter(|(_, r)| r.reason.is_none())
215-
.map(|((f, _), r)| (r, f))
216-
.collect();
217-
let arm = |r: &Row, f: &DomainFamily, strukt: &str| {
222+
let producible: Vec<&Row> = rows.iter().filter(|r| r.reason.is_none()).collect();
223+
let arm = |r: &Row, strukt: &str| {
218224
let name = &r.name;
219-
let module = format_ident!("{}", f.name);
225+
let module = format_ident!("{}", r.family);
220226
let ty = format_ident!("{strukt}");
221-
let (_, rust) = plaintext(f).expect("a producible family has a specified plaintext");
227+
let (_, rust) =
228+
plaintext_of(r.family).expect("a producible family has a specified plaintext");
222229
let source = format_ident!("{rust}");
223230
(name.clone(), quote!(super::#module::#ty), quote!(#source))
224231
};
225232
let encrypt_arms: TokenStream = producible
226233
.iter()
227-
.map(|(r, f)| {
228-
let (name, ty, source) = arm(r, f, &r.name);
234+
.map(|r| {
235+
let (name, ty, source) = arm(r, &r.name);
229236
quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), }
230237
})
231238
.collect();
232239
let decrypt_arms: TokenStream = producible
233240
.iter()
234-
.map(|(r, f)| {
235-
let (name, ty, source) = arm(r, f, &r.name);
241+
.map(|r| {
242+
let (name, ty, source) = arm(r, &r.name);
236243
quote! { #name => open_target::<#ty, #source, K>(#name, opener, column, stored), }
237244
})
238245
.collect();
246+
// A producible type with no query twin (a storage-only domain) gets no
247+
// query arm: the fall-through refuses it as answering no query.
239248
let query_arms: TokenStream = producible
240249
.iter()
241-
.map(|(r, f)| {
242-
let query = r
243-
.query
244-
.as_ref()
245-
.map(|(n, _)| n.as_str())
246-
.expect("a producible type has a query twin");
247-
let (name, ty, source) = arm(r, f, query);
248-
quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), }
250+
.filter_map(|r| {
251+
let (query, _) = r.query.as_ref()?;
252+
let (name, ty, source) = arm(r, query);
253+
Some(quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), })
249254
})
250255
.collect();
251256
let helpers = if producible.is_empty() {
@@ -267,7 +272,7 @@ pub fn render_targets_rs() -> String {
267272

268273
use vitaminc_aead_value::FfiValue;
269274

270-
use crate::encryption::targets::{#helpers refuse, Opener, Target, TargetError};
275+
use crate::encryption::targets::{#helpers refuse, refuse_query, Opener, Target, TargetError};
271276
use crate::Identifier;
272277
use stack_encrypt::{KeysetCipher, NonEmpty, Pending};
273278

@@ -309,7 +314,8 @@ pub fn render_targets_rs() -> String {
309314
}
310315

311316
/// Run the named type's query twin for one plaintext, or refuse the
312-
/// name as `encrypt_named` does.
317+
/// name: as `encrypt_named` does, or as answering no query
318+
/// (`TargetError::NoQuery`) for a producible type with no twin.
313319
pub(crate) fn query_named<'a, K: 'static>(
314320
name: &str,
315321
keyset: &'a KeysetCipher<'_, K>,
@@ -318,7 +324,7 @@ pub fn render_targets_rs() -> String {
318324
) -> Result<Pending<'a, Vec<u8>, K>, TargetError> {
319325
match name {
320326
#query_arms
321-
_ => Err(refuse(name)),
327+
_ => Err(refuse_query(name)),
322328
}
323329
}
324330
};
@@ -497,6 +503,49 @@ mod tests {
497503
// producible ones and the three fall-throughs.
498504
let arms = out.matches("\" => ").count();
499505
assert_eq!(arms, ENCRYPTION_DOMAINS.len() * 3, "arms: {out}");
500-
assert_eq!(out.matches("_ => Err(refuse(name))").count(), 3);
506+
assert_eq!(out.matches("_ => Err(refuse(name))").count(), 2);
507+
assert_eq!(out.matches("_ => Err(refuse_query(name))").count(), 1);
508+
}
509+
510+
/// A producible type with no query twin — a storage-only domain, once
511+
/// its derive lands — renders encrypt and decrypt arms and no query arm,
512+
/// rather than aborting the generator: the query dispatch's fall-through
513+
/// refuses it as answering no query. Not in the catalog today, so the
514+
/// row is flipped by hand.
515+
#[test]
516+
fn a_producible_type_without_a_query_twin_gets_no_query_arm() {
517+
let mut rows = rows();
518+
let text = rows
519+
.iter_mut()
520+
.find(|r| r.name == "Text")
521+
.expect("the storage-only text domain");
522+
assert!(text.query.is_none() && text.reason.is_some());
523+
text.reason = None;
524+
let out: String = render_targets_from(&rows)
525+
.split_whitespace()
526+
.collect::<Vec<_>>()
527+
.join(" ");
528+
let arms_for = |helper: &str| {
529+
out.matches(&format!(
530+
"\"Text\" => {{ {helper}::<super::text::Text, String, K>(\"Text\","
531+
))
532+
.count()
533+
+ out
534+
.matches(&format!(
535+
"\"Text\" => {helper}::<super::text::Text, String, K>(\"Text\","
536+
))
537+
.count()
538+
};
539+
assert_eq!(arms_for("run_target"), 1, "one encrypt arm: {out}");
540+
assert_eq!(arms_for("open_target"), 1, "one decrypt arm: {out}");
541+
assert!(
542+
!out.contains("TextQuery"),
543+
"no query arm for a type with no twin: {out}"
544+
);
545+
assert_eq!(
546+
out.matches("\" => ").count(),
547+
(ENCRYPTION_DOMAINS.len() + 1) * 3 - 1,
548+
"every producible type has three arms but the twinless one, which has two"
549+
);
501550
}
502551
}

‎packages/stack-encrypt/src/dynamic/target.rs‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,14 @@ pub enum TargetError {
167167
/// The descriptor's reason.
168168
reason: String,
169169
},
170+
/// The type is produced and answers no query: a storage-only EQL type
171+
/// has no query twin, so a query on a field that names it derives
172+
/// nothing.
173+
#[error("{name} answers no query: it is a storage-only type")]
174+
NoQuery {
175+
/// The type's name.
176+
name: String,
177+
},
170178
/// The plan extends every field's label by the caller's parts (a tenant,
171179
/// a region), and an EQL value stores a table and a column only: there
172180
/// is no column for the extended label. A plan with a target field is

0 commit comments

Comments
 (0)