From 7b5ccf1cecf4e6bffd9f3d0cf2a4f698c1217e7e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 01:10:47 -0700 Subject: [PATCH 01/30] feat(eql-bindings): EQL types as plan field targets, dispatched by name A Rust caller names an EQL type as a type: encrypt_as:: runs the plan TextEq's own EncryptFrom describes. A binding has no type to name. Its plan arrives as data and a field of it names the target as a string, "TextEq", so something has to resolve that string to the type and run the same plan. ADR-0007 (amended 2026-10-06) puts that dispatch in eql-codegen and the EQL types in a second guest build; this is the eql side of #1062. eql-codegen gains src/targets.rs, which renders crates/eql-bindings/src/v3/targets.rs beside inventory.rs under the same generate-to-file and byte-parity discipline: a TARGETS table with one row per stored catalog domain (name across languages, family, suffix, the plaintext ValueKind name a plan's "type" key spells, SQL domain, indexes by IndexSpec key, query twin, and producible with the plan's reason when not) and three by-name dispatches whose arms are exactly ENCRYPTION_DOMAINS. Producible is derived from the derive rather than listed twice: an arm runs >::encryption(), which only exists with the derive, and a test holds target_gap and encryption_gap to one answer. The reasons are the plan's (encoding unspecified outside text; match and OPE derived but no EQL type built; block ORE versus CLLW ORE; the JSON index is new), keyed on catalog facts so a domain added tomorrow gets one. eql-bindings gains the hand-written encryption::targets: the Target descriptor (serde::Serialize, documented as the se_targets wire format), TargetError, Opener, Identifier::from_label, and encrypt / decrypt / query, which refuse an unknown or unproducible name before the label or the value is read and otherwise resolve to the EQL value's JSON bytes through Pending::try_map, so a guest zips a target field into the one ZeroKMS request with the rest of a plan. No new stack-encrypt export was needed: Pending already erases its value type. FfiValue and ValueKind are named from vitaminc-aead-value directly, like PrfValue, so the feature needs nothing from stack-encrypt's own dynamic feature. The generated module is gated to the stack-encrypt feature in the hand-written v3/mod.rs rather than inside the generated file. The encryption test crate proves the dispatch is the typed path: same identifier and equality term as encrypt_as::, each side opens the other's value, query bytes identical to TextEqQuery, both openers, the refusals, and the table against the catalog and the compiled inventory. packages/eql/Cargo.lock also picks up sha2 under stack-auth, which the branch base added without refreshing this lockfile; test:encryption runs --locked and would have failed on the base. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/eql/Cargo.lock | 15 + packages/eql/crates/eql-bindings/Cargo.toml | 16 +- .../eql/crates/eql-bindings/src/encryption.rs | 7 + .../eql-bindings/src/encryption/targets.rs | 537 ++++++++++++ .../eql/crates/eql-bindings/src/v3/mod.rs | 8 + .../eql/crates/eql-bindings/src/v3/targets.rs | 766 ++++++++++++++++++ .../eql/crates/eql-codegen/src/bindings.rs | 21 +- packages/eql/crates/eql-codegen/src/lib.rs | 1 + .../eql/crates/eql-codegen/src/targets.rs | 493 +++++++++++ packages/eql/crates/eql-codegen/tests/cli.rs | 4 +- packages/eql/tests/encryption/Cargo.toml | 6 + .../eql/tests/encryption/tests/targets.rs | 528 ++++++++++++ 12 files changed, 2392 insertions(+), 10 deletions(-) create mode 100644 packages/eql/crates/eql-bindings/src/encryption/targets.rs create mode 100644 packages/eql/crates/eql-bindings/src/v3/targets.rs create mode 100644 packages/eql/crates/eql-codegen/src/targets.rs create mode 100644 packages/eql/tests/encryption/tests/targets.rs diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 32573ce95..5ee7ff102 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1202,7 +1202,9 @@ dependencies = [ "serde", "serde_json", "stack-encrypt", + "thiserror 2.0.20", "ts-rs", + "vitaminc-aead-value", "vitaminc-prf", ] @@ -1234,6 +1236,7 @@ version = "0.1.0" dependencies = [ "base64", "eql-bindings", + "eql-domains", "postgres", "serde", "serde_json", @@ -1241,6 +1244,7 @@ dependencies = [ "stack-kms", "tokio", "uuid", + "vitaminc-aead-value", ] [[package]] @@ -5072,6 +5076,17 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "vitaminc-aead-value" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" +dependencies = [ + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "zeroize", +] + [[package]] name = "vitaminc-context" version = "0.5.1" diff --git a/packages/eql/crates/eql-bindings/Cargo.toml b/packages/eql/crates/eql-bindings/Cargo.toml index 4895d3e23..cc39ed023 100644 --- a/packages/eql/crates/eql-bindings/Cargo.toml +++ b/packages/eql/crates/eql-bindings/Cargo.toml @@ -49,12 +49,26 @@ stack-encrypt = { path = "../../../stack-encrypt", version = "0.2.0", optional = # `PrfContext`), and two vitaminc lines in one graph do not interoperate, so # the requirement is stack-encrypt's. vitaminc-prf = { version = "0.5.0", optional = true } +# `FfiValue` / `ValueKind`: the runtime plaintext a plan field target takes +# and the kind names a plan's `"type"` key spells (`encryption::targets`). +# Named from vitaminc directly, like `PrfValue` above, so the feature needs +# nothing from stack-encrypt's own `dynamic` feature; the same line +# stack-encrypt and the Go guest build against. +vitaminc-aead-value = { version = "0.5.1", optional = true } +thiserror = { version = "2", optional = true } base64 = { version = "0.22", optional = true } hex = { version = "0.4", optional = true } [features] default = [] -stack-encrypt = ["dep:stack-encrypt", "dep:vitaminc-prf", "dep:base64", "dep:hex"] +stack-encrypt = [ + "dep:stack-encrypt", + "dep:vitaminc-prf", + "dep:vitaminc-aead-value", + "dep:thiserror", + "dep:base64", + "dep:hex", +] [package.metadata.docs.rs] features = ["stack-encrypt"] diff --git a/packages/eql/crates/eql-bindings/src/encryption.rs b/packages/eql/crates/eql-bindings/src/encryption.rs index df9a8b8d8..fccab8307 100644 --- a/packages/eql/crates/eql-bindings/src/encryption.rs +++ b/packages/eql/crates/eql-bindings/src/encryption.rs @@ -41,6 +41,11 @@ //! builder with `StackCipher::new().await?`, using your configured CipherStash //! credentials. The encryption and decryption calls are identical. //! +//! A binding that holds no Rust type to name reaches the same plans by the +//! type's *name* through [`targets`]: the catalog-generated table of EQL +//! types a data plan may name as a field target, and `encrypt` / `decrypt` / +//! `query` dispatching on that name. +//! //! Use the same table and column identifier for writes and queries. Passing //! `column.into()` on decryption also checks that the stored identifier matches //! the expected destination before retrieving keys. `Default::default()` instead @@ -72,6 +77,8 @@ //! require::(); //! ``` +pub mod targets; + use base64::{engine::general_purpose::STANDARD, Engine as _}; use stack_encrypt::sem::EqualityTerm; use stack_encrypt::target::transcode::{Reader, Transcode, Visitor}; diff --git a/packages/eql/crates/eql-bindings/src/encryption/targets.rs b/packages/eql/crates/eql-bindings/src/encryption/targets.rs new file mode 100644 index 000000000..e6a1f7c7a --- /dev/null +++ b/packages/eql/crates/eql-bindings/src/encryption/targets.rs @@ -0,0 +1,537 @@ +//! EQL types as plan field targets, by name. +//! +//! A Rust caller names an EQL type as a type: `encrypt_as::` runs the +//! plan `TextEq`'s own `EncryptFrom` describes. A binding has no type to +//! name — its plan arrives as data, and a field of that plan names its target +//! as a string, `"TextEq"`. This module is where that string meets the type: +//! a catalog-generated table of every EQL type a plan may name +//! ([`targets`], [`Target`]) and three entry points ([`encrypt`], +//! [`decrypt`], [`query`]) that dispatch on the name and run *the same plan* +//! the typed call runs. Nothing here derives a term or seals a byte itself +//! (ADR-0007: one engine, entered through a plan); the dispatch is generated +//! from `eql-domains::CATALOG` by `eql-codegen` into +//! [`crate::v3::targets`], beside the inventory, so a type cannot be in the +//! catalog and missing from the table. +//! +//! The guest build that holds the EQL types links this module; the build +//! without them has no `eql-bindings` and refuses a target name before +//! reaching here. +//! +//! # Wire format: the target table +//! +//! [`targets`] is what a guest serializes for its `se_targets` export, so a +//! generator (`stashgen`) can ask the engine it embeds which EQL types it +//! holds instead of carrying a copy of the rules. The JSON of one entry, with +//! every field always present: +//! +//! ```json +//! { +//! "name": "TextEq", +//! "family": "text", +//! "suffix": "Eq", +//! "plaintext": "string", +//! "sql_domain": "public.eql_v3_text_eq", +//! "indexes": ["eq"], +//! "query": "TextEqQuery", +//! "query_sql_domain": "eql_v3.query_text_eq", +//! "producible": true, +//! "reason": null +//! } +//! ``` +//! +//! | field | meaning | +//! |---|---| +//! | `name` | The type's name, one across Rust, TypeScript and Go: the catalog struct identifier. The data plan's target form names this. (Go spells the `Json` family's types `JSON`; that rename is the Go generator's.) | +//! | `family` | The catalog family, lower case: `text`, `integer`, `json`, … | +//! | `suffix` | What a query can do, as the plan's table of suffixes spells it: `""` (stored and read only), `Eq`, `Ord`, `OrdOpe`, `OrdOre`, `Match`, `Search`, `SearchOre`; `Search` for the SteVec document. | +//! | `plaintext` | The vitaminc [`ValueKind`] name the type is produced from — the same names a plan field's `"type"` key uses — or `null` while the family's plaintext encoding for the stack-encrypt producer profile is unspecified. | +//! | `sql_domain` | The schema-qualified PostgreSQL domain the stored value inhabits. | +//! | `indexes` | The indexes the type carries, by the engine's `IndexSpec::key()` names: `eq`, `match`, `ore`, `ope`, and `json` for the SteVec document. A query may ask a field typed with this target for exactly these. | +//! | `query` | The query twin's type name (`TextEqQuery`, `SteVecQuery`), or `null` for a storage-only type, which answers no query. | +//! | `query_sql_domain` | The query twin's PostgreSQL domain, `null` likewise. | +//! | `producible` | Whether the engine can produce this type today. [`encrypt`] and [`query`] refuse a type that is not, and a generator should too. | +//! | `reason` | Why not, when `producible` is `false`; `null` when it is. | +//! +//! The table has one row per stored domain in catalog order; query twins are +//! not rows (each row names its own). Adding a field is a wire change for +//! every reader of `se_targets`; renaming or removing one is a breaking one. +//! +//! # What crosses: bytes +//! +//! [`encrypt`] and [`query`] resolve to the EQL value as **JSON bytes** — the +//! bytes PostgreSQL stores or compares — and [`decrypt`] takes them back. +//! The [`Pending`] they return is the engine's own request carrier: the guest +//! zips it with the other fields' pendings so one plan still makes one +//! ZeroKMS request. A query derives no key, so its pending settles without +//! I/O, but it is a `Pending` all the same, for one shape at the call site. +//! +//! # The field's context +//! +//! A plan field's context is a [`Label`]; an EQL value stores an +//! [`Identifier`], table and column. The two are the same context when the +//! label has two segments ([`Identifier::from_label`]): `users/email` is +//! `{"t": "users", "c": "email"}`, sealed under the same AAD, bound to the +//! same ZeroKMS descriptor and deriving the same equality term as the typed +//! `encrypt_as::` call with `Identifier::for_column("users", +//! "email")`. A label of any other length is refused: there is no column to +//! store it in. + +use std::fmt; + +use serde::{de::DeserializeOwned, Serialize}; +use stack_encrypt::kms::MaybeSend; +use stack_encrypt::target::ExpectedContext; +use stack_encrypt::{ + DecryptInto, EncryptFrom, Error, KeysetCipher, Label, NonEmpty, Pending, StackCipher, +}; +use vitaminc_aead_value::{FfiValue, ValueKind}; + +use crate::v3::targets::{decrypt_named, encrypt_named, query_named, TARGETS}; +use crate::Identifier; + +/// One EQL type a plan may name as a field target: a row of [`targets`]. +/// +/// The fields are the wire format of the `se_targets` export; the module +/// documentation is their reference. Every value is a `&'static str` or a +/// slice of them because the whole table is a `const` generated from the +/// catalog. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)] +#[non_exhaustive] +pub struct Target { + /// The type's name across languages: `TextEq`. + pub name: &'static str, + /// The catalog family: `text`. + pub family: &'static str, + /// The query-capability suffix: `Eq`; empty for a storage-only type. + pub suffix: &'static str, + /// The [`ValueKind`] name of the plaintext this type is produced from, + /// or `None` while the family's encoding is unspecified. See + /// [`plaintext_kind`](Self::plaintext_kind) for the parsed kind. + pub plaintext: Option<&'static str>, + /// The stored value's PostgreSQL domain: `public.eql_v3_text_eq`. + pub sql_domain: &'static str, + /// The indexes the type carries, by `IndexSpec::key()` name. + pub indexes: &'static [&'static str], + /// The query twin's type name, or `None` for a storage-only type. + pub query: Option<&'static str>, + /// The query twin's PostgreSQL domain, or `None` likewise. + pub query_sql_domain: Option<&'static str>, + /// Whether the engine produces this type today. + pub producible: bool, + /// Why it does not, when it does not. + pub reason: Option<&'static str>, +} + +impl Target { + /// The plaintext kind, parsed. `None` when the table records none; the + /// table is generated from names vitaminc freezes, so a recorded name + /// always parses, and a `None` here means the same as a `None` in + /// [`plaintext`](Self::plaintext). + pub fn plaintext_kind(&self) -> Option { + self.plaintext.and_then(|name| name.parse().ok()) + } +} + +/// Every EQL type a plan may name as a target, in catalog order — the +/// `se_targets` export, as data. See the module documentation for the wire +/// format. +pub fn targets() -> &'static [Target] { + TARGETS +} + +/// The target of this name, or `None` when no EQL type has it. The name is +/// matched exactly: `"TextEq"`, not `"texteq"` or `"text_eq"`. +pub fn target(name: &str) -> Option<&'static Target> { + TARGETS.iter().find(|target| target.name == name) +} + +/// Why a name could not be resolved to a plan, or a value could not be +/// handed to one. +/// +/// Every variant is decided before any key is minted or retrieved: these +/// are statements about the name, the context or the value. The encryption +/// itself failing arrives through the returned [`Pending`] as a +/// [`stack_encrypt::Error`]. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum TargetError { + /// No EQL type has this name. + #[error("no such EQL type: {name}")] + Unknown { + /// The name as it was given. + name: String, + }, + /// The type exists, and the engine cannot produce it yet; [`Target::reason`] + /// says why. + #[error("the engine cannot produce {name} yet: {reason}")] + Unproducible { + /// The type's name. + name: &'static str, + /// The table's reason. + reason: &'static str, + }, + /// The field's context is not an EQL column: an [`Identifier`] is a + /// two-segment label, table then column, and this label is not one. + #[error("{label:?} is not an EQL column identifier: expected two segments, table and column")] + Context { + /// The label, rendered. + label: String, + }, + /// The value is not of the type's plaintext kind. Nothing is converted: + /// the stored bytes must be the declared kind's, and a conversion here + /// would make them something else. + #[error( + "{target} is produced from a {expected} plaintext, not {}", + found.map_or("a value with no kind", ValueKind::name) + )] + Plaintext { + /// The type the value was handed to. + target: &'static str, + /// The kind it takes. + expected: ValueKind, + /// The kind it was given, or `None` for a null, undefined or + /// passthrough value, which no kind holds. + found: Option, + }, + /// The stored bytes do not parse as the type: not JSON, not this + /// domain's shape, or another EQL version. + #[error("stored value is not a {target}: {source}")] + Stored { + /// The type the bytes were read as. + target: &'static str, + /// What the parser refused. + #[source] + source: serde_json::Error, + }, +} + +/// Which cipher [`decrypt`] opens through: the client, which opens a value +/// sealed under any of its keysets, or one keyset, which refuses a value +/// sealed under another ([`stack_encrypt::Error::ForeignKeyset`]) before any +/// key is retrieved. The runtime form of the engine's scope, for a caller +/// who chooses at runtime; a typed caller chooses by naming the cipher. +/// Both references convert into it, so a call site passes either. +pub enum Opener<'a, K> { + /// Values from any keyset the client holds. + Client(&'a StackCipher), + /// Values from this keyset only. + Keyset(&'a KeysetCipher<'a, K>), +} + +// By hand so `K: Debug` is not demanded: neither cipher demands it of its +// own `Debug`. +impl fmt::Debug for Opener<'_, K> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Opener::Client(cipher) => f.debug_tuple("Client").field(cipher).finish(), + Opener::Keyset(keyset) => f.debug_tuple("Keyset").field(keyset).finish(), + } + } +} + +impl<'a, K> From<&'a StackCipher> for Opener<'a, K> { + fn from(cipher: &'a StackCipher) -> Self { + Opener::Client(cipher) + } +} + +impl<'a, K> From<&'a KeysetCipher<'a, K>> for Opener<'a, K> { + fn from(keyset: &'a KeysetCipher<'a, K>) -> Self { + Opener::Keyset(keyset) + } +} + +impl Identifier { + /// The column a plan field's context names: a two-segment label, table + /// then column, as the identifier the value stores and is sealed under. + /// The same context as the label itself — see the module documentation. + /// + /// # Errors + /// + /// [`TargetError::Context`] for a label of any other length. + pub fn from_label(label: &Label) -> Result, TargetError> { + let context = || TargetError::Context { + label: label.to_string(), + }; + let mut segments = label.segments(); + let (Some(table), Some(column), None) = (segments.next(), segments.next(), segments.next()) + else { + return Err(context()); + }; + // A label segment is plain, so never empty; the `Err` arm is the + // type's, not a case this function can reach. + Identifier::for_column(table, column).map_err(|_| context()) + } +} + +/// Run the named type's own encryption plan for one plan field. +/// +/// `context` is the field's label, which must name a column (see +/// [`Identifier::from_label`]); `plaintext` must be of the type's plaintext +/// kind ([`Target::plaintext`]). The result settles to the EQL value's JSON +/// bytes, and merges with other pendings into one ZeroKMS request. +/// +/// # Errors +/// +/// [`TargetError::Unknown`] for a name no EQL type has, +/// [`TargetError::Unproducible`] for one the engine cannot produce yet, +/// [`TargetError::Context`] for a label that is not a column, and +/// [`TargetError::Plaintext`] for a value of another kind. All decided before +/// any key is minted; the encryption itself fails through the pending. +pub fn encrypt<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + context: &Label, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + producible(name)?; + let column = Identifier::from_label(context)?; + encrypt_named(name, keyset, column, plaintext) +} + +/// Open a stored value of the named type back to its plaintext, checking +/// that it was stored under `context`'s column. +/// +/// # Errors +/// +/// [`TargetError::Unknown`], [`TargetError::Unproducible`] and +/// [`TargetError::Context`] as for [`encrypt`], and [`TargetError::Stored`] +/// for bytes that are not this type. A stored identifier that differs from +/// `context`, a value sealed under a keyset the opener does not hold, and a +/// failed authentication all fail through the pending. +pub fn decrypt<'a, K: 'static>( + name: &str, + opener: impl Into>, + context: &Label, + stored: &[u8], +) -> Result, TargetError> { + producible(name)?; + let column = Identifier::from_label(context)?; + decrypt_named(name, opener.into(), column, stored) +} + +/// Run the named type's query twin for one plaintext: the operand that +/// matches stored values of the type under `context`'s column, as JSON +/// bytes. A query derives no data key, so the pending settles without I/O. +/// +/// # Errors +/// +/// As [`encrypt`]. +pub fn query<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + context: &Label, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + producible(name)?; + let column = Identifier::from_label(context)?; + query_named(name, keyset, column, plaintext) +} + +/// Refuse a name the dispatch has no arm for, as the table explains it. +fn producible(name: &str) -> Result<(), TargetError> { + match target(name) { + Some(target) if target.producible => Ok(()), + _ => Err(refuse(name)), + } +} + +/// The error for a name without a dispatch arm: unproducible when the table +/// has it, unknown otherwise. Called by the generated dispatch's fall-through. +pub(crate) fn refuse(name: &str) -> TargetError { + match target(name) { + Some(target) => TargetError::Unproducible { + name: target.name, + // A producible type reaching here is a generator bug: the table + // says yes and the dispatch has no arm. Say so rather than panic. + reason: target + .reason + .unwrap_or("the generated dispatch has no arm for this type"), + }, + None => TargetError::Unknown { + name: name.to_owned(), + }, + } +} + +mod sealed { + pub trait Sealed {} + impl Sealed for String {} +} + +/// A Rust plaintext an EQL type is produced from, read out of and written +/// back into the runtime value. Sealed: the implementations are exactly the +/// plaintext types the catalog's producible families name, and the generated +/// dispatch picks one per type. +pub trait Plaintext: sealed::Sealed + Sized + MaybeSend + 'static { + /// The kind of value this plaintext is. + const KIND: ValueKind; + /// Read the value as this plaintext, refusing any other kind. + fn from_value(target: &'static str, value: FfiValue) -> Result; + /// The opened plaintext, as the runtime value. + fn into_value(self) -> FfiValue; +} + +impl Plaintext for String { + const KIND: ValueKind = ValueKind::String; + + fn from_value(target: &'static str, value: FfiValue) -> Result { + let refused = |found| TargetError::Plaintext { + target, + expected: Self::KIND, + found, + }; + match value { + // The bytes are UTF-8 by `Utf8String`'s construction invariant; + // checked rather than assumed because this is boundary code. One + // that fails the check is a string in name only, so it is + // reported as the bytes it is. + FfiValue::String(text) => std::str::from_utf8(text.risky_ref()) + .map(str::to_owned) + .map_err(|_| refused(Some(ValueKind::Bytes))), + other => Err(refused(other.kind())), + } + } + + fn into_value(self) -> FfiValue { + FfiValue::String(self.into()) + } +} + +/// Run a target's own plan over one runtime value and resolve to the EQL +/// value's JSON bytes. The generated dispatch calls this with the type and +/// its plaintext; it is the one place the typed `encrypt_as` is reached from +/// a name. +pub(crate) fn run_target<'a, T, S, K>( + name: &'static str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, +) -> Result, K>, TargetError> +where + T: EncryptFrom> + Serialize, + S: Plaintext, + K: 'static, +{ + let plaintext = S::from_value(name, plaintext)?; + Ok(keyset + .encrypt_as::(&plaintext, column) + .try_map(|value| serde_json::to_vec(&value).map_err(|error| Error::Other(Box::new(error))))) +} + +/// Parse a stored EQL value as the target and describe opening it through +/// the target's own `DecryptInto`, under the column the caller expects. +pub(crate) fn open_target<'a, T, S, K>( + name: &'static str, + opener: Opener<'a, K>, + column: NonEmpty, + stored: &[u8], +) -> Result, TargetError> +where + T: DeserializeOwned + DecryptInto> + 'static, + S: Plaintext, + K: 'static, +{ + let value: T = serde_json::from_slice(stored).map_err(|source| TargetError::Stored { + target: name, + source, + })?; + let decryption = value.decryption::(column.into()).map(S::into_value); + Ok(match opener { + Opener::Client(cipher) => cipher.run_decryption(decryption), + Opener::Keyset(keyset) => keyset.run_decryption(decryption), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn label(segments: &[&str]) -> Label { + Label::new(segments).unwrap() + } + + #[test] + fn a_two_segment_label_is_the_column_identifier() { + let column = Identifier::from_label(&label(&["users", "email"])).unwrap(); + assert_eq!(column.clone().into_inner().t, "users"); + assert_eq!(column.into_inner().c, "email"); + } + + #[test] + fn a_label_of_any_other_length_is_not_a_column() { + for segments in [&["users"][..], &["tenant", "users", "email"][..]] { + let error = Identifier::from_label(&label(segments)).unwrap_err(); + assert!( + matches!(&error, TargetError::Context { label: l } if l == &segments.join("/")), + "{segments:?}: {error}" + ); + } + } + + #[test] + fn the_table_is_catalog_ordered_and_names_each_type_once() { + let names: Vec<&str> = targets().iter().map(|t| t.name).collect(); + let mut unique = names.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!( + unique.len(), + names.len(), + "a name resolves one type: {names:?}" + ); + assert_eq!( + target("TextEq").map(|t| t.sql_domain), + Some("public.eql_v3_text_eq") + ); + assert_eq!(target("texteq"), None, "names match exactly"); + for t in targets() { + assert_eq!( + t.producible, + t.reason.is_none(), + "{}: a reason exactly when not producible", + t.name + ); + assert_eq!( + t.plaintext_kind().is_some(), + t.plaintext.is_some(), + "{}: a recorded plaintext name is a vitaminc kind", + t.name + ); + } + } + + #[test] + fn refusals_name_the_type_and_its_reason() { + assert!(matches!(refuse("Nope"), TargetError::Unknown { name } if name == "Nope")); + assert!(matches!( + refuse("TextOrdOre"), + TargetError::Unproducible { name: "TextOrdOre", reason } if reason.contains("CLLW") + )); + assert!(producible("TextEq").is_ok()); + } + + #[test] + fn a_string_plaintext_is_read_exactly_and_nothing_else_is_converted() { + let text = String::from_value("TextEq", FfiValue::String("café".into())).unwrap(); + assert_eq!(text, "café"); + assert!(matches!( + String::from_value("TextEq", FfiValue::UInt64(34)), + Err(TargetError::Plaintext { + target: "TextEq", + expected: ValueKind::String, + found: Some(ValueKind::UInt64) + }) + )); + let error = String::from_value("TextEq", FfiValue::Null).unwrap_err(); + assert!(matches!(error, TargetError::Plaintext { found: None, .. })); + assert_eq!( + error.to_string(), + "TextEq is produced from a string plaintext, not a value with no kind" + ); + assert!(matches!( + String::from("x").into_value(), + FfiValue::String(s) if s.risky_ref() == b"x" + )); + } +} diff --git a/packages/eql/crates/eql-bindings/src/v3/mod.rs b/packages/eql/crates/eql-bindings/src/v3/mod.rs index b9c53156f..a23094056 100644 --- a/packages/eql/crates/eql-bindings/src/v3/mod.rs +++ b/packages/eql/crates/eql-bindings/src/v3/mod.rs @@ -142,6 +142,14 @@ pub mod payload; pub mod query_payload; pub mod real; pub mod smallint; +/// Generated: the table of EQL types a Stack Encrypt data plan may name as +/// a field target (`TARGETS`) and the by-name dispatch that runs a +/// producible type's own plan. Only meaningful with the engine, so it is +/// gated here, in the hand-written module list, rather than inside the +/// generated file. The descriptor type, the errors and the public entry +/// points are hand-written in [`crate::encryption::targets`]. +#[cfg(feature = "stack-encrypt")] +pub mod targets; pub mod terms; pub mod text; pub mod timestamp; diff --git a/packages/eql/crates/eql-bindings/src/v3/targets.rs b/packages/eql/crates/eql-bindings/src/v3/targets.rs new file mode 100644 index 000000000..f2c6ef35d --- /dev/null +++ b/packages/eql/crates/eql-bindings/src/v3/targets.rs @@ -0,0 +1,766 @@ +// @generated by eql-codegen from the eql-domains catalog — do not edit +//! 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. +use crate::encryption::targets::{open_target, refuse, run_target, Opener, Target, TargetError}; +use crate::Identifier; +use stack_encrypt::{KeysetCipher, NonEmpty, Pending}; +use vitaminc_aead_value::FfiValue; +/// Every EQL type a plan may name as a target, in +/// `eql-domains::CATALOG` order: one per stored domain (every flat +/// scalar domain plus the SteVec document). Query twins are not +/// targets; each row names its own under `query`. +pub const TARGETS: &[Target] = &[ + Target { + name: "Integer", + family: "integer", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_integer", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerEq", + family: "integer", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_integer_eq", + indexes: &["eq"], + query: Some("IntegerEqQuery"), + query_sql_domain: Some("eql_v3.query_integer_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerOrdOre", + family: "integer", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_integer_ord_ore", + indexes: &["ore"], + query: Some("IntegerOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_integer_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerOrd", + family: "integer", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_integer_ord", + indexes: &["ope"], + query: Some("IntegerOrdQuery"), + query_sql_domain: Some("eql_v3.query_integer_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "IntegerOrdOpe", + family: "integer", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_integer_ord_ope", + indexes: &["ope"], + query: Some("IntegerOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_integer_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Smallint", + family: "smallint", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_smallint", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintEq", + family: "smallint", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_smallint_eq", + indexes: &["eq"], + query: Some("SmallintEqQuery"), + query_sql_domain: Some("eql_v3.query_smallint_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintOrdOre", + family: "smallint", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_smallint_ord_ore", + indexes: &["ore"], + query: Some("SmallintOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_smallint_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintOrd", + family: "smallint", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_smallint_ord", + indexes: &["ope"], + query: Some("SmallintOrdQuery"), + query_sql_domain: Some("eql_v3.query_smallint_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SmallintOrdOpe", + family: "smallint", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_smallint_ord_ope", + indexes: &["ope"], + query: Some("SmallintOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_smallint_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Bigint", + family: "bigint", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_bigint", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintEq", + family: "bigint", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_bigint_eq", + indexes: &["eq"], + query: Some("BigintEqQuery"), + query_sql_domain: Some("eql_v3.query_bigint_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintOrdOre", + family: "bigint", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_bigint_ord_ore", + indexes: &["ore"], + query: Some("BigintOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_bigint_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintOrd", + family: "bigint", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_bigint_ord", + indexes: &["ope"], + query: Some("BigintOrdQuery"), + query_sql_domain: Some("eql_v3.query_bigint_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "BigintOrdOpe", + family: "bigint", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_bigint_ord_ope", + indexes: &["ope"], + query: Some("BigintOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_bigint_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Date", + family: "date", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_date", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateEq", + family: "date", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_date_eq", + indexes: &["eq"], + query: Some("DateEqQuery"), + query_sql_domain: Some("eql_v3.query_date_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateOrdOre", + family: "date", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_date_ord_ore", + indexes: &["ore"], + query: Some("DateOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_date_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateOrd", + family: "date", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_date_ord", + indexes: &["ope"], + query: Some("DateOrdQuery"), + query_sql_domain: Some("eql_v3.query_date_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DateOrdOpe", + family: "date", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_date_ord_ope", + indexes: &["ope"], + query: Some("DateOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_date_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Timestamp", + family: "timestamp", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_timestamp", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampEq", + family: "timestamp", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_eq", + indexes: &["eq"], + query: Some("TimestampEqQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampOrdOre", + family: "timestamp", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_ord_ore", + indexes: &["ore"], + query: Some("TimestampOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampOrd", + family: "timestamp", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_ord", + indexes: &["ope"], + query: Some("TimestampOrdQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "TimestampOrdOpe", + family: "timestamp", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_timestamp_ord_ope", + indexes: &["ope"], + query: Some("TimestampOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_timestamp_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Numeric", + family: "numeric", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_numeric", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericEq", + family: "numeric", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_numeric_eq", + indexes: &["eq"], + query: Some("NumericEqQuery"), + query_sql_domain: Some("eql_v3.query_numeric_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericOrdOre", + family: "numeric", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_numeric_ord_ore", + indexes: &["ore"], + query: Some("NumericOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_numeric_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericOrd", + family: "numeric", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_numeric_ord", + indexes: &["ope"], + query: Some("NumericOrdQuery"), + query_sql_domain: Some("eql_v3.query_numeric_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "NumericOrdOpe", + family: "numeric", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_numeric_ord_ope", + indexes: &["ope"], + query: Some("NumericOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_numeric_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Text", + family: "text", + suffix: "", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "the storage-only text domain follows once TextEq is proven end to end in PostgreSQL", + ), + }, + Target { + name: "TextEq", + family: "text", + suffix: "Eq", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_eq", + indexes: &["eq"], + query: Some("TextEqQuery"), + query_sql_domain: Some("eql_v3.query_text_eq"), + producible: true, + reason: None, + }, + Target { + name: "TextMatch", + family: "text", + suffix: "Match", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_match", + indexes: &["match"], + query: Some("TextMatchQuery"), + query_sql_domain: Some("eql_v3.query_text_match"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "TextOrdOre", + family: "text", + suffix: "OrdOre", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_ord_ore", + indexes: &["eq", "ore"], + query: Some("TextOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_text_ord_ore"), + producible: false, + reason: Some( + "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms", + ), + }, + Target { + name: "TextOrd", + family: "text", + suffix: "Ord", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_ord", + indexes: &["eq", "ope"], + query: Some("TextOrdQuery"), + query_sql_domain: Some("eql_v3.query_text_ord"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "TextOrdOpe", + family: "text", + suffix: "OrdOpe", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_ord_ope", + indexes: &["eq", "ope"], + query: Some("TextOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_text_ord_ope"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "TextSearchOre", + family: "text", + suffix: "SearchOre", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_search_ore", + indexes: &["eq", "ore", "match"], + query: Some("TextSearchOreQuery"), + query_sql_domain: Some("eql_v3.query_text_search_ore"), + producible: false, + reason: Some( + "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms", + ), + }, + Target { + name: "TextSearch", + family: "text", + suffix: "Search", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text_search", + indexes: &["eq", "ope", "match"], + query: Some("TextSearchQuery"), + query_sql_domain: Some("eql_v3.query_text_search"), + producible: false, + reason: Some( + "the engine derives match and OPE terms, but no EQL type is built from them yet", + ), + }, + Target { + name: "Boolean", + family: "boolean", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_boolean", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Real", + family: "real", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_real", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealEq", + family: "real", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_real_eq", + indexes: &["eq"], + query: Some("RealEqQuery"), + query_sql_domain: Some("eql_v3.query_real_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealOrdOre", + family: "real", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_real_ord_ore", + indexes: &["ore"], + query: Some("RealOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_real_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealOrd", + family: "real", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_real_ord", + indexes: &["ope"], + query: Some("RealOrdQuery"), + query_sql_domain: Some("eql_v3.query_real_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "RealOrdOpe", + family: "real", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_real_ord_ope", + indexes: &["ope"], + query: Some("RealOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_real_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "Double", + family: "double", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_double", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleEq", + family: "double", + suffix: "Eq", + plaintext: None, + sql_domain: "public.eql_v3_double_eq", + indexes: &["eq"], + query: Some("DoubleEqQuery"), + query_sql_domain: Some("eql_v3.query_double_eq"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleOrdOre", + family: "double", + suffix: "OrdOre", + plaintext: None, + sql_domain: "public.eql_v3_double_ord_ore", + indexes: &["ore"], + query: Some("DoubleOrdOreQuery"), + query_sql_domain: Some("eql_v3.query_double_ord_ore"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleOrd", + family: "double", + suffix: "Ord", + plaintext: None, + sql_domain: "public.eql_v3_double_ord", + indexes: &["ope"], + query: Some("DoubleOrdQuery"), + query_sql_domain: Some("eql_v3.query_double_ord"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "DoubleOrdOpe", + family: "double", + suffix: "OrdOpe", + plaintext: None, + sql_domain: "public.eql_v3_double_ord_ope", + indexes: &["ope"], + query: Some("DoubleOrdOpeQuery"), + query_sql_domain: Some("eql_v3.query_double_ord_ope"), + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, + Target { + name: "SteVecDocument", + family: "json", + suffix: "Search", + plaintext: None, + sql_domain: "public.eql_v3_json_search", + indexes: &["json"], + query: Some("SteVecQuery"), + query_sql_domain: Some("eql_v3.query_json"), + producible: false, + reason: Some("the JSON index is a new operation in the engine"), + }, + Target { + name: "Json", + family: "json", + suffix: "", + plaintext: None, + sql_domain: "public.eql_v3_json", + indexes: &[], + query: None, + query_sql_domain: None, + producible: false, + reason: Some( + "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is", + ), + }, +]; +/// Run the named type's own encryption plan for one field, or refuse +/// the name: the type is not producible (`TargetError::Unproducible`) +/// or does not exist (`TargetError::Unknown`). +pub(crate) fn encrypt_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + match name { + "TextEq" => { + run_target::("TextEq", keyset, column, plaintext) + } + _ => Err(refuse(name)), + } +} +/// Open a stored value of the named type back to its plaintext, or +/// refuse the name as `encrypt_named` does. +pub(crate) fn decrypt_named<'a, K: 'static>( + name: &str, + opener: Opener<'a, K>, + column: NonEmpty, + stored: &[u8], +) -> Result, TargetError> { + match name { + "TextEq" => open_target::("TextEq", opener, column, stored), + _ => Err(refuse(name)), + } +} +/// Run the named type's query twin for one plaintext, or refuse the +/// name as `encrypt_named` does. +pub(crate) fn query_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, +) -> Result, K>, TargetError> { + match name { + "TextEq" => { + run_target::("TextEq", keyset, column, plaintext) + } + _ => Err(refuse(name)), + } +} diff --git a/packages/eql/crates/eql-codegen/src/bindings.rs b/packages/eql/crates/eql-codegen/src/bindings.rs index 5da192264..d785beccf 100644 --- a/packages/eql/crates/eql-codegen/src/bindings.rs +++ b/packages/eql/crates/eql-codegen/src/bindings.rs @@ -574,7 +574,8 @@ pub fn render_inventory_rs() -> String { /// shapes are inventory members but not stored column payloads, so they are /// excluded — exactly the set `eql_bindings::from_v2` accepts as conversion /// targets ([`render_payload_rs`]'s `DomainPayload` variants). -fn stored_payload_domains() -> impl Iterator { +pub(crate) fn stored_payload_domains( +) -> impl Iterator { CATALOG .iter() .flat_map(|f| f.domains.iter().map(move |d| (f, d))) @@ -856,6 +857,9 @@ fn render_bindings(dir: &Path) -> Vec<(PathBuf, String)> { } rendered.push((dir.join("payload.rs"), render_payload_rs())); rendered.push((dir.join("query_payload.rs"), render_query_payload_rs())); + // The stack-encrypt target table and dispatch (`crate::targets`); gated + // to the `stack-encrypt` feature by the hand-written mod.rs. + rendered.push((dir.join("targets.rs"), crate::targets::render_targets_rs())); rendered.push((dir.join("inventory.rs"), render_inventory_rs())); rendered } @@ -1109,12 +1113,14 @@ mod tests { let tmp = crate::writer::test_support::tempdir(); let written = generate_bindings(tmp.path()).unwrap(); let dir = tmp.path().join("crates/eql-bindings/src/v3"); - // scalar families + jsonb_storage + payload + query_payload + inventory. - assert_eq!(written.len(), eql_domains::scalar_families().count() + 4); + // scalar families + jsonb_storage + payload + query_payload + targets + // + inventory. + assert_eq!(written.len(), eql_domains::scalar_families().count() + 5); assert!(dir.join("integer.rs").is_file()); assert!(dir.join("text.rs").is_file()); assert!(dir.join("json_storage.rs").is_file()); assert!(dir.join("payload.rs").is_file()); + assert!(dir.join("targets.rs").is_file()); assert!(dir.join("inventory.rs").is_file()); assert!( !dir.join("mod.rs").exists(), @@ -1135,7 +1141,8 @@ mod tests { // source, so a render panic aborts before deletion. Lock in the // load-bearing property: render writes NOTHING to disk. A pre-existing // file in the target dir survives the render call untouched, and render - // returns one entry per family plus payload and inventory (last). + // returns one entry per family plus payload, query_payload, targets + // and inventory (last). let tmp = crate::writer::test_support::tempdir(); let dir = tmp.path().join(V3_BINDINGS_DIR); std::fs::create_dir_all(&dir).unwrap(); @@ -1144,7 +1151,7 @@ mod tests { let rendered = render_bindings(&dir); - assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 4); + assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 5); assert_eq!( std::fs::read_to_string(&sentinel).unwrap(), "SENTINEL", @@ -1413,8 +1420,8 @@ mod tests { "the json family's scalar storage domain must generate json_storage.rs" ); // One file per scalar family + jsonb_storage + payload + query_payload + - // inventory. - assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 4); + // targets + inventory. + assert_eq!(rendered.len(), eql_domains::scalar_families().count() + 5); } #[test] diff --git a/packages/eql/crates/eql-codegen/src/lib.rs b/packages/eql/crates/eql-codegen/src/lib.rs index f05c70bfa..5d9968e96 100644 --- a/packages/eql/crates/eql-codegen/src/lib.rs +++ b/packages/eql/crates/eql-codegen/src/lib.rs @@ -14,6 +14,7 @@ pub mod dump; pub mod generate; pub mod operator_surface; pub mod ordering; +pub mod targets; pub mod writer; /// The repository root, derived from this crate's manifest dir (the generator diff --git a/packages/eql/crates/eql-codegen/src/targets.rs b/packages/eql/crates/eql-codegen/src/targets.rs new file mode 100644 index 000000000..07d9b27e9 --- /dev/null +++ b/packages/eql/crates/eql-codegen/src/targets.rs @@ -0,0 +1,493 @@ +//! The target-dispatch emitter: renders `eql_domains::CATALOG` to the +//! committed `crates/eql-bindings/src/v3/targets.rs` — the table of EQL +//! types a Stack Encrypt data plan may name as a field target, and the +//! by-name dispatch that runs a named type's own Rust plan. Generated beside +//! `inventory.rs` by the same mechanism ([`crate::bindings`]), and gated to +//! the `stack-encrypt` feature by the hand-written `v3/mod.rs`. +//! +//! The table is the stack-encrypt view of the catalog: for every stored +//! domain, the type's name in every language (`TextEq`), its family and +//! suffix, the plaintext kind it accepts, the indexes it carries, its query +//! twin, and whether the engine can produce it today — with the reason when +//! not. The dispatch has an arm for exactly the producible types, which are +//! exactly the domains carrying the `stack-encrypt` derives +//! ([`bindings::ENCRYPTION_DOMAINS`](crate::bindings::ENCRYPTION_DOMAINS)): +//! an arm runs `>::encryption()`, which only exists with +//! the derive, and [`target_gap`] is tested to agree with +//! [`encryption_gap`](crate::bindings::encryption_gap) on every domain. +//! +//! The reasons a type is not producible are the plan's +//! (`docs/plans/2026-10-04-plan-builder.md`, "EQL types"): every family but +//! text has no specified plaintext encoding; match and OPE terms are derived +//! but no EQL type is built from them; EQL's ORE term is block ORE where the +//! engine derives CLLW ORE; and the JSON index is a new engine operation. + +use proc_macro2::TokenStream; +use quote::{format_ident, quote}; + +use eql_domains::{Domain, DomainFamily, Shape, Term}; + +use crate::bindings::{encryption_gap, format_rs, stored_payload_domains}; + +/// The `IndexSpec::key()` a catalog term rides under in a plan — the +/// `"eq"` / `"match"` / `"ore"` / `"ope"` strings of stack-encrypt's data +/// grammar — so a Go generator can match a target's indexes against the +/// indexes a plan field may ask for by the same names. Exhaustive on +/// purpose: a new catalog term must say which engine index it is. +pub fn index_key(term: Term) -> &'static str { + match term { + Term::Hm => "eq", + Term::Bloom => "match", + Term::Ore => "ore", + Term::Ope => "ope", + } +} + +/// The index a SteVec document carries: the engine's JSON index, which has +/// no catalog `Term` because its terms live per `sv` leaf, not as flat +/// payload keys. +const JSON_INDEX_KEY: &str = "json"; + +/// The plaintext a family's domains are produced from, as a vitaminc +/// `ValueKind` name (what a plan field's `"type"` key spells) and the Rust +/// type the generated dispatch names. `None` while the family's plaintext +/// encoding for the stack-encrypt producer profile is unspecified, which is +/// every family but text today. +pub fn plaintext(family: &DomainFamily) -> Option<(&'static str, &'static str)> { + (family.name == "text").then_some(("string", "String")) +} + +/// Why the engine cannot produce a stored domain's EQL type today, or +/// `None` when it can. `None` exactly when the domain carries the +/// `stack-encrypt` derives ([`encryption_gap`]), because the dispatch runs +/// the derived plan; the reasons are the plan's, keyed on catalog facts so +/// a domain added tomorrow gets an answer. +pub fn target_gap(family: &DomainFamily, domain: &Domain) -> Option<&'static str> { + encryption_gap(family, domain)?; + Some(if matches!(domain.shape, Shape::SteVec) { + "the JSON index is a new operation in the engine" + } else if family.name != "text" { + "how this family encodes a plaintext for the stack-encrypt producer profile is not \ + specified; only the text family's encoding is" + } else if domain.terms.contains(&Term::Ore) { + "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are \ + different algorithms" + } else if domain + .terms + .iter() + .any(|t| matches!(t, Term::Bloom | Term::Ope)) + { + "the engine derives match and OPE terms, but no EQL type is built from them yet" + } else if domain.terms.is_empty() { + "the storage-only text domain follows once TextEq is proven end to end in PostgreSQL" + } else { + "an equality-only text domain derives the same way TextEq does; it is not listed in \ + ENCRYPTION_DOMAINS yet" + }) +} + +/// One row of the generated table, computed from the catalog. +struct Row { + name: String, + family: &'static str, + suffix: String, + plaintext: Option<&'static str>, + sql_domain: String, + indexes: Vec<&'static str>, + query: Option<(String, String)>, + reason: Option<&'static str>, +} + +/// The PascalCase of a bare domain name — `TextOrdOre` is family `text`, +/// suffix `OrdOre`; the storage domain's suffix is empty. The same mangler +/// as the struct identifier, run over the bare name alone: a scalar-shaped +/// `Domain` under an empty family name is `_`, and `struct_ident` +/// drops the empty segment. +fn suffix(domain: &Domain) -> String { + Domain { + name: domain.name, + terms: &[], + shape: Shape::Scalar, + } + .struct_ident("") +} + +fn row(family: &'static DomainFamily, domain: &'static Domain) -> Row { + let is_stevec = matches!(domain.shape, Shape::SteVec); + let indexes = if is_stevec { + vec![JSON_INDEX_KEY] + } else { + Term::payload_terms(domain.terms) + .into_iter() + .map(index_key) + .collect() + }; + let query = if is_stevec { + // The containment needle is the document's query form. Its struct + // is the hand-written `SteVecQuery`; its domain is the family's + // `query` domain (`eql_v3.query_json`), read from the catalog so + // the name is spelled once. + family + .domains + .iter() + .find(|d| matches!(d.shape, Shape::SteVec) && d.name == "query") + .map(|needle| { + ( + needle.rust_struct_name(family.name), + format!("eql_v3.{}", needle.full_name(family.name)), + ) + }) + } else if domain.terms.is_empty() { + None + } else { + Some(( + format!("{}Query", domain.struct_ident(family.name)), + format!("eql_v3.{}", domain.query_name(family.name)), + )) + }; + Row { + name: domain.rust_struct_name(family.name), + family: family.name, + suffix: suffix(domain), + plaintext: plaintext(family).map(|(kind, _)| kind), + sql_domain: format!("public.{}", domain.sql_typname(family.name)), + indexes, + query, + reason: target_gap(family, domain), + } +} + +fn option_str(value: Option<&str>) -> TokenStream { + match value { + Some(s) => quote!(Some(#s)), + None => quote!(None), + } +} + +/// Render the generated `crates/eql-bindings/src/v3/targets.rs`. +pub fn render_targets_rs() -> String { + let rows: Vec = stored_payload_domains().map(|(f, d)| row(f, d)).collect(); + + let entries: TokenStream = rows + .iter() + .map(|r| { + let name = &r.name; + let family = r.family; + let suffix = &r.suffix; + let plaintext = option_str(r.plaintext); + let sql_domain = &r.sql_domain; + let indexes = &r.indexes; + let query = option_str(r.query.as_ref().map(|(n, _)| n.as_str())); + let query_sql_domain = option_str(r.query.as_ref().map(|(_, d)| d.as_str())); + let producible = r.reason.is_none(); + let reason = option_str(r.reason); + quote! { + Target { + name: #name, + family: #family, + suffix: #suffix, + plaintext: #plaintext, + sql_domain: #sql_domain, + indexes: &[#(#indexes),*], + query: #query, + query_sql_domain: #query_sql_domain, + producible: #producible, + reason: #reason, + }, + } + }) + .collect(); + + // One arm per producible type. The plaintext Rust type is the family's; + // a producible family always has one, since a derive names it. + let producible: Vec<(&Row, &'static DomainFamily)> = stored_payload_domains() + .zip(rows.iter()) + .filter(|(_, r)| r.reason.is_none()) + .map(|((f, _), r)| (r, f)) + .collect(); + let arm = |r: &Row, f: &DomainFamily, strukt: &str| { + let name = &r.name; + let module = format_ident!("{}", f.name); + let ty = format_ident!("{strukt}"); + let (_, rust) = plaintext(f).expect("a producible family has a specified plaintext"); + let source = format_ident!("{rust}"); + (name.clone(), quote!(super::#module::#ty), quote!(#source)) + }; + let encrypt_arms: TokenStream = producible + .iter() + .map(|(r, f)| { + let (name, ty, source) = arm(r, f, &r.name); + quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), } + }) + .collect(); + let decrypt_arms: TokenStream = producible + .iter() + .map(|(r, f)| { + let (name, ty, source) = arm(r, f, &r.name); + quote! { #name => open_target::<#ty, #source, K>(#name, opener, column, stored), } + }) + .collect(); + let query_arms: TokenStream = producible + .iter() + .map(|(r, f)| { + let query = r + .query + .as_ref() + .map(|(n, _)| n.as_str()) + .expect("a producible type has a query twin"); + let (name, ty, source) = arm(r, f, query); + quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), } + }) + .collect(); + let helpers = if producible.is_empty() { + quote!() + } else { + quote!(open_target, run_target,) + }; + + let mod_doc = " 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."; + + let file = quote! { + #![doc = #mod_doc] + + use vitaminc_aead_value::FfiValue; + + use crate::encryption::targets::{#helpers refuse, Opener, Target, TargetError}; + use crate::Identifier; + use stack_encrypt::{KeysetCipher, NonEmpty, Pending}; + + /// Every EQL type a plan may name as a target, in + /// `eql-domains::CATALOG` order: one per stored domain (every flat + /// scalar domain plus the SteVec document). Query twins are not + /// targets; each row names its own under `query`. + pub const TARGETS: &[Target] = &[ + #entries + ]; + + /// Run the named type's own encryption plan for one field, or refuse + /// the name: the type is not producible (`TargetError::Unproducible`) + /// or does not exist (`TargetError::Unknown`). + pub(crate) fn encrypt_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, + ) -> Result, K>, TargetError> { + match name { + #encrypt_arms + _ => Err(refuse(name)), + } + } + + /// Open a stored value of the named type back to its plaintext, or + /// refuse the name as `encrypt_named` does. + pub(crate) fn decrypt_named<'a, K: 'static>( + name: &str, + opener: Opener<'a, K>, + column: NonEmpty, + stored: &[u8], + ) -> Result, TargetError> { + match name { + #decrypt_arms + _ => Err(refuse(name)), + } + } + + /// Run the named type's query twin for one plaintext, or refuse the + /// name as `encrypt_named` does. + pub(crate) fn query_named<'a, K: 'static>( + name: &str, + keyset: &'a KeysetCipher<'_, K>, + column: NonEmpty, + plaintext: FfiValue, + ) -> Result, K>, TargetError> { + match name { + #query_arms + _ => Err(refuse(name)), + } + } + }; + + format_rs(file) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::bindings::ENCRYPTION_DOMAINS; + + /// The dispatch runs a derived plan, so a type is producible exactly + /// when its domain carries the derive. The two answers come from two + /// functions; this is what holds them together. + #[test] + fn a_target_is_producible_exactly_when_its_domain_has_a_derive() { + let mut producible = Vec::new(); + for (family, domain) in stored_payload_domains() { + let full = domain.full_name(family.name); + assert_eq!( + target_gap(family, domain).is_none(), + encryption_gap(family, domain).is_none(), + "{full}: producible and derived must agree" + ); + if target_gap(family, domain).is_none() { + producible.push((family.name, domain.name)); + assert!( + plaintext(family).is_some(), + "{full}: a producible family names its plaintext" + ); + } + } + assert_eq!( + producible, + ENCRYPTION_DOMAINS.to_vec(), + "the producible set is the derived set" + ); + } + + /// Every reason is the plan's reason for that domain, selected by the + /// catalog fact that applies: a wrong branch order would hand the ORE + /// reason to `text_search` or the encoding reason to a text domain. + #[test] + fn every_unproducible_target_has_the_plan_reason_for_its_domain() { + for (family, domain) in stored_payload_domains() { + let full = domain.full_name(family.name); + let Some(reason) = target_gap(family, domain) else { + continue; + }; + let expected = if matches!(domain.shape, Shape::SteVec) { + "JSON index" + } else if family.name != "text" { + "encodes a plaintext" + } else if domain.terms.contains(&Term::Ore) { + "block ORE" + } else if domain + .terms + .iter() + .any(|t| matches!(t, Term::Bloom | Term::Ope)) + { + "match and OPE terms" + } else { + assert!( + domain.terms.is_empty(), + "{full}: an hm-only text domain other than eq is not in the catalog" + ); + "storage-only text domain" + }; + assert!(reason.contains(expected), "{full}: {reason}"); + } + // Spelled out for the four plan rows, so the table reads without + // the derivation above. + let text = eql_domains::TEXT; + let by_name = |name: &str| text.domain_by_name(name).expect("text domain"); + assert!(target_gap(&text, by_name("ord_ore")) + .unwrap() + .contains("CLLW")); + assert!(target_gap(&text, by_name("search_ore")) + .unwrap() + .contains("CLLW")); + assert!(target_gap(&text, by_name("match")) + .unwrap() + .contains("no EQL type")); + assert!(target_gap(&text, by_name("ord")) + .unwrap() + .contains("no EQL type")); + assert!(target_gap(&text, by_name("ord_ope")) + .unwrap() + .contains("no EQL type")); + assert!(target_gap(&text, by_name("search")) + .unwrap() + .contains("no EQL type")); + assert!( + target_gap(&text, by_name("eq")).is_none(), + "TextEq is producible" + ); + } + + #[test] + fn index_keys_are_the_engine_index_spec_keys() { + // The `IndexSpec::key()` strings of stack-encrypt's data grammar; + // a plan field spells an index by exactly these. + assert_eq!(index_key(Term::Hm), "eq"); + assert_eq!(index_key(Term::Bloom), "match"); + assert_eq!(index_key(Term::Ore), "ore"); + assert_eq!(index_key(Term::Ope), "ope"); + } + + #[test] + fn suffix_is_the_pascal_case_of_the_bare_domain_name() { + let text = eql_domains::TEXT; + let by_name = |name: &str| text.domain_by_name(name).expect("text domain"); + assert_eq!(suffix(by_name("")), ""); + assert_eq!(suffix(by_name("eq")), "Eq"); + assert_eq!(suffix(by_name("ord_ore")), "OrdOre"); + assert_eq!(suffix(by_name("search_ore")), "SearchOre"); + } + + #[test] + fn rendered_table_names_every_stored_domain_and_dispatches_the_producible_ones() { + let rendered = render_targets_rs(); + assert!(rendered.starts_with(crate::consts::RUST_GENERATED_MARKER)); + // rustfmt wraps a long arm over several lines; compare on one + // whitespace-collapsed line so the assertions below do not depend on + // where it broke. + let out: String = rendered.split_whitespace().collect::>().join(" "); + // One row per stored domain, in catalog order, named as the struct. + let mut last = 0; + for (family, domain) in stored_payload_domains() { + let needle = format!("name: \"{}\",", domain.rust_struct_name(family.name)); + let at = out[last..] + .find(&needle) + .unwrap_or_else(|| panic!("{needle} missing or out of order")); + last += at + needle.len(); + } + // The SteVec document carries the JSON index and the containment + // needle as its query form; the scalar storage domain carries none. + assert!(out.contains("name: \"SteVecDocument\"")); + assert!(out.contains("indexes: &[\"json\"]")); + assert!(out.contains("query: Some(\"SteVecQuery\")")); + assert!(out.contains("query_sql_domain: Some(\"eql_v3.query_json\")")); + assert!(out.contains("sql_domain: \"public.eql_v3_text_eq\"")); + assert!(out.contains("query_sql_domain: Some(\"eql_v3.query_text_eq\")")); + // Exactly the producible types have dispatch arms, in all three + // dispatches, and the query dispatch names the query twin. + for (family, domain) in ENCRYPTION_DOMAINS.iter().map(|(f, d)| { + let family = eql_domains::CATALOG + .iter() + .find(|x| x.name == *f) + .expect("family"); + (family, family.domain_by_name(d).expect("domain")) + }) { + let name = domain.rust_struct_name(family.name); + let module = family.name; + let arm = |helper: &str, ty: &str| { + format!( + "\"{name}\" => {{ {helper}::(\"{name}\", " + ) + }; + let flat_arm = |helper: &str, ty: &str| { + format!("\"{name}\" => {helper}::(\"{name}\", ") + }; + let count = |helper: &str, ty: &str| { + out.matches(&arm(helper, ty)).count() + out.matches(&flat_arm(helper, ty)).count() + }; + assert_eq!(count("run_target", &name), 1, "{name}: one encrypt arm"); + assert_eq!(count("open_target", &name), 1, "{name}: one decrypt arm"); + assert_eq!( + count("run_target", &format!("{name}Query")), + 1, + "{name}: one query arm" + ); + } + // No unproducible type has an arm: the only `=>` arms are the + // producible ones and the three fall-throughs. + let arms = out.matches("\" => ").count(); + assert_eq!(arms, ENCRYPTION_DOMAINS.len() * 3, "arms: {out}"); + assert_eq!(out.matches("_ => Err(refuse(name))").count(), 3); + } +} diff --git a/packages/eql/crates/eql-codegen/tests/cli.rs b/packages/eql/crates/eql-codegen/tests/cli.rs index 4beced971..4299ca989 100644 --- a/packages/eql/crates/eql-codegen/tests/cli.rs +++ b/packages/eql/crates/eql-codegen/tests/cli.rs @@ -35,7 +35,7 @@ fn tempdir() -> TempDir { /// the output-root override (test isolation) and never touches the committed /// `crates/eql-bindings/src/v3/*.rs`. The count is one file per scalar family /// plus the jsonb family's generated `jsonb_storage.rs`, `payload.rs`, -/// `query_payload.rs`, and `inventory.rs`. +/// `query_payload.rs`, `targets.rs`, and `inventory.rs`. #[test] fn bindings_subcommand_succeeds_and_reports_count() { let out_root = tempdir(); @@ -50,7 +50,7 @@ fn bindings_subcommand_succeeds_and_reports_count() { String::from_utf8_lossy(&out.stderr) ); let stdout = String::from_utf8_lossy(&out.stdout); - let expected = eql_domains::scalar_families().count() + 4; + let expected = eql_domains::scalar_families().count() + 5; assert!( stdout.contains(&format!("bindings: ok ({expected} files)")), "expected 'bindings: ok ({expected} files)' in stdout, got:\n{stdout}" diff --git a/packages/eql/tests/encryption/Cargo.toml b/packages/eql/tests/encryption/Cargo.toml index 4b95f0f20..ff99fb528 100644 --- a/packages/eql/tests/encryption/Cargo.toml +++ b/packages/eql/tests/encryption/Cargo.toml @@ -14,7 +14,13 @@ harness = false # Keep crypto/Postgres test dependencies out of eql-bindings' default test build. [dev-dependencies] eql-bindings = { path = "../../crates/eql-bindings", features = ["stack-encrypt"] } +# The parity oracle for the generated target table (tests/targets.rs): the +# table must name every stored catalog domain, in catalog order. +eql-domains = { path = "../../crates/eql-domains" } stack-encrypt = { path = "../../../stack-encrypt", default-features = false } +# `FfiValue`: the runtime plaintext the target dispatch takes. The same line +# eql-bindings names. +vitaminc-aead-value = "0.5.1" stack-kms = { path = "../../../stack-kms", default-features = false, features = ["test-support"] } serde = "1" serde_json = "1" diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs new file mode 100644 index 000000000..9330b18c6 --- /dev/null +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -0,0 +1,528 @@ +//! EQL types as plan field targets, reached by name: the dispatch in +//! `eql_bindings::encryption::targets` must run the same plan the typed +//! `encrypt_as::` call runs, refuse what the table says it cannot +//! produce, and the table must be the catalog. + +mod common; + +use eql_bindings::encryption::targets::{self as targets, Opener, Target, TargetError}; +use eql_bindings::v3::text::{TextEq, TextEqQuery}; +use eql_bindings::{v3, Identifier}; +use eql_domains::{Shape, Term, CATALOG}; +use stack_encrypt::Label; +use vitaminc_aead_value::{FfiValue, ValueKind}; + +fn email() -> Label { + Label::new(["users", "email"]).unwrap() +} + +fn column() -> stack_encrypt::NonEmpty { + Identifier::for_column("users", "email").unwrap() +} + +fn text(value: &str) -> FfiValue { + FfiValue::String(value.into()) +} + +fn opened(value: FfiValue) -> String { + match value { + FfiValue::String(s) => std::str::from_utf8(s.risky_ref()).unwrap().to_owned(), + other => panic!("a text target opens to a string, not {:?}", other.kind()), + } +} + +/// The refusal a dispatch returned. `Pending` has no `Debug`, so this stands +/// in for `unwrap_err`; an accepted call is the test's failure. +fn refused(result: Result) -> TargetError { + match result { + Err(error) => error, + Ok(_) => panic!("the dispatch accepted what it should have refused"), + } +} + +mod given_text_eq { + use super::*; + + /// The dispatch and the typed call are the same plan: same identifier, + /// same equality term, and each side's value opens through the other. + /// The ciphertexts differ because every write seals under a fresh key, + /// so byte identity is asserted on everything but `c`. + #[tokio::test] + async fn produces_what_encrypt_as_produces() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + for value in ["alice@example.com", "", "雪☃", "cafe\u{301}"] { + let by_name = targets::encrypt("TextEq", &keyset, &email(), text(value)) + .unwrap() + .await + .unwrap(); + let by_name: TextEq = serde_json::from_slice(&by_name).unwrap(); + let typed: TextEq = keyset + .encrypt_as(&value.to_owned(), column()) + .await + .unwrap(); + assert_eq!(by_name.v, typed.v, "{value:?}: same envelope version"); + assert_eq!( + by_name.i, typed.i, + "{value:?}: the label is the stored identifier" + ); + assert_eq!(by_name.hm, typed.hm, "{value:?}: same equality term"); + assert_ne!( + by_name.c, typed.c, + "{value:?}: each write seals under a fresh key" + ); + + let typed_opened: String = cipher + .decrypt_as(by_name.clone(), column().into()) + .await + .unwrap(); + assert_eq!( + typed_opened, value, + "{value:?}: the typed path opens the named value" + ); + let named_opened = targets::decrypt( + "TextEq", + &cipher, + &email(), + &serde_json::to_vec(&typed).unwrap(), + ) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened(named_opened), + value, + "{value:?}: the named path opens the typed value" + ); + } + let calls = calls.lock().unwrap(); + assert!( + calls.generate.iter().flatten().all(|d| d == "users/email"), + "both paths mint under the column's descriptor: {:?}", + calls.generate + ); + assert!( + calls.retrieve.iter().flatten().all(|d| d == "users/email"), + "both paths retrieve under the column's descriptor: {:?}", + calls.retrieve + ); + } + + #[tokio::test] + async fn round_trips_through_both_openers() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored = targets::encrypt("TextEq", &keyset, &email(), text("secret")) + .unwrap() + .await + .unwrap(); + let through_client = targets::decrypt("TextEq", Opener::Client(&cipher), &email(), &stored) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened(through_client), + "secret", + "the client opens its keyset's value" + ); + let through_keyset = targets::decrypt("TextEq", &keyset, &email(), &stored) + .unwrap() + .await + .unwrap(); + assert_eq!( + opened(through_keyset), + "secret", + "the keyset opens its own value" + ); + } + + #[tokio::test] + async fn query_bytes_are_the_typed_query_twin() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let by_name = targets::query("TextEq", &keyset, &email(), text("alice@example.com")) + .unwrap() + .await + .unwrap(); + let typed: TextEqQuery = keyset + .encrypt_as(&"alice@example.com".to_owned(), column()) + .await + .unwrap(); + assert_eq!( + by_name, + serde_json::to_vec(&typed).unwrap(), + "a query has no fresh key in it, so the bytes are identical" + ); + let stored = targets::encrypt("TextEq", &keyset, &email(), text("alice@example.com")) + .unwrap() + .await + .unwrap(); + let stored: TextEq = serde_json::from_slice(&stored).unwrap(); + assert_eq!(typed.hm, stored.hm, "the query matches the stored value"); + let calls = calls.lock().unwrap(); + assert_eq!( + calls.generate.len(), + 1, + "only the stored value minted a key" + ); + assert!(calls.retrieve.is_empty(), "a query retrieves nothing"); + } + + #[tokio::test] + async fn opens_under_the_expected_column_only() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let stored = targets::encrypt("TextEq", &keyset, &email(), text("secret")) + .unwrap() + .await + .unwrap(); + let other = Label::new(["users", "name"]).unwrap(); + let result = targets::decrypt("TextEq", &cipher, &other, &stored) + .unwrap() + .await; + match result { + Err(error) => assert!( + matches!(error, stack_encrypt::Error::ContextMismatch { .. }), + "a different expected column is refused before any key is retrieved: {error}" + ), + Ok(_) => panic!("a value stored under another column opened"), + } + assert!( + calls.lock().unwrap().retrieve.is_empty(), + "nothing was retrieved" + ); + } + + #[tokio::test] + async fn refuses_a_value_of_another_kind_before_minting() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let error = refused(targets::encrypt( + "TextEq", + &keyset, + &email(), + FfiValue::UInt64(34), + )); + assert!( + matches!( + error, + TargetError::Plaintext { + target: "TextEq", + expected: ValueKind::String, + found: Some(ValueKind::UInt64) + } + ), + "{error}" + ); + let error = refused(targets::query("TextEq", &keyset, &email(), FfiValue::Null)); + assert!( + matches!(error, TargetError::Plaintext { found: None, .. }), + "{error}" + ); + assert!( + calls.lock().unwrap().generate.is_empty(), + "nothing was minted" + ); + } + + #[tokio::test] + async fn refuses_bytes_that_are_not_the_type() { + let (cipher, _) = common::cipher().await; + for bytes in [ + &b"not json"[..], + br#"{"v":3,"i":{"t":"users","c":"email"},"hm":"00"}"#, + ] { + let error = refused(targets::decrypt("TextEq", &cipher, &email(), bytes)); + assert!( + matches!( + error, + TargetError::Stored { + target: "TextEq", + .. + } + ), + "{error}" + ); + } + } + + #[tokio::test] + async fn refuses_a_label_that_is_not_a_column() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + let tenant = Label::new(["tenant", "users", "email"]).unwrap(); + let error = refused(targets::encrypt("TextEq", &keyset, &tenant, text("x"))); + assert!( + matches!(&error, TargetError::Context { label } if label == "tenant/users/email"), + "{error}" + ); + } +} + +mod given_a_name_the_engine_cannot_produce { + use super::*; + + #[tokio::test] + async fn every_entry_point_refuses_it_with_the_table_reason() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let unproducible = |error: TargetError, name: &str| { + let reason = targets::target(name).unwrap().reason.unwrap(); + assert!( + matches!(&error, TargetError::Unproducible { name: n, reason: r } if *n == name && *r == reason), + "{name}: {error}" + ); + }; + for target in targets::targets().iter().filter(|t| !t.producible) { + let name = target.name; + unproducible( + refused(targets::encrypt(name, &keyset, &email(), text("x"))), + name, + ); + unproducible( + refused(targets::query(name, &keyset, &email(), text("x"))), + name, + ); + unproducible( + refused(targets::decrypt(name, &cipher, &email(), b"{}")), + name, + ); + } + let error = refused(targets::encrypt("TextOrdOre", &keyset, &email(), text("x"))); + assert_eq!( + error.to_string(), + "the engine cannot produce TextOrdOre yet: EQL stores a block ORE term and the engine \ + derives a CLLW ORE term; the two are different algorithms" + ); + // Refused by name before the label or the value is looked at. + let error = refused(targets::encrypt( + "TextOrdOre", + &keyset, + &Label::new(["one"]).unwrap(), + FfiValue::Null, + )); + assert!(matches!(error, TargetError::Unproducible { .. }), "{error}"); + let calls = calls.lock().unwrap(); + assert!( + calls.generate.is_empty() && calls.retrieve.is_empty(), + "no key was touched" + ); + } +} + +mod given_an_unknown_name { + use super::*; + + #[tokio::test] + async fn every_entry_point_says_no_such_type() { + let (cipher, _) = common::cipher().await; + let keyset = cipher.default_keyset(); + for name in ["Nope", "texteq", "text_eq", "TextEqQuery", ""] { + let unknown = |error: TargetError| { + assert!( + matches!(&error, TargetError::Unknown { name: n } if n == name), + "{name:?}: {error}" + ); + }; + unknown(refused(targets::encrypt( + name, + &keyset, + &email(), + text("x"), + ))); + unknown(refused(targets::query(name, &keyset, &email(), text("x")))); + unknown(refused(targets::decrypt(name, &cipher, &email(), b"{}"))); + } + assert_eq!( + refused(targets::encrypt("Nope", &keyset, &email(), text("x"))).to_string(), + "no such EQL type: Nope" + ); + } +} + +mod the_table { + use super::*; + + /// The stored domains of the catalog, in catalog order: every scalar + /// domain plus the SteVec document — the rows the table must have. + fn stored_domains() -> Vec<( + &'static eql_domains::DomainFamily, + &'static eql_domains::Domain, + )> { + CATALOG + .iter() + .flat_map(|f| f.domains.iter().map(move |d| (f, d))) + .filter(|(f, d)| d.is_scalar() || d.full_name(f.name) == "json_search") + .collect() + } + + fn index_key(term: Term) -> &'static str { + match term { + Term::Hm => "eq", + Term::Bloom => "match", + Term::Ore => "ore", + Term::Ope => "ope", + } + } + + #[test] + fn names_every_stored_catalog_domain_in_order() { + let table = targets::targets(); + let expected = stored_domains(); + assert_eq!( + table.iter().map(|t| t.name).collect::>(), + expected + .iter() + .map(|(f, d)| d.rust_struct_name(f.name)) + .collect::>(), + "one row per stored domain, named as its struct, in catalog order" + ); + for (row, (family, domain)) in table.iter().zip(&expected) { + let name = row.name; + assert_eq!(row.family, family.name, "{name}: family"); + assert_eq!( + row.sql_domain, + format!("public.{}", domain.sql_typname(family.name)), + "{name}: stored domain" + ); + let indexes: Vec<&str> = if matches!(domain.shape, Shape::SteVec) { + vec!["json"] + } else { + Term::payload_terms(domain.terms) + .into_iter() + .map(index_key) + .collect() + }; + assert_eq!(row.indexes, indexes.as_slice(), "{name}: indexes"); + let suffix: String = domain + .name + .split('_') + .filter(|s| !s.is_empty()) + .map(|s| { + let mut c = s.chars(); + c.next().unwrap().to_uppercase().collect::() + c.as_str() + }) + .collect(); + assert_eq!(row.suffix, suffix, "{name}: suffix"); + assert_eq!( + row.plaintext, + (family.name == "text").then_some("string"), + "{name}: only text's plaintext encoding is specified" + ); + assert_eq!( + row.plaintext_kind(), + (family.name == "text").then_some(ValueKind::String), + "{name}: the plaintext name is a vitaminc kind" + ); + if matches!(domain.shape, Shape::SteVec) { + assert_eq!( + row.query, + Some("SteVecQuery"), + "{name}: the containment needle" + ); + assert_eq!(row.query_sql_domain, Some("eql_v3.query_json"), "{name}"); + } else if domain.terms.is_empty() { + assert_eq!( + (row.query, row.query_sql_domain), + (None, None), + "{name}: storage-only" + ); + } else { + assert_eq!( + row.query.map(str::to_owned), + Some(format!("{}Query", domain.struct_ident(family.name))), + "{name}: query twin" + ); + assert_eq!( + row.query_sql_domain.map(str::to_owned), + Some(format!("eql_v3.{}", domain.query_name(family.name))), + "{name}: query domain" + ); + } + } + } + + #[test] + fn every_row_is_a_compiled_domain_type_and_so_is_its_query() { + let stored: Vec<&str> = v3::all().iter().map(|d| d.sql_domain()).collect(); + let queries: Vec<&str> = v3::all_query().iter().map(|d| d.sql_domain()).collect(); + for row in targets::targets() { + assert!( + stored.contains(&row.sql_domain), + "{}: {} is in all()", + row.name, + row.sql_domain + ); + if let Some(query) = row.query_sql_domain { + // The SteVec needle is in `all()`, not `all_query()`: it is + // a hand-written document shape, not a scalar twin. + assert!( + queries.contains(&query) || stored.contains(&query), + "{}: {query} is a compiled domain type", + row.name + ); + } + } + } + + #[test] + fn text_eq_is_the_one_producible_type_today() { + let producible: Vec<&str> = targets::targets() + .iter() + .filter(|t| t.producible) + .map(|t| t.name) + .collect(); + assert_eq!( + producible, + ["TextEq"], + "the plan's list: the engine produces TextEq only" + ); + for row in targets::targets().iter().filter(|t| !t.producible) { + let reason = row.reason.expect("an unproducible type has a reason"); + let expected = match (row.family, row.suffix) { + ("json", "Search") => "JSON index", + (family, _) if family != "text" => "encodes a plaintext", + (_, "OrdOre" | "SearchOre") => "CLLW", + (_, "Match" | "Ord" | "OrdOpe" | "Search") => "no EQL type is built", + (_, "") => "storage-only", + (_, suffix) => panic!("{}: unexpected text suffix {suffix}", row.name), + }; + assert!(reason.contains(expected), "{}: {reason}", row.name); + } + } + + /// The `se_targets` wire format, pinned on one row: a field renamed or + /// dropped here is a change every reader of the export sees. + #[test] + fn serializes_as_the_documented_wire_format() { + let row: &Target = targets::target("TextEq").unwrap(); + assert_eq!( + serde_json::to_value(row).unwrap(), + serde_json::json!({ + "name": "TextEq", + "family": "text", + "suffix": "Eq", + "plaintext": "string", + "sql_domain": "public.eql_v3_text_eq", + "indexes": ["eq"], + "query": "TextEqQuery", + "query_sql_domain": "eql_v3.query_text_eq", + "producible": true, + "reason": null + }) + ); + let row = targets::target("Integer").unwrap(); + let json = serde_json::to_value(row).unwrap(); + assert_eq!( + json["plaintext"], + serde_json::Value::Null, + "an unspecified plaintext is null, not absent" + ); + assert_eq!(json["query"], serde_json::Value::Null); + assert_eq!(json["producible"], false); + assert!(json["reason"].is_string()); + // The whole table serializes as a list, the shape of the export. + let all = serde_json::to_value(targets::targets()).unwrap(); + assert_eq!(all.as_array().unwrap().len(), targets::targets().len()); + } +} From 0275684950bb1317ae94c82015ad7cd51b7781e3 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 01:12:11 -0700 Subject: [PATCH 02/30] docs(eql-bindings): document EQL types as plan field targets The README's stack-encrypt section gains the by-name path beside the typed one, with the call shape and where the table comes from; the crate doc points a binding author at encryption::targets; the crate CHANGELOG's Unreleased section records the addition. A changeset for @cipherstash/eql goes with it: packages/eql/AGENTS.md asks for one on every releasable change, crate-only included, because the SQL bundle, the crate and the npm package ship in lockstep at one version, and the crate's release notes are assembled from the changesets rather than from the crate CHANGELOG. Minor, since the surface is additive and the SQL and TypeScript are unchanged. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- .changeset/eql-plan-field-targets.md | 5 +++ packages/eql/crates/eql-bindings/CHANGELOG.md | 12 +++++++ packages/eql/crates/eql-bindings/README.md | 35 +++++++++++++++++++ packages/eql/crates/eql-bindings/src/lib.rs | 4 +++ 4 files changed, 56 insertions(+) create mode 100644 .changeset/eql-plan-field-targets.md diff --git a/.changeset/eql-plan-field-targets.md b/.changeset/eql-plan-field-targets.md new file mode 100644 index 000000000..e1fd16c14 --- /dev/null +++ b/.changeset/eql-plan-field-targets.md @@ -0,0 +1,5 @@ +--- +'@cipherstash/eql': minor +--- + +**The `eql-bindings` crate resolves an EQL type named as a string to its own Stack Encrypt plan** (`stack-encrypt` feature). `eql_bindings::encryption::targets` carries a catalog-generated table of every EQL type a data plan may name as a field target — its name across languages, family and suffix, the plaintext `ValueKind` it takes, the indexes it carries, its query twin, and whether the engine can produce it today with the reason when not — and `encrypt` / `decrypt` / `query` entry points that dispatch on the name and run the type's derived `EncryptFrom` / `DecryptInto`, resolving to the EQL value's JSON bytes through the engine's `Pending` so a guest batches it with the rest of a plan. This is the EQL half of EQL types as plan field targets (cipherstash/stack#1062): a Go data plan names `TextEq` and the guest returns the finished EQL value instead of assembling one. `TextEq` is the only producible type; every other name is refused with the plan's reason. The SQL surface and the TypeScript package are unchanged. diff --git a/packages/eql/crates/eql-bindings/CHANGELOG.md b/packages/eql/crates/eql-bindings/CHANGELOG.md index 2bfe64ee6..242167c93 100644 --- a/packages/eql/crates/eql-bindings/CHANGELOG.md +++ b/packages/eql/crates/eql-bindings/CHANGELOG.md @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **EQL types as plan field targets, by name** (`stack-encrypt` feature). + `eql_bindings::encryption::targets` holds a catalog-generated table + (`targets()`) of every EQL type a Stack Encrypt data plan may name as a + field target — name, family, suffix, plaintext `ValueKind`, SQL domain, + indexes, query twin, and whether the engine can produce it today with the + reason when not — serializable as the guest's `se_targets` export, and + `encrypt` / `decrypt` / `query` entry points that dispatch on the name and + run the type's own `EncryptFrom` / `DecryptInto` plan, resolving to the EQL + value's JSON bytes through the engine's `Pending`. `Identifier::from_label` + reads a two-segment plan label as the column identifier. `TextEq` is the + one producible type; every other name is refused with its reason. + - **Scalar query-operand bindings.** Every term-bearing scalar domain now has a generated query twin — `IntegerEqQuery`, `IntegerOrdOpeQuery`, `TextSearchQuery`, … — the **enveloped term-only** operand `{v, i, }` diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index 97e278a73..cc3581834 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -172,6 +172,41 @@ column returns zero rows with no error. The separation will come from the EQL version itself (v4), not from a marker inside v3. Until then, one profile per column, and record which. +### EQL types as plan field targets + +A binding has no Rust type to name: its plan arrives as data, and a field of +that plan names its EQL type as a string (`"TextEq"`). The +`eql_bindings::encryption::targets` module is where the string meets the type. +`targets()` is the catalog-generated table of every EQL type a plan may name — +its name across languages, family and suffix, the plaintext kind it takes (a +vitaminc `ValueKind` name, the same names a plan field's `"type"` key uses), the +indexes it carries (`eq` / `match` / `ore` / `ope` / `json`), its query twin, +and whether the engine can produce it today, with the reason when not. A guest +serializes it for its `se_targets` export; the module docs are the wire format. +`encrypt`, `decrypt` and `query` dispatch on the name and run the type's own +Rust plan — the same `EncryptFrom` the typed `encrypt_as::` runs, so +the two paths produce the same identifier and equality term and open each +other's values. An unproducible name is refused with the table's reason; an +unknown one with "no such EQL type". + +```rust,ignore +use eql_bindings::encryption::targets; +use stack_encrypt::Label; +use vitaminc_aead_value::FfiValue; + +let column = Label::new(["users", "email"])?; // the field's context: table/column +let stored: Vec = targets::encrypt("TextEq", &keyset, &column, FfiValue::String("alice@example.com".into()))?.await?; +let probe: Vec = targets::query("TextEq", &keyset, &column, FfiValue::String("alice@example.com".into()))?.await?; +let opened: FfiValue = targets::decrypt("TextEq", &cipher, &column, &stored)?.await?; +``` + +The table and the dispatch are generated into `src/v3/targets.rs` beside +`inventory.rs` (`mise run types:generate`), gated to the `stack-encrypt` +feature, and drift-gated by the same parity tests. 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; `TextEq` is the only one today. + ### Developing the `stack-encrypt` feature Stack Encrypt lives in this repository (`packages/stack-encrypt`), and the diff --git a/packages/eql/crates/eql-bindings/src/lib.rs b/packages/eql/crates/eql-bindings/src/lib.rs index b88f65b61..1a0f3458f 100644 --- a/packages/eql/crates/eql-bindings/src/lib.rs +++ b/packages/eql/crates/eql-bindings/src/lib.rs @@ -32,6 +32,10 @@ feature = "stack-encrypt", doc = "See the [complete encryption example](encryption#example), including cipher setup, table/column identifiers, and decryption." )] +#![cfg_attr( + feature = "stack-encrypt", + doc = "A binding that names an EQL type as a string reaches the same plans through [`encryption::targets`]: the catalog-generated table of types a data plan may name as a field target, and the by-name `encrypt` / `decrypt` / `query` dispatch." +)] use schemars::JsonSchema; use serde::{Deserialize, Serialize}; From 5db0ef72322afbfe271152eccf69edb06276033a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 02:32:02 -0700 Subject: [PATCH 03/30] feat(stack-encrypt)!: a data plan field may name an EQL type as its target ADR-0007, amended 2026-10-06: the data grammar gains a target field form, exclusive with the output verbs, and the guest build that holds the EQL types returns the finished value. The engine knows no EQL type and cannot depend on eql-bindings (eql-bindings depends on it), so the lowering dispatches a target field through a resolver the host installs. dynamic::TargetResolver is that seam: the EQL types a build holds, as TargetDescriptor (whose to_value is the se_targets wire entry, keys fixed here in the crate that owns the data grammar), and encrypt / decrypt / query, each running the named type's own plan and handing back the engine's Pending. dynamic::NoTargets is the resolver of a build without EQL types and refuses every name with "this build holds no EQL types". dynamic::record reads {"context", "target", "type"?} beside the output form (plan_with; a field with both or neither is refused), resolves the name when the plan is built so se_plan_check reports an unknown or unproducible type, zips the resolver's Pending into the record's so the record is still one ZeroKMS request (encrypt_with), stores the EQL JSON under the field's "eql" key (EQL_KEY) as a passthrough byte node, opens it back through the resolver confined to the scope's keyset (decrypt_with), and derives a target field's query value (query). The bare plan, encrypt and decrypt run under NoTargets, so a plan from the build with EQL types handed to the build without them is refused, never half-sealed. Two rules the resolver cannot decide are decided here. A target field's "type", when declared, must be the kind the EQL type is produced from, and is that kind when undeclared, so every value is checked as any typed field's is. And an extended plan refuses a target field: an EQL value is stored under a table and a column, there is no column for a tenant part, and dropping the extension silently would seal under a label the plan did not declare. A target field is also keyed under its identity like a sealed one, so two fields under one label are refused across the two forms. The "eql" node is a passthrough on the wire and that is safe where a passthrough under "c" is not: its bytes are not handed back as plaintext. Opening runs the EQL type's own decryption, which authenticates the ciphertext inside the JSON under the field's column and refuses a different stored identifier. Tests run a resolver shaped like the EQL one over this engine (one leaf under the label in a JSON envelope) and pin: one generate and one retrieve for a record mixing sealed and target fields, batches in order, a plan of targets alone, every refusal before a key is touched, the two forms' exclusivity, the kind and extension rules, the identity rule across forms, the stored node's shape, the query path and the field-naming of a resolver's refusal. The check_record fuzz model gains the target key. Breaking: dynamic::Error gains Target; dynamic::FieldPlan::outputs may be empty for a target field. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/stack-encrypt/CHANGELOG.md | 23 + .../fuzz/fuzz_targets/check_record.rs | 14 +- packages/stack-encrypt/src/dynamic/mod.rs | 15 + packages/stack-encrypt/src/dynamic/record.rs | 1306 +++++++++++++++-- packages/stack-encrypt/src/dynamic/target.rs | 574 ++++++++ 5 files changed, 1848 insertions(+), 84 deletions(-) create mode 100644 packages/stack-encrypt/src/dynamic/target.rs diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index fa493e9ad..adfd8d35f 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **A data plan field may name an EQL type as its target.** Beside the + output form, `dynamic::record::plan_with` reads `{"context": [...], + "target": "TextEq", "type"?: ...}` — the two forms are exclusive — and + resolves the name through a `dynamic::TargetResolver` the host installs: + the EQL types a build holds (`targets()`, serializable as + `TargetDescriptor::to_value()` for a guest's `se_targets` export) and how + to run one. `encrypt_with` zips the type's own `Pending` into the record's, + so a record with target fields is still one ZeroKMS request, and stores + the EQL value's JSON bytes under the field's `"eql"` key + (`record::EQL_KEY`); `decrypt_with` opens it back through the resolver, + confined to the scope's keyset like every other leaf; `record::query` + derives a target field's query value. A name the resolver does not know + or cannot produce, a `"type"` other than the type's plaintext kind, and + an extended plan (an EQL value is stored under a table and a column, so + a tenant part has no column) are refused when the plan is built + (`Error::Target`, `TargetError`), so a guest's `se_plan_check` reports + them. `dynamic::NoTargets` is the resolver of a build without EQL types + and refuses every name; the bare `plan`, `encrypt` and `decrypt` run + under it. `FieldPlan::with_target`, `FieldPlan::target()`, + `Plan::new_with`. + ### Breaking - **A target description carries a source mode.** `Encryption` gains a diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs index addf296bc..481838852 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -92,14 +92,19 @@ impl Ctx { } } -/// One entry of a field spec: the two keys the parser knows and one it -/// does not, each with a value that may or may not be the right shape. +/// One entry of a field spec: the three keys the parser knows and one it +/// does not, each with a value that may or may not be the right shape. A +/// `"target"` names an EQL type; `check_record` runs under `NoTargets`, the +/// build without them, so every plan with one is refused when it is built +/// — the parser's path to that refusal is what this exercises. #[derive(Arbitrary, Debug)] enum SpecEntry { Context(Ctx), Outputs(Vec), + Target(Name), ContextWrongShape(u32), OutputsWrongShape(u32), + TargetWrongShape(u32), Unknown(Ctx), } @@ -116,8 +121,13 @@ impl SpecEntry { .collect(), ), ), + SpecEntry::Target(name) => ( + "target".to_string(), + FfiValue::String(name.as_str().into()), + ), SpecEntry::ContextWrongShape(v) => ("context".to_string(), FfiValue::UInt32(v)), SpecEntry::OutputsWrongShape(v) => ("outputs".to_string(), FfiValue::UInt32(v)), + SpecEntry::TargetWrongShape(v) => ("target".to_string(), FfiValue::UInt32(v)), SpecEntry::Unknown(ctx) => ("bogus".to_string(), ctx.into_value()), } } diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 04ceab49e..c8b0cf09c 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -26,6 +26,10 @@ //! is the same bytes (ADR-0007). The one step that stays dynamic is //! dispatching a value whose type is known only at run time to the typed //! term operation, which [`IndexSpec`]'s `Index` impls do. +//! * [`TargetResolver`] — the EQL types a build holds, installed by the host +//! that links them, so a plan field may name one as its target and the +//! lowering runs that type's own plan in the same request; [`NoTargets`] +//! is the build without them. //! * [`Value`] — an [`FfiValue`] as a plan field's plaintext, the type of //! every field lowered from data; [`TermBytes`] — the term such a field //! derives, as its frozen bytes. @@ -68,6 +72,7 @@ mod context; mod kind; pub mod record; +mod target; mod term; mod value; @@ -76,6 +81,7 @@ use std::fmt; pub use context::{borrowed, context}; pub use kind::{admits, read}; pub use record::{FieldPlan, Output, Plan}; +pub use target::{NoTargets, TargetDescriptor, TargetError, TargetResolver}; pub use term::{term, Scalar, TermBytes}; pub use value::Value; /// vitaminc's language-neutral value tree — the runtime value every binding @@ -207,6 +213,15 @@ pub enum Error { #[error("internal invariant violated")] Internal, + /// A plan field names an EQL type as its target and the name, the + /// field's label or type, or the value does not fit: the build holds no + /// EQL types, no type has the name, the engine cannot produce it yet, + /// the plan is extended, or the value is of another kind. Decided when + /// the plan is built or the value is read, before any key is touched + /// — save [`TargetError::Other`], which is the resolver's own failure. + #[error(transparent)] + Target(#[from] TargetError), + /// Sealing, opening or deriving failed. #[error(transparent)] Cipher(#[from] crate::Error), diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 26c27a1ce..ea456b20b 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -57,6 +57,27 @@ //! the two-segment and shared-prefix rules hold, and it seals the same //! bytes. //! +//! # A field that names an EQL type +//! +//! Instead of outputs, a field may name an EQL type as its **target**: +//! `{"context": [...], "target": "TextEq"}`. The two forms are exclusive — +//! a field with both is refused — and the lowering runs the named type's +//! own plan through the [`TargetResolver`] the host installed +//! ([`plan_with`], [`encrypt_with`], [`decrypt_with`], [`query`]), zipping +//! its [`Pending`] into the record's so the whole record is still one +//! ZeroKMS request. The bare entry points ([`plan`], [`encrypt`], +//! [`decrypt`]) run with [`NoTargets`], the resolver of a build without EQL +//! types, which refuses every target name when the plan is built: a plan +//! that parses there has no target field. +//! +//! A target field's label must be a column, `/`, because +//! that is what an EQL value stores in its `i`; an extended plan (a tenant +//! part on every label) has no column for it and is refused with +//! [`TargetError::Extended`] rather than silently dropping the extension. +//! The field's `"type"`, when declared, must be the kind the EQL type is +//! produced from ([`TargetError::Kind`]); undeclared, it is that kind, so +//! every value is checked against it as any typed field's is. +//! //! # What a field's `"type"` decides, and what it does not //! //! Every field lowered from data is a [`Value`]: its plaintext type is the @@ -92,10 +113,19 @@ //! //! A record is stored as `field → { output-key → node }`: `"c"` is the //! field's ciphertext, each term rides under its index key (`"eq"`, -//! `"match"`, `"ore"`, `"ope"`) as a passthrough byte node, and a passthrough -//! field rides under `"passthrough"`. A row written under one spelling is -//! read under the same spelling or not at all, so the keys are fixed here -//! and every binding agrees on them by construction. +//! `"match"`, `"ore"`, `"ope"`) as a passthrough byte node, a passthrough +//! field rides under `"passthrough"`, and a target field's EQL value rides +//! under [`EQL_KEY`] (`"eql"`) as a passthrough byte node holding the EQL +//! JSON. A row written under one spelling is read under the same spelling +//! or not at all, so the keys are fixed here and every binding agrees on +//! them by construction. +//! +//! The `"eql"` node is a passthrough on the wire and that is safe where a +//! passthrough under `"c"` is not: its bytes are not handed back as +//! plaintext. Opening them runs the EQL type's own decryption, which reads +//! the ciphertext *inside* the JSON, authenticates it under the field's +//! column and refuses a different stored identifier — so a forged `"eql"` +//! node opens to nothing, as a forged `"c"` leaf does. //! //! Under `"c"` a passthrough is refused in both directions, and that is //! load-bearing: opening a passthrough retrieves no key and opens no AEAD, @@ -106,8 +136,11 @@ //! that a round-trip invariant rather than data loss. use vitaminc_aead_value::{FfiValue, ValueKind}; +use vitaminc_protected::Controlled; -use super::{admits, utf8, Error, Scalar, Scope, TermBytes, Value}; +use super::{ + admits, utf8, Error, NoTargets, Scalar, Scope, TargetError, TargetResolver, TermBytes, Value, +}; use crate::plan::{FieldValues, FieldsBuilder, Opens, Runs}; use crate::target::{CallerContext, DeclaredContext, Decryption, Encrypted, IndexSpec}; use crate::{ @@ -115,6 +148,11 @@ use crate::{ StackCipherText, }; +/// The map key a target field's EQL value rides under in a stored record: +/// wire format, like the output keys (see the +/// [module docs](self#the-stored-record-is-wire-format)). +pub const EQL_KEY: &str = "eql"; + /// What a plan field asks for. /// /// The keys are wire format twice over: they are how a binding spells an @@ -170,13 +208,15 @@ impl Output { } } -/// The verb a field's outputs lower to: one of the plan builder's four. +/// The verb a field's outputs lower to: one of the plan builder's four, or +/// a target, which the resolver runs beside the lowered plan. #[derive(Clone, Copy, PartialEq, Eq, Debug)] enum Verb { Encrypt, EncryptIndex, Index, Passthrough, + Target, } /// One field of a record plan: what to call it, what label to seal it @@ -195,6 +235,7 @@ pub struct FieldPlan { label: Label, extension: Vec>, outputs: Vec, + target: Option, field_type: Option, } @@ -244,6 +285,40 @@ impl FieldPlan { label, extension, outputs, + target: None, + field_type: None, + }) + } + + /// A field plan that names an EQL type as its target instead of + /// outputs: the type's own plan decides what is sealed and which terms + /// sit beside it. Whether the name is one this build can run is decided + /// when the plan is built ([`Plan::new_with`]), against the resolver. + /// + /// # Errors + /// + /// [`Error::Plan`] if `target` is empty, or if `context` is not a label + /// of at least two segments, optionally extended. + pub fn with_target( + name: impl Into, + context: NonEmpty>, + target: impl Into, + ) -> Result { + let target = target.into(); + if target.is_empty() { + return Err(Error::Plan); + } + let (label, extension) = split_context(context.get())?; + if label.segments().len() < 2 { + return Err(Error::Plan); + } + Ok(Self { + name: name.into(), + context, + label, + extension, + outputs: Vec::new(), + target: Some(target), field_type: None, }) } @@ -293,11 +368,17 @@ impl FieldPlan { self.label.segments().last().unwrap_or("") } - /// What the field produces. + /// What the field produces. Empty for a field that names a target: its + /// outputs are the EQL type's own. pub fn outputs(&self) -> &[Output] { &self.outputs } + /// The EQL type this field names as its target, if it does. + pub fn target(&self) -> Option<&str> { + self.target.as_deref() + } + /// The declared type of the field's values, if the plan declares one. /// A host with no types of its own reads this to know what a decrypted /// value is. @@ -317,7 +398,9 @@ impl FieldPlan { } fn verb(&self) -> Verb { - if self.outputs.contains(&Output::Passthrough) { + if self.target.is_some() { + Verb::Target + } else if self.outputs.contains(&Output::Passthrough) { Verb::Passthrough } else if self.has_ciphertext() { if self.indexes().is_empty() { @@ -361,6 +444,11 @@ impl FieldPlan { kind: self.field_type, } } + + /// Whether the field is run by the resolver rather than the lowered plan. + fn is_target(&self) -> bool { + self.target.is_some() + } } /// The text of a text part. @@ -435,7 +523,19 @@ enum Context { impl Plan { /// A plan over `fields`, in the order given — which is the order of the /// fields in every result — under the context every field's label - /// shares. + /// shares, for a build without EQL types: [`new_with`](Self::new_with) + /// under [`NoTargets`], so a field that names a target is refused. + /// + /// # Errors + /// + /// As [`new_with`](Self::new_with). + pub fn new(fields: Vec) -> Result { + Self::new_with(fields, &NoTargets) + } + + /// A plan over `fields`, in the order given — which is the order of the + /// fields in every result — under the context every field's label + /// shares, resolving each target field's name through `resolver`. /// /// # Errors /// @@ -444,12 +544,18 @@ impl Plan { /// labels sit under different contexts or carry different extensions, /// or does not build as a fields plan: two sealed or indexed fields /// keyed under one identity, for instance, whose terms would be - /// interchangeable. - pub fn new(fields: Vec) -> Result { + /// interchangeable. [`Error::Target`] if a target field names a type the + /// resolver does not know or cannot produce, declares a `"type"` other + /// than the kind that type is produced from, or sits in an extended plan. + pub fn new_with( + mut fields: Vec, + resolver: &(impl TargetResolver + ?Sized), + ) -> Result { let Some(first) = fields.first() else { return Err(Error::Plan); }; let context = first.prefix()?; + let extension = first.extension.clone(); for field in &fields { // `prefix` refuses a one-segment label, which has nothing to // sit under; a longer one must sit under the first field's. @@ -457,6 +563,30 @@ impl Plan { return Err(Error::Plan); } } + for field in fields.iter_mut().filter(|field| field.is_target()) { + let name = field.target.clone().unwrap_or_default(); + let descriptor = resolver.resolve(&name)?; + if !extension.is_empty() { + return Err(TargetError::Extended { + name: field.name.clone(), + label: field.label.to_string(), + } + .into()); + } + match (field.field_type, descriptor.plaintext) { + (Some(declared), expected) if expected != Some(declared) => { + return Err(TargetError::Kind { + name: field.name.clone(), + target: name, + expected, + declared, + } + .into()); + } + (None, Some(kind)) => field.field_type = Some(kind), + _ => {} + } + } Self::build(Context::Label(context), fields) } @@ -500,6 +630,11 @@ impl Plan { { return Err(Error::Plan); } + // A target field's label must be a column, `
/`, + // which a context field's one-segment identity is not. + if fields.iter().any(FieldPlan::is_target) { + return Err(Error::Plan); + } let fields = fields .into_iter() .map(|mut field| { @@ -529,6 +664,23 @@ impl Plan { if field.extension != extension { return Err(Error::Plan); } + // A target field is keyed under its identity like a sealed one; + // the builder checks that rule for the fields it lowers, so the + // target fields are checked against every field here. + if field.is_target() + && fields[..at] + .iter() + .any(|prior| prior.identity() == field.identity()) + { + return Err(Error::Plan); + } + if !field.is_target() + && fields[..at] + .iter() + .any(|prior| prior.is_target() && prior.identity() == field.identity()) + { + return Err(Error::Plan); + } } let plan = Self { context, @@ -591,7 +743,9 @@ impl Plan { } }; for field in &self.fields { - if self.context_field() == Some(field.name.as_str()) { + // The context field is declared by `context_field` above; a + // target field is the resolver's. + if self.context_field() == Some(field.name.as_str()) || field.is_target() { continue; } builder = declare(builder, field); @@ -616,6 +770,11 @@ impl Plan { fn shape(&self) -> Vec { self.fields.iter().map(FieldPlan::shape).collect() } + + /// The fields that name a target, in plan order: the resolver's. + fn target_fields(&self) -> impl Iterator + '_ { + self.fields.iter().filter(|field| field.is_target()) + } } /// One field's verb, over a [`Value`]; its indexes are the [`IndexSpec`]s @@ -630,10 +789,24 @@ fn declare( Verb::EncryptIndex => builder.encrypt_index::(name, field.indexes()), Verb::Index => builder.index::(name, field.indexes()), Verb::Passthrough => builder.passthrough::(name), + // `lower` never hands a target field here: the resolver runs it. + Verb::Target => builder, } } -/// Read a record plan from a decoded value. +/// Read a record plan from a decoded value, for a build without EQL types: +/// [`plan_with`] under [`NoTargets`], so a field that names a `"target"` is +/// refused ([`TargetError::NoTargets`]). +/// +/// # Errors +/// +/// As [`plan_with`]. +pub fn plan(value: FfiValue) -> Result { + plan_with(value, &NoTargets) +} + +/// Read a record plan from a decoded value, resolving each target field's +/// name through `resolver`. /// /// The plan is an [`FfiValue::Object`]: /// @@ -649,6 +822,20 @@ fn declare( /// value is not a string is refused rather than read as a field of that /// name. /// +/// A field may name an EQL type as its target instead of outputs: +/// +/// ```text +/// { : { "context": , "target": "", "type": }, ... } +/// ``` +/// +/// A field has `"outputs"` or `"target"`, never both. `"target"` is the +/// type's name as the resolver lists it (`"TextEq"`), resolved when the plan +/// is built so a name the build cannot run fails here and not at the first +/// value; its `"type"`, when given, must be the kind the type is produced +/// from. See the [module docs](self#a-field-that-names-an-eql-type). A +/// target field needs a column for its label, so it is refused under a +/// context field. +/// /// `` is an index in its wire form, which is its key — `"eq"`, /// `"match"`, `"ore"` or `"ope"` — save for a match index with options other /// than the defaults, which is a one-entry object mapping `"match"` to them: @@ -694,12 +881,12 @@ fn declare( /// # Examples /// /// ``` -/// use stack_encrypt::dynamic::{record, FfiValue, Output}; +/// use stack_encrypt::dynamic::{record, FfiValue, NoTargets, Output}; /// use stack_encrypt::target::IndexSpec; /// /// // As a binding would decode it from its caller: seal `age` under the /// // label users/age and index it for equality. -/// let plan = record::plan(FfiValue::Object(vec![( +/// let plan = record::plan_with(FfiValue::Object(vec![( /// "age".to_string(), /// FfiValue::Object(vec![ /// ( @@ -717,7 +904,7 @@ fn declare( /// ]), /// ), /// ]), -/// )]))?; +/// )]), &NoTargets)?; /// /// assert_eq!(plan.fields().len(), 1); /// assert_eq!(plan.fields()[0].name(), "age"); @@ -733,24 +920,29 @@ fn declare( /// /// [`Error::Plan`] for a plan that is not an object of field specs, an /// empty plan, a field named twice, a spec with a key other than -/// `"context"`, `"outputs"` and `"type"` or with one given twice, missing -/// `"context"` or `"outputs"`, an output list that is not a list of outputs -/// (above), is empty, names an output key twice or names `"passthrough"` -/// beside another output, a `"type"` that is not a string naming a -/// [`ValueKind`], a type that does not admit one of the field's index -/// outputs, a context that is not a label of at least two segments -/// (optionally extended), fields under different contexts or extensions, -/// or a plan the builder refuses ([`Plan::new`]); with `"context_field"`, -/// a value that is not a string, given twice, naming no field of the -/// plan, or a plan [`Plan::with_context_field`] refuses. [`Error::Context`] -/// for a `"context"` that is present but is not a context at all, or -/// renders empty. +/// `"context"`, `"outputs"`, `"target"` and `"type"` or with one given +/// twice, missing `"context"`, having neither `"outputs"` nor `"target"` +/// or having both, an output list that is not a list of outputs (above), +/// is empty, names an output key twice or names `"passthrough"` beside +/// another output, a `"target"` that is not a non-empty string, a `"type"` +/// that is not a string naming a [`ValueKind`], a type that does not admit +/// one of the field's index outputs, a context that is not a label of at +/// least two segments (optionally extended), fields under different +/// contexts or extensions, or a plan the builder refuses ([`Plan::new`]); +/// with `"context_field"`, a value that is not a string, given twice, +/// naming no field of the plan, or a plan [`Plan::with_context_field`] +/// refuses. [`Error::Context`] for a `"context"` that is present but is +/// not a context at all, or renders empty. [`Error::Target`] for a target +/// the resolver refuses ([`Plan::new_with`]). /// /// The transport codec refuses duplicate object keys before a binding's /// value reaches here, but an [`FfiValue`] can be built with them directly /// and this is a public parser, so it refuses them itself rather than /// letting the last one win. -pub fn plan(value: FfiValue) -> Result { +pub fn plan_with( + value: FfiValue, + resolver: &(impl TargetResolver + ?Sized), +) -> Result { let FfiValue::Object(entries) = value else { return Err(Error::Plan); }; @@ -772,10 +964,17 @@ pub fn plan(value: FfiValue) -> Result { }; let mut context: Option>> = None; let mut outputs: Option> = None; + let mut target: Option = None; let mut field_type: Option = None; for (key, value) in spec { match key.as_str() { "context" if context.is_none() => context = Some(super::context(value)?), + "target" if target.is_none() => { + let FfiValue::String(s) = &value else { + return Err(Error::Plan); + }; + target = Some(utf8(s).ok_or(Error::Plan)?.to_owned()); + } "outputs" if outputs.is_none() => { let FfiValue::Array(items) = value else { return Err(Error::Plan); @@ -793,15 +992,17 @@ pub fn plan(value: FfiValue) -> Result { let name = utf8(s).ok_or(Error::Plan)?; field_type = Some(name.parse().map_err(|_| Error::Plan)?); } - // An unknown key, or one of the three given twice. + // An unknown key, or one of the four given twice. _ => return Err(Error::Plan), } } - let field = FieldPlan::new( - name, - context.ok_or(Error::Plan)?, - outputs.ok_or(Error::Plan)?, - )?; + let context = context.ok_or(Error::Plan)?; + // One form or the other: a field with both, or neither, is refused. + let field = match (outputs, target) { + (Some(outputs), None) => FieldPlan::new(name, context, outputs)?, + (None, Some(target)) => FieldPlan::with_target(name, context, target)?, + _ => return Err(Error::Plan), + }; fields.push(match field_type { Some(field_type) => field.with_type(field_type)?, None => field, @@ -811,7 +1012,7 @@ pub fn plan(value: FfiValue) -> Result { // hand-built one are refused alike. match context_field { Some(name) => Plan::with_context_field(name, fields), - None => Plan::new(fields), + None => Plan::new_with(fields, resolver), } } @@ -889,32 +1090,143 @@ pub fn plan(value: FfiValue) -> Result { /// [`Error::Source`] if the source does not fit the plan; [`Error::Term`] /// if a value has no term the plan asks for. Both are decided here, before /// the pending exists. A failure of the pending itself is the engine's. +/// +/// A plan with a target field needs the resolver that built it: +/// [`encrypt_with`]. This runs under [`NoTargets`], so such a plan is +/// refused ([`Error::Target`]). pub fn encrypt<'a, K: 'static>( cipher: &'a KeysetCipher<'_, K>, source: FfiValue, plan: &Plan, +) -> Result, Error> { + encrypt_with(cipher, source, plan, &NoTargets) +} + +/// [`encrypt`], running each target field's EQL type through `resolver`: +/// the type's own plan yields a [`Pending`] that is zipped into the +/// record's, so a record with target fields is still one ZeroKMS request. +/// A target field's value rides under [`EQL_KEY`] in the result. +/// +/// # Errors +/// +/// As [`encrypt`], plus [`Error::Target`] for a target value of another +/// kind than the type takes, or a name the resolver refuses. +pub fn encrypt_with<'a, K: 'static>( + cipher: &'a KeysetCipher<'_, K>, + source: FfiValue, + plan: &Plan, + resolver: &(impl TargetResolver + ?Sized), ) -> Result, Error> { let rows = source_rows(source, plan)?; let lowered = plan.lower::().map_err(|_| Error::Internal)?; let extend = plan.declared_context(); let shape = plan.shape(); + // Every target field of every row, in row-major order, run by the + // resolver; `all` merges their requests with the lowered plan's below. + let mut pendings = Vec::new(); + let mut targets = |row: Row| -> Result { + for (field, value) in plan.target_fields().zip(row.targets) { + let name = field.target().unwrap_or_default(); + pendings.push( + resolver + .encrypt(name, cipher, field.label(), value) + .map_err(|error| Error::Target(name_target(error, &field.name)))?, + ); + } + Ok(row.values) + }; Ok(match rows { - Rows::One(values) => { + Rows::One(row) => { + let values = targets(row)?; + let eql = Pending::all(cipher, pendings); Runs::::pending(&lowered, cipher, &values, None, extend) - .try_map(move |values| shape_record(values, &shape)) + .zip(eql) + .try_map(move |(values, eql)| shape_record(values, eql, &shape)) + } + Rows::Batch(rows) => { + let values = rows + .into_iter() + .map(&mut targets) + .collect::, Error>>()?; + let eql = Pending::all(cipher, pendings); + let per_row = plan.target_fields().count(); + Runs::<[FieldValues], K>::pending(&lowered, cipher, &values, None, extend) + .zip(eql) + .try_map(move |(rows, eql)| { + let mut eql = eql.into_iter(); + rows.into_iter() + .map(|values| { + let own: Vec> = eql.by_ref().take(per_row).collect(); + shape_record(values, own, &shape) + }) + .collect::, _>>() + .map(CipherText::Sequence) + }) } - Rows::Batch(rows) => Runs::<[FieldValues], K>::pending( - &lowered, cipher, &rows, None, extend, - ) - .try_map(move |rows| { - rows.into_iter() - .map(|values| shape_record(values, &shape)) - .collect::, _>>() - .map(CipherText::Sequence) - }), }) } +/// Derive the EQL query value of one target field for one plaintext: the +/// operand that matches stored values of the field, as JSON bytes, through +/// the type's own query plan. A query derives no data key, so the pending +/// settles without I/O; it is a [`Pending`] all the same, for one shape at +/// the call site. +/// +/// # Errors +/// +/// [`Error::Plan`] if the plan has no field `field` or it is not a target +/// field (a term of an indexed field is [`super::term`](super::term())); +/// [`Error::Source`] for a value of another kind than the field declares; +/// [`Error::Target`] for what the resolver refuses. +pub fn query<'a, K: 'static>( + cipher: &'a KeysetCipher<'_, K>, + plan: &Plan, + field: &str, + value: FfiValue, + resolver: &(impl TargetResolver + ?Sized), +) -> Result, K>, Error> { + let field = plan + .fields + .iter() + .find(|candidate| candidate.name == field) + .ok_or(Error::Plan)?; + let name = field.target().ok_or(Error::Plan)?; + check_field(&value, field)?; + resolver + .query(name, cipher, field.label(), value) + .map_err(|error| Error::Target(name_target(error, &field.name))) +} + +/// A resolver's refusal, with the field named where the resolver could not +/// name it: a resolver sees a type and a label, the lowering knows the +/// field. +fn name_target(error: TargetError, field: &str) -> TargetError { + match error { + TargetError::Column { label, reason, .. } => TargetError::Column { + name: field.to_owned(), + label, + reason, + }, + TargetError::Plaintext { + target, + expected, + found, + .. + } => TargetError::Plaintext { + name: field.to_owned(), + target, + expected, + found, + }, + TargetError::Stored { target, reason, .. } => TargetError::Stored { + name: field.to_owned(), + target, + reason, + }, + other => other, + } +} + /// Decrypt a record — or a batch — produced by [`encrypt`] under the same /// plan. /// @@ -951,32 +1263,96 @@ pub fn encrypt<'a, K: 'static>( /// value of another kind than it declares /// ([`PlanError::FieldType`](crate::PlanError::FieldType) — the type tag /// is inside the AEAD envelope, so only opening can see it). +/// +/// A plan with a target field needs the resolver that built it: +/// [`decrypt_with`]. This runs under [`NoTargets`], so such a plan is +/// refused ([`Error::Target`]). pub fn decrypt<'a, K: 'static>( scope: Scope<'a, K>, record: StackCipherText, plan: &Plan, expected: Option
/, and a cipher extended with a tenant part refuses a struct with an encrypt_into field (ErrEncoding) rather than dropping the extension. The example keeps its tenant extension and its separate columns for that reason; the README and the plan record the rule as an open question. CI builds the eql guest and its test build, checks their imports and checksums, and hands them to the other platforms with the rest; the Go workflow also runs when eql-bindings or eql-domains change. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/cmd/stashgen/README.md | 10 +- languages/golang/cmd/stashgen/main.go | 5 + languages/golang/cmd/stashgen/main_test.go | 27 +- languages/golang/encrypt/README.md | 14 + languages/golang/encrypt/eql/eql.go | 123 + languages/golang/encrypt/eql/eql_gen.go | 2207 +++++++++++++++++ languages/golang/encrypt/eql/eql_test.go | 79 + languages/golang/encrypt/eql/guest.go | 30 + languages/golang/encrypt/eql/wasm/README.md | 8 +- languages/golang/encrypt/eql_test.go | 267 ++ languages/golang/encrypt/export_test.go | 50 +- languages/golang/encrypt/gensupport/codec.go | 10 +- .../golang/encrypt/gensupport/declaration.go | 16 +- languages/golang/encrypt/gensupport/field.go | 16 +- .../gensupport/gensupport_internal_test.go | 11 +- languages/golang/encrypt/guest.go | 15 +- languages/golang/encrypt/guest_test.go | 19 +- .../encrypt/internal/eqlguest/eqlguest.go | 15 + .../internal/testusers/contact_stash.go | 137 + .../encrypt/internal/testusers/contacts.go | 15 + languages/golang/encrypt/records.go | 76 +- languages/golang/internal/record/record.go | 64 +- .../golang/internal/record/record_test.go | 32 + languages/golang/stashgen/engine.go | 41 +- languages/golang/stashgen/read.go | 18 +- mise.toml | 10 +- packages/eql/crates/eql-codegen/src/go_eql.rs | 202 ++ packages/eql/crates/eql-codegen/src/lib.rs | 1 + packages/eql/crates/eql-codegen/src/main.rs | 18 + .../eql/crates/eql-codegen/src/targets.rs | 31 +- .../crates/eql-codegen/tests/go_eql_parity.rs | 27 + packages/eql/mise.toml | 9 +- .../encryption/fixtures/text_eq_query.json | 7 + .../eql/tests/encryption/tests/targets.rs | 51 + 34 files changed, 3579 insertions(+), 82 deletions(-) create mode 100644 languages/golang/encrypt/eql/eql.go create mode 100644 languages/golang/encrypt/eql/eql_gen.go create mode 100644 languages/golang/encrypt/eql/eql_test.go create mode 100644 languages/golang/encrypt/eql/guest.go create mode 100644 languages/golang/encrypt/eql_test.go create mode 100644 languages/golang/encrypt/internal/eqlguest/eqlguest.go create mode 100644 languages/golang/encrypt/internal/testusers/contact_stash.go create mode 100644 languages/golang/encrypt/internal/testusers/contacts.go create mode 100644 packages/eql/crates/eql-codegen/src/go_eql.rs create mode 100644 packages/eql/crates/eql-codegen/tests/go_eql_parity.rs create mode 100644 packages/eql/tests/encryption/fixtures/text_eq_query.json diff --git a/languages/golang/cmd/stashgen/README.md b/languages/golang/cmd/stashgen/README.md index 41f930570..95d37e3f4 100644 --- a/languages/golang/cmd/stashgen/README.md +++ b/languages/golang/cmd/stashgen/README.md @@ -148,10 +148,14 @@ It ignores its own output file when it loads the package, so a stale file does n The same input always gives the same file: fields keep their declared order, and the file carries no version and no time. `stashgen` checks each declaration with the engine, and holds no copy of the engine's rules: it runs the WASI guest the SDK embeds and asks it, one field at a time, so the error names the field. -It asks the engine for the EQL types it holds: each name, its plaintext type, its indexes and its query form. -This build of the engine produces no EQL type, so `encrypt_into` is refused with "EQL types are not available yet"; the next release adds `TextEq` and `encrypt/eql`. +It asks the engine for the EQL types it holds: each name, its plaintext type, its indexes, its query form, and whether the engine produces it today. +The command links the build of the engine that holds the EQL types (`encrypt/eql`), so it can answer for every type; a generated file imports `encrypt/eql` only when it names one. +The engine produces `TextEq` today; `encrypt_into` with any other type is refused with the type's name. Separate columns work today for four indexes: `equality`, `match`, `ore` and `ope`. +A field with `encrypt_into` is stored under its table and column, which is what an EQL value records in its `i`. +A cipher extended with a tenant part (`cipher.Extend(...)`) has no column for the extended label, so it refuses a struct with an `encrypt_into` field; use `index=` columns for a tenant-extended struct until that rule is settled. + ## When stashgen stops `stashgen` stops with an error, and writes no file, for each of these. @@ -225,5 +229,5 @@ A protobuf message with a `oneof` cannot be generated from a policy: protoc-gen- ## Status -The command runs the WASI guest the SDK embeds, so it needs the guest built: `mise run wasm:guest:build`. +The command runs the WASI guest the SDK embeds, so it needs the guests built: `mise run wasm:guest:build wasm:guest:build:eql`. The library, `github.com/cipherstash/stack/languages/golang/stashgen`, takes any `Engine`; `stashgen.Generate` takes one with `WithEngine`, and `stashgen/enginetest` has a static one for tests. diff --git a/languages/golang/cmd/stashgen/main.go b/languages/golang/cmd/stashgen/main.go index 1371a56de..7d1c30040 100644 --- a/languages/golang/cmd/stashgen/main.go +++ b/languages/golang/cmd/stashgen/main.go @@ -15,6 +15,11 @@ import ( "os" "strings" + // The build of the engine that holds the EQL types: the generator + // asks the embedded guest which EQL types it produces, so it links the + // build that has them. Generated code imports encrypt/eql only when it + // names one. + _ "github.com/cipherstash/stack/languages/golang/encrypt/eql" "github.com/cipherstash/stack/languages/golang/stashgen" ) diff --git a/languages/golang/cmd/stashgen/main_test.go b/languages/golang/cmd/stashgen/main_test.go index 274ca0717..fc7ae619f 100644 --- a/languages/golang/cmd/stashgen/main_test.go +++ b/languages/golang/cmd/stashgen/main_test.go @@ -118,9 +118,10 @@ func TestRunFlags(t *testing.T) { } } -// The real engine: the embedded guest, which produces no EQL type in this -// build, so a struct with encrypt_into is refused and nothing is written. -// Skips when the guest is not built. +// The real engine: the embedded guest, which the command links with the EQL +// types, so a struct with encrypt_into=TextEq generates, and a type the +// engine cannot produce yet is refused with nothing written. Skips when the +// guest is not built. func TestRunAsksTheEmbeddedEngine(t *testing.T) { checker, err := encrypt.NewChecker(context.Background()) if err != nil { @@ -129,16 +130,32 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) { _ = checker.Close() dir := writeModule(t, map[string]string{"model.go": userSource}) var stdout, stderr bytes.Buffer + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 0 { + t.Fatalf("exit %d, want 0\n%s", code, stderr.String()) + } + written, err := os.ReadFile(filepath.Join(dir, "user_stash.go")) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(written), "eql.TextEq") || !strings.Contains(string(written), `EncryptInto("email", gensupport.String, "TextEq")`) { + t.Fatalf("the generated file does not name the EQL type:\n%s", written) + } + // A type the engine cannot produce yet: refused by name, nothing written. + unproducible := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt_into=TextMatch", 1) + dir = writeModule(t, map[string]string{"model.go": unproducible}) + stderr.Reset() if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 { t.Fatalf("exit %d, want 1\n%s", code, stderr.String()) } - if !strings.Contains(stderr.String(), "EQL types are not available yet") { + // Refused by the generator with the engine's reason, before the engine + // is asked: se_targets lists the type as not producible. + if !strings.Contains(stderr.String(), "cannot produce the EQL type TextMatch yet") { t.Fatalf("stderr = %q", stderr.String()) } if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) { t.Fatal("a file was written after the engine refused") } - // Separate columns are what the engine runs today. + // Separate columns, as before. columns := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt,index=equality;match", 1) dir = writeModule(t, map[string]string{"model.go": columns}) stderr.Reset() diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index d9c5dd177..e2a36e8a5 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -240,6 +240,20 @@ reports it, `Client.MemoryLockError` says why not, and `WithRequireLockedMemory` makes `NewClient` refuse to start unlocked. See the package documentation for the full account. +## EQL columns + +A field tagged `encrypt_into=TextEq` is stored as one EQL value, the JSON +PostgreSQL holds in a `public.eql_v3_text_eq` column, as an `eql.TextEq` +from package `encrypt/eql`. The guest builds the value: the generated code +imports `encrypt/eql`, which embeds the build of the engine that holds the +EQL types and registers it on import, so the program runs that build and +stores what it returns. `Fields.Email.Query` returns the `eql.TextEqQuery` +the column's `eql_v3.query_text_eq` operand takes. `mise run +wasm:guest:build:eql` builds that guest beside the other. A cipher extended +with a tenant part refuses a struct with an `encrypt_into` field: an EQL +value is stored under a table and a column, and the extended label has no +column. + ## Building The package embeds `wasm/stack_encrypt_guest.wasm`, a build artefact of the diff --git a/languages/golang/encrypt/eql/eql.go b/languages/golang/encrypt/eql/eql.go new file mode 100644 index 000000000..e44369223 --- /dev/null +++ b/languages/golang/encrypt/eql/eql.go @@ -0,0 +1,123 @@ +// Package eql holds the EQL types a program stores: one Go type per EQL +// type, each holding the EQL value as the JSON bytes PostgreSQL stores in +// the type's domain, and the query types that match them. +// +// A field with `stash:"email,encrypt_into=TextEq"` is an [TextEq] in the +// generated encrypted type, and its Fields entry's Query returns a +// [TextEqQuery]. The guest builds every value: generated code stores what +// the guest returns and never assembles an EQL value itself (ADR-0007, +// amended 2026-10-06). Each type is a driver.Valuer and sql.Scanner, so +// database/sql, pgx, sqlx and GORM take it as the column's value, and a +// json.Marshaler that emits the value as the JSON it is. +// +// Importing this package links the build of the engine that holds the EQL +// types: eql_gen.go's init registers the embedded module with package +// encrypt, and that registration cannot fail. A generated file that names +// an EQL type imports this package, so a program with EQL types runs the +// build that has them and a program without runs the smaller one. +// +// The types and the [Types] table are generated from the EQL catalog by +// eql-codegen (`mise run types:generate` in packages/eql); eql_gen.go is +// committed and drift-gated. The engine produces [TextEq] today; every +// other type is listed with the reason it cannot be produced yet, and +// stashgen refuses it. +package eql + +import ( + "database/sql/driver" + "encoding/json" + "errors" + "fmt" +) + +// Type is one EQL type as the catalog describes it: what stashgen learns +// from the engine's se_targets export, in Go. Name is the engine's name, +// the value of encrypt_into; GoName is the type's name in this package. +type Type struct { + // Name is the type's name across languages, and the engine's: TextEq. + Name string + // GoName is the type's name in this package: Name, save the JSON family + // (Json is JSON). + GoName string + // Family is the catalog family: text. + Family string + // Suffix is the query-capability suffix: Eq; empty for a storage-only + // type. + Suffix string + // Plaintext is the wire kind the type is produced from (string, ...), + // or "" while unspecified. + Plaintext string + // SQLDomain is the stored value's PostgreSQL domain. + SQLDomain string + // Indexes are the indexes the type carries: eq, match, ore, ope, json. + Indexes []string + // Query is the query type's engine name, or "" for a storage-only type. + Query string + // QuerySQLDomain is the query type's PostgreSQL domain, or "". + QuerySQLDomain string + // Producible says whether the engine produces the type today. + Producible bool + // Reason says why not, when it does not. + Reason string +} + +// Lookup finds the type the engine names, or false. +func Lookup(name string) (Type, bool) { + for _, t := range Types { + if t.Name == name { + return t, true + } + } + return Type{}, false +} + +// errNull is a NULL scanned into an EQL type. +var errNull = errors.New("eql: cannot scan NULL into an EQL value") + +// value is an EQL value as the driver takes it: the JSON text, or NULL for +// an empty value. +func value(b []byte) (driver.Value, error) { + if b == nil { + return nil, nil + } + return string(b), nil +} + +// scan reads a column into an EQL value: jsonb comes back as bytes or text. +func scan(dst *[]byte, src any, kind string) error { + switch v := src.(type) { + case []byte: + out := make([]byte, len(v)) + copy(out, v) + *dst = out + return nil + case string: + *dst = []byte(v) + return nil + case nil: + return fmt.Errorf("%w: %s", errNull, kind) + default: + return fmt.Errorf("eql: cannot scan %T into %s", src, kind) + } +} + +// marshal is the EQL value as JSON: the bytes it is, null when empty. +func marshal(b []byte, kind string) ([]byte, error) { + if b == nil { + return []byte("null"), nil + } + if !json.Valid(b) { + return nil, fmt.Errorf("eql: %s holds bytes that are not JSON", kind) + } + return b, nil +} + +// unmarshal reads an EQL value from JSON: the document as it is. +func unmarshal(dst *[]byte, b []byte) error { + if string(b) == "null" { + *dst = nil + return nil + } + *dst = append([]byte(nil), b...) + return nil +} diff --git a/languages/golang/encrypt/eql/eql_gen.go b/languages/golang/encrypt/eql/eql_gen.go new file mode 100644 index 000000000..911a82c97 --- /dev/null +++ b/languages/golang/encrypt/eql/eql_gen.go @@ -0,0 +1,2207 @@ +// Code generated by eql-codegen from the eql-domains catalog. DO NOT EDIT. + +package eql + +import "database/sql/driver" + +// Types is every EQL type the catalog has, in catalog order: what the +// engine's se_targets export lists, as Go. Producible says whether the +// engine produces the type today; stashgen refuses encrypt_into for one +// it does not, with the Reason. +var Types = []Type{ + {Name: "Integer", GoName: "Integer", Family: "integer", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_integer", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerEq", GoName: "IntegerEq", Family: "integer", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_integer_eq", Indexes: []string{"eq"}, Query: "IntegerEqQuery", QuerySQLDomain: "eql_v3.query_integer_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerOrdOre", GoName: "IntegerOrdOre", Family: "integer", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_integer_ord_ore", Indexes: []string{"ore"}, Query: "IntegerOrdOreQuery", QuerySQLDomain: "eql_v3.query_integer_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerOrd", GoName: "IntegerOrd", Family: "integer", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_integer_ord", Indexes: []string{"ope"}, Query: "IntegerOrdQuery", QuerySQLDomain: "eql_v3.query_integer_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "IntegerOrdOpe", GoName: "IntegerOrdOpe", Family: "integer", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_integer_ord_ope", Indexes: []string{"ope"}, Query: "IntegerOrdOpeQuery", QuerySQLDomain: "eql_v3.query_integer_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Smallint", GoName: "Smallint", Family: "smallint", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_smallint", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintEq", GoName: "SmallintEq", Family: "smallint", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_smallint_eq", Indexes: []string{"eq"}, Query: "SmallintEqQuery", QuerySQLDomain: "eql_v3.query_smallint_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintOrdOre", GoName: "SmallintOrdOre", Family: "smallint", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_smallint_ord_ore", Indexes: []string{"ore"}, Query: "SmallintOrdOreQuery", QuerySQLDomain: "eql_v3.query_smallint_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintOrd", GoName: "SmallintOrd", Family: "smallint", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_smallint_ord", Indexes: []string{"ope"}, Query: "SmallintOrdQuery", QuerySQLDomain: "eql_v3.query_smallint_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SmallintOrdOpe", GoName: "SmallintOrdOpe", Family: "smallint", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_smallint_ord_ope", Indexes: []string{"ope"}, Query: "SmallintOrdOpeQuery", QuerySQLDomain: "eql_v3.query_smallint_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Bigint", GoName: "Bigint", Family: "bigint", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_bigint", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintEq", GoName: "BigintEq", Family: "bigint", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_bigint_eq", Indexes: []string{"eq"}, Query: "BigintEqQuery", QuerySQLDomain: "eql_v3.query_bigint_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintOrdOre", GoName: "BigintOrdOre", Family: "bigint", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_bigint_ord_ore", Indexes: []string{"ore"}, Query: "BigintOrdOreQuery", QuerySQLDomain: "eql_v3.query_bigint_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintOrd", GoName: "BigintOrd", Family: "bigint", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_bigint_ord", Indexes: []string{"ope"}, Query: "BigintOrdQuery", QuerySQLDomain: "eql_v3.query_bigint_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "BigintOrdOpe", GoName: "BigintOrdOpe", Family: "bigint", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_bigint_ord_ope", Indexes: []string{"ope"}, Query: "BigintOrdOpeQuery", QuerySQLDomain: "eql_v3.query_bigint_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Date", GoName: "Date", Family: "date", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_date", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateEq", GoName: "DateEq", Family: "date", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_date_eq", Indexes: []string{"eq"}, Query: "DateEqQuery", QuerySQLDomain: "eql_v3.query_date_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateOrdOre", GoName: "DateOrdOre", Family: "date", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_date_ord_ore", Indexes: []string{"ore"}, Query: "DateOrdOreQuery", QuerySQLDomain: "eql_v3.query_date_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateOrd", GoName: "DateOrd", Family: "date", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_date_ord", Indexes: []string{"ope"}, Query: "DateOrdQuery", QuerySQLDomain: "eql_v3.query_date_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DateOrdOpe", GoName: "DateOrdOpe", Family: "date", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_date_ord_ope", Indexes: []string{"ope"}, Query: "DateOrdOpeQuery", QuerySQLDomain: "eql_v3.query_date_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Timestamp", GoName: "Timestamp", Family: "timestamp", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_timestamp", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampEq", GoName: "TimestampEq", Family: "timestamp", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_eq", Indexes: []string{"eq"}, Query: "TimestampEqQuery", QuerySQLDomain: "eql_v3.query_timestamp_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampOrdOre", GoName: "TimestampOrdOre", Family: "timestamp", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_ord_ore", Indexes: []string{"ore"}, Query: "TimestampOrdOreQuery", QuerySQLDomain: "eql_v3.query_timestamp_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampOrd", GoName: "TimestampOrd", Family: "timestamp", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_ord", Indexes: []string{"ope"}, Query: "TimestampOrdQuery", QuerySQLDomain: "eql_v3.query_timestamp_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "TimestampOrdOpe", GoName: "TimestampOrdOpe", Family: "timestamp", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_timestamp_ord_ope", Indexes: []string{"ope"}, Query: "TimestampOrdOpeQuery", QuerySQLDomain: "eql_v3.query_timestamp_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Numeric", GoName: "Numeric", Family: "numeric", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_numeric", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericEq", GoName: "NumericEq", Family: "numeric", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_numeric_eq", Indexes: []string{"eq"}, Query: "NumericEqQuery", QuerySQLDomain: "eql_v3.query_numeric_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericOrdOre", GoName: "NumericOrdOre", Family: "numeric", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_numeric_ord_ore", Indexes: []string{"ore"}, Query: "NumericOrdOreQuery", QuerySQLDomain: "eql_v3.query_numeric_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericOrd", GoName: "NumericOrd", Family: "numeric", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_numeric_ord", Indexes: []string{"ope"}, Query: "NumericOrdQuery", QuerySQLDomain: "eql_v3.query_numeric_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "NumericOrdOpe", GoName: "NumericOrdOpe", Family: "numeric", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_numeric_ord_ope", Indexes: []string{"ope"}, Query: "NumericOrdOpeQuery", QuerySQLDomain: "eql_v3.query_numeric_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Text", GoName: "Text", Family: "text", Suffix: "", Plaintext: "string", SQLDomain: "public.eql_v3_text", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "the storage-only text domain follows once TextEq is proven end to end in PostgreSQL"}, + {Name: "TextEq", GoName: "TextEq", Family: "text", Suffix: "Eq", Plaintext: "string", SQLDomain: "public.eql_v3_text_eq", Indexes: []string{"eq"}, Query: "TextEqQuery", QuerySQLDomain: "eql_v3.query_text_eq", Producible: true, Reason: ""}, + {Name: "TextMatch", GoName: "TextMatch", Family: "text", Suffix: "Match", Plaintext: "string", SQLDomain: "public.eql_v3_text_match", Indexes: []string{"match"}, Query: "TextMatchQuery", QuerySQLDomain: "eql_v3.query_text_match", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "TextOrdOre", GoName: "TextOrdOre", Family: "text", Suffix: "OrdOre", Plaintext: "string", SQLDomain: "public.eql_v3_text_ord_ore", Indexes: []string{"eq", "ore"}, Query: "TextOrdOreQuery", QuerySQLDomain: "eql_v3.query_text_ord_ore", Producible: false, Reason: "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms"}, + {Name: "TextOrd", GoName: "TextOrd", Family: "text", Suffix: "Ord", Plaintext: "string", SQLDomain: "public.eql_v3_text_ord", Indexes: []string{"eq", "ope"}, Query: "TextOrdQuery", QuerySQLDomain: "eql_v3.query_text_ord", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "TextOrdOpe", GoName: "TextOrdOpe", Family: "text", Suffix: "OrdOpe", Plaintext: "string", SQLDomain: "public.eql_v3_text_ord_ope", Indexes: []string{"eq", "ope"}, Query: "TextOrdOpeQuery", QuerySQLDomain: "eql_v3.query_text_ord_ope", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "TextSearchOre", GoName: "TextSearchOre", Family: "text", Suffix: "SearchOre", Plaintext: "string", SQLDomain: "public.eql_v3_text_search_ore", Indexes: []string{"eq", "ore", "match"}, Query: "TextSearchOreQuery", QuerySQLDomain: "eql_v3.query_text_search_ore", Producible: false, Reason: "EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms"}, + {Name: "TextSearch", GoName: "TextSearch", Family: "text", Suffix: "Search", Plaintext: "string", SQLDomain: "public.eql_v3_text_search", Indexes: []string{"eq", "ope", "match"}, Query: "TextSearchQuery", QuerySQLDomain: "eql_v3.query_text_search", Producible: false, Reason: "the engine derives match and OPE terms, but no EQL type is built from them yet"}, + {Name: "Boolean", GoName: "Boolean", Family: "boolean", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_boolean", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Real", GoName: "Real", Family: "real", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_real", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealEq", GoName: "RealEq", Family: "real", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_real_eq", Indexes: []string{"eq"}, Query: "RealEqQuery", QuerySQLDomain: "eql_v3.query_real_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealOrdOre", GoName: "RealOrdOre", Family: "real", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_real_ord_ore", Indexes: []string{"ore"}, Query: "RealOrdOreQuery", QuerySQLDomain: "eql_v3.query_real_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealOrd", GoName: "RealOrd", Family: "real", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_real_ord", Indexes: []string{"ope"}, Query: "RealOrdQuery", QuerySQLDomain: "eql_v3.query_real_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "RealOrdOpe", GoName: "RealOrdOpe", Family: "real", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_real_ord_ope", Indexes: []string{"ope"}, Query: "RealOrdOpeQuery", QuerySQLDomain: "eql_v3.query_real_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "Double", GoName: "Double", Family: "double", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_double", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleEq", GoName: "DoubleEq", Family: "double", Suffix: "Eq", Plaintext: "", SQLDomain: "public.eql_v3_double_eq", Indexes: []string{"eq"}, Query: "DoubleEqQuery", QuerySQLDomain: "eql_v3.query_double_eq", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleOrdOre", GoName: "DoubleOrdOre", Family: "double", Suffix: "OrdOre", Plaintext: "", SQLDomain: "public.eql_v3_double_ord_ore", Indexes: []string{"ore"}, Query: "DoubleOrdOreQuery", QuerySQLDomain: "eql_v3.query_double_ord_ore", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleOrd", GoName: "DoubleOrd", Family: "double", Suffix: "Ord", Plaintext: "", SQLDomain: "public.eql_v3_double_ord", Indexes: []string{"ope"}, Query: "DoubleOrdQuery", QuerySQLDomain: "eql_v3.query_double_ord", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "DoubleOrdOpe", GoName: "DoubleOrdOpe", Family: "double", Suffix: "OrdOpe", Plaintext: "", SQLDomain: "public.eql_v3_double_ord_ope", Indexes: []string{"ope"}, Query: "DoubleOrdOpeQuery", QuerySQLDomain: "eql_v3.query_double_ord_ope", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, + {Name: "SteVecDocument", GoName: "SteVecDocument", Family: "json", Suffix: "Search", Plaintext: "", SQLDomain: "public.eql_v3_json_search", Indexes: []string{"json"}, Query: "SteVecQuery", QuerySQLDomain: "eql_v3.query_json", Producible: false, Reason: "the JSON index is a new operation in the engine"}, + {Name: "Json", GoName: "JSON", Family: "json", Suffix: "", Plaintext: "", SQLDomain: "public.eql_v3_json", Indexes: nil, Query: "", QuerySQLDomain: "", Producible: false, Reason: "how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is"}, +} + +// Integer is the EQL type stored in public.eql_v3_integer: the integer family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Integer []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Integer) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer column. +func (v *Integer) Scan(src any) error { + return scan((*[]byte)(v), src, "Integer") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Integer) MarshalJSON() ([]byte, error) { + return marshal(v, "Integer") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Integer) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerEq is the EQL type stored in public.eql_v3_integer_eq: the integer family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_eq column. +func (v *IntegerEq) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerEq) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerEqQuery is the query value for IntegerEq: the operand eql_v3.query_integer_eq takes. +type IntegerEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_eq column. +func (v *IntegerEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOre is the EQL type stored in public.eql_v3_integer_ord_ore: the integer family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_ord_ore column. +func (v *IntegerOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOreQuery is the query value for IntegerOrdOre: the operand eql_v3.query_integer_ord_ore takes. +type IntegerOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_ord_ore column. +func (v *IntegerOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrd is the EQL type stored in public.eql_v3_integer_ord: the integer family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_ord column. +func (v *IntegerOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdQuery is the query value for IntegerOrd: the operand eql_v3.query_integer_ord takes. +type IntegerOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_ord column. +func (v *IntegerOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOpe is the EQL type stored in public.eql_v3_integer_ord_ope: the integer family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type IntegerOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_integer_ord_ope column. +func (v *IntegerOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// IntegerOrdOpeQuery is the query value for IntegerOrdOpe: the operand eql_v3.query_integer_ord_ope takes. +type IntegerOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v IntegerOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_integer_ord_ope column. +func (v *IntegerOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "IntegerOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v IntegerOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "IntegerOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *IntegerOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Smallint is the EQL type stored in public.eql_v3_smallint: the smallint family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Smallint []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Smallint) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint column. +func (v *Smallint) Scan(src any) error { + return scan((*[]byte)(v), src, "Smallint") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Smallint) MarshalJSON() ([]byte, error) { + return marshal(v, "Smallint") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Smallint) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintEq is the EQL type stored in public.eql_v3_smallint_eq: the smallint family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_eq column. +func (v *SmallintEq) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintEq) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintEqQuery is the query value for SmallintEq: the operand eql_v3.query_smallint_eq takes. +type SmallintEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_eq column. +func (v *SmallintEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOre is the EQL type stored in public.eql_v3_smallint_ord_ore: the smallint family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_ord_ore column. +func (v *SmallintOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOreQuery is the query value for SmallintOrdOre: the operand eql_v3.query_smallint_ord_ore takes. +type SmallintOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_ord_ore column. +func (v *SmallintOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrd is the EQL type stored in public.eql_v3_smallint_ord: the smallint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_ord column. +func (v *SmallintOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdQuery is the query value for SmallintOrd: the operand eql_v3.query_smallint_ord takes. +type SmallintOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_ord column. +func (v *SmallintOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOpe is the EQL type stored in public.eql_v3_smallint_ord_ope: the smallint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type SmallintOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_smallint_ord_ope column. +func (v *SmallintOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SmallintOrdOpeQuery is the query value for SmallintOrdOpe: the operand eql_v3.query_smallint_ord_ope takes. +type SmallintOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SmallintOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_smallint_ord_ope column. +func (v *SmallintOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SmallintOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SmallintOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SmallintOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SmallintOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Bigint is the EQL type stored in public.eql_v3_bigint: the bigint family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Bigint []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Bigint) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint column. +func (v *Bigint) Scan(src any) error { + return scan((*[]byte)(v), src, "Bigint") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Bigint) MarshalJSON() ([]byte, error) { + return marshal(v, "Bigint") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Bigint) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintEq is the EQL type stored in public.eql_v3_bigint_eq: the bigint family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_eq column. +func (v *BigintEq) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintEq) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintEqQuery is the query value for BigintEq: the operand eql_v3.query_bigint_eq takes. +type BigintEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_eq column. +func (v *BigintEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOre is the EQL type stored in public.eql_v3_bigint_ord_ore: the bigint family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_ord_ore column. +func (v *BigintOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOreQuery is the query value for BigintOrdOre: the operand eql_v3.query_bigint_ord_ore takes. +type BigintOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_ord_ore column. +func (v *BigintOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrd is the EQL type stored in public.eql_v3_bigint_ord: the bigint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_ord column. +func (v *BigintOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdQuery is the query value for BigintOrd: the operand eql_v3.query_bigint_ord takes. +type BigintOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_ord column. +func (v *BigintOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOpe is the EQL type stored in public.eql_v3_bigint_ord_ope: the bigint family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type BigintOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_bigint_ord_ope column. +func (v *BigintOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// BigintOrdOpeQuery is the query value for BigintOrdOpe: the operand eql_v3.query_bigint_ord_ope takes. +type BigintOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v BigintOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_bigint_ord_ope column. +func (v *BigintOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "BigintOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v BigintOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "BigintOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *BigintOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Date is the EQL type stored in public.eql_v3_date: the date family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Date []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Date) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date column. +func (v *Date) Scan(src any) error { + return scan((*[]byte)(v), src, "Date") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Date) MarshalJSON() ([]byte, error) { + return marshal(v, "Date") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Date) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateEq is the EQL type stored in public.eql_v3_date_eq: the date family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_eq column. +func (v *DateEq) Scan(src any) error { + return scan((*[]byte)(v), src, "DateEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateEq) MarshalJSON() ([]byte, error) { + return marshal(v, "DateEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateEqQuery is the query value for DateEq: the operand eql_v3.query_date_eq takes. +type DateEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_eq column. +func (v *DateEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOre is the EQL type stored in public.eql_v3_date_ord_ore: the date family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_ord_ore column. +func (v *DateOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOreQuery is the query value for DateOrdOre: the operand eql_v3.query_date_ord_ore takes. +type DateOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_ord_ore column. +func (v *DateOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrd is the EQL type stored in public.eql_v3_date_ord: the date family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_ord column. +func (v *DateOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdQuery is the query value for DateOrd: the operand eql_v3.query_date_ord takes. +type DateOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_ord column. +func (v *DateOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOpe is the EQL type stored in public.eql_v3_date_ord_ope: the date family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DateOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_date_ord_ope column. +func (v *DateOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DateOrdOpeQuery is the query value for DateOrdOpe: the operand eql_v3.query_date_ord_ope takes. +type DateOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DateOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_date_ord_ope column. +func (v *DateOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DateOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DateOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DateOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DateOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Timestamp is the EQL type stored in public.eql_v3_timestamp: the timestamp family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Timestamp []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Timestamp) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp column. +func (v *Timestamp) Scan(src any) error { + return scan((*[]byte)(v), src, "Timestamp") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Timestamp) MarshalJSON() ([]byte, error) { + return marshal(v, "Timestamp") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Timestamp) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampEq is the EQL type stored in public.eql_v3_timestamp_eq: the timestamp family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_eq column. +func (v *TimestampEq) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampEq) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampEqQuery is the query value for TimestampEq: the operand eql_v3.query_timestamp_eq takes. +type TimestampEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_eq column. +func (v *TimestampEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOre is the EQL type stored in public.eql_v3_timestamp_ord_ore: the timestamp family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_ord_ore column. +func (v *TimestampOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOreQuery is the query value for TimestampOrdOre: the operand eql_v3.query_timestamp_ord_ore takes. +type TimestampOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_ord_ore column. +func (v *TimestampOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrd is the EQL type stored in public.eql_v3_timestamp_ord: the timestamp family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_ord column. +func (v *TimestampOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdQuery is the query value for TimestampOrd: the operand eql_v3.query_timestamp_ord takes. +type TimestampOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_ord column. +func (v *TimestampOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOpe is the EQL type stored in public.eql_v3_timestamp_ord_ope: the timestamp family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type TimestampOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_timestamp_ord_ope column. +func (v *TimestampOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TimestampOrdOpeQuery is the query value for TimestampOrdOpe: the operand eql_v3.query_timestamp_ord_ope takes. +type TimestampOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TimestampOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_timestamp_ord_ope column. +func (v *TimestampOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TimestampOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TimestampOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TimestampOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TimestampOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Numeric is the EQL type stored in public.eql_v3_numeric: the numeric family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Numeric []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Numeric) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric column. +func (v *Numeric) Scan(src any) error { + return scan((*[]byte)(v), src, "Numeric") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Numeric) MarshalJSON() ([]byte, error) { + return marshal(v, "Numeric") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Numeric) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericEq is the EQL type stored in public.eql_v3_numeric_eq: the numeric family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_eq column. +func (v *NumericEq) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericEq) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericEqQuery is the query value for NumericEq: the operand eql_v3.query_numeric_eq takes. +type NumericEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_eq column. +func (v *NumericEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOre is the EQL type stored in public.eql_v3_numeric_ord_ore: the numeric family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_ord_ore column. +func (v *NumericOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOreQuery is the query value for NumericOrdOre: the operand eql_v3.query_numeric_ord_ore takes. +type NumericOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_ord_ore column. +func (v *NumericOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrd is the EQL type stored in public.eql_v3_numeric_ord: the numeric family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_ord column. +func (v *NumericOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdQuery is the query value for NumericOrd: the operand eql_v3.query_numeric_ord takes. +type NumericOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_ord column. +func (v *NumericOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOpe is the EQL type stored in public.eql_v3_numeric_ord_ope: the numeric family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type NumericOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_numeric_ord_ope column. +func (v *NumericOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// NumericOrdOpeQuery is the query value for NumericOrdOpe: the operand eql_v3.query_numeric_ord_ope takes. +type NumericOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v NumericOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_numeric_ord_ope column. +func (v *NumericOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "NumericOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v NumericOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "NumericOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *NumericOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Text is the EQL type stored in public.eql_v3_text: the text family with no index. +// The engine cannot produce it yet: the storage-only text domain follows once TextEq is proven end to end in PostgreSQL. +type Text []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Text) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text column. +func (v *Text) Scan(src any) error { + return scan((*[]byte)(v), src, "Text") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Text) MarshalJSON() ([]byte, error) { + return marshal(v, "Text") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Text) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextEq is the EQL type stored in public.eql_v3_text_eq: the text family with the eq index. +// The engine produces it from a string. +type TextEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_eq column. +func (v *TextEq) Scan(src any) error { + return scan((*[]byte)(v), src, "TextEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextEq) MarshalJSON() ([]byte, error) { + return marshal(v, "TextEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextEqQuery is the query value for TextEq: the operand eql_v3.query_text_eq takes. +type TextEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_eq column. +func (v *TextEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextMatch is the EQL type stored in public.eql_v3_text_match: the text family with the match index. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextMatch []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextMatch) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_match column. +func (v *TextMatch) Scan(src any) error { + return scan((*[]byte)(v), src, "TextMatch") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextMatch) MarshalJSON() ([]byte, error) { + return marshal(v, "TextMatch") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextMatch) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextMatchQuery is the query value for TextMatch: the operand eql_v3.query_text_match takes. +type TextMatchQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextMatchQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_match column. +func (v *TextMatchQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextMatchQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextMatchQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextMatchQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextMatchQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOre is the EQL type stored in public.eql_v3_text_ord_ore: the text family with the eq and ore indexes. +// The engine cannot produce it yet: EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms. +type TextOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_ord_ore column. +func (v *TextOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOreQuery is the query value for TextOrdOre: the operand eql_v3.query_text_ord_ore takes. +type TextOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_ord_ore column. +func (v *TextOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrd is the EQL type stored in public.eql_v3_text_ord: the text family with the eq and ope indexes. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_ord column. +func (v *TextOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdQuery is the query value for TextOrd: the operand eql_v3.query_text_ord takes. +type TextOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_ord column. +func (v *TextOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOpe is the EQL type stored in public.eql_v3_text_ord_ope: the text family with the eq and ope indexes. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_ord_ope column. +func (v *TextOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextOrdOpeQuery is the query value for TextOrdOpe: the operand eql_v3.query_text_ord_ope takes. +type TextOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_ord_ope column. +func (v *TextOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearchOre is the EQL type stored in public.eql_v3_text_search_ore: the text family with the eq and ore and match indexes. +// The engine cannot produce it yet: EQL stores a block ORE term and the engine derives a CLLW ORE term; the two are different algorithms. +type TextSearchOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearchOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_search_ore column. +func (v *TextSearchOre) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearchOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearchOre) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearchOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearchOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearchOreQuery is the query value for TextSearchOre: the operand eql_v3.query_text_search_ore takes. +type TextSearchOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearchOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_search_ore column. +func (v *TextSearchOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearchOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearchOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearchOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearchOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearch is the EQL type stored in public.eql_v3_text_search: the text family with the eq and ope and match indexes. +// The engine cannot produce it yet: the engine derives match and OPE terms, but no EQL type is built from them yet. +type TextSearch []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearch) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_text_search column. +func (v *TextSearch) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearch") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearch) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearch") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearch) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// TextSearchQuery is the query value for TextSearch: the operand eql_v3.query_text_search takes. +type TextSearchQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v TextSearchQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_text_search column. +func (v *TextSearchQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "TextSearchQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v TextSearchQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "TextSearchQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *TextSearchQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Boolean is the EQL type stored in public.eql_v3_boolean: the boolean family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Boolean []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Boolean) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_boolean column. +func (v *Boolean) Scan(src any) error { + return scan((*[]byte)(v), src, "Boolean") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Boolean) MarshalJSON() ([]byte, error) { + return marshal(v, "Boolean") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Boolean) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Real is the EQL type stored in public.eql_v3_real: the real family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Real []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Real) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real column. +func (v *Real) Scan(src any) error { + return scan((*[]byte)(v), src, "Real") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Real) MarshalJSON() ([]byte, error) { + return marshal(v, "Real") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Real) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealEq is the EQL type stored in public.eql_v3_real_eq: the real family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_eq column. +func (v *RealEq) Scan(src any) error { + return scan((*[]byte)(v), src, "RealEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealEq) MarshalJSON() ([]byte, error) { + return marshal(v, "RealEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealEqQuery is the query value for RealEq: the operand eql_v3.query_real_eq takes. +type RealEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_eq column. +func (v *RealEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOre is the EQL type stored in public.eql_v3_real_ord_ore: the real family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_ord_ore column. +func (v *RealOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOreQuery is the query value for RealOrdOre: the operand eql_v3.query_real_ord_ore takes. +type RealOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_ord_ore column. +func (v *RealOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrd is the EQL type stored in public.eql_v3_real_ord: the real family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_ord column. +func (v *RealOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdQuery is the query value for RealOrd: the operand eql_v3.query_real_ord takes. +type RealOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_ord column. +func (v *RealOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOpe is the EQL type stored in public.eql_v3_real_ord_ope: the real family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type RealOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_real_ord_ope column. +func (v *RealOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// RealOrdOpeQuery is the query value for RealOrdOpe: the operand eql_v3.query_real_ord_ope takes. +type RealOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v RealOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_real_ord_ope column. +func (v *RealOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "RealOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v RealOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "RealOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *RealOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// Double is the EQL type stored in public.eql_v3_double: the double family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type Double []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v Double) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double column. +func (v *Double) Scan(src any) error { + return scan((*[]byte)(v), src, "Double") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v Double) MarshalJSON() ([]byte, error) { + return marshal(v, "Double") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *Double) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleEq is the EQL type stored in public.eql_v3_double_eq: the double family with the eq index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleEq []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleEq) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_eq column. +func (v *DoubleEq) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleEq") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleEq) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleEq") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleEq) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleEqQuery is the query value for DoubleEq: the operand eql_v3.query_double_eq takes. +type DoubleEqQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleEqQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_eq column. +func (v *DoubleEqQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleEqQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleEqQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleEqQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleEqQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOre is the EQL type stored in public.eql_v3_double_ord_ore: the double family with the ore index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleOrdOre []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOre) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_ord_ore column. +func (v *DoubleOrdOre) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOre") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOre) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOre") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOre) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOreQuery is the query value for DoubleOrdOre: the operand eql_v3.query_double_ord_ore takes. +type DoubleOrdOreQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOreQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_ord_ore column. +func (v *DoubleOrdOreQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOreQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOreQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOreQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOreQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrd is the EQL type stored in public.eql_v3_double_ord: the double family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleOrd []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrd) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_ord column. +func (v *DoubleOrd) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrd") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrd) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrd") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrd) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdQuery is the query value for DoubleOrd: the operand eql_v3.query_double_ord takes. +type DoubleOrdQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_ord column. +func (v *DoubleOrdQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOpe is the EQL type stored in public.eql_v3_double_ord_ope: the double family with the ope index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type DoubleOrdOpe []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOpe) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_double_ord_ope column. +func (v *DoubleOrdOpe) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOpe") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOpe) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOpe") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOpe) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// DoubleOrdOpeQuery is the query value for DoubleOrdOpe: the operand eql_v3.query_double_ord_ope takes. +type DoubleOrdOpeQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v DoubleOrdOpeQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_double_ord_ope column. +func (v *DoubleOrdOpeQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "DoubleOrdOpeQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v DoubleOrdOpeQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "DoubleOrdOpeQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *DoubleOrdOpeQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SteVecDocument is the EQL type stored in public.eql_v3_json_search: the json family with the json index. +// The engine cannot produce it yet: the JSON index is a new operation in the engine. +type SteVecDocument []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SteVecDocument) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_json_search column. +func (v *SteVecDocument) Scan(src any) error { + return scan((*[]byte)(v), src, "SteVecDocument") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SteVecDocument) MarshalJSON() ([]byte, error) { + return marshal(v, "SteVecDocument") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SteVecDocument) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// SteVecQuery is the query value for SteVecDocument: the operand eql_v3.query_json takes. +type SteVecQuery []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v SteVecQuery) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a eql_v3.query_json column. +func (v *SteVecQuery) Scan(src any) error { + return scan((*[]byte)(v), src, "SteVecQuery") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v SteVecQuery) MarshalJSON() ([]byte, error) { + return marshal(v, "SteVecQuery") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *SteVecQuery) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} + +// JSON is the EQL type stored in public.eql_v3_json: the json family with no index. +// The engine cannot produce it yet: how this family encodes a plaintext for the stack-encrypt producer profile is not specified; only the text family's encoding is. +type JSON []byte + +// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty. +func (v JSON) Value() (driver.Value, error) { + return value(v) +} + +// Scan reads a public.eql_v3_json column. +func (v *JSON) Scan(src any) error { + return scan((*[]byte)(v), src, "JSON") +} + +// MarshalJSON is the EQL value as the JSON it is, or null when empty. +func (v JSON) MarshalJSON() ([]byte, error) { + return marshal(v, "JSON") +} + +// UnmarshalJSON reads the EQL value from JSON, as it is. +func (v *JSON) UnmarshalJSON(b []byte) error { + return unmarshal((*[]byte)(v), b) +} diff --git a/languages/golang/encrypt/eql/eql_test.go b/languages/golang/encrypt/eql/eql_test.go new file mode 100644 index 000000000..d951dc36f --- /dev/null +++ b/languages/golang/encrypt/eql/eql_test.go @@ -0,0 +1,79 @@ +package eql_test + +import ( + "encoding/json" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt/eql" +) + +func TestTypesTableNamesTextEqAsTheOneProducibleType(t *testing.T) { + var producible []string + for _, typ := range eql.Types { + if typ.Producible { + producible = append(producible, typ.Name) + if typ.Reason != "" { + t.Errorf("%s: a producible type has no reason", typ.Name) + } + } else if typ.Reason == "" { + t.Errorf("%s: an unproducible type has a reason", typ.Name) + } + } + if len(producible) != 1 || producible[0] != "TextEq" { + t.Fatalf("producible = %v, want [TextEq]", producible) + } + textEq, ok := eql.Lookup("TextEq") + if !ok || textEq.GoName != "TextEq" || textEq.Plaintext != "string" || textEq.Query != "TextEqQuery" || textEq.SQLDomain != "public.eql_v3_text_eq" || len(textEq.Indexes) != 1 || textEq.Indexes[0] != "eq" { + t.Fatalf("TextEq = %+v", textEq) + } + if j, ok := eql.Lookup("Json"); !ok || j.GoName != "JSON" { + t.Fatalf("Json's Go name is JSON: %+v", j) + } + if _, ok := eql.Lookup("Nope"); ok { + t.Fatal("Lookup found a type that does not exist") + } +} + +func TestValuesRoundTripThroughTheDriverAndJSON(t *testing.T) { + doc := []byte(`{"v":3,"i":{"t":"users","c":"email"},"c":"stack-encrypt:1:AA==","hm":"00"}`) + v := eql.TextEq(doc) + value, err := v.Value() + if err != nil || value != string(doc) { + t.Fatalf("Value = %v, %v", value, err) + } + for _, src := range []any{doc, string(doc)} { + var scanned eql.TextEq + if err := scanned.Scan(src); err != nil || string(scanned) != string(doc) { + t.Fatalf("Scan(%T) = %s, %v", src, scanned, err) + } + } + var scanned eql.TextEq + if err := scanned.Scan(nil); err == nil { + t.Fatal("NULL scanned into an EQL value") + } + if err := scanned.Scan(42); err == nil { + t.Fatal("an integer scanned into an EQL value") + } + out, err := json.Marshal(struct{ Email eql.TextEq }{v}) + if err != nil || string(out) != `{"Email":`+string(doc)+`}` { + t.Fatalf("Marshal = %s, %v", out, err) + } + var back struct{ Email eql.TextEq } + if err := json.Unmarshal(out, &back); err != nil || string(back.Email) != string(doc) { + t.Fatalf("Unmarshal = %s, %v", back.Email, err) + } + empty, err := json.Marshal(struct{ Email eql.TextEq }{}) + if err != nil || string(empty) != `{"Email":null}` { + t.Fatalf("an empty value marshals as null: %s, %v", empty, err) + } + if nilValue, err := eql.TextEq(nil).Value(); err != nil || nilValue != nil { + t.Fatalf("an empty value is NULL: %v, %v", nilValue, err) + } + if _, err := eql.TextEq([]byte("not json")).MarshalJSON(); err == nil { + t.Fatal("bytes that are not JSON marshalled") + } + var q eql.TextEqQuery + if err := json.Unmarshal([]byte("null"), &q); err != nil || q != nil { + t.Fatalf("null unmarshals to an empty value: %v, %v", q, err) + } +} diff --git a/languages/golang/encrypt/eql/guest.go b/languages/golang/encrypt/eql/guest.go new file mode 100644 index 000000000..b9eb684d1 --- /dev/null +++ b/languages/golang/encrypt/eql/guest.go @@ -0,0 +1,30 @@ +package eql + +import ( + "embed" + + "github.com/cipherstash/stack/languages/golang/encrypt/internal/eqlguest" +) + +// The guest module with the EQL types is a build artefact of the Rust +// crate in ../guest with its `eql` feature, copied here by +// `mise run wasm:guest:build:eql`. It is embedded as a directory so the +// package compiles without it; encrypt.NewClient reports its absence. Its +// deterministic-kms test build lives under encrypt/testdata with the other +// test build: nothing test-only is embedded. +// +//go:embed wasm +var guestFS embed.FS + +const guestPath = "wasm/stack_encrypt_guest_eql.wasm" + +// Linking this package selects the build of the engine that holds the EQL +// types. The registration cannot fail: an absent module registers nothing, +// and package encrypt reports it as ErrGuestNotBuilt as it would its own. +func init() { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + return + } + eqlguest.Register(wasm) +} diff --git a/languages/golang/encrypt/eql/wasm/README.md b/languages/golang/encrypt/eql/wasm/README.md index 96b3bd908..10baaac6f 100644 --- a/languages/golang/encrypt/eql/wasm/README.md +++ b/languages/golang/encrypt/eql/wasm/README.md @@ -8,7 +8,7 @@ registers the module on import, so a program whose generated code names an EQL type runs this build; `NewClient` reports `ErrGuestNotBuilt` when it is absent, and the package's tests skip. -`stack_encrypt_guest_eql_deterministic.wasm` is the same build with the -`deterministic-kms` feature too, from `mise run wasm:guest:build:eql:deterministic`: -a TEST build whose keys derive from a seed, so the tests seal and open a -`TextEq` field with no ZeroKMS. Only the tests load it. +The same build with the `deterministic-kms` feature too — a TEST build whose +keys derive from a seed, so the tests seal and open a `TextEq` field with no +ZeroKMS — is written by `mise run wasm:guest:build:eql:deterministic` under +`encrypt/testdata/`, not here: nothing test-only is embedded. diff --git a/languages/golang/encrypt/eql_test.go b/languages/golang/encrypt/eql_test.go new file mode 100644 index 000000000..3db74c936 --- /dev/null +++ b/languages/golang/encrypt/eql_test.go @@ -0,0 +1,267 @@ +package encrypt_test + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers" + "github.com/cipherstash/stack/languages/golang/internal/record" +) + +// A field sealed into an EQL type, through generated code, over the +// deterministic test build of the guest WITH the EQL types (ADR-0007, +// amended 2026-10-06): the guest runs TextEq's own plan and returns the +// finished value, Go stores it as it is. The other build refuses the same +// declaration before any value crosses. + +func deterministicEQLClient(t *testing.T) *encrypt.Client { + t.Helper() + c, err := encrypt.NewDeterministicEQLClient(context.Background(), testSeed) + if errors.Is(err, encrypt.ErrDeterministicEQLGuestNotBuilt) || errors.Is(err, encrypt.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = c.Close() }) + return c +} + +var contacts = []testusers.Contact{ + {ID: 1, Email: "bob@example.com", Notes: "likes cats"}, + {ID: 2, Email: "alice@example.com", Notes: "likes dogs"}, + {ID: 3, Email: "bob@example.com"}, +} + +// eqlEnvelope is the EQL v3 envelope of a TextEq value, as PostgreSQL's +// public.eql_v3_text_eq domain checks it. +type eqlEnvelope struct { + V int `json:"v"` + I struct{ T, C string } + C string `json:"c"` + HM string `json:"hm"` +} + +func decodeEnvelope(t *testing.T, value []byte) eqlEnvelope { + t.Helper() + var raw map[string]json.RawMessage + if err := json.Unmarshal(value, &raw); err != nil { + t.Fatalf("the EQL value is not JSON: %v: %s", err, value) + } + var e eqlEnvelope + if err := json.Unmarshal(value, &e); err != nil { + t.Fatal(err) + } + var id struct { + T string `json:"t"` + C string `json:"c"` + } + if err := json.Unmarshal(raw["i"], &id); err != nil { + t.Fatal(err) + } + e.I.T, e.I.C = id.T, id.C + if len(raw) != 4 { + t.Fatalf("a TextEq value has exactly v, i, c and hm: %s", value) + } + return e +} + +func TestTextEqFieldSealsToTheEQLEnvelope(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + encrypted, err := testusers.EncryptContact(ctx, cipher, contacts) + if err != nil { + t.Fatal(err) + } + if len(encrypted) != len(contacts) { + t.Fatalf("%d rows for %d values", len(encrypted), len(contacts)) + } + for i, e := range encrypted { + if e.ID != contacts[i].ID { + t.Errorf("row %d: passthrough id %d, want %d", i, e.ID, contacts[i].ID) + } + env := decodeEnvelope(t, e.Email) + if env.V != 3 { + t.Errorf("row %d: v = %d, want 3", i, env.V) + } + if env.I.T != "users" || env.I.C != "email" { + t.Errorf("row %d: i = %+v, want users/email", i, env.I) + } + if !strings.HasPrefix(env.C, "stack-encrypt:1:") { + t.Errorf("row %d: c does not carry the stack-encrypt producer marker: %q", i, env.C) + } + if len(env.HM) != 64 { + t.Errorf("row %d: hm is %d hex characters, want 64", i, len(env.HM)) + } + if len(e.Notes.Ciphertext) == 0 { + t.Errorf("row %d: notes not sealed", i) + } + } + // Equal plaintexts share the equality term and nothing else. + first, third := decodeEnvelope(t, encrypted[0].Email), decodeEnvelope(t, encrypted[2].Email) + if first.HM != third.HM { + t.Error("equal emails must share hm") + } + if first.C == third.C { + t.Error("each write seals under a fresh key") + } + if first.HM == decodeEnvelope(t, encrypted[1].Email).HM { + t.Error("different emails must not share hm") + } + + // The value is what the driver stores and reads back. + v, err := encrypted[0].Email.Value() + if err != nil || v.(string) != string(encrypted[0].Email) { + t.Fatalf("Value = %v, %v", v, err) + } + var scanned eql.TextEq + if err := scanned.Scan([]byte(encrypted[0].Email)); err != nil || !bytes.Equal(scanned, encrypted[0].Email) { + t.Fatalf("Scan = %s, %v", scanned, err) + } + + // Opens through the cipher and through the client. + for _, d := range []encrypt.Decrypter{cipher, c} { + decrypted, err := testusers.DecryptContact(ctx, d, encrypted) + if err != nil { + t.Fatal(err) + } + for i := range contacts { + if decrypted[i] != contacts[i] { + t.Errorf("row %d: %+v, want %+v", i, decrypted[i], contacts[i]) + } + } + } +} + +func TestTextEqQueryMatchesTheStoredValueAndTheRustFixture(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + raw, err := os.ReadFile(filepath.Join("..", "..", "..", "packages", "eql", "tests", "encryption", "fixtures", "text_eq_query.json")) + if err != nil { + t.Fatal(err) + } + var fixture struct { + Table, Column, Plaintext, Query string + } + if err := json.Unmarshal(raw, &fixture); err != nil { + t.Fatal(err) + } + if fixture.Table != "users" || fixture.Column != "email" { + t.Fatalf("the fixture's column changed: %+v", fixture) + } + + query, err := testusers.ContactFields.Email.Query(ctx, cipher, fixture.Plaintext) + if err != nil { + t.Fatal(err) + } + // Byte for byte what eql-bindings' own dispatch derived under the same + // index key: the Go field runs TextEq's plan and no other. + if string(query) != fixture.Query { + t.Fatalf("Query = %s\nwant %s", query, fixture.Query) + } + stored, err := testusers.ContactFields.Email.Encrypt(ctx, cipher, fixture.Plaintext) + if err != nil { + t.Fatal(err) + } + var probe struct { + HM string `json:"hm"` + } + if err := json.Unmarshal(query, &probe); err != nil { + t.Fatal(err) + } + if env := decodeEnvelope(t, stored); env.HM != probe.HM { + t.Fatalf("the query's hm %s does not match the stored %s", probe.HM, env.HM) + } + if other, _ := testusers.ContactFields.Email.Query(ctx, cipher, "Bob@example.com"); string(other) == fixture.Query { + t.Fatal("no case folding: a different plaintext has a different term") + } +} + +func TestTextEqIsRefusedByTheBuildWithoutEQLTypes(t *testing.T) { + ctx := context.Background() + plain, err := encrypt.PlainGuest() + if errors.Is(err, encrypt.ErrGuestNotBuilt) { + t.Skip(err) + } + if err != nil { + t.Fatal(err) + } + checker, err := encrypt.NewCheckerOver(ctx, plain) + if err != nil { + t.Fatal(err) + } + defer func() { _ = checker.Close() }() + targets, err := checker.Targets(ctx) + if err != nil { + t.Fatal(err) + } + if len(targets) != 0 { + t.Fatalf("the build without EQL types lists %d targets", len(targets)) + } + plan := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}} + if err := checker.Check(ctx, plan); !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("Check = %v, want ErrEncoding", err) + } + + // The build with them lists the catalog and runs the plan. + withEQL, err := encrypt.EmbeddedGuest() + if err != nil { + t.Fatal(err) + } + eqlChecker, err := encrypt.NewCheckerOver(ctx, withEQL) + if err != nil { + t.Fatal(err) + } + defer func() { _ = eqlChecker.Close() }() + targets, err = eqlChecker.Targets(ctx) + if err != nil { + t.Fatal(err) + } + found := false + for _, target := range targets { + found = found || target.Name == "TextEq" + } + if !found { + t.Fatalf("TextEq is not among the %d targets", len(targets)) + } + if err := eqlChecker.Check(ctx, plan); err != nil { + t.Fatalf("the eql build refuses TextEq: %v", err) + } + for _, name := range []string{"TextOrdOre", "Nope"} { + refused := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: name}}} + if err := eqlChecker.Check(ctx, refused); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("%s: Check = %v, want ErrEncoding", name, err) + } + } + // A declared type other than TextEq's plaintext, and an extended plan. + wrongKind := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.UInt64, Target: "TextEq"}}} + if err := eqlChecker.Check(ctx, wrongKind); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("a uint64 TextEq field: Check = %v, want ErrEncoding", err) + } + extended := &record.Plan{Context: []string{"users"}, Extension: []any{uint64(7)}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}} + if err := eqlChecker.Check(ctx, extended); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("an extended plan with a target field: Check = %v, want ErrEncoding", err) + } +} + +func TestExtendedCipherRefusesATextEqField(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + tenant := c.DefaultKeyset().Extend(uint64(7)) + _, err := testusers.EncryptContact(ctx, tenant, contacts[:1]) + if !errors.Is(err, encrypt.ErrEncoding) { + t.Fatalf("an extended cipher sealed a TextEq field: %v", err) + } +} diff --git a/languages/golang/encrypt/export_test.go b/languages/golang/encrypt/export_test.go index e3322e275..40e74cd4d 100644 --- a/languages/golang/encrypt/export_test.go +++ b/languages/golang/encrypt/export_test.go @@ -77,11 +77,36 @@ var ErrDeterministicGuestNotBuilt = errors.New("encrypt: deterministic guest not // of the guest, seeded: every key derives from the seed and the context, so // it opens what the Rust record fixture sealed under the same seed and // needs no ZeroKMS. ErrDeterministicGuestNotBuilt when the build is absent. +// This is the build WITHOUT the EQL types, whatever is linked: the tests +// name the build they run against (NewDeterministicEQLClient is the other), +// so each build keeps its own coverage. func NewDeterministicClient(ctx context.Context, seed [32]byte) (*Client, error) { wasm, err := os.ReadFile(deterministicGuestPath) if err != nil { return nil, ErrDeterministicGuestNotBuilt } + return deterministicClient(ctx, wasm, seed) +} + +// ErrDeterministicEQLGuestNotBuilt says the test build with the EQL types +// is absent, or package eql is not linked. +var ErrDeterministicEQLGuestNotBuilt = errors.New("encrypt: deterministic eql guest not built; run `mise run wasm:guest:build:eql:deterministic`") + +// deterministicEQLGuestPath is the test build WITH the EQL types, which +// `mise run wasm:guest:build:eql:deterministic` writes under testdata too. +const deterministicEQLGuestPath = "testdata/stack_encrypt_guest_eql_deterministic.wasm" + +// NewDeterministicEQLClient is NewDeterministicClient over the test build +// WITH the EQL types. +func NewDeterministicEQLClient(ctx context.Context, seed [32]byte) (*Client, error) { + wasm, err := os.ReadFile(deterministicEQLGuestPath) + if err != nil { + return nil, ErrDeterministicEQLGuestNotBuilt + } + return deterministicClient(ctx, wasm, seed) +} + +func deterministicClient(ctx context.Context, wasm []byte, seed [32]byte) (*Client, error) { tr := &transport{rt: refusingTransport{}, token: noToken{}} inst, err := newInstance(ctx, wasm, tr, guest.BestEffort) if err != nil { @@ -122,9 +147,32 @@ func RawClient(t *testing.T, wasm []byte) *Client { return c } -// EmbeddedGuest is the embedded guest's bytes, or ErrGuestNotBuilt. +// EmbeddedGuest is the guest a client runs: the build with the EQL types +// when package eql is linked (it is, by the generated test types), else +// this package's own. ErrGuestNotBuilt when absent. func EmbeddedGuest() ([]byte, error) { return embeddedGuest() } +// PlainGuest is this package's own embedded guest, the build without the +// EQL types, whatever is linked. ErrGuestNotBuilt when absent. +func PlainGuest() ([]byte, error) { + wasm, err := guestFS.ReadFile(guestPath) + if err != nil { + return nil, ErrGuestNotBuilt + } + return wasm, nil +} + +// NewCheckerOver is NewChecker over the given guest bytes, so a test asks a +// named build its questions. +func NewCheckerOver(ctx context.Context, wasm []byte) (*Checker, error) { + t := &transport{rt: refusingTransport{}, token: noToken{}} + inst, err := newInstance(ctx, wasm, t, guest.BestEffort) + if err != nil { + return nil, err + } + return &Checker{c: newClient(inst, t)}, nil +} + // LiveClient is liveClient for the external tests: a client against real // ZeroKMS from the STACK_ENCRYPT_TEST_* variables, or a skip. func LiveClient(t *testing.T) *Client { return liveClient(t) } diff --git a/languages/golang/encrypt/gensupport/codec.go b/languages/golang/encrypt/gensupport/codec.go index 284e6d83e..77e23cb60 100644 --- a/languages/golang/encrypt/gensupport/codec.go +++ b/languages/golang/encrypt/gensupport/codec.go @@ -14,9 +14,9 @@ import ( // what Value reads. Passthrough fields are in it too. type Values map[string]any -// Output is what one field became: the passthrough value, or the ciphertext -// and each term the field declares. EQL is the EQL value of an encrypt_into -// field, once the engine produces one. +// Output is what one field became: the passthrough value, the ciphertext +// and each term the field declares, or EQL, the EQL value of an encrypt_into +// field as the JSON bytes its column holds. type Output struct { Value any Ciphertext encrypt.Ciphertext @@ -163,7 +163,7 @@ func (c *Codec[P, E]) Decrypt(ctx context.Context, d encrypt.Decrypter, encrypte } records[i][f.name] = record.Outputs{Context: label} case f.sealed(): - records[i][f.name] = record.Outputs{Ciphertext: o.Ciphertext} + records[i][f.name] = record.Outputs{Ciphertext: o.Ciphertext, EQL: o.EQL} default: passthrough[i][f.name] = o.Value } @@ -227,7 +227,7 @@ func outputOf(o record.Outputs) Output { // The context field comes back as the passthrough it is. return Output{Value: o.Context} } - out := Output{Ciphertext: o.Ciphertext} + out := Output{Ciphertext: o.Ciphertext, EQL: o.EQL} for k, term := range o.Terms { switch k { case record.Equality: diff --git a/languages/golang/encrypt/gensupport/declaration.go b/languages/golang/encrypt/gensupport/declaration.go index 6d5237eff..8bb574a13 100644 --- a/languages/golang/encrypt/gensupport/declaration.go +++ b/languages/golang/encrypt/gensupport/declaration.go @@ -129,7 +129,9 @@ func (d Declaration) Index(name string, kind Kind, indexes ...encrypt.Index) Dec return d.add(field{name: name, kind: kind, verb: verbIndex, indexes: indexes}) } -// EncryptInto seals the field into one EQL value of the named type. +// EncryptInto seals the field into one EQL value of the named type, such as +// TextEq: the type's own plan decides what is sealed and which terms sit +// beside it, and the stored field holds the value as it is. func (d Declaration) EncryptInto(name string, kind Kind, eqlType string) Declaration { return d.add(field{name: name, kind: kind, verb: verbEncryptInto, eqlType: eqlType}) } @@ -181,6 +183,10 @@ func (d Declaration) add(f field) Declaration { d.err = fmt.Errorf("gensupport: field %q: an indexed field names at least one index", f.name) return d } + if f.verb == verbEncryptInto && f.eqlType == "" { + d.err = fmt.Errorf("gensupport: field %q: EncryptInto names an EQL type", f.name) + return d + } // Clip, so the append copies and never writes into an array that an // earlier Declaration shares. d.fields = append(slices.Clip(d.fields), f) @@ -219,9 +225,11 @@ func (d Declaration) plan() (*record.Plan, error) { rf := record.Field{Name: f.name, Identity: f.identity, Kind: record.Kind(f.kind)} switch f.verb { case verbEncryptInto: - // The next build of the engine lists its EQL types through - // se_targets; until then no declaration can seal into one. - return nil, fmt.Errorf("gensupport: field %q: EQL types are not available yet", f.name) + // The EQL type's own plan decides the outputs; the engine + // returns the finished value. Whether the engine holds the type + // is the engine's to say, at the first call (stashgen asked it + // when the file was written). + rf.Target = f.eqlType case verbEncrypt: rf.Outputs = []record.Output{record.Ciphertext} case verbEncryptIndex: diff --git a/languages/golang/encrypt/gensupport/field.go b/languages/golang/encrypt/gensupport/field.go index b8081ba23..8006b0a01 100644 --- a/languages/golang/encrypt/gensupport/field.go +++ b/languages/golang/encrypt/gensupport/field.go @@ -65,13 +65,21 @@ func (f Field[T]) Encrypt(ctx context.Context, c *encrypt.Cipher, v T) (Output, return outputOf(sealed[0][f.name]), nil } -// Query derives the EQL query value for one value of an encrypt_into field. -// No EQL type is available in this build of the engine, so it fails. -func (f Field[T]) Query(context.Context, *encrypt.Cipher, T) (Output, error) { +// Query derives the EQL query value for one value of an encrypt_into field: +// the operand that matches stored values of the field, in Output.EQL. The +// engine runs the EQL type's own query plan, with no data key. +func (f Field[T]) Query(ctx context.Context, c *encrypt.Cipher, v T) (Output, error) { if f.err != nil { return Output{}, f.err } - return Output{}, fmt.Errorf("gensupport: %s: EQL types are not available yet", f.name) + if c == nil { + return Output{}, fmt.Errorf("gensupport: %s: Query needs a cipher", f.name) + } + out, err := c.Query(ctx, f.plan, f.name, v) + if err != nil { + return Output{}, err + } + return Output{EQL: out}, nil } // Equality derives the field's equality term for one value. diff --git a/languages/golang/encrypt/gensupport/gensupport_internal_test.go b/languages/golang/encrypt/gensupport/gensupport_internal_test.go index 059348b92..72a40f043 100644 --- a/languages/golang/encrypt/gensupport/gensupport_internal_test.go +++ b/languages/golang/encrypt/gensupport/gensupport_internal_test.go @@ -69,7 +69,7 @@ func TestDeclarationRefusals(t *testing.T) { "opaque with another field": DeclareOpaque("u").Encrypt("a", String), "identity of no field": Declare("u").Encrypt("a", String).Identity("b", "x"), "seals nothing": Declare("u").Passthrough("id"), - "EQL not available": Declare("u").EncryptInto("email", String, "TextEq"), + "EQL type unnamed": Declare("u").EncryptInto("email", String, ""), "json index": Declare("u").EncryptIndex("a", String, encrypt.JSON()), "name not a label": Declare("u").Encrypt("1a", String), } @@ -78,9 +78,16 @@ func TestDeclarationRefusals(t *testing.T) { t.Errorf("%s: accepted", name) } } - if _, err := Declare("u").EncryptInto("email", String, "TextEq").plan(); err == nil || !strings.Contains(err.Error(), "EQL types are not available yet") { + // encrypt_into lowers to the plan's target form: the EQL type's name, + // no outputs. Whether the engine holds the type is the engine's answer, + // at the first call. + p, err := Declare("u").EncryptInto("email", String, "TextEq").plan() + if err != nil { t.Fatalf("encrypt_into: %v", err) } + if f := p.Field("email"); f == nil || f.Target != "TextEq" || len(f.Outputs) != 0 || f.Kind != record.String { + t.Fatalf("encrypt_into lowered to %+v", p.Fields) + } } func TestConvertStaysWithinAFamily(t *testing.T) { diff --git a/languages/golang/encrypt/guest.go b/languages/golang/encrypt/guest.go index 59096fb9f..2b512572c 100644 --- a/languages/golang/encrypt/guest.go +++ b/languages/golang/encrypt/guest.go @@ -8,6 +8,7 @@ import ( "fmt" "sync" + "github.com/cipherstash/stack/languages/golang/encrypt/internal/eqlguest" "github.com/cipherstash/stack/languages/golang/internal/guest" "github.com/tetratelabs/wazero" "github.com/tetratelabs/wazero/api" @@ -20,6 +21,11 @@ import ( // directory so the package compiles without it; NewClient reports its // absence. // +// This is the build without the EQL types. Package eql embeds the build +// with them and registers it on import (internal/eqlguest), so a program +// whose generated code names an EQL type runs that build instead; see +// embeddedGuest. +// //go:embed wasm var guestFS embed.FS @@ -29,7 +35,13 @@ const guestPath = "wasm/stack_encrypt_guest.wasm" // embedded. var ErrGuestNotBuilt = errors.New("encrypt: guest module not built — run `mise run wasm:guest:build`") +// embeddedGuest is the guest a client runs: the build with the EQL types +// when package eql is linked (it registers the module on import, which +// cannot fail), else this package's own. func embeddedGuest() ([]byte, error) { + if wasm := eqlguest.Module(); wasm != nil { + return wasm, nil + } wasm, err := guestFS.ReadFile(guestPath) if err != nil { return nil, ErrGuestNotBuilt @@ -61,7 +73,7 @@ type instance struct { exports guest.Exports cipherInit, shutdown, keyset api.Function - term api.Function + term, query api.Function encryptRecord, decryptRecord api.Function planCheck, targets api.Function } @@ -148,6 +160,7 @@ func newInstance(ctx context.Context, wasm []byte, t *transport, policy guest.Lo "se_shutdown": &inst.shutdown, "se_keyset": &inst.keyset, "se_term": &inst.term, + "se_query": &inst.query, "se_encrypt_record": &inst.encryptRecord, "se_decrypt_record": &inst.decryptRecord, "se_plan_check": &inst.planCheck, diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index 5da718efa..2c9bab09d 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -715,9 +715,24 @@ func TestPlanCheckAnswersWithoutACipher(t *testing.T) { t.Errorf("plan %d: %v, want ErrEncoding", i, err) } } + // The test binary links encrypt/eql (through the generated test types), + // so the embedded guest is the build with the EQL types: it lists the + // catalog and runs a plan that names TextEq. The build without them is + // asked the same questions in eql_test.go. targets, err := k.Targets(ctx) - if err != nil || len(targets) != 0 { - t.Fatalf("Targets = %v, %v; want none in this build", targets, err) + if err != nil || len(targets) == 0 { + t.Fatalf("Targets = %v, %v; want the catalog in the eql build", targets, err) + } + found := false + for _, target := range targets { + found = found || target.Name == "TextEq" + } + if !found { + t.Fatalf("TextEq is not among the targets: %v", targets) + } + textEq := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}} + if err := k.Check(ctx, textEq); err != nil { + t.Fatalf("a TextEq field: %v", err) } } diff --git a/languages/golang/encrypt/internal/eqlguest/eqlguest.go b/languages/golang/encrypt/internal/eqlguest/eqlguest.go new file mode 100644 index 000000000..21ccbab1f --- /dev/null +++ b/languages/golang/encrypt/internal/eqlguest/eqlguest.go @@ -0,0 +1,15 @@ +// Package eqlguest is where package eql hands the guest build with the EQL +// types to package encrypt. Package encrypt embeds the build without them +// and cannot import eql (eql imports encrypt), so eql registers its module +// here on import and encrypt's embeddedGuest reads it first. Registration +// cannot fail: it is two assignments at program start. +package eqlguest + +var module []byte + +// Register installs the eql guest build. Called once, by package eql's init. +func Register(wasm []byte) { module = wasm } + +// Module is the registered eql guest build, or nil when package eql is not +// linked or its module was not built. +func Module() []byte { return module } diff --git a/languages/golang/encrypt/internal/testusers/contact_stash.go b/languages/golang/encrypt/internal/testusers/contact_stash.go new file mode 100644 index 000000000..f8f30e438 --- /dev/null +++ b/languages/golang/encrypt/internal/testusers/contact_stash.go @@ -0,0 +1,137 @@ +// Code generated by stashgen. DO NOT EDIT. + +package testusers + +import ( + "context" + "log/slog" + + "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" + "github.com/cipherstash/stack/languages/golang/encrypt/gensupport" +) + +// Stops compiling when the library does not accept this version of generated file. +const _ = gensupport.GeneratedVersion1 + +// Contact prints its sealed fields in the clear: it has no String or LogValue +// method. Write them, or run stashgen with -redact. + +type EncryptedContact struct { + ID int64 + Email eql.TextEq + Notes EncryptedContactNotes +} + +type EncryptedContactNotes struct { + Ciphertext encrypt.Ciphertext +} + +func (e EncryptedContact) String() string { + return gensupport.Redacted("EncryptedContact", map[string]any{"ID": e.ID}, "Email", "Notes") +} + +func (e EncryptedContact) LogValue() slog.Value { + return gensupport.RedactedLog(map[string]any{"ID": e.ID}, "Email", "Notes") +} + +// Stops compiling when Contact gains, loses, reorders or retypes a field. +var _ = contactShape(Contact{}) + +type contactShape struct { + _ struct{} + ID int64 + Email string + Notes string +} + +var contactDeclaration = gensupport.Declare("users"). + Passthrough("id"). + EncryptInto("email", gensupport.String, "TextEq"). + Encrypt("notes", gensupport.String) + +var contactCodec = gensupport.New(gensupport.Generated[Contact, EncryptedContact]{ + TypeName: "Contact", + Declaration: contactDeclaration, + PrintsPlaintext: true, + Source: func(v Contact) gensupport.Values { + return gensupport.Values{ + "id": v.ID, + "email": v.Email, + "notes": v.Notes, + } + }, + Seal: func(rec gensupport.Record) (EncryptedContact, error) { + var e EncryptedContact + var err error + if e.ID, err = gensupport.Passthrough[int64](rec, "id"); err != nil { + return EncryptedContact{}, err + } + e.Email = eql.TextEq(rec["email"].EQL) + e.Notes = EncryptedContactNotes{Ciphertext: rec["notes"].Ciphertext} + return e, nil + }, + Open: func(e EncryptedContact) gensupport.Record { + return gensupport.Record{ + "id": {Value: e.ID}, + "email": {EQL: e.Email}, + "notes": {Ciphertext: e.Notes.Ciphertext}, + } + }, + Value: func(e EncryptedContact, vals gensupport.Values) (Contact, error) { + var v Contact + var err error + if v.ID, err = gensupport.Get[int64](vals, "id"); err != nil { + return Contact{}, err + } + if v.Email, err = gensupport.Get[string](vals, "email"); err != nil { + return Contact{}, err + } + if v.Notes, err = gensupport.Get[string](vals, "notes"); err != nil { + return Contact{}, err + } + return v, nil + }, +}) + +// EncryptContact seals each Contact in one ZeroKMS request. The result has one +// element for each input, in the same order. +func EncryptContact(ctx context.Context, cipher *encrypt.Cipher, values []Contact) ([]EncryptedContact, error) { + return contactCodec.Encrypt(ctx, cipher, values) +} + +// DecryptContact opens each EncryptedContact in one ZeroKMS request. +func DecryptContact(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedContact) ([]Contact, error) { + return contactCodec.Decrypt(ctx, d, encrypted) +} + +var ContactFields = struct { + Email ContactEmailField + Notes ContactNotesField +}{ + Email: ContactEmailField{gensupport.NewField[string](contactDeclaration, "email")}, + Notes: ContactNotesField{gensupport.NewField[string](contactDeclaration, "notes")}, +} + +type ContactEmailField struct { + field gensupport.Field[string] +} + +func (f ContactEmailField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEq, error) { + out, err := f.field.Encrypt(ctx, c, v) + return eql.TextEq(out.EQL), err +} + +func (f ContactEmailField) Query(ctx context.Context, c *encrypt.Cipher, v string) (eql.TextEqQuery, error) { + out, err := f.field.Query(ctx, c, v) + return eql.TextEqQuery(out.EQL), err +} + +type ContactNotesField struct { + field gensupport.Field[string] +} + +func (f ContactNotesField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) (EncryptedContactNotes, error) { + out, err := f.field.Encrypt(ctx, c, v) + return EncryptedContactNotes{Ciphertext: out.Ciphertext}, err +} diff --git a/languages/golang/encrypt/internal/testusers/contacts.go b/languages/golang/encrypt/internal/testusers/contacts.go new file mode 100644 index 000000000..433f91ac1 --- /dev/null +++ b/languages/golang/encrypt/internal/testusers/contacts.go @@ -0,0 +1,15 @@ +package testusers + +//go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type Contact -name Contact + +// Contact is one row with an email stored as an EQL value, the shape the +// cross-language fixture packages/eql/tests/encryption/fixtures/text_eq_query.json +// is derived under (table users, column email). Its generated file imports +// encrypt/eql, so the encrypt test binary links the guest build with the +// EQL types; the tests that want the build without them load it by name. +type Contact struct { + _ struct{} `stash:"context=users"` + ID int64 `stash:"id,passthrough"` + Email string `stash:"email,encrypt_into=TextEq"` + Notes string `stash:"notes,encrypt"` +} diff --git a/languages/golang/encrypt/records.go b/languages/golang/encrypt/records.go index 04c9be1ca..d6ceea7ac 100644 --- a/languages/golang/encrypt/records.go +++ b/languages/golang/encrypt/records.go @@ -172,6 +172,42 @@ func (cph *Cipher) Derive(ctx context.Context, plan *record.Plan, field string, }) } +// Query derives the EQL query value of a field that names an EQL type, for +// one value: the operand that matches stored values of the field, as the +// JSON bytes the field's eql_v3.query_* domain takes. The engine runs the +// EQL type's own query plan; no data key is minted. For generated code. +func (cph *Cipher) Query(ctx context.Context, plan *record.Plan, field string, value any) ([]byte, error) { + p, err := cph.plan(plan) + if err != nil { + return nil, err + } + f := p.Field(field) + if f == nil { + return nil, fmt.Errorf("%w: the plan has no field %q", ErrEncoding, field) + } + if !f.IsTarget() { + return nil, fmt.Errorf("%w: field %q names no EQL type; its terms are derived by index", ErrEncoding, field) + } + encodedValue, err := vcffi.Marshal(value) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrEncoding, err) + } + // The probe value is plaintext: its transport copy is wiped once it is + // in the guest. + defer wipe(encodedValue) + encodedPlan, err := vcffi.Marshal(p.Wire()) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrEncoding, err) + } + opts, err := vcffi.Marshal(options(cph.keyset, "")) + if err != nil { + return nil, err + } + return cph.client.call(ctx, func(inst *instance) ([]byte, error) { + return inst.call(ctx, inst.query, buf(encodedValue), buf(encodedPlan), buf([]byte(field)), buf(opts)) + }) +} + // Open decrypts records sealed under the plan by any keyset of this client: // each record is opened under the keyset that sealed it, with one ZeroKMS // request for each 500 sealed values from each keyset. For generated code. @@ -286,6 +322,16 @@ func sealedOf(p *record.Plan, node any) (record.Sealed, error) { o.Ciphertext = leaf continue } + if key == record.EQLKey { + // The EQL value's JSON, as a passthrough byte node like a + // term's; the ciphertext is inside the JSON. + value, err := termBytes(out) + if err != nil { + return nil, fmt.Errorf("field %q: the EQL value: %v", name, err) + } + o.EQL = value + continue + } term, err := termBytes(out) if err != nil { return nil, fmt.Errorf("field %q output %q: %v", name, key, err) @@ -329,6 +375,10 @@ var errNoCiphertext = errors.New("encrypt: the record has no ciphertext for a se // errNoContext is a stored record missing its context field. var errNoContext = errors.New("encrypt: the record has no value for its context field") +// errNoEQL is a stored record missing the EQL value of a field that names +// an EQL type. +var errNoEQL = errors.New("encrypt: the record has no EQL value for a field that names an EQL type") + // open decrypts records under the keyset the selector names, and, when // context is not "", refuses a record whose stored context field names // another context before any key is retrieved. @@ -346,14 +396,20 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, context string, p tree[p.ContextField] = map[string]any{string(record.Passthrough): vcvalue.Plain{V: outputs.Context}} } for _, f := range p.Fields { - hasCiphertext := false - for _, o := range f.Outputs { - hasCiphertext = hasCiphertext || o == record.Ciphertext - } - if !hasCiphertext { + if !f.HasCiphertext() { continue } outputs, ok := rec[f.Name] + if f.IsTarget() { + if !ok || outputs.EQL == nil { + return nil, fmt.Errorf("%w: row %d, field %q", errNoEQL, i, f.Name) + } + // The EQL value rides as a passthrough byte node, the shape + // the engine stored it in; opening it runs the EQL type's own + // decryption on the ciphertext inside the JSON. + tree[f.Name] = map[string]any{record.EQLKey: vcvalue.Plain{V: append([]byte(nil), outputs.EQL...)}} + continue + } if !ok || outputs.Ciphertext == nil { return nil, fmt.Errorf("%w: row %d, field %q", errNoCiphertext, i, f.Name) } @@ -400,14 +456,10 @@ func (c *Client) open(ctx context.Context, sel KeysetSelector, context string, p src[f.Key] = f.Value } for _, f := range p.Fields { - if _, ok := src[f.Name]; !ok { + if _, ok := src[f.Name]; !ok && f.HasCiphertext() { // An index-only field has no ciphertext to open and comes - // back as nothing; every sealed field must. - for _, o := range f.Outputs { - if o == record.Ciphertext { - return nil, fmt.Errorf("%w: record %d lacks field %q", ErrInternal, i, f.Name) - } - } + // back as nothing; every sealed or target field must. + return nil, fmt.Errorf("%w: record %d lacks field %q", ErrInternal, i, f.Name) } } sources[i] = src diff --git a/languages/golang/internal/record/record.go b/languages/golang/internal/record/record.go index e521ee4ae..48b71c348 100644 --- a/languages/golang/internal/record/record.go +++ b/languages/golang/internal/record/record.go @@ -67,6 +67,12 @@ const ( Passthrough Output = "passthrough" ) +// EQLKey is the stored key a target field's EQL value rides under: not an +// output a plan asks for (the field names a Target instead), but the key of +// its one node in a sealed record, fixed by stack-encrypt's +// dynamic::record::EQL_KEY. +const EQLKey = "eql" + // IsTerm reports whether the output is an index term. func (o Output) IsTerm() bool { return o != Ciphertext && o != "" } @@ -80,8 +86,31 @@ type Field struct { Identity string // Kind is the declared type, or Untyped. Kind Kind - // Outputs are the field's outputs: Ciphertext and/or terms, at least one. + // Outputs are the field's outputs: Ciphertext and/or terms, at least one + // — unless the field names a Target, which has none of its own. Outputs []Output + // Target is the EQL type the field seals into (encrypt_into), such as + // TextEq, or "". A field has outputs or a target, never both: the EQL + // type's own plan decides what is sealed and which terms sit beside it, + // and the engine returns the finished value under EQLKey. + Target string +} + +// IsTarget reports whether the field names an EQL type. +func (f Field) IsTarget() bool { return f.Target != "" } + +// HasCiphertext reports whether the field's stored node is opened on +// decrypt: a ciphertext output, or an EQL value. +func (f Field) HasCiphertext() bool { + if f.IsTarget() { + return true + } + for _, o := range f.Outputs { + if o == Ciphertext { + return true + } + } + return false } // Plan is a declaration as the engine reads it: one context, any extension, @@ -220,6 +249,12 @@ func (p *Plan) Validate() error { if !f.Kind.Known() { return fmt.Errorf("record: field %q: unknown kind %q", f.Name, f.Kind) } + if f.IsTarget() { + if len(f.Outputs) != 0 { + return fmt.Errorf("record: field %q names the EQL type %s and outputs; a field has one or the other", f.Name, f.Target) + } + continue + } if len(f.Outputs) == 0 { return fmt.Errorf("record: field %q has no output", f.Name) } @@ -266,9 +301,9 @@ func (p *Plan) Field(name string) *Field { } // Wire renders the plan as the guest parses it: per field, its context -// (the label, extended), its outputs and its type; with a context field, -// the plan-level "context_field" key and that field's own entry, a -// passthrough of type string. Validate first. +// (the label, extended), its outputs or its target, and its type; with a +// context field, the plan-level "context_field" key and that field's own +// entry, a passthrough of type string. Validate first. func (p *Plan) Wire() vcvalue.Object { out := make(vcvalue.Object, 0, len(p.Fields)+2) if p.ContextField != "" { @@ -282,13 +317,15 @@ func (p *Plan) Wire() vcvalue.Object { ) } for _, f := range p.Fields { - outputs := make([]any, len(f.Outputs)) - for i, o := range f.Outputs { - outputs[i] = string(o) - } - spec := vcvalue.Object{ - {Key: "context", Value: p.FieldContext(f)}, - {Key: "outputs", Value: outputs}, + spec := vcvalue.Object{{Key: "context", Value: p.FieldContext(f)}} + if f.IsTarget() { + spec = append(spec, vcvalue.Field{Key: "target", Value: f.Target}) + } else { + outputs := make([]any, len(f.Outputs)) + for i, o := range f.Outputs { + outputs[i] = string(o) + } + spec = append(spec, vcvalue.Field{Key: "outputs", Value: outputs}) } if f.Kind != Untyped { spec = append(spec, vcvalue.Field{Key: "type", Value: string(f.Kind)}) @@ -345,10 +382,13 @@ type Source = map[string]any // Outputs is what the engine produced for one sealed field, or what it // stored for the context field. type Outputs struct { - // Ciphertext is the frozen leaf bytes, or nil for an index-only field. + // Ciphertext is the frozen leaf bytes, or nil for an index-only field + // or a target field. Ciphertext []byte // Terms are the index terms by output, each its frozen bytes. Terms map[Output][]byte + // EQL is the EQL value's JSON bytes for a field that names a Target. + EQL []byte // Context is the context field's value as the record stores it, in the // clear, and nothing for any other field. Context string diff --git a/languages/golang/internal/record/record_test.go b/languages/golang/internal/record/record_test.go index c2e71b798..11005f1fa 100644 --- a/languages/golang/internal/record/record_test.go +++ b/languages/golang/internal/record/record_test.go @@ -75,6 +75,38 @@ func TestExtensionNestsToTheLeftAndIdentityReplacesTheName(t *testing.T) { } } +func TestTargetFieldWiresItsTypeInsteadOfOutputs(t *testing.T) { + p := &Plan{Context: []string{"users"}, Fields: []Field{ + {Name: "email", Kind: String, Target: "TextEq"}, + {Name: "notes", Kind: String, Outputs: []Output{Ciphertext}}, + }} + if err := p.Validate(); err != nil { + t.Fatal(err) + } + if !p.Fields[0].IsTarget() || !p.Fields[0].HasCiphertext() || p.Fields[1].IsTarget() { + t.Fatal("a target field is one, and is opened; a sealed field is not a target") + } + want := vcvalue.Object{ + {Key: "email", Value: vcvalue.Object{ + {Key: "context", Value: []any{"users", "email"}}, + {Key: "target", Value: "TextEq"}, + {Key: "type", Value: "string"}, + }}, + {Key: "notes", Value: vcvalue.Object{ + {Key: "context", Value: []any{"users", "notes"}}, + {Key: "outputs", Value: []any{"c"}}, + {Key: "type", Value: "string"}, + }}, + } + if wire := p.Wire(); !reflect.DeepEqual(wire, want) { + t.Fatalf("Wire = %#v", wire) + } + both := &Plan{Context: []string{"users"}, Fields: []Field{{Name: "email", Target: "TextEq", Outputs: []Output{Ciphertext}}}} + if err := both.Validate(); err == nil { + t.Fatal("a field with a target and outputs was accepted") + } +} + func TestValidateRefusals(t *testing.T) { one := func(f Field) *Plan { return &Plan{Context: []string{"users"}, Fields: []Field{f}} } cases := map[string]*Plan{ diff --git a/languages/golang/stashgen/engine.go b/languages/golang/stashgen/engine.go index b5d363e03..11bb5fbd2 100644 --- a/languages/golang/stashgen/engine.go +++ b/languages/golang/stashgen/engine.go @@ -104,7 +104,7 @@ func (e *guestEngine) Check(ctx context.Context, d Declaration) error { field := d.field(f.Name) return &FieldError{Type: d.Type, Field: field.GoName, Reason: fmt.Sprintf( "the engine refuses the declaration: %s over a %s value under %q (%v)", - describeOutputs(f.Outputs), kindWord(f.Kind), plan.Descriptor(f), err)} + describeField(f), kindWord(f.Kind), plan.Descriptor(f), err)} } } if err := e.checker.Check(ctx, plan); err != nil { @@ -138,10 +138,30 @@ func lowerDeclaration(d Declaration, eqlTypes []EQLType) (*record.Plan, error) { rf := record.Field{Name: f.Name, Identity: f.Identity, Kind: wireKind(f.GoType)} switch f.Verb { case VerbEncryptInto: - // The reader refused anything the engine does not produce; a - // producible type reaches the engine's check in the next build, - // which lowers it. Until then no type is producible. - return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("the engine cannot seal into the EQL type %s in this build", f.EQLType)} + if len(eqlTypes) == 0 { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: "EQL types are not available yet; the engine produces none in this build"} + } + var eqlType *EQLType + for i := range eqlTypes { + if eqlTypes[i].Name == f.EQLType { + eqlType = &eqlTypes[i] + } + } + if eqlType == nil { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("the engine cannot produce the EQL type %s yet: no EQL type has that name", f.EQLType)} + } + if !eqlType.Producible { + // The engine's own reason, from se_targets: the generator + // holds no copy of why a type waits. + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("the engine cannot produce the EQL type %s yet: %s", f.EQLType, eqlType.Reason)} + } + // The engine checks the kind too (se_plan_check refuses a + // "type" other than the EQL type's plaintext); saying it here + // names the two kinds. + if eqlType.Plaintext != KindOther && eqlType.Plaintext != f.GoType.Kind { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s seals a %s, and %s is %s", f.EQLType, eqlType.Plaintext, f.GoType, f.GoType.Kind)} + } + rf.Target = f.EQLType case VerbEncrypt, VerbEncryptIndex: rf.Outputs = append(rf.Outputs, record.Ciphertext) } @@ -226,6 +246,17 @@ func kindWord(k record.Kind) string { return string(k) } +// describeField names what the engine was asked for a field: the EQL type +// it names, or its outputs. For an EQL type the engine refuses, the refusal +// is the engine's (an unknown or unproducible type, a kind other than its +// plaintext, an extended context); the name is what a reader needs. +func describeField(f record.Field) string { + if f.IsTarget() { + return "the EQL type " + f.Target + } + return describeOutputs(f.Outputs) +} + func describeOutputs(outputs []record.Output) string { words := make([]string, 0, len(outputs)) for _, o := range outputs { diff --git a/languages/golang/stashgen/read.go b/languages/golang/stashgen/read.go index 54661ccfa..264d353bd 100644 --- a/languages/golang/stashgen/read.go +++ b/languages/golang/stashgen/read.go @@ -135,6 +135,18 @@ type modelField struct { output *output } +// eqlGoName is the Go type name in encrypt/eql of an EQL type the engine +// names: the same name in every language, save the JSON family, whose Go +// names start with JSON (Json -> JSON, JsonSearch -> JSONSearch), as Go +// spells initialisms. The generated encrypt/eql package follows the same +// rule (eql-codegen's go_eql renderer). +func eqlGoName(name string) string { + if strings.HasPrefix(name, "Json") { + return "JSON" + strings.TrimPrefix(name, "Json") + } + return name +} + // reader builds a genFile from a loaded package. type reader struct { pkg *packages.Package @@ -701,10 +713,10 @@ func (r *reader) buildFields(c *collected) error { return fieldErr(typeName, cf.goName, "the engine cannot produce the EQL type %s yet: %s", field.EQLType, reason) } f.imports.add(eqlPath, "eql") - g.outputType = "eql." + eqlType.Name - g.outputs = []output{{name: "EQL", typeExpr: g.outputType, pathType: eqlPath + "." + eqlType.Name}} + g.outputType = "eql." + eqlGoName(eqlType.Name) + g.outputs = []output{{name: "EQL", typeExpr: g.outputType, pathType: eqlPath + "." + eqlGoName(eqlType.Name)}} if eqlType.Query != "" { - g.queryType = "eql." + eqlType.Query + g.queryType = "eql." + eqlGoName(eqlType.Query) } default: g.outputType = f.encName + cf.goName diff --git a/mise.toml b/mise.toml index 3fd21e9b8..99c81a215 100644 --- a/mise.toml +++ b/mise.toml @@ -254,8 +254,9 @@ echo "size with EQL types: $(wc -c < ../eql/wasm/stack_encrypt_guest_eql.wasm """ # The deterministic-kms TEST build with the EQL types: what the hermetic Go -# tests of a TextEq field load (encrypt/eql's tests), as the one above is -# what the other hermetic tests load. Same gate as the deterministic build. +# tests of a TextEq field load, as the one above is what the other hermetic +# tests load. Same gate, and the same rule: nothing test-only in an embed, so +# it lands under encrypt/testdata beside the other test build. [tasks."wasm:guest:build:eql:deterministic"] description = "Build the deterministic-kms TEST build of the stack-encrypt WASI guest with the EQL types (features eql,deterministic-kms), for the hermetic Go tests of EQL fields" shell = "bash -c" @@ -272,9 +273,8 @@ python3 "$root/scripts/check-wasm-imports.py" "$module" \\ --deny-prefix wasi_snapshot_preview1:sock_ \\ --deny-prefix wasi_snapshot_preview1:fd_prestat \\ --require wasi_snapshot_preview1:random_get -mkdir -p ../eql/wasm -cp "$module" ../eql/wasm/stack_encrypt_guest_eql_deterministic.wasm -echo "test build at languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql_deterministic.wasm" +cp "$module" ../testdata/stack_encrypt_guest_eql_deterministic.wasm +echo "test build at languages/golang/encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm" """ [tasks."wasm:guest:test"] diff --git a/packages/eql/crates/eql-codegen/src/go_eql.rs b/packages/eql/crates/eql-codegen/src/go_eql.rs new file mode 100644 index 000000000..66e89d328 --- /dev/null +++ b/packages/eql/crates/eql-codegen/src/go_eql.rs @@ -0,0 +1,202 @@ +//! The Go `encrypt/eql` package emitter: renders `eql_domains::CATALOG` to +//! the committed `languages/golang/encrypt/eql/eql_gen.go` in the Go module +//! — one Go type per EQL type, holding the value as the JSON bytes its +//! column stores, the query types that match them, and a `Types` table a +//! generator or a program can consult. The plan +//! (`docs/plans/2026-10-04-plan-builder.md`, "EQL types") puts the package +//! here: "eql-codegen writes the package encrypt/eql from the EQL catalog". +//! +//! The rows are [`crate::targets`]', the same the Rust `TARGETS` table is +//! rendered from, so the Go table and the engine's `se_targets` cannot +//! disagree about a type. The Go names follow the catalog names with one +//! rule: the JSON family's begin with `JSON` (`Json` is `JSON`), as Go +//! spells initialisms; `stashgen` applies the same rule when it writes a +//! field's type. +//! +//! Generate-to-file like the Rust bindings: `eql-codegen go-eql` writes the +//! file, `mise run types:check` diffs it, and `tests/go_eql_parity.rs` +//! asserts the committed file is what the catalog renders. The output is +//! gofmt-clean by construction — one-line table rows and block-bodied +//! methods, the two shapes gofmt does not realign — and the Go CI's +//! `gofmt -l` is the check that it stayed so. + +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; + +use crate::targets::{rows, Row}; + +/// The generated file, relative to the EQL subtree root (`repo_root()`): +/// the Go module lives beside `packages/` in the monorepo. +pub const GO_EQL_PATH: &str = "../../languages/golang/encrypt/eql/eql_gen.go"; + +/// The header every generated Go file carries: the `Code generated ... DO +/// NOT EDIT.` form `go vet` and editors recognise. +pub const GO_GENERATED_MARKER: &str = + "// Code generated by eql-codegen from the eql-domains catalog. DO NOT EDIT."; + +/// The Go type name of a catalog name: the same, save the JSON family. +pub fn go_name(name: &str) -> String { + match name.strip_prefix("Json") { + Some(rest) => format!("JSON{rest}"), + None => name.to_owned(), + } +} + +/// A Go string literal. +fn quote(s: &str) -> String { + format!("{s:?}") +} + +/// A Go `[]string` literal, `nil` when empty. +fn strings(items: &[&str]) -> String { + if items.is_empty() { + return "nil".to_owned(); + } + let quoted: Vec = items.iter().map(|s| quote(s)).collect(); + format!("[]string{{{}}}", quoted.join(", ")) +} + +fn table_row(row: &Row) -> String { + let (query, query_domain) = row + .query + .as_ref() + .map(|(q, d)| (q.as_str(), d.as_str())) + .unwrap_or_default(); + format!( + "\t{{Name: {}, GoName: {}, Family: {}, Suffix: {}, Plaintext: {}, SQLDomain: {}, \ + Indexes: {}, Query: {}, QuerySQLDomain: {}, Producible: {}, Reason: {}}},\n", + quote(&row.name), + quote(&go_name(&row.name)), + quote(row.family), + quote(&row.suffix), + quote(row.plaintext.unwrap_or_default()), + quote(&row.sql_domain), + strings(&row.indexes), + quote(query), + quote(query_domain), + row.reason.is_none(), + quote(row.reason.unwrap_or_default()), + ) +} + +/// The four methods every EQL type and query type carries, block-bodied so +/// gofmt leaves them as written. +fn methods(out: &mut String, go: &str, domain: &str) { + let _ = write!( + out, + "\n// Value is the EQL value as the driver takes it: the JSON text, or NULL when empty.\n\ + func (v {go}) Value() (driver.Value, error) {{\n\treturn value(v)\n}}\n\ + \n// Scan reads a {domain} column.\n\ + func (v *{go}) Scan(src any) error {{\n\treturn scan((*[]byte)(v), src, {q})\n}}\n\ + \n// MarshalJSON is the EQL value as the JSON it is, or null when empty.\n\ + func (v {go}) MarshalJSON() ([]byte, error) {{\n\treturn marshal(v, {q})\n}}\n\ + \n// UnmarshalJSON reads the EQL value from JSON, as it is.\n\ + func (v *{go}) UnmarshalJSON(b []byte) error {{\n\treturn unmarshal((*[]byte)(v), b)\n}}\n", + q = quote(go), + ); +} + +/// Render the Go source of `eql_gen.go`. +pub fn render_go_eql() -> String { + let rows = rows(); + let mut out = String::new(); + let _ = writeln!(out, "{GO_GENERATED_MARKER}\n\npackage eql\n\nimport \"database/sql/driver\"\n"); + out.push_str( + "// Types is every EQL type the catalog has, in catalog order: what the\n\ + // engine's se_targets export lists, as Go. Producible says whether the\n\ + // engine produces the type today; stashgen refuses encrypt_into for one\n\ + // it does not, with the Reason.\n\ + var Types = []Type{\n", + ); + for row in &rows { + out.push_str(&table_row(row)); + } + out.push_str("}\n"); + for row in &rows { + let go = go_name(&row.name); + let indexes = if row.indexes.is_empty() { + "no index".to_owned() + } else { + format!("the {} index{}", row.indexes.join(" and "), if row.indexes.len() > 1 { "es" } else { "" }) + }; + let status = match (row.reason, row.plaintext) { + (None, Some(kind)) => format!("The engine produces it from a {kind}."), + (None, None) => "The engine produces it.".to_owned(), + (Some(reason), _) => format!("The engine cannot produce it yet: {reason}."), + }; + let _ = write!( + out, + "\n// {go} is the EQL type stored in {domain}: the {family} family with {indexes}.\n// {status}\ntype {go} []byte\n", + domain = row.sql_domain, + family = row.family, + ); + methods(&mut out, &go, &row.sql_domain); + if let Some((query, query_domain)) = &row.query { + let query_go = go_name(query); + let _ = write!( + out, + "\n// {query_go} is the query value for {go}: the operand {query_domain} takes.\ntype {query_go} []byte\n", + ); + methods(&mut out, &query_go, query_domain); + } + } + out +} + +/// Write `eql_gen.go` under `out_root` (the EQL subtree root, or the +/// override `EQL_CODEGEN_OUT_ROOT`). Returns the path written. +pub fn generate_go_eql(out_root: &Path) -> std::io::Result { + let path = out_root.join(GO_EQL_PATH); + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + std::fs::write(&path, render_go_eql())?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn go_names_follow_the_catalog_save_the_json_family() { + assert_eq!(go_name("TextEq"), "TextEq"); + assert_eq!(go_name("TextEqQuery"), "TextEqQuery"); + assert_eq!(go_name("Json"), "JSON"); + assert_eq!(go_name("SteVecDocument"), "SteVecDocument"); + } + + #[test] + fn the_package_has_one_type_per_row_and_one_query_type_per_twin() { + let out = render_go_eql(); + assert!(out.starts_with(GO_GENERATED_MARKER)); + assert!(out.contains("\npackage eql\n")); + for row in rows() { + let go = go_name(&row.name); + assert_eq!(out.matches(&format!("\ntype {go} []byte\n")).count(), 1, "{go}"); + assert!(out.contains(&format!("Name: {:?}, GoName: {go:?}", row.name)), "{go} in the table"); + if let Some((query, _)) = &row.query { + let query_go = go_name(query); + assert_eq!(out.matches(&format!("\ntype {query_go} []byte\n")).count(), 1, "{query_go}"); + } + } + // The one query type two rows share (text_ord and text_ord_ope + // have distinct names; the SteVec needle belongs to one document), + // so no type is declared twice. + assert_eq!(out.matches("\ntype JSON []byte\n").count(), 1); + assert_eq!(out.matches("\ntype SteVecQuery []byte\n").count(), 1); + // TextEq is producible and says so; TextOrdOre is not and says why. + assert!(out.contains("// TextEq is the EQL type stored in public.eql_v3_text_eq")); + assert!(out.contains("Producible: true, Reason: \"\"")); + assert!(out.contains("Name: \"TextOrdOre\"") && out.contains("CLLW")); + // Every table row is one line: the shape gofmt leaves alone. + for line in out.lines().filter(|l| l.starts_with("\t{Name: ")) { + assert!(line.ends_with("},"), "{line}"); + } + } + + #[test] + fn rendering_is_deterministic() { + assert_eq!(render_go_eql(), render_go_eql()); + } +} diff --git a/packages/eql/crates/eql-codegen/src/lib.rs b/packages/eql/crates/eql-codegen/src/lib.rs index 5d9968e96..03cdf3e48 100644 --- a/packages/eql/crates/eql-codegen/src/lib.rs +++ b/packages/eql/crates/eql-codegen/src/lib.rs @@ -12,6 +12,7 @@ pub mod consts; pub mod context; pub mod dump; pub mod generate; +pub mod go_eql; pub mod operator_surface; pub mod ordering; pub mod targets; diff --git a/packages/eql/crates/eql-codegen/src/main.rs b/packages/eql/crates/eql-codegen/src/main.rs index 1fc7eba0a..2dc95b195 100644 --- a/packages/eql/crates/eql-codegen/src/main.rs +++ b/packages/eql/crates/eql-codegen/src/main.rs @@ -69,6 +69,23 @@ fn main() -> ExitCode { } } + // `go-eql`: regenerate the committed Go package encrypt/eql in the Go + // module (languages/golang) from the catalog: one Go type per EQL type, + // the query types, and the Types table. Wired into `mise run + // types:generate` beside `bindings`; `types:check` diffs the file. + if args.len() == 2 && args[1] == "go-eql" { + match eql_codegen::go_eql::generate_go_eql(&out_root()) { + Ok(path) => { + println!("generated {}", path.display()); + return ExitCode::SUCCESS; + } + Err(e) => { + eprintln!("error: {e}"); + return ExitCode::FAILURE; + } + } + } + // `order`: print the install order of the whole src/v3 SQL surface, one // repo-relative path per line, dependency before dependent. Consumed by // tasks/build.sh (`> src/deps-ordered-v3.txt`), which concatenates the files @@ -139,5 +156,6 @@ fn main() -> ExitCode { eprintln!(" eql-codegen list-schemas (print owned schemas, public first)"); eprintln!(" eql-codegen dump-catalog (print catalog surface as JSON)"); eprintln!(" eql-codegen bindings (regenerate eql-bindings Rust payload types)"); + eprintln!(" eql-codegen go-eql (regenerate the Go package encrypt/eql)"); ExitCode::from(2) } diff --git a/packages/eql/crates/eql-codegen/src/targets.rs b/packages/eql/crates/eql-codegen/src/targets.rs index 07d9b27e9..67f9f430e 100644 --- a/packages/eql/crates/eql-codegen/src/targets.rs +++ b/packages/eql/crates/eql-codegen/src/targets.rs @@ -86,16 +86,25 @@ pub fn target_gap(family: &DomainFamily, domain: &Domain) -> Option<&'static str }) } -/// One row of the generated table, computed from the catalog. -struct Row { - name: String, - family: &'static str, - suffix: String, - plaintext: Option<&'static str>, - sql_domain: String, - indexes: Vec<&'static str>, - query: Option<(String, String)>, - reason: Option<&'static str>, +/// One row of the generated table, computed from the catalog: what both +/// the Rust `TARGETS` table and the Go `encrypt/eql` package +/// ([`crate::go_eql`]) are rendered from, so the two cannot disagree. +pub(crate) struct Row { + pub(crate) name: String, + pub(crate) family: &'static str, + pub(crate) suffix: String, + pub(crate) plaintext: Option<&'static str>, + pub(crate) sql_domain: String, + pub(crate) indexes: Vec<&'static str>, + /// The query twin's struct identifier and SQL domain, if the type + /// answers a query. + pub(crate) query: Option<(String, String)>, + pub(crate) reason: Option<&'static str>, +} + +/// Every row, in catalog order: one per stored domain. +pub(crate) fn rows() -> Vec { + stored_payload_domains().map(|(f, d)| row(f, d)).collect() } /// The PascalCase of a bare domain name — `TextOrdOre` is family `text`, @@ -166,7 +175,7 @@ fn option_str(value: Option<&str>) -> TokenStream { /// Render the generated `crates/eql-bindings/src/v3/targets.rs`. pub fn render_targets_rs() -> String { - let rows: Vec = stored_payload_domains().map(|(f, d)| row(f, d)).collect(); + let rows = rows(); let entries: TokenStream = rows .iter() diff --git a/packages/eql/crates/eql-codegen/tests/go_eql_parity.rs b/packages/eql/crates/eql-codegen/tests/go_eql_parity.rs new file mode 100644 index 000000000..96bf3878e --- /dev/null +++ b/packages/eql/crates/eql-codegen/tests/go_eql_parity.rs @@ -0,0 +1,27 @@ +//! The Go `encrypt/eql` parity gate, the twin of `bindings_parity.rs`: the +//! committed `languages/golang/encrypt/eql/eql_gen.go` must be byte for byte +//! what the catalog renders. Without this, a catalog change would leave the +//! Go types behind until `mise run types:check` in CI, and a hand edit would +//! survive `cargo test`. + +use std::fs; + +use eql_codegen::go_eql::{render_go_eql, GO_EQL_PATH}; +use eql_codegen::repo_root; + +#[test] +fn go_eql_matches_the_committed_file() { + let path = repo_root().join(GO_EQL_PATH); + let committed = fs::read_to_string(&path).unwrap_or_else(|e| { + panic!( + "committed Go package {} is missing or unreadable ({e}); run `mise run types:generate` and commit", + path.display() + ) + }); + assert_eq!( + render_go_eql(), + committed, + "{}: the committed Go package is stale or hand-edited — run `mise run types:generate` and commit the result", + path.display() + ); +} diff --git a/packages/eql/mise.toml b/packages/eql/mise.toml index 8ec42876c..f4bbd55f6 100644 --- a/packages/eql/mise.toml +++ b/packages/eql/mise.toml @@ -371,6 +371,9 @@ fi rm -rf "$tmp"' EXIT # Default no-arg eql-codegen stays SQL-only; `bindings` is the Rust-only subcommand. cargo run -q -p eql-codegen -- bindings +# The Go package encrypt/eql in the Go module, from the same catalog rows +# as the Rust target table (crates/eql-codegen/src/go_eql.rs). +cargo run -q -p eql-codegen -- go-eql TS_RS_EXPORT_DIR="$tmp/bindings" EQL_TYPES_SCHEMA_DIR="$tmp/schema" cargo test -p eql-bindings rm -rf "$bindings" "$schema" mv "$tmp/bindings" "$bindings" @@ -389,12 +392,12 @@ set -euo pipefail # runs in contexts (test-eql.yml's Rust job, the pre-commit hook) that # provision mise tools but not pnpm/node_modules. node packages/eql/scripts/sync-generated.mjs -git diff --exit-code -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated || { - echo "eql-bindings generated Rust/TS/JSON or @cipherstash/eql package output is stale — run 'mise run types:generate' and 'mise run typescript:generate' and commit the result" >&2 +git diff --exit-code -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go || { + echo "eql-bindings generated Rust/TS/JSON, the Go package encrypt/eql or @cipherstash/eql package output is stale — run 'mise run types:generate' and 'mise run typescript:generate' and commit the result" >&2 exit 1 } # git diff is blind to brand-new files; untracked output is stale too. -untracked=$(git ls-files --others --exclude-standard -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated) +untracked=$(git ls-files --others --exclude-standard -- crates/eql-bindings/src/v3 crates/eql-bindings/bindings crates/eql-bindings/schema packages/eql/src/generated ../../languages/golang/encrypt/eql/eql_gen.go) if [ -n "$untracked" ]; then echo "eql-bindings has uncommitted generated files:" >&2 echo "$untracked" >&2 diff --git a/packages/eql/tests/encryption/fixtures/text_eq_query.json b/packages/eql/tests/encryption/fixtures/text_eq_query.json new file mode 100644 index 000000000..ed65759f2 --- /dev/null +++ b/packages/eql/tests/encryption/fixtures/text_eq_query.json @@ -0,0 +1,7 @@ +{ + "_comment": "The TextEqQuery eql-bindings derives for the plaintext under FakeDataKeySource's index key (keyset nil), read by the Go SDK's hermetic test; regenerate with EQL_UPDATE_FIXTURES=1, do not edit.", + "column": "email", + "plaintext": "bob@example.com", + "query": "{\"v\":3,\"i\":{\"t\":\"users\",\"c\":\"email\"},\"hm\":\"ec534cfbf2336785378c3761ff88623c46361b2ba7c5fa83930ebacae49cf646\"}", + "table": "users" +} diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs index 9330b18c6..070536411 100644 --- a/packages/eql/tests/encryption/tests/targets.rs +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -526,3 +526,54 @@ mod the_table { assert_eq!(all.as_array().unwrap().len(), targets::targets().len()); } } + +mod the_cross_language_fixture { + use super::*; + use std::path::PathBuf; + + /// `fixtures/text_eq_query.json`: the `TextEqQuery` for one plaintext + /// under the key source every test in the repository derives terms + /// under (`FakeDataKeySource`'s index key, keyset nil), as the Rust + /// dispatch produces it. The Go SDK's hermetic test seals the same + /// plaintext through generated code over the deterministic guest build + /// — whose index key is the same — and asserts its `Fields.Email.Query` + /// bytes equal these, which is the cross-language half of the proof + /// that a Go `encrypt_into=TextEq` field runs this plan and no other. + /// Regenerate with `EQL_UPDATE_FIXTURES=1`; the bytes must not change + /// otherwise. + fn fixture_path() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("fixtures/text_eq_query.json") + } + + const PLAINTEXT: &str = "bob@example.com"; + + #[tokio::test] + async fn the_text_eq_query_fixture_is_what_the_dispatch_derives() { + let (cipher, calls) = common::cipher().await; + let keyset = cipher.default_keyset(); + let query = targets::query("TextEq", &keyset, &email(), text(PLAINTEXT)) + .unwrap() + .await + .unwrap(); + assert!(calls.lock().unwrap().generate.is_empty(), "a query mints nothing"); + let fixture = serde_json::json!({ + "_comment": "The TextEqQuery eql-bindings derives for the plaintext under FakeDataKeySource's index key (keyset nil), read by the Go SDK's hermetic test; regenerate with EQL_UPDATE_FIXTURES=1, do not edit.", + "table": "users", + "column": "email", + "plaintext": PLAINTEXT, + "query": String::from_utf8(query.clone()).unwrap(), + }); + let rendered = serde_json::to_string_pretty(&fixture).unwrap() + "\n"; + if std::env::var_os("EQL_UPDATE_FIXTURES").is_some() { + std::fs::write(fixture_path(), &rendered).unwrap(); + } + let committed = std::fs::read_to_string(fixture_path()) + .expect("fixtures/text_eq_query.json is committed; EQL_UPDATE_FIXTURES=1 writes it"); + assert_eq!( + committed, rendered, + "the committed fixture is what the dispatch derives today" + ); + let typed: TextEqQuery = keyset.encrypt_as(&PLAINTEXT.to_owned(), column()).await.unwrap(); + assert_eq!(query, serde_json::to_vec(&typed).unwrap(), "and what the typed path derives"); + } +} From c5b6bdf16380670907bad68e2a8f16591d5850e0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 02:52:39 -0700 Subject: [PATCH 07/30] docs: EQL types as plan field targets, across the Go SDK and the plan The stashgen README says what the engine produces (TextEq), where the generator learns it, and the tenant-extension rule; the encrypt README gains an EQL columns section; the plan's "EQL types" section carries a status line and the open question on extension; AGENTS.md's repository layout names the eql guest build and the generated Go package; the eql-bindings README says where the engine's resolver is implemented and why not here. The CI workflow and the binding test script are updated alongside. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- .github/workflows/tests-golang.yml | 33 ++++++++++++++++++---- AGENTS.md | 2 +- docs/plans/2026-10-04-plan-builder.md | 3 ++ packages/eql/crates/eql-bindings/README.md | 8 +++++- scripts/go-binding-test.sh | 7 +++-- 5 files changed, 43 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests-golang.yml b/.github/workflows/tests-golang.yml index 67531f441..9bfff334a 100644 --- a/.github/workflows/tests-golang.yml +++ b/.github/workflows/tests-golang.yml @@ -8,8 +8,9 @@ name: Tests (Go) # wasi-check the stack crates build for wasm32-wasip1 with no JS-host or # native-HTTP dependencies, the no-http shape passes its tests # and docs, both guests pass lint and tests and are built with -# their import surfaces checked (the stack-encrypt guest twice: -# the real build and the deterministic-kms test build), their +# their import surfaces checked (the stack-encrypt guest four +# times: the real build, the build with the EQL types, and the +# deterministic-kms test build of each), their # sha256 is recorded, `go:test` runs against them, and # `go generate` leaves the tree unchanged. # go-lint golangci-lint, Linux only. @@ -32,6 +33,9 @@ on: - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** + # The eql guest build links eql-bindings. + - packages/eql/crates/eql-bindings/** + - packages/eql/crates/eql-domains/** - languages/golang/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh @@ -54,6 +58,9 @@ on: - packages/stack-encrypt/** - packages/stack-encrypt-derive/** - packages/stack-guest-abi/** + # The eql guest build links eql-bindings. + - packages/eql/crates/eql-bindings/** + - packages/eql/crates/eql-domains/** - languages/golang/** - scripts/check-wasm-imports.py - scripts/go-binding-test.sh @@ -156,6 +163,18 @@ jobs: - name: stack-encrypt guest deterministic test build run: mise run wasm:guest:build:deterministic + # The build with the EQL types (ADR-0007, amended 2026-10-06), which + # package encrypt/eql embeds and registers on import, and its + # deterministic test build, which the hermetic tests of a TextEq field + # load. Same import-surface gate. The build task prints both builds' + # sizes: the plan asks for that measurement before the SDK settles on + # one build or two. + - name: stack-encrypt guest eql build and import-surface gate + run: mise run wasm:guest:build:eql + + - name: stack-encrypt guest eql deterministic test build + run: mise run wasm:guest:build:eql:deterministic + - name: Credential guest lint and tests run: mise run wasm:auth-guest:test @@ -167,7 +186,7 @@ jobs: # same bytes rather than a stale or rebuilt guest. - name: Record the guests' checksums run: | - for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do (cd languages/golang && openssl dgst -sha256 "$guest" | awk '{print $NF}' > "$guest.sha256" && echo "$guest sha256 $(cat "$guest.sha256")") done @@ -180,6 +199,10 @@ jobs: languages/golang/encrypt/wasm/stack_encrypt_guest.wasm.sha256 languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm languages/golang/encrypt/testdata/stack_encrypt_guest_deterministic.wasm.sha256 + languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm + languages/golang/encrypt/eql/wasm/stack_encrypt_guest_eql.wasm.sha256 + languages/golang/encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm + languages/golang/encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm.sha256 languages/golang/auth/wasm/stack_auth_guest.wasm languages/golang/auth/wasm/stack_auth_guest.wasm.sha256 if-no-files-found: error @@ -290,7 +313,7 @@ jobs: - name: The guests are the ones Linux built and checked run: | cd languages/golang - for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do want=$(cat "$guest.sha256") got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') if [ "$want" != "$got" ]; then @@ -356,7 +379,7 @@ jobs: - name: The guests are the ones the wasi-check job built and checked run: | cd languages/golang - for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do + for guest in encrypt/wasm/stack_encrypt_guest.wasm encrypt/testdata/stack_encrypt_guest_deterministic.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm encrypt/testdata/stack_encrypt_guest_eql_deterministic.wasm auth/wasm/stack_auth_guest.wasm; do want=$(cat "$guest.sha256") got=$(openssl dgst -sha256 "$guest" | awk '{print $NF}') if [ "$want" != "$got" ]; then diff --git a/AGENTS.md b/AGENTS.md index da9c497b2..6e9020f11 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -92,7 +92,7 @@ Every npm package except EQL lives under `languages/typescript/`: packages in `l links are provenance only. - `packages/stack-auth`, `packages/stack-profile`, `packages/stack-kms`, `packages/stack-encrypt`, `packages/stack-encrypt-derive`, `packages/stack-guest-abi`: The Rust crates imported from `cipherstash/cipherstash-suite` with their history — `stack-auth` and `stack-profile` (published to crates.io, one version group), `stack-kms` (published to crates.io from 0.1.0, its own version group, re-exported by `stack-encrypt` as `stack_encrypt::kms`), `stack-encrypt` and `stack-encrypt-derive` (published to crates.io from 0.1.0, one version group; `eql-bindings`' `stack-encrypt` feature depends on them from the registry), and `stack-guest-abi` (`publish = false`). They are the members of the **root Cargo workspace**, with the three node binding crates below. See "Working on the Rust crates". - `languages/typescript/packages/auth`, `languages/typescript/packages/profile`, `languages/typescript/packages/stack-auth-wasm`: The node bindings of those crates. `@cipherstash/auth` (napi-rs v2) and its six `platforms/*` packages are published to npm from this repository by `release.yml` (`auth-artifacts`, `publish-auth`); a change to what it ships, the `stack-auth` crate included, needs an `@cipherstash/auth` changeset (`require-auth-npm-changeset.yml`). `@cipherstash/profile` and its platforms are private and never published; `@cipherstash/stack-auth-wasm` is private and builds the wasm that `@cipherstash/auth` ships. Their `build` and `test` scripts never invoke cargo; `build:native`, `build:debug` and `test:cargo` do. -- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. There is no Go release process yet. +- `languages/golang`: The Go module — the SDK `encrypt` with its generated-code support `encrypt/gensupport` and the policy packages `encrypt/policy` and `encrypt/policy/protosource`; the credential package `auth`; the generator `stashgen` and its command `cmd/stashgen`; and `internal` (the shared guest plumbing and the `record` wire model). A wazero host with no cgo. Its two WASI guests (`encrypt/guest`, `auth/guest`) are detached Cargo workspaces built by `mise run wasm:guest:build` and `mise run wasm:auth-guest:build`; `mise run wasm:guest:build:deterministic` builds the seeded TEST build the hermetic Go tests use; the `.wasm` files they embed are gitignored. The stack-encrypt guest has a second build with the EQL types (`mise run wasm:guest:build:eql`, cargo feature `eql`, linking `eql-bindings` by path; `wasm:guest:build:eql:deterministic` is its test build), embedded by `encrypt/eql` and registered on import, so a program whose generated code names an EQL type runs it (ADR-0007, amended). `encrypt/eql`'s types and `Types` table are generated from the EQL catalog by `eql-codegen` (`eql_gen.go`, written by `mise run types:generate` in `packages/eql` and drift-gated by `types:check` and `cargo test -p eql-codegen`). Generated `*_stash.go` files are committed and CI fails when `go generate ./...` changes one. There is no Go release process yet. - `e2e/*`: Cross-package end-to-end tests (package managers, supply chain, Prisma example README) - `languages/typescript/examples/*`: Working apps (basic, prisma, supabase-worker) - `docs/plans/*`: Internal design plans. User-facing documentation lives at https://cipherstash.com/docs (not in this repo). diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index bd45d5433..0d1d8d44e 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -667,6 +667,9 @@ The ciphertext inside it is a Stack Encrypt ciphertext, which starts with `stack "EQL v4" in this plan is the name of that form, and not a new envelope. The engine produces one EQL type today: `TextEq`. +Status (2026-10-06, #1062): `TextEq` is producible through the data plan's target form. +`stashgen` accepts `encrypt_into=TextEq`, the guest build with the EQL types returns the finished value, and `encrypt/eql` holds the generated Go types; every other type is listed by `se_targets` with the reason it is not producible, and `stashgen` refuses it. +Open question for Dan: an EQL value is stored under a table and a column, so a target field's label must be exactly `
/`; a cipher extended with a tenant part has no column for the extended label, and a plan with a target field refuses the extension rather than dropping it. The other types wait for work in the engine: - **Every family but `Text`:** how the family encodes a plaintext is not specified. diff --git a/packages/eql/crates/eql-bindings/README.md b/packages/eql/crates/eql-bindings/README.md index cc3581834..823db57d4 100644 --- a/packages/eql/crates/eql-bindings/README.md +++ b/packages/eql/crates/eql-bindings/README.md @@ -202,7 +202,13 @@ let opened: FfiValue = targets::decrypt("TextEq", &cipher, &column, &stored)?.aw The table and the dispatch are generated into `src/v3/targets.rs` beside `inventory.rs` (`mise run types:generate`), gated to the `stack-encrypt` -feature, and drift-gated by the same parity tests. A type is producible exactly +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 when its generated struct carries the `stack-encrypt` derives (`ENCRYPTION_DOMAINS` in `eql-codegen`), because the dispatch runs the derived plan; `TextEq` is the only one today. diff --git a/scripts/go-binding-test.sh b/scripts/go-binding-test.sh index 8ba565ffe..c5b60101b 100755 --- a/scripts/go-binding-test.sh +++ b/scripts/go-binding-test.sh @@ -9,19 +9,20 @@ # the Rust build is the slow part. # # Usage: go-binding-test.sh [...] -# With no guest paths, both guests the module embeds are expected. +# With no guest paths, every guest the module embeds is expected: the +# stack-encrypt guest, its build with the EQL types, and the credential guest. set -euo pipefail dir=${1:?usage: go-binding-test.sh [...]} shift || true if [ $# -eq 0 ]; then - set -- encrypt/wasm/stack_encrypt_guest.wasm auth/wasm/stack_auth_guest.wasm + set -- encrypt/wasm/stack_encrypt_guest.wasm encrypt/eql/wasm/stack_encrypt_guest_eql.wasm auth/wasm/stack_auth_guest.wasm fi cd "$dir" for guest in "$@"; do if [ ! -f "$guest" ]; then - echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build wasm:auth-guest:build" >&2 + echo "guest module not built at $dir/$guest — run: mise run wasm:guest:build wasm:guest:build:eql wasm:auth-guest:build" >&2 exit 1 fi done From da4702d3bcd58a25f892d8cb9ad49b547611302c Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 02:58:11 -0700 Subject: [PATCH 08/30] docs(golang): the guest targets module links resolve in both feature builds The resolver trait and the no-targets resolver are imported under one feature each, so an intra-doc link to either breaks the other build's rustdoc (wasm:guest:test documents without the eql feature); plain code spans name them instead. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/encrypt/guest/src/targets.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs index 81e0604fd..e5a153b87 100644 --- a/languages/golang/encrypt/guest/src/targets.rs +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -1,14 +1,17 @@ -//! The EQL types this build holds: the [`TargetResolver`] the record -//! operations run target fields through. +//! The EQL types this build holds: the `TargetResolver` +//! (`stack_encrypt::dynamic::TargetResolver`) the record operations run +//! 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 [`EqlTargets`], +//! 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`]: `se_targets` lists nothing, and a plan that +//! 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. //! From 6f51277e3e83adc0bc3ef1a15fd61b965fde0f6b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:13:53 -0700 Subject: [PATCH 09/30] style(eql): rustfmt the EQL workspace The root workspace's `cargo fmt --all --check` does not reach the EQL workspace at `packages/eql`, whose `test:crates` task runs its own check. Two files added on this branch were not formatted. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/eql/crates/eql-codegen/src/go_eql.rs | 28 +++++++++++++++---- .../eql/tests/encryption/tests/targets.rs | 16 +++++++++-- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/eql/crates/eql-codegen/src/go_eql.rs b/packages/eql/crates/eql-codegen/src/go_eql.rs index 66e89d328..0389e2921 100644 --- a/packages/eql/crates/eql-codegen/src/go_eql.rs +++ b/packages/eql/crates/eql-codegen/src/go_eql.rs @@ -100,7 +100,10 @@ fn methods(out: &mut String, go: &str, domain: &str) { pub fn render_go_eql() -> String { let rows = rows(); let mut out = String::new(); - let _ = writeln!(out, "{GO_GENERATED_MARKER}\n\npackage eql\n\nimport \"database/sql/driver\"\n"); + let _ = writeln!( + out, + "{GO_GENERATED_MARKER}\n\npackage eql\n\nimport \"database/sql/driver\"\n" + ); out.push_str( "// Types is every EQL type the catalog has, in catalog order: what the\n\ // engine's se_targets export lists, as Go. Producible says whether the\n\ @@ -117,7 +120,11 @@ pub fn render_go_eql() -> String { let indexes = if row.indexes.is_empty() { "no index".to_owned() } else { - format!("the {} index{}", row.indexes.join(" and "), if row.indexes.len() > 1 { "es" } else { "" }) + format!( + "the {} index{}", + row.indexes.join(" and "), + if row.indexes.len() > 1 { "es" } else { "" } + ) }; let status = match (row.reason, row.plaintext) { (None, Some(kind)) => format!("The engine produces it from a {kind}."), @@ -173,11 +180,22 @@ mod tests { assert!(out.contains("\npackage eql\n")); for row in rows() { let go = go_name(&row.name); - assert_eq!(out.matches(&format!("\ntype {go} []byte\n")).count(), 1, "{go}"); - assert!(out.contains(&format!("Name: {:?}, GoName: {go:?}", row.name)), "{go} in the table"); + assert_eq!( + out.matches(&format!("\ntype {go} []byte\n")).count(), + 1, + "{go}" + ); + assert!( + out.contains(&format!("Name: {:?}, GoName: {go:?}", row.name)), + "{go} in the table" + ); if let Some((query, _)) = &row.query { let query_go = go_name(query); - assert_eq!(out.matches(&format!("\ntype {query_go} []byte\n")).count(), 1, "{query_go}"); + assert_eq!( + out.matches(&format!("\ntype {query_go} []byte\n")).count(), + 1, + "{query_go}" + ); } } // The one query type two rows share (text_ord and text_ord_ope diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs index 070536411..6bbc73328 100644 --- a/packages/eql/tests/encryption/tests/targets.rs +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -555,7 +555,10 @@ mod the_cross_language_fixture { .unwrap() .await .unwrap(); - assert!(calls.lock().unwrap().generate.is_empty(), "a query mints nothing"); + assert!( + calls.lock().unwrap().generate.is_empty(), + "a query mints nothing" + ); let fixture = serde_json::json!({ "_comment": "The TextEqQuery eql-bindings derives for the plaintext under FakeDataKeySource's index key (keyset nil), read by the Go SDK's hermetic test; regenerate with EQL_UPDATE_FIXTURES=1, do not edit.", "table": "users", @@ -573,7 +576,14 @@ mod the_cross_language_fixture { committed, rendered, "the committed fixture is what the dispatch derives today" ); - let typed: TextEqQuery = keyset.encrypt_as(&PLAINTEXT.to_owned(), column()).await.unwrap(); - assert_eq!(query, serde_json::to_vec(&typed).unwrap(), "and what the typed path derives"); + let typed: TextEqQuery = keyset + .encrypt_as(&PLAINTEXT.to_owned(), column()) + .await + .unwrap(); + assert_eq!( + query, + serde_json::to_vec(&typed).unwrap(), + "and what the typed path derives" + ); } } From 413b6c0f07ce34bf7d5b1828171da0547222964e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:18:35 -0700 Subject: [PATCH 10/30] test(stack-encrypt): pin with_target's segment bound from both sides MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mutants gate found `label.segments().len() < 2` in FieldPlan::with_target survived being flipped to `>`: a one-segment list is already refused by split_context as no label, so the only observable difference under the flipped operator is that a three-segment label is refused by the constructor, and no test said it must not be. The new test pins the bound from both sides: one segment refused, two accepted, three accepted intact (the first length above the bound) — a longer label is still a label; whether a resolver can store under it is the resolver's rule, and the extension rule is Plan::new_with's. `cargo mutants -p stack-encrypt --re 'FieldPlan::with_target'`: 5 mutants, 4 caught, 1 unviable, none missed. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/stack-encrypt/src/dynamic/record.rs | 28 ++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 53bbcb351..256404f53 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -5098,6 +5098,34 @@ mod tests { ); } + /// The constructor's one bound on the label is "at least two + /// segments": a column is table and column, and a longer label is + /// still a label — whether a resolver can store under it is the + /// resolver's to say, and the extension rule is `Plan::new_with`'s. + /// Pinned from both sides so the bound cannot drift to "exactly two" + /// or flip: one segment refused (it is no label), two accepted, and + /// three — the first length above the bound — accepted intact. + #[test] + fn with_target_takes_any_label_of_two_or_more_segments() { + let ctx = |segments: &[&str]| context(strings(segments)).expect("a context value"); + assert!( + matches!( + FieldPlan::with_target("email", ctx(&["users"]), TEXT_EQ), + Err(Error::Plan) + ), + "one segment is not a label" + ); + let two = FieldPlan::with_target("email", ctx(&["users", "email"]), TEXT_EQ) + .expect("two segments: table and column"); + assert_eq!(two.label().to_string(), "users/email"); + let three = + FieldPlan::with_target("email", ctx(&["tenant", "users", "email"]), TEXT_EQ) + .expect("three segments are a label; the column rule is the resolver's"); + assert_eq!(three.label().segments().count(), 3); + assert_eq!(three.identity(), "email"); + assert_eq!(three.target(), Some(TEXT_EQ)); + } + #[test] fn the_target_and_output_forms_are_exclusive() { let both = obj(vec![ From 8986b7eb7032a0f13f9360649ad0fe70704b87b2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:22:34 -0700 Subject: [PATCH 11/30] fix(eql-bindings): open a target through decrypt_as, which crates.io stack-encrypt has The crates.io gate fired: `cargo publish --dry-run --all-features` builds the packaged eql-bindings against the registry stack-encrypt its manifest names, 0.2.0, and `open_target` called `run_decryption`, which landed after that release (#1069). The rule in Cargo.toml stands: this crate uses only the API the published stack-encrypt carries, until its next release. `decrypt_as` is in 0.2.0 on both ciphers and runs the same DecryptInto plan, so opening a stored EQL value goes through it and maps the plaintext with Pending::map; nothing observable changes, and the encryption test crate's round trips, both openers and the refusals pass as before. The dry run passes again and joins the verification list. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- .../crates/eql-bindings/src/encryption/targets.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/eql/crates/eql-bindings/src/encryption/targets.rs b/packages/eql/crates/eql-bindings/src/encryption/targets.rs index e6a1f7c7a..ea49416a1 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/targets.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/targets.rs @@ -436,11 +436,15 @@ where target: name, source, })?; - let decryption = value.decryption::(column.into()).map(S::into_value); + // `decrypt_as`, not `run_decryption`: this crate compiles against the + // crates.io stack-encrypt its manifest names (0.2.0), which has the + // typed call and not the by-value runner. Same plan, same pending. + let expected: ExpectedContext = column.into(); Ok(match opener { - Opener::Client(cipher) => cipher.run_decryption(decryption), - Opener::Keyset(keyset) => keyset.run_decryption(decryption), - }) + Opener::Client(cipher) => cipher.decrypt_as::(value, expected), + Opener::Keyset(keyset) => keyset.decrypt_as::(value, expected), + } + .map(S::into_value)) } #[cfg(test)] From 9a224104fd83e7387bf7be8c8a3555329a5477b2 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:22:34 -0700 Subject: [PATCH 12/30] test(scripts): the eql guest's Cargo.lock now records eql-bindings The stack-encrypt Go guest's eql build links eql-bindings by path, so its lockfile is the third that records the crate the lockstep bump rewrites. The bump's own set is discovered by walking every Cargo.lock for a path entry (sync-lockstep-versions.mjs, cargoLockWorkspaces), so the guest lock was already rewritten; the freshness test pins the walk's result by name, and now names all three. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- scripts/__tests__/cargo-lock-freshness.test.mjs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/__tests__/cargo-lock-freshness.test.mjs b/scripts/__tests__/cargo-lock-freshness.test.mjs index 288be3628..ed7d965cd 100644 --- a/scripts/__tests__/cargo-lock-freshness.test.mjs +++ b/scripts/__tests__/cargo-lock-freshness.test.mjs @@ -170,13 +170,18 @@ describe('Cargo.lock records this tree’s crates at their real versions', () => // pushing it out of sync: `scripts/sync-lockstep-versions.mjs` writes its // `Cargo.toml` on every release. If this crate ever drops out of the pair // set, the check that matters most has silently stopped running. It is - // in the two locks whose workspaces build it; the stack-* workspaces do - // not depend on it. + // in the three locks whose workspaces build it: EQL's own, protect-ffi's, + // and the stack-encrypt Go guest's, whose `eql` build links it by path + // (ADR-0007, amended). The stack-* workspaces do not depend on it. The + // bump's own set is discovered by walking (`cargoLockWorkspaces`), so a + // lock added here is one it already rewrites; this pins that the walk + // still sees all three. expect( PAIRS.filter(({ name }) => name === 'eql-bindings') .map(({ lock }) => lock) .sort(), ).toEqual([ + 'languages/golang/encrypt/guest/Cargo.lock', 'languages/typescript/packages/protect-ffi/Cargo.lock', 'packages/eql/Cargo.lock', ]) From 550a318bc03492176a6910ceb3cbbb7cd5474ab6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:31:48 -0700 Subject: [PATCH 13/30] test(stack-encrypt): pin TargetDescriptor's Display; exclude NoTargets' equivalent mutant MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mutants gate reported two survivors in dynamic/target.rs. The TargetDescriptor Display impl renders the type's name, and no test asserted the text, so `Ok(Default::default())` passed; a test now pins the exact rendering for a producible and an unproducible descriptor, and in a formatted sentence. NoTargets::targets returning `vec![]` is what the function does — the resolver of a build without EQL types holds none, which the existing test asserts and `resolve` turns into TargetError::NoTargets — so that replacement is equivalent and is excluded in .cargo/mutants.toml, anchored on its replacement text so a behaviour-changing mutation in the same function stays covered. `cargo mutants -p stack-encrypt --re 'NoTargets>::targets|TargetDescriptor>::fmt'`: 3 mutants, 2 caught, 1 unviable, none missed. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- .cargo/mutants.toml | 4 ++++ packages/stack-encrypt/src/dynamic/target.rs | 12 ++++++++++++ 2 files changed, 16 insertions(+) diff --git a/.cargo/mutants.toml b/.cargo/mutants.toml index c15952b75..7c477a6cd 100644 --- a/.cargo/mutants.toml +++ b/.cargo/mutants.toml @@ -73,6 +73,10 @@ exclude_re = [ 'stack-encrypt/src/sem/mod\.rs:\d+:\d+: replace ::options -> MatchOptions with Default::default\(\)$', # Both unit-context conversions explicitly return Self::default(). 'stack-encrypt/src/target/context\.rs:\d+:\d+: replace for (DeclaredContext|ExpectedContext)>::from -> Self with Default::default\(\)$', + # The resolver of a build without EQL types holds none: its `targets` IS + # the empty list (`dynamic::target::NoTargets`), which a test asserts on + # and `resolve` turns into `TargetError::NoTargets`; `vec![]` is the body. + 'stack-encrypt/src/dynamic/target\.rs:\d+:\d+: replace ::targets -> Vec with vec!\[\]$', ] # Headroom over the measured baseline before a slow-but-correct mutant is diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs index 55fa3c712..6149b4763 100644 --- a/packages/stack-encrypt/src/dynamic/target.rs +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -533,6 +533,18 @@ mod tests { ); } + /// A descriptor displays as its name alone, producible or not: what a + /// log line or an error names a type by, with no status attached. + #[test] + fn a_descriptor_displays_as_its_name() { + assert_eq!(text_eq().to_string(), "TextEq"); + assert_eq!(format!("{}", text_ord_ore()), "TextOrdOre"); + assert_eq!( + format!("targets: {}, {}", text_eq(), text_ord_ore()), + "targets: TextEq, TextOrdOre" + ); + } + #[test] fn no_targets_refuses_every_name_with_the_build_reason() { let error = NoTargets.resolve("TextEq").unwrap_err(); From aed96c23300665b230dd68a6a3c8938c41cb2f9d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:41:44 -0700 Subject: [PATCH 14/30] fix(stack-encrypt)!: a target field's label is refused when it is not table and column Review of #1095: a target field whose label had three or more segments passed se_plan_check and then failed on every value with TargetError::Column from the resolver. A Go struct with context=app/users and email,encrypt_into=TextEq lowers to the label app/users/email; stashgen asks the engine for its rules, so it wrote the code, and Encrypt, Decrypt and Fields.Email.Query all failed afterwards. Plan::new_with now refuses it beside the Extended check, naming the field (TargetError::Column), so the refusal reaches se_plan_check and the generator before any code is written. FieldPlan::with_target keeps its constructor bound of two or more segments, like every field constructor, and the module docs now say where each rule lives. Go's record.Plan.Validate refuses an encrypt_into field whose context has more than one segment or any extension, and stashgen's lowerDeclaration names the field for the same, so the host says it first. Tests: the three-segment plan refused with the field and label named and nothing minted, the same label accepted for a sealed field; Go Validate refusals and the plain-field acceptance; the stashgen command refusing context=app/users by field with nothing written. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/cmd/stashgen/main_test.go | 14 ++++ languages/golang/internal/record/record.go | 10 +++ .../golang/internal/record/record_test.go | 16 +++++ languages/golang/stashgen/engine.go | 8 +++ packages/stack-encrypt/src/dynamic/record.rs | 64 ++++++++++++++++--- 5 files changed, 104 insertions(+), 8 deletions(-) diff --git a/languages/golang/cmd/stashgen/main_test.go b/languages/golang/cmd/stashgen/main_test.go index fc7ae619f..b1ddc8362 100644 --- a/languages/golang/cmd/stashgen/main_test.go +++ b/languages/golang/cmd/stashgen/main_test.go @@ -155,6 +155,20 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) { t.Fatal("a file was written after the engine refused") } + // A context of two segments leaves an EQL field no column: refused by + // name, before the engine is asked, and nothing written. + deep := strings.Replace(userSource, "context=users", "context=app/users", 1) + dir = writeModule(t, map[string]string{"model.go": deep}) + stderr.Reset() + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 { + t.Fatalf("exit %d, want 1\n%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "User.Email") || !strings.Contains(stderr.String(), `"app/users" has 2 segments`) { + t.Fatalf("stderr = %q", stderr.String()) + } + if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) { + t.Fatal("a file was written after the refusal") + } // Separate columns, as before. columns := strings.Replace(userSource, "encrypt_into=TextEq", "encrypt,index=equality;match", 1) dir = writeModule(t, map[string]string{"model.go": columns}) diff --git a/languages/golang/internal/record/record.go b/languages/golang/internal/record/record.go index 48b71c348..30bdb73d9 100644 --- a/languages/golang/internal/record/record.go +++ b/languages/golang/internal/record/record.go @@ -253,6 +253,16 @@ func (p *Plan) Validate() error { if len(f.Outputs) != 0 { return fmt.Errorf("record: field %q names the EQL type %s and outputs; a field has one or the other", f.Name, f.Target) } + // An EQL value is stored under a table and a column: the + // context is the table, the identity the column. The engine + // refuses anything else when the plan is built; saying it here + // names the field before the guest is asked. + if len(p.Context) != 1 { + return fmt.Errorf("record: field %q names the EQL type %s, and an EQL column is a table and a column: the context %q has %d segments, not one", f.Name, f.Target, strings.Join(p.Context, "/"), len(p.Context)) + } + if len(p.Extension) != 0 { + return fmt.Errorf("record: field %q names the EQL type %s, and an EQL column is a table and a column: an extended context has no column", f.Name, f.Target) + } continue } if len(f.Outputs) == 0 { diff --git a/languages/golang/internal/record/record_test.go b/languages/golang/internal/record/record_test.go index 11005f1fa..e12441418 100644 --- a/languages/golang/internal/record/record_test.go +++ b/languages/golang/internal/record/record_test.go @@ -105,6 +105,22 @@ func TestTargetFieldWiresItsTypeInsteadOfOutputs(t *testing.T) { if err := both.Validate(); err == nil { t.Fatal("a field with a target and outputs was accepted") } + // An EQL column is a table and a column: a two-segment context, or an + // extension, leaves the target field no column, and the refusal names + // the field — before the guest is asked. + deep := &Plan{Context: []string{"app", "users"}, Fields: []Field{{Name: "email", Kind: String, Target: "TextEq"}}} + if err := deep.Validate(); err == nil || !strings.Contains(err.Error(), `field "email"`) || !strings.Contains(err.Error(), `"app/users" has 2 segments`) { + t.Fatalf("a target under app/users: %v", err) + } + extended := &Plan{Context: []string{"users"}, Extension: []any{uint64(7)}, Fields: []Field{{Name: "email", Kind: String, Target: "TextEq"}}} + if err := extended.Validate(); err == nil || !strings.Contains(err.Error(), "extended context has no column") { + t.Fatalf("an extended target: %v", err) + } + // The same context seals a plain field as before. + plain := &Plan{Context: []string{"app", "users"}, Fields: []Field{{Name: "email", Kind: String, Outputs: []Output{Ciphertext}}}} + if err := plain.Validate(); err != nil { + t.Fatal(err) + } } func TestValidateRefusals(t *testing.T) { diff --git a/languages/golang/stashgen/engine.go b/languages/golang/stashgen/engine.go index 11bb5fbd2..58973b947 100644 --- a/languages/golang/stashgen/engine.go +++ b/languages/golang/stashgen/engine.go @@ -161,6 +161,14 @@ func lowerDeclaration(d Declaration, eqlTypes []EQLType) (*record.Plan, error) { if eqlType.Plaintext != KindOther && eqlType.Plaintext != f.GoType.Kind { return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s seals a %s, and %s is %s", f.EQLType, eqlType.Plaintext, f.GoType, f.GoType.Kind)} } + // An EQL value is stored under a table and a column: the struct's + // context is the table and the field's name the column, so a + // context of two or more segments leaves the field no column. + // The engine refuses it too (se_plan_check); naming it here + // names the field. + if len(segments) != 1 { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s is stored under a table and a column, and the context %q has %d segments, not one", f.EQLType, d.Context, len(segments))} + } rf.Target = f.EQLType case VerbEncrypt, VerbEncryptIndex: rf.Outputs = append(rf.Outputs, record.Ciphertext) diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 256404f53..5971faa9b 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -71,8 +71,12 @@ //! that parses there has no target field. //! //! A target field's label must be a column, `
/`, because -//! that is what an EQL value stores in its `i`; an extended plan (a tenant -//! part on every label) has no column for it and is refused with +//! that is what an EQL value stores in its `i`: [`Plan::new_with`] refuses +//! a target field whose label has any other number of segments +//! ([`TargetError::Column`]) — [`FieldPlan::with_target`] itself takes any +//! label of two or more segments, as every field constructor does; the +//! column rule is the plan's — and an extended plan (a tenant part on every +//! label) has no column for it and is refused with //! [`TargetError::Extended`] rather than silently dropping the extension. //! The field's `"type"`, when declared, must be the kind the EQL type is //! produced from ([`TargetError::Kind`]); undeclared, it is that kind, so @@ -573,6 +577,19 @@ impl Plan { } .into()); } + // An EQL value is stored under a table and a column, so the + // label is exactly two segments. Decided here, where the plan is + // built and se_plan_check reports it, not at the first value: a + // generator that asks the engine must be told before it writes + // the code. The resolver re-checks, since it is public API. + if field.label.segments().len() != 2 { + return Err(TargetError::Column { + name: field.name.clone(), + label: field.label.to_string(), + reason: "an EQL column is a two-segment label: table and column".to_owned(), + } + .into()); + } match (field.field_type, descriptor.plaintext) { (Some(declared), expected) if expected != Some(declared) => { return Err(TargetError::Kind { @@ -5099,12 +5116,12 @@ mod tests { } /// The constructor's one bound on the label is "at least two - /// segments": a column is table and column, and a longer label is - /// still a label — whether a resolver can store under it is the - /// resolver's to say, and the extension rule is `Plan::new_with`'s. - /// Pinned from both sides so the bound cannot drift to "exactly two" - /// or flip: one segment refused (it is no label), two accepted, and - /// three — the first length above the bound — accepted intact. + /// segments", the same as every field constructor's: a longer label + /// is still a label. The column rule — exactly two — is + /// `Plan::new_with`'s, where the plan is built (the test after this + /// one). Pinned from both sides so the constructor's bound cannot + /// drift to "exactly two" or flip: one segment refused (it is no + /// label), two accepted, and three accepted intact. #[test] fn with_target_takes_any_label_of_two_or_more_segments() { let ctx = |segments: &[&str]| context(strings(segments)).expect("a context value"); @@ -5126,6 +5143,37 @@ mod tests { assert_eq!(three.target(), Some(TEXT_EQ)); } + /// `context=app/users` with `email,encrypt_into=TextEq` in Go gives + /// the label `app/users/email`: no column for it. Refused when the + /// plan is built, so `se_plan_check` tells the generator before it + /// writes the code, and no value ever reaches the resolver. + #[tokio::test] + async fn a_target_label_that_is_not_table_and_column_is_refused_when_the_plan_is_built() { + let cipher = cipher().await; + let value = obj(vec![( + "email", + target_spec(strings(&["app", "users", "email"]), TEXT_EQ), + )]); + let error = target_error(plan_with(value, &FakeEql).unwrap_err()); + assert!( + matches!(&error, TargetError::Column { name, label, .. } if name == "email" && label == "app/users/email"), + "{error}" + ); + assert_eq!( + error.to_string(), + "email: the label app/users/email is not an EQL column: an EQL column is a \ + two-segment label: table and column" + ); + // A sealed field under the same three-segment label is fine: the + // rule is the EQL column's, not the plan's. + let value = obj(vec![( + "email", + spec(strings(&["app", "users", "email"]), &["c"]), + )]); + assert!(plan_with(value, &FakeEql).is_ok()); + assert_eq!(generates(&cipher), 0, "nothing minted"); + } + #[test] fn the_target_and_output_forms_are_exclusive() { let both = obj(vec![ From e56f6f2799732c1369664323555b7d7498b4cc98 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:42:20 -0700 Subject: [PATCH 15/30] test(stack-encrypt): a keyset scope refuses a target value from another keyset Review of #1095: the resolver opens a target value through the client, which opens every keyset's values, and the lowering's `confine` is the only thing that holds it to the scope's keyset. No test used a second keyset on a target field, so removing `scoped_to` there would have let one tenant's cipher open another's column with every test green. The new test seals a plan of one target field under acme and opens it under globex: ForeignKeyset before any key is retrieved; acme's own scope still opens it. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/stack-encrypt/src/dynamic/record.rs | 45 ++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 5971faa9b..dc8e4f75d 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -5174,6 +5174,51 @@ mod tests { assert_eq!(generates(&cipher), 0, "nothing minted"); } + /// The resolver opens a target value through the client, which opens + /// every keyset's values; `confine` is what holds it to the scope's + /// keyset. A plan of one target field, so no lowered leaf can cause + /// the refusal instead: this fails if `confine` stops calling + /// `scoped_to`, and one tenant's cipher opens another's column. + #[tokio::test] + async fn a_keyset_scope_refuses_a_target_value_from_another_keyset() { + let cipher = cipher().await; + let named = |n: &str| IdentifiedBy::Name(n.to_string().into()); + let acme = cipher.keyset(named("acme")).await.expect("acme"); + let globex = cipher.keyset(named("globex")).await.expect("globex"); + let plan = plan_with( + obj(vec![("email", target_spec(label("email"), TEXT_EQ))]), + &FakeEql, + ) + .unwrap(); + let sealed = encrypt_with(&acme, obj(vec![("email", s("a@x"))]), &plan, &FakeEql) + .unwrap() + .await + .unwrap(); + let result = decrypt_with(Scope::Keyset(globex), sealed, &plan, &FakeEql) + .expect("the record fits") + .await; + match result { + Err(crate::Error::ForeignKeyset { .. }) => {} + Err(other) => panic!("refused, but not as a foreign keyset: {other}"), + Ok(_) => panic!("globex opened acme's target value"), + } + assert_eq!( + retrieves(&cipher), + 0, + "refused before any key was retrieved" + ); + // acme's own scope, and the client, still open it. + let sealed = encrypt_with(&acme, obj(vec![("email", s("a@x"))]), &plan, &FakeEql) + .unwrap() + .await + .unwrap(); + let opened = decrypt_with(Scope::Keyset(acme), sealed, &plan, &FakeEql) + .unwrap() + .await + .expect("its own keyset opens it"); + assert_eq!(text_of(&object(opened)[0].1), "a@x"); + } + #[test] fn the_target_and_output_forms_are_exclusive() { let both = obj(vec![ From 3c146c1523141175375361dbb08f7b022b364ae5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:42:20 -0700 Subject: [PATCH 16/30] test(eql-bindings): a keyset opener refuses a value from another keyset Review of #1095: `Opener::Keyset`'s docs promised the refusal and no test proved it; had that arm opened through the client, a keyset caller would have read another tenant's plaintext. Sealed under acme, opened under globex: ForeignKeyset with nothing retrieved; acme and the client open it. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- .../eql/tests/encryption/tests/targets.rs | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/eql/tests/encryption/tests/targets.rs b/packages/eql/tests/encryption/tests/targets.rs index 6bbc73328..e5528b43a 100644 --- a/packages/eql/tests/encryption/tests/targets.rs +++ b/packages/eql/tests/encryption/tests/targets.rs @@ -136,6 +136,44 @@ mod given_text_eq { ); } + /// A keyset opener refuses a value another keyset sealed, before any key + /// is retrieved; the client opens it. If `Opener::Keyset` opened through + /// the client, one tenant's cipher would read another's column. + #[tokio::test] + async fn a_keyset_opener_refuses_a_value_from_another_keyset() { + use stack_kms::IdentifiedBy; + let (cipher, calls) = common::cipher().await; + let named = |n: &str| IdentifiedBy::Name(n.to_string().into()); + let acme = cipher.keyset(named("acme")).await.unwrap(); + let globex = cipher.keyset(named("globex")).await.unwrap(); + let stored = targets::encrypt("TextEq", &acme, &email(), text("secret")) + .unwrap() + .await + .unwrap(); + let result = targets::decrypt("TextEq", &globex, &email(), &stored) + .unwrap() + .await; + match result { + Err(stack_encrypt::Error::ForeignKeyset { .. }) => {} + Err(other) => panic!("refused, but not as a foreign keyset: {other}"), + Ok(_) => panic!("globex opened acme's value"), + } + assert!( + calls.lock().unwrap().retrieve.is_empty(), + "nothing was retrieved" + ); + let own = targets::decrypt("TextEq", &acme, &email(), &stored) + .unwrap() + .await + .expect("its own keyset opens it"); + assert_eq!(opened(own), "secret"); + let through_client = targets::decrypt("TextEq", &cipher, &email(), &stored) + .unwrap() + .await + .expect("the client opens any of its keysets' values"); + assert_eq!(opened(through_client), "secret"); + } + #[tokio::test] async fn query_bytes_are_the_typed_query_twin() { let (cipher, calls) = common::cipher().await; From d347a6cca4f82d22ab11d9d62d44158bfa684201 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:43:49 -0700 Subject: [PATCH 17/30] fix(eql-bindings): a target's plaintext copy is wiped when it is dropped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #1095: `String::from_value` copied the plaintext out of the runtime value's `Protected` buffer into a plain `String`, and nothing wiped the copy — in the guest it stayed in freed linear memory until the allocator reused it, for every TextEq value sealed and every query. The copy now lives in `zeroize::Zeroizing` for the one call that reads it (`run_target`, which both the encrypt and the query paths go through), and `Plaintext` carries a `Zeroize` bound so every plaintext a future family names is held to the same rule as `Scalar::Text` in the data plan path. `zeroize` is an optional dependency under the `stack-encrypt` feature; the crates.io dry run still passes. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/eql/Cargo.lock | 1 + packages/eql/crates/eql-bindings/Cargo.toml | 4 ++++ .../crates/eql-bindings/src/encryption/targets.rs | 12 +++++++++--- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 5ee7ff102..18f8ac534 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -1206,6 +1206,7 @@ dependencies = [ "ts-rs", "vitaminc-aead-value", "vitaminc-prf", + "zeroize", ] [[package]] diff --git a/packages/eql/crates/eql-bindings/Cargo.toml b/packages/eql/crates/eql-bindings/Cargo.toml index cc39ed023..602ff7851 100644 --- a/packages/eql/crates/eql-bindings/Cargo.toml +++ b/packages/eql/crates/eql-bindings/Cargo.toml @@ -55,6 +55,9 @@ vitaminc-prf = { version = "0.5.0", optional = true } # nothing from stack-encrypt's own `dynamic` feature; the same line # stack-encrypt and the Go guest build against. vitaminc-aead-value = { version = "0.5.1", optional = true } +# The plaintext a target reads out of its `Protected` runtime value is held +# in `Zeroizing` until it is sealed, so the copy is wiped like the original. +zeroize = { version = "1", optional = true } thiserror = { version = "2", optional = true } base64 = { version = "0.22", optional = true } hex = { version = "0.4", optional = true } @@ -65,6 +68,7 @@ stack-encrypt = [ "dep:stack-encrypt", "dep:vitaminc-prf", "dep:vitaminc-aead-value", + "dep:zeroize", "dep:thiserror", "dep:base64", "dep:hex", diff --git a/packages/eql/crates/eql-bindings/src/encryption/targets.rs b/packages/eql/crates/eql-bindings/src/encryption/targets.rs index ea49416a1..410773e64 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/targets.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/targets.rs @@ -362,8 +362,11 @@ mod sealed { /// A Rust plaintext an EQL type is produced from, read out of and written /// back into the runtime value. Sealed: the implementations are exactly the /// plaintext types the catalog's producible families name, and the generated -/// dispatch picks one per type. -pub trait Plaintext: sealed::Sealed + Sized + MaybeSend + 'static { +/// dispatch picks one per type. `Zeroize`, because [`from_value`](Self::from_value) +/// copies the plaintext out of the runtime value's `Protected` buffer and +/// the copy is wiped when it is dropped ([`run_target`] holds it in +/// [`zeroize::Zeroizing`]), as the original is. +pub trait Plaintext: sealed::Sealed + Sized + MaybeSend + zeroize::Zeroize + 'static { /// The kind of value this plaintext is. const KIND: ValueKind; /// Read the value as this plaintext, refusing any other kind. @@ -413,7 +416,10 @@ where S: Plaintext, K: 'static, { - let plaintext = S::from_value(name, plaintext)?; + // The copy lives only until `encrypt_as` has cloned what it seals, and + // is wiped when this returns: the runtime value kept its bytes in + // `Protected`, and the copy is held to the same rule. + let plaintext = zeroize::Zeroizing::new(S::from_value(name, plaintext)?); Ok(keyset .encrypt_as::(&plaintext, column) .try_map(|value| serde_json::to_vec(&value).map_err(|error| Error::Other(Box::new(error))))) From 293c2a5fd0eec22bd98040b6b76b0b603061281d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:43:49 -0700 Subject: [PATCH 18/30] test(golang): the status table pins both Error::Target arms Review of #1095: the order of the two `Error::Target` arms in status_for_dynamic decides that a resolver failure is STATUS_INTERNAL and every other target refusal STATUS_ENCODING, and no test covered either. Every refusal variant now asserts STATUS_ENCODING and `TargetError::Other` STATUS_INTERNAL, so swapping or dropping an arm fails here. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/encrypt/guest/src/status.rs | 60 ++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 1ac587763..4d3971d57 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -361,6 +361,66 @@ mod tests { ); } + /// The two `Error::Target` arms, in order: every target refusal is the + /// caller's input, and the resolver's own failure is never reported as + /// such. Swapping the arms, or dropping the `Other` one, fails here. + #[test] + fn target_refusals_are_encoding_and_a_resolver_failure_is_internal() { + use stack_encrypt::dynamic::{Error, TargetError}; + use vitaminc_aead_value::ValueKind; + let refusals = [ + TargetError::NoTargets { + name: "TextEq".into(), + }, + TargetError::Unknown { + name: "Nope".into(), + }, + TargetError::Unproducible { + name: "TextOrdOre".into(), + reason: "block ORE".into(), + }, + TargetError::Extended { + name: "email".into(), + label: "users/email".into(), + }, + TargetError::Kind { + name: "email".into(), + target: "TextEq".into(), + expected: Some(ValueKind::String), + declared: ValueKind::UInt64, + }, + TargetError::Column { + name: "email".into(), + label: "app/users/email".into(), + reason: "two segments".into(), + }, + TargetError::Plaintext { + name: "email".into(), + target: "TextEq".into(), + expected: Some(ValueKind::String), + found: None, + }, + TargetError::Stored { + name: "email".into(), + target: "TextEq".into(), + reason: "not JSON".into(), + }, + ]; + for refusal in refusals { + let label = refusal.to_string(); + assert_eq!( + status_for_dynamic(&Error::Target(refusal)), + STATUS_ENCODING, + "{label}: a target refusal is the caller's input" + ); + } + assert_eq!( + status_for_dynamic(&Error::Target(TargetError::Other("boom".into()))), + STATUS_INTERNAL, + "the resolver's own failure is never the caller's input" + ); + } + #[test] fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() { use stack_encrypt::dynamic::Error; From f4e4eb2f64121988df7ed5b626b0a9a53f0e7d03 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:49:10 -0700 Subject: [PATCH 19/30] 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 --- languages/golang/encrypt/guest/Cargo.lock | 1 + languages/golang/encrypt/guest/src/status.rs | 3 + languages/golang/encrypt/guest/src/targets.rs | 3 + .../eql-bindings/src/encryption/targets.rs | 69 ++++++++++++ .../eql/crates/eql-bindings/src/v3/targets.rs | 9 +- .../eql/crates/eql-codegen/src/targets.rs | 101 +++++++++++++----- packages/stack-encrypt/src/dynamic/target.rs | 8 ++ 7 files changed, 165 insertions(+), 29 deletions(-) diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 3e761bcd7..b64caaf00 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -711,6 +711,7 @@ dependencies = [ "ts-rs", "vitaminc-aead-value", "vitaminc-prf", + "zeroize", ] [[package]] diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 4d3971d57..6dcd10bf9 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -383,6 +383,9 @@ mod tests { name: "email".into(), label: "users/email".into(), }, + TargetError::NoQuery { + name: "Text".into(), + }, TargetError::Kind { name: "email".into(), target: "TextEq".into(), diff --git a/languages/golang/encrypt/guest/src/targets.rs b/languages/golang/encrypt/guest/src/targets.rs index e5a153b87..4b344c99c 100644 --- a/languages/golang/encrypt/guest/src/targets.rs +++ b/languages/golang/encrypt/guest/src/targets.rs @@ -116,6 +116,9 @@ fn convert(error: eql_bindings::encryption::targets::TargetError) -> TargetError 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, diff --git a/packages/eql/crates/eql-bindings/src/encryption/targets.rs b/packages/eql/crates/eql-bindings/src/encryption/targets.rs index 410773e64..06dc034f1 100644 --- a/packages/eql/crates/eql-bindings/src/encryption/targets.rs +++ b/packages/eql/crates/eql-bindings/src/encryption/targets.rs @@ -170,6 +170,13 @@ pub enum TargetError { /// The table's reason. reason: &'static str, }, + /// The type is produced, and answers no query: a storage-only type has + /// no query twin, so there is no operand to derive. + #[error("{name} answers no query: it is a storage-only type")] + NoQuery { + /// The type's name. + name: &'static str, + }, /// The field's context is not an EQL column: an [`Identifier`] is a /// two-segment label, table then column, and this label is not one. #[error("{label:?} is not an EQL column identifier: expected two segments, table and column")] @@ -324,6 +331,9 @@ pub fn query<'a, K: 'static>( plaintext: FfiValue, ) -> Result, K>, TargetError> { producible(name)?; + if let Some(error) = no_query(target(name)) { + return Err(error); + } let column = Identifier::from_label(context)?; query_named(name, keyset, column, plaintext) } @@ -338,6 +348,24 @@ fn producible(name: &str) -> Result<(), TargetError> { /// The error for a name without a dispatch arm: unproducible when the table /// has it, unknown otherwise. Called by the generated dispatch's fall-through. +/// The error for a query on a name the query dispatch has no arm for: a +/// producible type with no query twin answers no query; otherwise what +/// [`refuse`] says. Called by the generated query dispatch's fall-through. +pub(crate) fn refuse_query(name: &str) -> TargetError { + no_query(target(name)).unwrap_or_else(|| refuse(name)) +} + +/// `NoQuery` for a producible type without a query twin, else `None`: the +/// one case the query dispatch refuses that the others do not. +fn no_query(target: Option<&'static Target>) -> Option { + match target { + Some(target) if target.producible && target.query.is_none() => { + Some(TargetError::NoQuery { name: target.name }) + } + _ => None, + } +} + pub(crate) fn refuse(name: &str) -> TargetError { match target(name) { Some(target) => TargetError::Unproducible { @@ -511,6 +539,47 @@ mod tests { } } + #[test] + fn a_producible_type_without_a_query_twin_answers_no_query() { + // No such type in the catalog today (`Text` is not producible), so + // the rule is pinned on a synthetic row; `refuse_query` on the live + // table falls through to `refuse`. + let storage_only = Target { + name: "Text", + family: "text", + suffix: "", + plaintext: Some("string"), + sql_domain: "public.eql_v3_text", + indexes: &[], + query: None, + query_sql_domain: None, + producible: true, + reason: None, + }; + let leaked: &'static Target = Box::leak(Box::new(storage_only)); + assert!(matches!( + no_query(Some(leaked)), + Some(TargetError::NoQuery { name: "Text" }) + )); + assert_eq!( + no_query(Some(leaked)).unwrap().to_string(), + "Text answers no query: it is a storage-only type" + ); + assert!( + no_query(target("TextEq")).is_none(), + "TextEq answers a query" + ); + assert!( + no_query(target("Text")).is_none(), + "an unproducible type is refused as that" + ); + assert!(matches!( + refuse_query("Text"), + TargetError::Unproducible { .. } + )); + assert!(matches!(refuse_query("Nope"), TargetError::Unknown { .. })); + } + #[test] fn refusals_name_the_type_and_its_reason() { assert!(matches!(refuse("Nope"), TargetError::Unknown { name } if name == "Nope")); diff --git a/packages/eql/crates/eql-bindings/src/v3/targets.rs b/packages/eql/crates/eql-bindings/src/v3/targets.rs index f2c6ef35d..e767f3715 100644 --- a/packages/eql/crates/eql-bindings/src/v3/targets.rs +++ b/packages/eql/crates/eql-bindings/src/v3/targets.rs @@ -1,6 +1,8 @@ // @generated by eql-codegen from the eql-domains catalog — do not edit //! 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. -use crate::encryption::targets::{open_target, refuse, run_target, Opener, Target, TargetError}; +use crate::encryption::targets::{ + open_target, refuse, refuse_query, run_target, Opener, Target, TargetError, +}; use crate::Identifier; use stack_encrypt::{KeysetCipher, NonEmpty, Pending}; use vitaminc_aead_value::FfiValue; @@ -750,7 +752,8 @@ pub(crate) fn decrypt_named<'a, K: 'static>( } } /// Run the named type's query twin for one plaintext, or refuse the -/// name as `encrypt_named` does. +/// name: as `encrypt_named` does, or as answering no query +/// (`TargetError::NoQuery`) for a producible type with no twin. pub(crate) fn query_named<'a, K: 'static>( name: &str, keyset: &'a KeysetCipher<'_, K>, @@ -761,6 +764,6 @@ pub(crate) fn query_named<'a, K: 'static>( "TextEq" => { run_target::("TextEq", keyset, column, plaintext) } - _ => Err(refuse(name)), + _ => Err(refuse_query(name)), } } diff --git a/packages/eql/crates/eql-codegen/src/targets.rs b/packages/eql/crates/eql-codegen/src/targets.rs index 67f9f430e..959a71a86 100644 --- a/packages/eql/crates/eql-codegen/src/targets.rs +++ b/packages/eql/crates/eql-codegen/src/targets.rs @@ -54,7 +54,12 @@ const JSON_INDEX_KEY: &str = "json"; /// encoding for the stack-encrypt producer profile is unspecified, which is /// every family but text today. pub fn plaintext(family: &DomainFamily) -> Option<(&'static str, &'static str)> { - (family.name == "text").then_some(("string", "String")) + plaintext_of(family.name) +} + +/// [`plaintext`], by family name: what a rendered row carries. +fn plaintext_of(family: &str) -> Option<(&'static str, &'static str)> { + (family == "text").then_some(("string", "String")) } /// Why the engine cannot produce a stored domain's EQL type today, or @@ -175,8 +180,13 @@ fn option_str(value: Option<&str>) -> TokenStream { /// Render the generated `crates/eql-bindings/src/v3/targets.rs`. pub fn render_targets_rs() -> String { - let rows = rows(); + render_targets_from(&rows()) +} +/// Render the target table and dispatch from the given rows: the catalog's +/// in [`render_targets_rs`], a synthetic set in tests (a producible type +/// with no query twin is not in the catalog today). +fn render_targets_from(rows: &[Row]) -> String { let entries: TokenStream = rows .iter() .map(|r| { @@ -209,43 +219,38 @@ pub fn render_targets_rs() -> String { // One arm per producible type. The plaintext Rust type is the family's; // a producible family always has one, since a derive names it. - let producible: Vec<(&Row, &'static DomainFamily)> = stored_payload_domains() - .zip(rows.iter()) - .filter(|(_, r)| r.reason.is_none()) - .map(|((f, _), r)| (r, f)) - .collect(); - let arm = |r: &Row, f: &DomainFamily, strukt: &str| { + let producible: Vec<&Row> = rows.iter().filter(|r| r.reason.is_none()).collect(); + let arm = |r: &Row, strukt: &str| { let name = &r.name; - let module = format_ident!("{}", f.name); + let module = format_ident!("{}", r.family); let ty = format_ident!("{strukt}"); - let (_, rust) = plaintext(f).expect("a producible family has a specified plaintext"); + let (_, rust) = + plaintext_of(r.family).expect("a producible family has a specified plaintext"); let source = format_ident!("{rust}"); (name.clone(), quote!(super::#module::#ty), quote!(#source)) }; let encrypt_arms: TokenStream = producible .iter() - .map(|(r, f)| { - let (name, ty, source) = arm(r, f, &r.name); + .map(|r| { + let (name, ty, source) = arm(r, &r.name); quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), } }) .collect(); let decrypt_arms: TokenStream = producible .iter() - .map(|(r, f)| { - let (name, ty, source) = arm(r, f, &r.name); + .map(|r| { + let (name, ty, source) = arm(r, &r.name); quote! { #name => open_target::<#ty, #source, K>(#name, opener, column, stored), } }) .collect(); + // A producible type with no query twin (a storage-only domain) gets no + // query arm: the fall-through refuses it as answering no query. let query_arms: TokenStream = producible .iter() - .map(|(r, f)| { - let query = r - .query - .as_ref() - .map(|(n, _)| n.as_str()) - .expect("a producible type has a query twin"); - let (name, ty, source) = arm(r, f, query); - quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), } + .filter_map(|r| { + let (query, _) = r.query.as_ref()?; + let (name, ty, source) = arm(r, query); + Some(quote! { #name => run_target::<#ty, #source, K>(#name, keyset, column, plaintext), }) }) .collect(); let helpers = if producible.is_empty() { @@ -267,7 +272,7 @@ pub fn render_targets_rs() -> String { use vitaminc_aead_value::FfiValue; - use crate::encryption::targets::{#helpers refuse, Opener, Target, TargetError}; + use crate::encryption::targets::{#helpers refuse, refuse_query, Opener, Target, TargetError}; use crate::Identifier; use stack_encrypt::{KeysetCipher, NonEmpty, Pending}; @@ -309,7 +314,8 @@ pub fn render_targets_rs() -> String { } /// Run the named type's query twin for one plaintext, or refuse the - /// name as `encrypt_named` does. + /// name: as `encrypt_named` does, or as answering no query + /// (`TargetError::NoQuery`) for a producible type with no twin. pub(crate) fn query_named<'a, K: 'static>( name: &str, keyset: &'a KeysetCipher<'_, K>, @@ -318,7 +324,7 @@ pub fn render_targets_rs() -> String { ) -> Result, K>, TargetError> { match name { #query_arms - _ => Err(refuse(name)), + _ => Err(refuse_query(name)), } } }; @@ -497,6 +503,49 @@ mod tests { // producible ones and the three fall-throughs. let arms = out.matches("\" => ").count(); assert_eq!(arms, ENCRYPTION_DOMAINS.len() * 3, "arms: {out}"); - assert_eq!(out.matches("_ => Err(refuse(name))").count(), 3); + assert_eq!(out.matches("_ => Err(refuse(name))").count(), 2); + assert_eq!(out.matches("_ => Err(refuse_query(name))").count(), 1); + } + + /// A producible type with no query twin — a storage-only domain, once + /// its derive lands — renders encrypt and decrypt arms and no query arm, + /// rather than aborting the generator: the query dispatch's fall-through + /// refuses it as answering no query. Not in the catalog today, so the + /// row is flipped by hand. + #[test] + fn a_producible_type_without_a_query_twin_gets_no_query_arm() { + let mut rows = rows(); + let text = rows + .iter_mut() + .find(|r| r.name == "Text") + .expect("the storage-only text domain"); + assert!(text.query.is_none() && text.reason.is_some()); + text.reason = None; + let out: String = render_targets_from(&rows) + .split_whitespace() + .collect::>() + .join(" "); + let arms_for = |helper: &str| { + out.matches(&format!( + "\"Text\" => {{ {helper}::(\"Text\"," + )) + .count() + + out + .matches(&format!( + "\"Text\" => {helper}::(\"Text\"," + )) + .count() + }; + assert_eq!(arms_for("run_target"), 1, "one encrypt arm: {out}"); + assert_eq!(arms_for("open_target"), 1, "one decrypt arm: {out}"); + assert!( + !out.contains("TextQuery"), + "no query arm for a type with no twin: {out}" + ); + assert_eq!( + out.matches("\" => ").count(), + (ENCRYPTION_DOMAINS.len() + 1) * 3 - 1, + "every producible type has three arms but the twinless one, which has two" + ); } } diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs index 6149b4763..8547d4b03 100644 --- a/packages/stack-encrypt/src/dynamic/target.rs +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -167,6 +167,14 @@ pub enum TargetError { /// The descriptor's reason. reason: String, }, + /// The type is produced and answers no query: a storage-only EQL type + /// has no query twin, so a query on a field that names it derives + /// nothing. + #[error("{name} answers no query: it is a storage-only type")] + NoQuery { + /// The type's name. + name: String, + }, /// The plan extends every field's label by the caller's parts (a tenant, /// a region), and an EQL value stores a table and a column only: there /// is no column for the extended label. A plan with a target field is From 985a2f25aad56cbb8cad6df000d8a47dcf6bcec0 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:52:14 -0700 Subject: [PATCH 20/30] test(stack-encrypt): the eql node's shape, and a target slot the resolver did not answer Review of #1095: no test reached record_row's refusal of a non-passthrough node under "eql" (the misplaced case put the leaf under "c", where taking "eql" failed first), and none reached the ResponseShape returns in the Verb::Target arms of shape_record and open_record. A ciphertext leaf under "eql" is now refused by check_record and decrypt_with with nothing retrieved, and the adapters handed fewer target values than the plan has target fields report ResponseShape on both sides. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/stack-encrypt/src/dynamic/record.rs | 64 ++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index dc8e4f75d..2fd67c669 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -5219,6 +5219,70 @@ mod tests { assert_eq!(text_of(&object(opened)[0].1), "a@x"); } + /// The node under `"eql"` must be a passthrough: a ciphertext leaf + /// there is a record the plan did not produce, refused before any + /// key is retrieved. (The "misplaced" case in the test below puts + /// the leaf under `"c"`, where `take` of `"eql"` fails first; this + /// one reaches the node-shape refusal itself.) + #[tokio::test] + async fn a_non_passthrough_node_under_eql_is_refused() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = mixed_plan(); + let mut row = seal_mixed(&keyset, &plan).await; + row.retain(|(k, _)| k != "email"); + row.push(( + "email".to_string(), + CipherText::Map(vec![(EQL_KEY.to_string(), forged(s("x")))]), + )); + assert!(matches!( + check_record(CipherText::Map(row), &plan), + Err(Error::Record) + )); + let mut row = seal_mixed(&keyset, &plan).await; + row.retain(|(k, _)| k != "email"); + row.push(( + "email".to_string(), + CipherText::Map(vec![(EQL_KEY.to_string(), forged(s("x")))]), + )); + let error = refused(decrypt_with( + Scope::Client(&cipher), + CipherText::Map(row), + &plan, + &FakeEql, + )); + assert!(matches!(error, Error::Record), "{error:?}"); + assert_eq!(retrieves(&cipher), 0); + } + + /// The adapters trust the resolver to answer one value per target + /// field; fewer is the engine's shape disagreeing with the plan's, + /// reported as `ResponseShape` (a binding's internal status), never + /// as a refusal the caller is told to fix. + #[test] + fn a_target_slot_the_resolver_did_not_answer_is_a_response_shape_error() { + let plan = plan_with( + obj(vec![("email", target_spec(label("email"), TEXT_EQ))]), + &FakeEql, + ) + .unwrap(); + let shape = plan.shape(); + assert!( + matches!( + shape_record(FieldValues::new(), Vec::new(), &shape), + Err(crate::Error::ResponseShape) + ), + "a missing target value on the encrypt side" + ); + assert!( + matches!( + open_record(FieldValues::new(), Vec::new(), &shape), + Err(crate::Error::ResponseShape) + ), + "a missing target value on the decrypt side" + ); + } + #[test] fn the_target_and_output_forms_are_exclusive() { let both = obj(vec![ From 9e4e57d2249e1bcd71c81d7487ce1c2de3b2eacd Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:52:14 -0700 Subject: [PATCH 21/30] test(stack-encrypt): fuzz check_record under a resolver that holds TextEq Review of #1095: the check_record target parsed plans under NoTargets, so every plan with a "target" key stopped at that refusal and the fuzzer never ran the target checks in Plan::new_with or the "eql" node read in record_row, which reads untrusted record bytes. Plans now parse under a fixed resolver with one producible type (TextEq over strings) and nothing that runs; the name alphabet gains "eql" and "TextEq", the tree model a passthrough of bytes, and the documented rule for a target field (present once, one "eql" node, a passthrough of bytes) joins the model. The corpus replays. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- packages/stack-encrypt/fuzz/Cargo.lock | 1 + packages/stack-encrypt/fuzz/Cargo.toml | 3 + .../fuzz/fuzz_targets/check_record.rs | 114 +++++++++++++++--- 3 files changed, 102 insertions(+), 16 deletions(-) diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 65a39e705..880a27eff 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -2056,6 +2056,7 @@ dependencies = [ "libfuzzer-sys", "stack-encrypt", "uuid", + "vitaminc-protected", ] [[package]] diff --git a/packages/stack-encrypt/fuzz/Cargo.toml b/packages/stack-encrypt/fuzz/Cargo.toml index b4e81248a..5a3bca512 100644 --- a/packages/stack-encrypt/fuzz/Cargo.toml +++ b/packages/stack-encrypt/fuzz/Cargo.toml @@ -15,6 +15,9 @@ edition = "2021" cargo-fuzz = true [dependencies] +# `Protected`, for the check_record model's passthrough-of-bytes node: the +# same line stack-encrypt builds on. +vitaminc-protected = "0.5.1" libfuzzer-sys = "0.4" # Structure-aware inputs for `check_record` and `plan_build`: the mirror # types derive `Arbitrary`, so the fuzzer mutates trees and plans, not bytes. diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs index 481838852..ebe55eb06 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -12,22 +12,80 @@ //! drawn from a small name alphabet so field names, output keys and map //! keys collide often, with passthroughs and duplicate keys anywhere. //! -//! Two invariants. Neither `plan` nor `check_record` may panic on any +//! Two invariants. Neither `plan_with` nor `check_record` may panic on any //! input. And for a plan that parses, `check_record` accepts the tree //! exactly when the model does — the rules from the record docs, written //! independently of the walk: one map, or a sequence of maps; every //! ciphertext-bearing plan field present exactly once in every row; that //! field a map with exactly one `"c"`; and under that `"c"` no passthrough -//! and no repeated map key at any depth. +//! and no repeated map key at any depth. A target field (one naming an EQL +//! type) is present exactly once with exactly one `"eql"` node, which is a +//! passthrough carrying bytes. +//! +//! Plans parse under a fixed resolver holding one producible type, `TextEq` +//! over strings, so the target checks in `Plan::new_with` and the `"eql"` +//! node read in `record_row` run under fuzzing; every other target name is +//! refused when the plan is built, as under `NoTargets`. use std::sync::OnceLock; use arbitrary::Arbitrary; use libfuzzer_sys::fuzz_target; -use stack_encrypt::dynamic::record::{check_record, plan, Plan}; -use stack_encrypt::dynamic::FfiValue; -use stack_encrypt::{SealedValue, StackCipherText}; +use stack_encrypt::dynamic::record::{check_record, plan_with, Plan}; +use stack_encrypt::dynamic::{ + FfiValue, TargetDescriptor, TargetError, TargetResolver, ValueKind, +}; +use stack_encrypt::{KeysetCipher, Label, Pending, SealedValue, StackCipher, StackCipherText}; use uuid::Uuid; +use vitaminc_protected::Protected; + +/// The resolver plans parse under: one producible type, `TextEq` over +/// strings, and nothing that runs — `check_record` never reaches a cipher. +struct Fixed; + +impl TargetResolver for Fixed { + fn targets(&self) -> Vec { + vec![TargetDescriptor::new( + "TextEq", + "text", + "Eq", + Some(ValueKind::String), + "public.eql_v3_text_eq", + vec!["eq".to_string()], + Some("TextEqQuery".to_string()), + Some("eql_v3.query_text_eq".to_string()), + true, + None, + )] + } + fn encrypt<'a, K: 'static>( + &self, + _: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + unreachable!("check_record runs no cipher") + } + fn decrypt<'a, K: 'static>( + &self, + _: &str, + _: &'a StackCipher, + _: &Label, + _: &[u8], + ) -> Result, TargetError> { + unreachable!("check_record runs no cipher") + } + fn query<'a, K: 'static>( + &self, + _: &str, + _: &'a KeysetCipher<'_, K>, + _: &Label, + _: FfiValue, + ) -> Result, K>, TargetError> { + unreachable!("check_record runs no cipher") + } +} /// The name alphabet: the plan's field names, the tree's map keys and the /// output keys all draw from it, so `"c"` is at once an output key and a @@ -41,6 +99,10 @@ enum Name { Match, Ore, Ope, + /// The stored key of a target field's value, and a plausible field name. + Eql, + /// The one target name the fixed resolver produces. + TextEq, Other, } @@ -54,6 +116,8 @@ impl Name { Name::Match => "match", Name::Ore => "ore", Name::Ope => "ope", + Name::Eql => "eql", + Name::TextEq => "TextEq", Name::Other => "zz", } } @@ -155,7 +219,9 @@ impl PlanSpec { } } -/// A stored ciphertext tree, with every `CipherText` variant reachable. +/// A stored ciphertext tree, with every `CipherText` variant reachable. A +/// passthrough carries a null or bytes: a target field's `"eql"` node is a +/// passthrough of bytes, and one of anything else is refused. #[derive(Arbitrary, Debug)] enum Tree { Single, @@ -163,6 +229,7 @@ enum Tree { EmptySequence, EmptyMap, Passthrough, + PassthroughBytes, Sequence(Vec), Map(Vec<(Name, Tree)>), } @@ -186,6 +253,9 @@ impl Tree { Tree::EmptySequence => StackCipherText::EmptySequence(leaf()), Tree::EmptyMap => StackCipherText::EmptyMap(leaf()), Tree::Passthrough => StackCipherText::Passthrough(Box::new(FfiValue::Null)), + Tree::PassthroughBytes => StackCipherText::Passthrough(Box::new(FfiValue::Bytes( + Protected::new(b"{}".to_vec()), + ))), Tree::Sequence(items) => { StackCipherText::Sequence(items.into_iter().map(Tree::into_ciphertext).collect()) } @@ -202,7 +272,7 @@ impl Tree { /// key given twice, at any depth. fn is_clean(&self) -> bool { match self { - Tree::Passthrough => false, + Tree::Passthrough | Tree::PassthroughBytes => false, Tree::Sequence(items) => items.iter().all(Tree::is_clean), Tree::Map(entries) => { let unique = entries @@ -235,11 +305,14 @@ fn model_accepts(tree: &Tree, plan: &Plan) -> bool { rows.iter().all(|row| { plan.fields() .iter() - .filter(|field| field.has_ciphertext()) + .filter(|field| field.has_ciphertext() || field.target().is_some()) .all(|field| { - // The field exactly once in the row, and `"c"` exactly once - // in its output map: a second copy is how a stale ciphertext - // would be smuggled in beside the current one. + // The field exactly once in the row, and its one node + // exactly once in its output map: a second copy is how a + // stale ciphertext would be smuggled in beside the current + // one. A sealed field's node is `"c"` and must be clean; a + // target field's is `"eql"` and must be a passthrough of + // bytes — the EQL value, whose ciphertext is inside it. let mut named = row .iter() .filter(|(name, _)| name.as_str() == field.name()) @@ -247,14 +320,23 @@ fn model_accepts(tree: &Tree, plan: &Plan) -> bool { let (Some(Tree::Map(outputs)), None) = (named.next(), named.next()) else { return false; }; - let mut cs = outputs + let key = if field.target().is_some() { + Name::Eql + } else { + Name::C + }; + let mut nodes = outputs .iter() - .filter(|(name, _)| *name == Name::C) + .filter(|(name, _)| *name == key) .map(|(_, node)| node); - let (Some(ct), None) = (cs.next(), cs.next()) else { + let (Some(node), None) = (nodes.next(), nodes.next()) else { return false; }; - ct.is_clean() + if field.target().is_some() { + matches!(node, Tree::PassthroughBytes) + } else { + node.is_clean() + } }) }) } @@ -276,7 +358,7 @@ fuzz_target!(|case: Case| { let Case { plan: spec, record } = case; // The plan parser is fuzzed for panics only: its rules are a separate // model, and a plan that does not parse has no record to check. - let Ok(plan) = plan(spec.into_value()) else { + let Ok(plan) = plan_with(spec.into_value(), &Fixed) else { if trace { eprintln!("verdict: plan refused"); } From 5e04d306638d2c26f383986c24c0fb64434eb7df Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:52:14 -0700 Subject: [PATCH 22/30] fix(golang): a program that links encrypt/eql never falls back to the build without EQL types Review of #1095: the init comment in encrypt/eql said an absent EQL module is reported as ErrGuestNotBuilt, while embeddedGuest fell back to the build without the EQL types, where every encrypt_into call then failed with ErrEncoding at the first use. The code now agrees with the stronger promise: linking encrypt/eql registers that fact whether or not the module was built, and NewClient fails at startup with ErrEQLGuestNotBuilt when it was not, naming the task that builds it. A program without EQL types is unchanged. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/encrypt/eql/guest.go | 8 +++++--- languages/golang/encrypt/guest.go | 17 ++++++++++++++--- .../encrypt/internal/eqlguest/eqlguest.go | 17 ++++++++++++++--- 3 files changed, 33 insertions(+), 9 deletions(-) diff --git a/languages/golang/encrypt/eql/guest.go b/languages/golang/encrypt/eql/guest.go index b9eb684d1..d38aee3ec 100644 --- a/languages/golang/encrypt/eql/guest.go +++ b/languages/golang/encrypt/eql/guest.go @@ -19,12 +19,14 @@ var guestFS embed.FS const guestPath = "wasm/stack_encrypt_guest_eql.wasm" // Linking this package selects the build of the engine that holds the EQL -// types. The registration cannot fail: an absent module registers nothing, -// and package encrypt reports it as ErrGuestNotBuilt as it would its own. +// types, and only that build: a program whose generated code names an EQL +// type must not fall back to the build without them, where every +// encrypt_into call would fail. The registration cannot fail; an absent +// module registers nil, and encrypt.NewClient reports ErrEQLGuestNotBuilt. func init() { wasm, err := guestFS.ReadFile(guestPath) if err != nil { - return + wasm = nil } eqlguest.Register(wasm) } diff --git a/languages/golang/encrypt/guest.go b/languages/golang/encrypt/guest.go index 2b512572c..cddc1bade 100644 --- a/languages/golang/encrypt/guest.go +++ b/languages/golang/encrypt/guest.go @@ -35,11 +35,22 @@ const guestPath = "wasm/stack_encrypt_guest.wasm" // embedded. var ErrGuestNotBuilt = errors.New("encrypt: guest module not built — run `mise run wasm:guest:build`") +// ErrEQLGuestNotBuilt is returned by NewClient when the program links +// package eql (its generated code names an EQL type) and the guest build +// with the EQL types is not embedded. There is no fallback to the build +// without them: every encrypt_into call would fail there, so the program +// fails at startup instead. +var ErrEQLGuestNotBuilt = errors.New("encrypt: guest module with the EQL types not built — run `mise run wasm:guest:build:eql`") + // embeddedGuest is the guest a client runs: the build with the EQL types -// when package eql is linked (it registers the module on import, which -// cannot fail), else this package's own. +// when package eql is linked (it registers on import, which cannot fail), +// and only that build; else this package's own. func embeddedGuest() ([]byte, error) { - if wasm := eqlguest.Module(); wasm != nil { + if eqlguest.Linked() { + wasm := eqlguest.Module() + if wasm == nil { + return nil, ErrEQLGuestNotBuilt + } return wasm, nil } wasm, err := guestFS.ReadFile(guestPath) diff --git a/languages/golang/encrypt/internal/eqlguest/eqlguest.go b/languages/golang/encrypt/internal/eqlguest/eqlguest.go index 21ccbab1f..11e001ada 100644 --- a/languages/golang/encrypt/internal/eqlguest/eqlguest.go +++ b/languages/golang/encrypt/internal/eqlguest/eqlguest.go @@ -5,10 +5,21 @@ // cannot fail: it is two assignments at program start. package eqlguest -var module []byte +var ( + linked bool + module []byte +) -// Register installs the eql guest build. Called once, by package eql's init. -func Register(wasm []byte) { module = wasm } +// Register says package eql is linked and installs its guest build, nil +// when the module was not built. Called once, by package eql's init. +func Register(wasm []byte) { + linked = true + module = wasm +} + +// Linked reports whether package eql is linked: the program names an EQL +// type, so it must run the build that holds them and never the other. +func Linked() bool { return linked } // Module is the registered eql guest build, or nil when package eql is not // linked or its module was not built. From 9ce5b1ab23632039c4528021ec030ec80f8c8483 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:52:14 -0700 Subject: [PATCH 23/30] test(golang): host refusals around a target field, and a tampered EQL value Review of #1095: the errNoEQL branch in Open and the two field-selection refusals in Query ran in no test, and no Go test opened a changed EQL value. Over the uninitialised guest, Open refuses a target field with no EQL value, with a ciphertext where the value should be, and missing, naming the field before the guest is asked; Query refuses a field the plan does not have and a field that names no EQL type the same way. Over the deterministic eql build, a TextEq value moved to another column does not open, bytes that are not a TextEq (an empty object, non-JSON, a query value) are ErrEncoding, a flipped ciphertext byte fails authentication, and the untouched value still opens. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/encrypt/eql_test.go | 76 ++++++++++++++++++++++++++ languages/golang/encrypt/guest_test.go | 35 ++++++++++++ 2 files changed, 111 insertions(+) diff --git a/languages/golang/encrypt/eql_test.go b/languages/golang/encrypt/eql_test.go index 3db74c936..895fc41a4 100644 --- a/languages/golang/encrypt/eql_test.go +++ b/languages/golang/encrypt/eql_test.go @@ -189,6 +189,82 @@ func TestTextEqQueryMatchesTheStoredValueAndTheRustFixture(t *testing.T) { } } +// A stored EQL value changed before it is opened: moved to another column, +// or replaced with bytes that are not a TextEq. Neither opens, and the +// second is refused as malformed input before any key is retrieved. +func TestATamperedEQLValueDoesNotOpen(t *testing.T) { + c := deterministicEQLClient(t) + ctx := context.Background() + cipher := c.DefaultKeyset() + + moved, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + original := moved[0].Email + moved[0].Email = eql.TextEq(bytes.Replace(original, []byte(`"c":"email"`), []byte(`"c":"notes"`), 1)) + if bytes.Equal(moved[0].Email, original) { + t.Fatal("the stored identifier was not where the test expected it") + } + if _, err := testusers.DecryptContact(ctx, cipher, moved); err == nil { + t.Fatal("an EQL value moved to another column opened") + } + + junk, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + for name, value := range map[string]eql.TextEq{ + "an empty object": eql.TextEq(`{}`), + "not JSON": eql.TextEq(`not json`), + "a query value": eql.TextEq(`{"v":3,"i":{"t":"users","c":"email"},"hm":"00"}`), + } { + junk[0].Email = value + if _, err := testusers.DecryptContact(ctx, cipher, junk); !errors.Is(err, encrypt.ErrEncoding) { + t.Errorf("%s: Decrypt = %v, want ErrEncoding", name, err) + } + } + + // One flipped byte in the ciphertext inside the value: authenticated, + // so it does not open; the untouched value still does. + flipped, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + var doc map[string]json.RawMessage + if err := json.Unmarshal(flipped[0].Email, &doc); err != nil { + t.Fatal(err) + } + var ct string + if err := json.Unmarshal(doc["c"], &ct); err != nil { + t.Fatal(err) + } + last := []byte(ct) + if last[len(last)-2] == 'A' { + last[len(last)-2] = 'B' + } else { + last[len(last)-2] = 'A' + } + doc["c"], _ = json.Marshal(string(last)) + tampered, _ := json.Marshal(doc) + flipped[0].Email = eql.TextEq(tampered) + if _, err := testusers.DecryptContact(ctx, cipher, flipped); err == nil { + t.Fatal("a tampered ciphertext opened") + } + if _, err := testusers.DecryptContact(ctx, cipher, contactsSealed(t, ctx, cipher)); err != nil { + t.Fatalf("the untouched value no longer opens: %v", err) + } +} + +func contactsSealed(t *testing.T, ctx context.Context, cipher *encrypt.Cipher) []testusers.EncryptedContact { + t.Helper() + sealed, err := testusers.EncryptContact(ctx, cipher, contacts[:1]) + if err != nil { + t.Fatal(err) + } + return sealed +} + func TestTextEqIsRefusedByTheBuildWithoutEQLTypes(t *testing.T) { ctx := context.Background() plain, err := encrypt.PlainGuest() diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index 2c9bab09d..a82caae59 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -597,6 +597,41 @@ func fixtureRecord() record.Sealed { // A record opened under a plan that seals a field it does not carry is // refused on the host, with the field named, before the guest is asked. +// A record whose target field has no EQL value is refused by the host, +// before the guest sees it: the errNoEQL branch, which a change could +// otherwise drop and send a malformed record on. +func TestTargetWithoutEQLIsRefusedBeforeTheGuest(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + plan := &record.Plan{ + Context: []string{"users"}, + Fields: []record.Field{{Name: "email", Kind: record.String, Target: "TextEq"}}, + } + for name, rec := range map[string]record.Sealed{ + "no outputs": {"email": {}}, + "a ciphertext where the EQL value should be": {"email": {Ciphertext: fixtureLeaf}}, + "the field missing": {}, + } { + _, err := c.DefaultKeyset().Open(ctx, plan, []record.Sealed{rec}) + if !errors.Is(err, errNoEQL) || errors.Is(err, ErrState) || !strings.Contains(err.Error(), `field "email"`) { + t.Errorf("%s: Open = %v, want errNoEQL before the guest, naming the field", name, err) + } + } +} + +// Query refuses a field the plan does not have, and a field that names no +// EQL type, before the guest is asked. +func TestQueryRejectsMissingAndNonTargetFieldsBeforeTheGuest(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + for _, field := range []string{"email", "missing"} { + _, err := c.DefaultKeyset().Query(ctx, usersPlan(), field, "a@b.c") + if !errors.Is(err, ErrEncoding) || errors.Is(err, ErrState) || !strings.Contains(err.Error(), field) { + t.Errorf("Query(%q) = %v, want ErrEncoding before the guest, naming the field", field, err) + } + } +} + func TestMismatchedPlanIsRefusedBeforeTheGuest(t *testing.T) { ctx := context.Background() c := rawInstance(t) From cb175eb81bcf25e6bf0970fc4643bafc2ac497e6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 03:52:14 -0700 Subject: [PATCH 24/30] test(golang): the generator's kind check and Go-name rule against the eql build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #1095: lowerDeclaration's plaintext-kind check (Age int64 with encrypt_into=TextEq) ran only against the real engine with EQL types, which no test linked; and EQLGoName and eql-codegen's go_name held one rule with nothing comparing them. TestRunAsksTheEmbeddedEngine, the one test that links encrypt/eql, gains the kind-mismatch case ("TextEq seals a string, and int64 is int", nothing written). The Go-name rule is exported as stashgen.EQLGoName and checked against every row of the generated eql.Types table from cmd/stashgen — not from stashgen's own tests, which must not link encrypt/eql or the embedded engine they test changes build. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/cmd/stashgen/main_test.go | 44 ++++++++++++++++++++++ languages/golang/stashgen/read.go | 18 +++++---- 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/languages/golang/cmd/stashgen/main_test.go b/languages/golang/cmd/stashgen/main_test.go index b1ddc8362..b832fcb4f 100644 --- a/languages/golang/cmd/stashgen/main_test.go +++ b/languages/golang/cmd/stashgen/main_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/cipherstash/stack/languages/golang/encrypt" + "github.com/cipherstash/stack/languages/golang/encrypt/eql" "github.com/cipherstash/stack/languages/golang/stashgen" "github.com/cipherstash/stack/languages/golang/stashgen/enginetest" ) @@ -155,6 +156,22 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) { if _, err := os.Stat(filepath.Join(dir, "user_stash.go")); !os.IsNotExist(err) { t.Fatal("a file was written after the engine refused") } + // A Go type the EQL type does not seal: TextEq takes a string. The + // generator refuses it from the engine's plaintext kind, before the + // engine is asked. Only this test links encrypt/eql, so only it reaches + // the kind check against the real target list. + mismatched := strings.Replace(userSource, "Email string `stash:\"email,encrypt_into=TextEq\"`", "Age int64 `stash:\"age,encrypt_into=TextEq\"`", 1) + if mismatched == userSource { + t.Fatal("the fixture's Email line changed shape") + } + dir = writeModule(t, map[string]string{"model.go": mismatched}) + stderr.Reset() + if code := run([]string{"-type", "User"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 1 { + t.Fatalf("exit %d, want 1\n%s", code, stderr.String()) + } + if !strings.Contains(stderr.String(), "User.Age") || !strings.Contains(stderr.String(), "TextEq seals a string, and int64 is int") { + t.Fatalf("stderr = %q", stderr.String()) + } // A context of two segments leaves an EQL field no column: refused by // name, before the engine is asked, and nothing written. deep := strings.Replace(userSource, "context=users", "context=app/users", 1) @@ -178,6 +195,33 @@ func TestRunAsksTheEmbeddedEngine(t *testing.T) { } } +// stashgen.EQLGoName and eql-codegen's go_name hold one rule (Json is JSON). +// The generated encrypt/eql table records both names for every type, so a +// type whose GoName the Go rule does not reproduce is a rule the two no +// longer share. This test lives here because the command links encrypt/eql; +// the stashgen package's own tests must not, or the embedded engine they +// test changes build. +func TestEQLGoNameAgreesWithTheGeneratedPackage(t *testing.T) { + if len(eql.Types) == 0 { + t.Fatal("the generated eql.Types table is empty") + } + renamed := 0 + for _, typ := range eql.Types { + if got := stashgen.EQLGoName(typ.Name); got != typ.GoName { + t.Errorf("%s: EQLGoName = %s, generated GoName = %s", typ.Name, got, typ.GoName) + } + if typ.Name != typ.GoName { + renamed++ + } + } + if renamed == 0 { + t.Fatal("the rule renamed nothing: the Json family is spelled JSON") + } + if stashgen.EQLGoName("Json") != "JSON" || stashgen.EQLGoName("TextEq") != "TextEq" || stashgen.EQLGoName("SteVecQuery") != "SteVecQuery" { + t.Fatal("the one rule: a Json prefix is JSON, every other name is itself") + } +} + func TestModelFlagsRepeat(t *testing.T) { var m modelFlags for _, s := range []string{"Rows=ContactRow", "Legacy=userdb.Contact:contactRow"} { diff --git a/languages/golang/stashgen/read.go b/languages/golang/stashgen/read.go index 264d353bd..6839e4a96 100644 --- a/languages/golang/stashgen/read.go +++ b/languages/golang/stashgen/read.go @@ -135,12 +135,14 @@ type modelField struct { output *output } -// eqlGoName is the Go type name in encrypt/eql of an EQL type the engine +// EQLGoName is the Go type name in encrypt/eql of an EQL type the engine // names: the same name in every language, save the JSON family, whose Go -// names start with JSON (Json -> JSON, JsonSearch -> JSONSearch), as Go -// spells initialisms. The generated encrypt/eql package follows the same -// rule (eql-codegen's go_eql renderer). -func eqlGoName(name string) string { +// names start with JSON (Json is JSON), as Go spells initialisms. The +// generated encrypt/eql package follows the same rule (eql-codegen's go_eql +// renderer), and its Types table records both names, which is what holds +// the two in agreement (a test in cmd/stashgen reads it; this package's own +// tests cannot link encrypt/eql without changing the engine they test). +func EQLGoName(name string) string { if strings.HasPrefix(name, "Json") { return "JSON" + strings.TrimPrefix(name, "Json") } @@ -713,10 +715,10 @@ func (r *reader) buildFields(c *collected) error { return fieldErr(typeName, cf.goName, "the engine cannot produce the EQL type %s yet: %s", field.EQLType, reason) } f.imports.add(eqlPath, "eql") - g.outputType = "eql." + eqlGoName(eqlType.Name) - g.outputs = []output{{name: "EQL", typeExpr: g.outputType, pathType: eqlPath + "." + eqlGoName(eqlType.Name)}} + g.outputType = "eql." + EQLGoName(eqlType.Name) + g.outputs = []output{{name: "EQL", typeExpr: g.outputType, pathType: eqlPath + "." + EQLGoName(eqlType.Name)}} if eqlType.Query != "" { - g.queryType = "eql." + eqlGoName(eqlType.Query) + g.queryType = "eql." + EQLGoName(eqlType.Query) } default: g.outputType = f.encName + cf.goName From 31f68140d623c1455b3e6a60c4e765fd16d9ca44 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 19:25:31 -0700 Subject: [PATCH 25/30] chore(golang): adopt #1094's third-review changes in the EQL build #1094 renamed record.UInt64 to Uint64 and rewrote the generated Encrypt/Decrypt comments to state the 500-value batch size; this regenerates testusers.Contact and updates eql_test.go to match. #1094 also moved the stashgen quick start to index= columns, because that build refuses encrypt_into. This build produces TextEq, so the quick start goes back to encrypt_into=TextEq with Fields.Email.Query, and step 6 says what an eql.TextEq column binds as. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc --- languages/golang/cmd/stashgen/README.md | 9 ++++----- languages/golang/encrypt/eql_test.go | 2 +- .../golang/encrypt/internal/testusers/contact_stash.go | 7 ++++--- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/languages/golang/cmd/stashgen/README.md b/languages/golang/cmd/stashgen/README.md index 95d37e3f4..d3f38554e 100644 --- a/languages/golang/cmd/stashgen/README.md +++ b/languages/golang/cmd/stashgen/README.md @@ -21,8 +21,8 @@ Your program calls those functions, and it never builds or names a plan. type User struct { _ struct{} `stash:"context=users"` ID int64 `stash:"id,passthrough"` - Email string `stash:"email,encrypt,index=equality;match"` - Name string `stash:"name,encrypt"` + Email string `stash:"email,encrypt_into=TextEq"` + Name string `stash:"name,encrypt_into=TextEq"` } ``` @@ -40,12 +40,11 @@ Your program calls those functions, and it never builds or names a plan. ```go encrypted, err := users.Encrypt(ctx, cipher, people) opened, err := users.Decrypt(ctx, cipher, encrypted) - term, err := users.Fields.Email.Equality(ctx, cipher, "bob@example.com") + query, err := users.Fields.Email.Query(ctx, cipher, "bob@example.com") ``` 6. Store the encrypted type. - Each sealed field is one or more byte columns: `Email.Ciphertext`, `Email.Equality`, `Email.Match`. - `encrypt.Ciphertext` and each term type implement `driver.Valuer` and `sql.Scanner`, so a database library binds and scans each one as bytes; map each one to its own column. + Each `encrypt_into` field is one EQL column: `eql.TextEq` implements `driver.Valuer` and `sql.Scanner`, so a database library binds and scans it as the JSON a `public.eql_v3_text_eq` column holds. 7. Run the generator again after each change to the struct or to a tag. A change to the fields of the struct stops the build until you do. diff --git a/languages/golang/encrypt/eql_test.go b/languages/golang/encrypt/eql_test.go index 895fc41a4..833724c85 100644 --- a/languages/golang/encrypt/eql_test.go +++ b/languages/golang/encrypt/eql_test.go @@ -322,7 +322,7 @@ func TestTextEqIsRefusedByTheBuildWithoutEQLTypes(t *testing.T) { } } // A declared type other than TextEq's plaintext, and an extended plan. - wrongKind := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.UInt64, Target: "TextEq"}}} + wrongKind := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "email", Kind: record.Uint64, Target: "TextEq"}}} if err := eqlChecker.Check(ctx, wrongKind); !errors.Is(err, encrypt.ErrEncoding) { t.Errorf("a uint64 TextEq field: Check = %v, want ErrEncoding", err) } diff --git a/languages/golang/encrypt/internal/testusers/contact_stash.go b/languages/golang/encrypt/internal/testusers/contact_stash.go index f8f30e438..716df4cf7 100644 --- a/languages/golang/encrypt/internal/testusers/contact_stash.go +++ b/languages/golang/encrypt/internal/testusers/contact_stash.go @@ -94,13 +94,14 @@ var contactCodec = gensupport.New(gensupport.Generated[Contact, EncryptedContact }, }) -// EncryptContact seals each Contact in one ZeroKMS request. The result has one -// element for each input, in the same order. +// EncryptContact seals each Contact, with one ZeroKMS request for each 500 +// sealed values. The result has one element for each input, in the same order. func EncryptContact(ctx context.Context, cipher *encrypt.Cipher, values []Contact) ([]EncryptedContact, error) { return contactCodec.Encrypt(ctx, cipher, values) } -// DecryptContact opens each EncryptedContact in one ZeroKMS request. +// DecryptContact opens each EncryptedContact, with one ZeroKMS request for each +// 500 sealed values. func DecryptContact(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedContact) ([]Contact, error) { return contactCodec.Decrypt(ctx, d, encrypted) } From 4c85516f4773369f8f965310e93fb48c2ae84f30 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 21:32:19 -0700 Subject: [PATCH 26/30] docs(golang): eql names the interfaces its types implement, not libraries The package doc said database/sql, pgx, sqlx and GORM take an EQL type as the column value. No test runs those libraries yet, so it names only the interfaces, as the encrypt README does. --- languages/golang/encrypt/eql/eql.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/languages/golang/encrypt/eql/eql.go b/languages/golang/encrypt/eql/eql.go index e44369223..24b3d7c17 100644 --- a/languages/golang/encrypt/eql/eql.go +++ b/languages/golang/encrypt/eql/eql.go @@ -6,9 +6,9 @@ // generated encrypted type, and its Fields entry's Query returns a // [TextEqQuery]. The guest builds every value: generated code stores what // the guest returns and never assembles an EQL value itself (ADR-0007, -// amended 2026-10-06). Each type is a driver.Valuer and sql.Scanner, so -// database/sql, pgx, sqlx and GORM take it as the column's value, and a -// json.Marshaler that emits the value as the JSON it is. +// amended 2026-10-06). Each type implements driver.Valuer and sql.Scanner +// for one column, and json.Marshaler, which writes the value as the JSON +// it is. // // Importing this package links the build of the engine that holds the EQL // types: eql_gen.go's init registers the embedded module with package From 1c729536e346371d88360a006e340bb8ffb49a3e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 6 Oct 2026 21:32:20 -0700 Subject: [PATCH 27/30] chore(golang): regenerate contact_stash.go for the request-count comment --- languages/golang/encrypt/internal/testusers/contact_stash.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/languages/golang/encrypt/internal/testusers/contact_stash.go b/languages/golang/encrypt/internal/testusers/contact_stash.go index 716df4cf7..05869aea8 100644 --- a/languages/golang/encrypt/internal/testusers/contact_stash.go +++ b/languages/golang/encrypt/internal/testusers/contact_stash.go @@ -95,13 +95,14 @@ var contactCodec = gensupport.New(gensupport.Generated[Contact, EncryptedContact }) // EncryptContact seals each Contact, with one ZeroKMS request for each 500 -// sealed values. The result has one element for each input, in the same order. +// sealed values, plus one the first time a keyset is used. The result has one +// element for each input, in the same order. func EncryptContact(ctx context.Context, cipher *encrypt.Cipher, values []Contact) ([]EncryptedContact, error) { return contactCodec.Encrypt(ctx, cipher, values) } // DecryptContact opens each EncryptedContact, with one ZeroKMS request for each -// 500 sealed values. +// 500 sealed values, plus one the first time a keyset is used. func DecryptContact(ctx context.Context, d encrypt.Decrypter, encrypted []EncryptedContact) ([]Contact, error) { return contactCodec.Decrypt(ctx, d, encrypted) } From 24c20d128088bd9a78480eca9d474afcacdee25e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 02:47:45 -0700 Subject: [PATCH 28/30] test(stack-encrypt): the EQL tests and the generator follow the base's expected context The base branch gave `record::decrypt`, `decrypt_with` and `check_record` the context a caller expects a record's context field to hold, and gave the generator's lowering a plan whose context may come from a field rather than from `context=`. The tests this branch added call the three with the old arity, and its lowering read the context segments it had parsed, which a context-field declaration does not have. Every test call passes `None` (no expectation), and the lowering reads the plan's context, which is empty under a context field, so a declaration that names an EQL type beside a context field is refused as one whose context is not one segment. --- languages/golang/encrypt/guest/src/ops.rs | 15 +++---- languages/golang/stashgen/engine.go | 4 +- packages/stack-encrypt/src/dynamic/record.rs | 46 +++++++++++++------- 3 files changed, 38 insertions(+), 27 deletions(-) diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index 6f1b75e98..040c4c462 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -191,16 +191,11 @@ where K: DataKeySource + Sync + 'static, { let plan = parse_plan(plan)?; - let value = dynamic::record::decrypt_with( - scope, - decode_tree(record)?, - &plan, - expected, - &resolver(), - ) - .map_err(|e| status_for_dynamic(&e))? - .await - .map_err(|e| status_for_error(&e))?; + let value = + dynamic::record::decrypt_with(scope, decode_tree(record)?, &plan, expected, &resolver()) + .map_err(|e| status_for_dynamic(&e))? + .await + .map_err(|e| status_for_error(&e))?; encode_value(value) } diff --git a/languages/golang/stashgen/engine.go b/languages/golang/stashgen/engine.go index 58973b947..d8829012e 100644 --- a/languages/golang/stashgen/engine.go +++ b/languages/golang/stashgen/engine.go @@ -166,8 +166,8 @@ func lowerDeclaration(d Declaration, eqlTypes []EQLType) (*record.Plan, error) { // context of two or more segments leaves the field no column. // The engine refuses it too (se_plan_check); naming it here // names the field. - if len(segments) != 1 { - return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s is stored under a table and a column, and the context %q has %d segments, not one", f.EQLType, d.Context, len(segments))} + if len(plan.Context) != 1 { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s is stored under a table and a column, and the context %q has %d segments, not one", f.EQLType, d.Context, len(plan.Context))} } rf.Target = f.EQLType case VerbEncrypt, VerbEncryptIndex: diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 2fd67c669..7caeb53c3 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -1439,12 +1439,12 @@ pub fn check_record( let rows = record_rows(record, plan)?; let lowered = plan.lower::<()>().map_err(|_| Error::Internal)?; let check = |row: &StoredRow| { - Opens::::check(&lowered, &row.values, expected).map_err(|error| { - match error { + Opens::::check(&lowered, &row.values, expected).map_err( + |error| match error { mismatch @ crate::Error::ContextMismatch { .. } => Error::Cipher(mismatch), _ => Error::Record, - } - }) + }, + ) }; match &rows { Rows::One(row) => check(row), @@ -4967,6 +4967,7 @@ mod tests { Scope::Client(&cipher), CipherText::Map(record), &plan, + None, &FakeEql, ) .expect("the record fits") @@ -4989,6 +4990,7 @@ mod tests { Scope::Keyset(cipher.default_keyset()), CipherText::Map(record), &plan, + None, &FakeEql, ) .expect("the record fits") @@ -5016,7 +5018,7 @@ mod tests { 1, "three rows, two leaves each, one request" ); - let opened = decrypt_with(Scope::Client(&cipher), sealed, &plan, &FakeEql) + let opened = decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &FakeEql) .expect("the batch fits") .await .expect("opens"); @@ -5047,7 +5049,7 @@ mod tests { .await .expect("seals"); assert_eq!(generates(&cipher), 1); - let opened = decrypt_with(Scope::Client(&cipher), sealed, &plan, &FakeEql) + let opened = decrypt_with(Scope::Client(&cipher), sealed, &plan, None, &FakeEql) .expect("fits") .await .expect("opens"); @@ -5088,7 +5090,12 @@ mod tests { assert!(matches!(error, TargetError::NoTargets { .. }), "{error}"); assert_eq!(generates(&cipher), 0, "nothing minted"); let sealed = CipherText::Map(seal_mixed(&keyset, &plan).await); - let error = target_error(refused(decrypt(Scope::Client(&cipher), sealed, &plan))); + let error = target_error(refused(decrypt( + Scope::Client(&cipher), + sealed, + &plan, + None, + ))); assert!(matches!(error, TargetError::NoTargets { .. }), "{error}"); assert_eq!(retrieves(&cipher), 0, "nothing retrieved"); } @@ -5194,7 +5201,7 @@ mod tests { .unwrap() .await .unwrap(); - let result = decrypt_with(Scope::Keyset(globex), sealed, &plan, &FakeEql) + let result = decrypt_with(Scope::Keyset(globex), sealed, &plan, None, &FakeEql) .expect("the record fits") .await; match result { @@ -5212,7 +5219,7 @@ mod tests { .unwrap() .await .unwrap(); - let opened = decrypt_with(Scope::Keyset(acme), sealed, &plan, &FakeEql) + let opened = decrypt_with(Scope::Keyset(acme), sealed, &plan, None, &FakeEql) .unwrap() .await .expect("its own keyset opens it"); @@ -5236,7 +5243,7 @@ mod tests { CipherText::Map(vec![(EQL_KEY.to_string(), forged(s("x")))]), )); assert!(matches!( - check_record(CipherText::Map(row), &plan), + check_record(CipherText::Map(row), &plan, None), Err(Error::Record) )); let mut row = seal_mixed(&keyset, &plan).await; @@ -5249,6 +5256,7 @@ mod tests { Scope::Client(&cipher), CipherText::Map(row), &plan, + None, &FakeEql, )); assert!(matches!(error, Error::Record), "{error:?}"); @@ -5434,7 +5442,10 @@ mod tests { // A ciphertext leaf where the EQL value should be. let misplaced = CipherText::Map(vec![("c".to_string(), forged(s("x")))]); let record = with_email(seal_mixed(&keyset, &plan).await, misplaced); - assert!(matches!(check_record(record, &plan), Err(Error::Record))); + assert!(matches!( + check_record(record, &plan, None), + Err(Error::Record) + )); // The node twice. let mut row = seal_mixed(&keyset, &plan).await; let CipherText::Map(mut outputs) = node(&mut row, "email") else { @@ -5447,7 +5458,7 @@ mod tests { outputs.extend(again); row.push(("email".to_string(), CipherText::Map(outputs))); assert!(matches!( - check_record(CipherText::Map(row), &plan), + check_record(CipherText::Map(row), &plan, None), Err(Error::Record) )); // A payload that is not bytes. @@ -5456,17 +5467,21 @@ mod tests { CipherText::Passthrough(Box::new(FfiValue::Null) as BoxedPassthrough), )]); let record = with_email(seal_mixed(&keyset, &plan).await, null); - assert!(matches!(check_record(record, &plan), Err(Error::Record))); + assert!(matches!( + check_record(record, &plan, None), + Err(Error::Record) + )); // Bytes that are not the type: the shape fits, and the resolver // refuses them before any key is retrieved. let record = with_email(seal_mixed(&keyset, &plan).await, bytes_node(b"not json")); - assert!(check_record(record, &plan).is_ok(), "the shape fits"); + assert!(check_record(record, &plan, None).is_ok(), "the shape fits"); let retrieved = retrieves(&cipher); let record = with_email(seal_mixed(&keyset, &plan).await, bytes_node(b"not json")); let error = target_error(refused(decrypt_with( Scope::Client(&cipher), record, &plan, + None, &FakeEql, ))); assert!( @@ -5476,7 +5491,7 @@ mod tests { assert_eq!(retrieves(&cipher), retrieved, "nothing retrieved"); // An untouched record still opens. let record = CipherText::Map(seal_mixed(&keyset, &plan).await); - let opened = decrypt_with(Scope::Client(&cipher), record, &plan, &FakeEql) + let opened = decrypt_with(Scope::Client(&cipher), record, &plan, None, &FakeEql) .unwrap() .await .unwrap(); @@ -5558,6 +5573,7 @@ mod tests { Scope::Client(&cipher), junk, &plan, + None, &FakeEql, ))); match error { From eb1c7bb2b0345bde50223cd41f5addcfd058334d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 02:59:31 -0700 Subject: [PATCH 29/30] fix(stack-encrypt): a plan with a context field refuses an EQL target An EQL value is stored under a table and a column, both fixed by the declaration, so the resolver refuses a target field in an extended plan: the caller's parts leave it no column. A plan that takes its context from a field of each record has the same problem one level up: its labels are identities alone, and the table is whatever each record names. Such a plan was refused only because a target field could not take a one-segment label, which made the refusal a bare `Error::Plan` with nothing to say about why. `Plan::with_context_field` now refuses a target field with `TargetError::ContextField`, naming the field and the context field, in every build; `FieldPlan::with_target` takes any label, as every field constructor does, and the plan holds the segment rules. The Go generator's lowering says the same for a struct that names an EQL type beside a `context_field`, naming the field, before the engine is asked. --- languages/golang/encrypt/guest/src/status.rs | 4 + languages/golang/stashgen/engine.go | 10 +- languages/golang/stashgen/lower_test.go | 30 ++++++ packages/stack-encrypt/src/dynamic/record.rs | 104 ++++++++++++++----- packages/stack-encrypt/src/dynamic/target.rs | 15 +++ 5 files changed, 134 insertions(+), 29 deletions(-) create mode 100644 languages/golang/stashgen/lower_test.go diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 6dcd10bf9..35babc7f7 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -383,6 +383,10 @@ mod tests { name: "email".into(), label: "users/email".into(), }, + TargetError::ContextField { + name: "email".into(), + context_field: "tenant".into(), + }, TargetError::NoQuery { name: "Text".into(), }, diff --git a/languages/golang/stashgen/engine.go b/languages/golang/stashgen/engine.go index d8829012e..baa20710f 100644 --- a/languages/golang/stashgen/engine.go +++ b/languages/golang/stashgen/engine.go @@ -163,9 +163,13 @@ func lowerDeclaration(d Declaration, eqlTypes []EQLType) (*record.Plan, error) { } // An EQL value is stored under a table and a column: the struct's // context is the table and the field's name the column, so a - // context of two or more segments leaves the field no column. - // The engine refuses it too (se_plan_check); naming it here - // names the field. + // context of two or more segments leaves the field no column, + // and a struct whose context is one of its fields has no table + // of its own. The engine refuses both too (se_plan_check); + // naming them here names the field. + if d.ContextField != "" { + return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s is stored under a table and a column, and a struct whose context is its field %q has no table of its own; give the struct a context= tag", f.EQLType, d.ContextField)} + } if len(plan.Context) != 1 { return nil, &FieldError{Type: d.Type, Field: f.GoName, Reason: fmt.Sprintf("%s is stored under a table and a column, and the context %q has %d segments, not one", f.EQLType, d.Context, len(plan.Context))} } diff --git a/languages/golang/stashgen/lower_test.go b/languages/golang/stashgen/lower_test.go new file mode 100644 index 000000000..140776870 --- /dev/null +++ b/languages/golang/stashgen/lower_test.go @@ -0,0 +1,30 @@ +package stashgen + +import ( + "strings" + "testing" +) + +// A struct that names an EQL type beside a context_field has no table of +// its own to store the EQL value under: the lowering refuses it, naming the +// field, as the engine would. +func TestLoweringRefusesAnEQLTypeBesideAContextField(t *testing.T) { + eql := []EQLType{{Name: "TextEq", Plaintext: KindString, Indexes: []IndexName{IndexEquality}, Query: "TextEqQuery", Producible: true}} + text := GoType{Name: "string", Kind: KindString, Basic: "string"} + d := Declaration{Type: "Note", ContextField: "tenant", Fields: []Field{ + {Name: "tenant", GoName: "Tenant", GoType: text, Verb: VerbContextField}, + {Name: "email", GoName: "Email", GoType: text, Verb: VerbEncryptInto, EQLType: "TextEq"}, + }} + _, err := lowerDeclaration(d, eql) + fe, ok := err.(*FieldError) + if !ok || fe.Field != "Email" || !strings.Contains(fe.Reason, "has no table of its own") { + t.Fatalf("lowerDeclaration = %v, want a FieldError on Email about the table", err) + } + // Without the EQL type the context field lowers: no context, the + // field's name as the plan's context field, and the sealed field alone. + d.Fields[1] = Field{Name: "email", GoName: "Email", GoType: text, Verb: VerbEncrypt} + plan, err := lowerDeclaration(d, eql) + if err != nil || plan.ContextField != "tenant" || len(plan.Context) != 0 || len(plan.Fields) != 1 { + t.Fatalf("lowerDeclaration = %+v, %v", plan, err) + } +} diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 7caeb53c3..87d1a8eed 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -74,10 +74,12 @@ //! that is what an EQL value stores in its `i`: [`Plan::new_with`] refuses //! a target field whose label has any other number of segments //! ([`TargetError::Column`]) — [`FieldPlan::with_target`] itself takes any -//! label of two or more segments, as every field constructor does; the -//! column rule is the plan's — and an extended plan (a tenant part on every +//! label, as every field constructor does; the segment rules are the +//! plan's — and an extended plan (a tenant part on every //! label) has no column for it and is refused with -//! [`TargetError::Extended`] rather than silently dropping the extension. +//! [`TargetError::Extended`] rather than silently dropping the extension, +//! as is a plan with a context field, whose labels are identities alone +//! under whatever table each record names ([`TargetError::ContextField`]). //! The field's `"type"`, when declared, must be the kind the EQL type is //! produced from ([`TargetError::Kind`]); undeclared, it is that kind, so //! every value is checked against it as any typed field's is. @@ -301,8 +303,9 @@ impl FieldPlan { /// /// # Errors /// - /// [`Error::Plan`] if `target` is empty, or if `context` is not a label - /// of at least two segments, optionally extended. + /// [`Error::Plan`] if `target` is empty, or if `context` is not a label, + /// optionally extended. How many segments the label needs is the plan's + /// rule ([`Plan::new_with`]), as it is for [`new`](Self::new). pub fn with_target( name: impl Into, context: NonEmpty>, @@ -313,9 +316,6 @@ impl FieldPlan { return Err(Error::Plan); } let (label, extension) = split_context(context.get())?; - if label.segments().len() < 2 { - return Err(Error::Plan); - } Ok(Self { name: name.into(), context, @@ -623,7 +623,10 @@ impl Plan { /// [`Output::Passthrough`] (a context is not sealed, and indexing it /// would derive a term from a value that is not secret), declares a /// type other than [`ValueKind::String`], or any field's label has - /// more than one segment. + /// more than one segment. [`Error::Target`] + /// ([`TargetError::ContextField`]) if a field names an EQL type as its + /// target: an EQL value is stored under a table the declaration fixes, + /// and a plan with a context field has none. pub fn with_context_field( context_field: impl Into, fields: Vec, @@ -648,9 +651,15 @@ impl Plan { return Err(Error::Plan); } // A target field's label must be a column, `
/`, - // which a context field's one-segment identity is not. - if fields.iter().any(FieldPlan::is_target) { - return Err(Error::Plan); + // which a context field's one-segment identity is not: the table + // is each record's own, and an EQL value stores one fixed by the + // declaration. The same refusal as an extended plan's. + if let Some(target) = fields.iter().find(|field| field.is_target()) { + return Err(TargetError::ContextField { + name: target.name.clone(), + context_field, + } + .into()); } let fields = fields .into_iter() @@ -851,7 +860,7 @@ pub fn plan(value: FfiValue) -> Result { /// value; its `"type"`, when given, must be the kind the type is produced /// from. See the [module docs](self#a-field-that-names-an-eql-type). A /// target field needs a column for its label, so it is refused under a -/// context field. +/// context field ([`TargetError::ContextField`]). /// /// `` is an index in its wire form, which is its key — `"eq"`, /// `"match"`, `"ore"` or `"ope"` — save for a match index with options other @@ -5122,22 +5131,23 @@ mod tests { ); } - /// The constructor's one bound on the label is "at least two - /// segments", the same as every field constructor's: a longer label - /// is still a label. The column rule — exactly two — is - /// `Plan::new_with`'s, where the plan is built (the test after this - /// one). Pinned from both sides so the constructor's bound cannot - /// drift to "exactly two" or flip: one segment refused (it is no - /// label), two accepted, and three accepted intact. + /// The constructor's one bound on the label is "a label", the same + /// as every field constructor's: how many segments it needs is the + /// plan's rule, where the plan is built. A one-segment label is a + /// field's identity under a context field, and under a plan with a + /// context of its own it has nothing to sit under; the column rule + /// — exactly two — is `Plan::new_with`'s (the test after this one). + /// Pinned from both sides so the constructor's bound cannot drift: + /// one segment accepted and then refused by the plan, two accepted, + /// and three accepted intact. #[test] - fn with_target_takes_any_label_of_two_or_more_segments() { + fn with_target_takes_any_label_and_the_plan_holds_the_segment_rules() { let ctx = |segments: &[&str]| context(strings(segments)).expect("a context value"); + let one = FieldPlan::with_target("email", ctx(&["users"]), TEXT_EQ) + .expect("one segment is a label; the plan decides"); assert!( - matches!( - FieldPlan::with_target("email", ctx(&["users"]), TEXT_EQ), - Err(Error::Plan) - ), - "one segment is not a label" + matches!(Plan::new_with(vec![one], &FakeEql), Err(Error::Plan)), + "under a plan with a context of its own, one segment has nothing to sit under" ); let two = FieldPlan::with_target("email", ctx(&["users", "email"]), TEXT_EQ) .expect("two segments: table and column"); @@ -5388,6 +5398,48 @@ mod tests { assert!(plan_with(value, &FakeEql).is_ok()); } + /// A plan with a context field has no table of its own: each + /// record names one. An EQL value stores a table the declaration + /// fixes, so a target field is refused there as it is in an extended + /// plan, and before the resolver is asked. + #[test] + fn a_context_field_plan_refuses_a_target_field() { + let identity = |field: &str| strings(&[field]); + let value = obj(vec![ + ("context_field", s("tenant")), + ("tenant", spec(identity("tenant"), &["passthrough"])), + ("age", spec(identity("age"), &["c"])), + ("email", target_spec(identity("email"), TEXT_EQ)), + ]); + let error = target_error(plan_with(value, &FakeEql).unwrap_err()); + assert!( + matches!(&error, TargetError::ContextField { name, context_field } if name == "email" && context_field == "tenant"), + "{error}" + ); + // Refused by the plan, whatever the build holds: a build with + // no EQL types says the same. + let value = obj(vec![ + ("context_field", s("tenant")), + ("tenant", spec(identity("tenant"), &["passthrough"])), + ("email", target_spec(identity("email"), TEXT_EQ)), + ]); + assert!(matches!( + target_error(plan(value).unwrap_err()), + TargetError::ContextField { .. } + )); + // The same plan without the target field takes its context + // from the field as before. + let value = obj(vec![ + ("context_field", s("tenant")), + ("tenant", spec(identity("tenant"), &["passthrough"])), + ("age", spec(identity("age"), &["c"])), + ]); + assert_eq!( + plan_with(value, &FakeEql).expect("parses").context_field(), + Some("tenant") + ); + } + #[test] fn a_target_field_is_keyed_under_its_identity_like_a_sealed_one() { // Two fields under one label, one of them a target: the terms diff --git a/packages/stack-encrypt/src/dynamic/target.rs b/packages/stack-encrypt/src/dynamic/target.rs index 8547d4b03..0df41bf08 100644 --- a/packages/stack-encrypt/src/dynamic/target.rs +++ b/packages/stack-encrypt/src/dynamic/target.rs @@ -189,6 +189,21 @@ pub enum TargetError { /// The field's label, before the extension. label: String, }, + /// The plan takes its context from a field of each record (the + /// plan-level `"context_field"`), so a field's label is its identity + /// alone and the table is whatever each record names; an EQL value + /// stores a table and a column fixed by the declaration. A plan with a + /// target field has a context of its own. + #[error( + "{name}: an EQL value is stored under a table and a column, so a plan that takes \ + its context from its field {context_field} has no table for it" + )] + ContextField { + /// The target field's name. + name: String, + /// The field the plan takes its context from. + context_field: String, + }, /// The field's declared `"type"` is not the kind the EQL type is /// produced from. #[error( From c8b498e570b8f5380efa7abf2f838d30bffbb4f4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 7 Oct 2026 03:57:57 -0700 Subject: [PATCH 30/30] fix(golang): the guest's EQL round-trip test passes the expected context The context_field change gave validate::record_tree and decrypt_record an expected-context parameter. The test behind the guest's eql feature still called both the old way, so `cargo test --all-features` on the guest did not compile. Both calls pass None: the plan has a context of its own. --- languages/golang/encrypt/guest/src/ops.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/languages/golang/encrypt/guest/src/ops.rs b/languages/golang/encrypt/guest/src/ops.rs index 040c4c462..b4f2bf554 100644 --- a/languages/golang/encrypt/guest/src/ops.rs +++ b/languages/golang/encrypt/guest/src/ops.rs @@ -735,9 +735,9 @@ mod tests { assert_eq!(eql["hm"].as_str().unwrap().len(), 64); // Opens back through the EQL type's own decryption. - validate::record_tree(&sealed, &plan).expect("the tree fits"); + validate::record_tree(&sealed, &plan, None).expect("the tree fits"); let opened = - block_on(decrypt_record(Scope::Client(&cipher), &sealed, &plan)).expect("opens"); + block_on(decrypt_record(Scope::Client(&cipher), &sealed, &plan, None)).expect("opens"); let FfiValue::Object(values) = decode_value(&opened).expect("a value") else { panic!("an object") };