diff --git a/packages/eql/tests/encryption/tests/migration_shim.rs b/packages/eql/tests/encryption/tests/migration_shim.rs
new file mode 100644
index 000000000..ecc013b74
--- /dev/null
+++ b/packages/eql/tests/encryption/tests/migration_shim.rs
@@ -0,0 +1,211 @@
+//! A migration shim: an EQL v4 type encrypted from a value that contains its
+//! own column, with no context argument.
+//!
+//! To move EQL v3 data to v4, each v3 value is decrypted, then encrypted again
+//! as v4. The decrypted value arrives with the column it was stored in, `i`.
+//! The shim takes the context from `i`, through `Encryption::context_from`,
+//! and the value from `p`. To rename the column, edit `i` before the call.
+//!
+//! One generic impl covers every v4 type whose context is its column. The
+//! shim must write the v4 type itself, byte for byte where the bytes are
+//! deterministic. A query built the usual way finds the value, and the value
+//! opens as the original plaintext.
+
+mod common;
+
+use std::cmp::Ordering;
+
+use eql_bindings::v4::integer::{IntegerOrdOre, IntegerOrdOreQuery};
+use eql_bindings::v4::text::{TextEq, TextEqQuery};
+use eql_bindings::Identifier;
+use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext, RequiredContext, Zeroize};
+use stack_encrypt::{DecryptFrom, EncryptInto, Error, NonEmpty};
+
+/// A value decrypted from EQL v3, with the column it was stored in.
+struct V3Plaintext
{
+ i: Identifier,
+ p: P,
+}
+impl Zeroize for V3Plaintext {
+ // The column is not secret: it is stored in the clear in `i`.
+ fn zeroize(&mut self) {
+ self.p.zeroize();
+ }
+}
+
+/// A v4 type, encrypted from a v3 plaintext, under the column in the plaintext.
+struct Shim(T);
+
+impl EncryptTarget> for Shim
+where
+ P: Zeroize,
+ T: EncryptTarget>>,
+{
+ type ContextInput = NoCallerContext;
+ fn encryption<'s, K: 'static>() -> Encryption<'s, V3Plaintext
, Self, K, NoCallerContext>
+ where
+ V3Plaintext
: 's,
+ {
+ T::encryption()
+ .project(|source: &V3Plaintext
| &source.p)
+ .context_from(|source: &V3Plaintext
| &source.i)
+ .map(Shim)
+ }
+}
+
+fn identifier(table: &str, column: &str) -> Identifier {
+ Identifier {
+ t: table.to_owned(),
+ c: column.to_owned(),
+ }
+}
+
+fn column(table: &str, column: &str) -> NonEmpty {
+ Identifier::for_column(table, column).unwrap()
+}
+
+async fn email_query(
+ keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>,
+ name: &str,
+) -> TextEqQuery {
+ "alan@example.com"
+ .to_owned()
+ .encrypt_into_with_context(keyset, column("customers", name))
+ .await
+ .unwrap()
+}
+
+async fn age_query(
+ keyset: &stack_encrypt::KeysetCipher<'_, common::Observed>,
+ age: i32,
+) -> IntegerOrdOreQuery {
+ age.encrypt_into_with_context(keyset, column("customers", "age"))
+ .await
+ .unwrap()
+}
+
+#[tokio::test]
+async fn a_v3_value_encrypts_as_the_v4_type_with_no_context_argument() {
+ let (cipher, _) = common::cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let source = V3Plaintext {
+ i: identifier("customers", "email"),
+ p: "alan@example.com".to_owned(),
+ };
+ let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap();
+
+ assert_eq!(
+ (migrated.i.t.as_str(), migrated.i.c.as_str()),
+ ("customers", "email")
+ );
+ let query: TextEqQuery = "alan@example.com"
+ .to_owned()
+ .encrypt_into_with_context(&keyset, column("customers", "email"))
+ .await
+ .unwrap();
+ assert_eq!(
+ migrated.hm, query.hm,
+ "a query built the usual way finds it"
+ );
+
+ let opened = String::decrypt_from_with_context(migrated, &keyset, column("customers", "email"))
+ .await
+ .unwrap();
+ assert_eq!(opened, "alan@example.com");
+}
+
+#[tokio::test]
+async fn the_same_shim_covers_an_ordered_type() {
+ let (cipher, _) = common::cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let source = V3Plaintext {
+ i: identifier("customers", "age"),
+ p: 41_i32,
+ };
+ let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap();
+
+ // A block ORE term contains a nonce, so the test compares it by order.
+ // The migrated value orders after the query for 40, equal to the query
+ // for 41, and before the query for 42.
+ let compare = |query: &IntegerOrdOreQuery| migrated.ob.partial_cmp(&query.ob);
+ assert_eq!(
+ compare(&age_query(&keyset, 40).await),
+ Some(Ordering::Greater)
+ );
+ assert_eq!(
+ compare(&age_query(&keyset, 41).await),
+ Some(Ordering::Equal)
+ );
+ assert_eq!(compare(&age_query(&keyset, 42).await), Some(Ordering::Less));
+
+ let opened = i32::decrypt_from_with_context(migrated, &keyset, column("customers", "age"))
+ .await
+ .unwrap();
+ assert_eq!(opened, 41);
+}
+
+#[tokio::test]
+async fn a_renamed_column_is_the_column_the_value_is_sealed_under() {
+ let (cipher, _) = common::cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let mut source = V3Plaintext {
+ i: identifier("customers", "email"),
+ p: "alan@example.com".to_owned(),
+ };
+ source.i.c = "contact_email".to_owned();
+ let Shim(migrated): Shim = source.encrypt_into(&keyset).await.unwrap();
+ assert_eq!(migrated.i.c, "contact_email");
+
+ // The query for the new column finds the value. The query for the old
+ // column does not.
+ assert_eq!(migrated.hm, email_query(&keyset, "contact_email").await.hm);
+ assert_ne!(migrated.hm, email_query(&keyset, "email").await.hm);
+
+ // The ciphertext is sealed under the new column, so it opens there.
+ let opened = String::decrypt_from_with_context(
+ migrated.clone(),
+ &keyset,
+ column("customers", "contact_email"),
+ )
+ .await
+ .unwrap();
+ assert_eq!(opened, "alan@example.com");
+
+ // Opened as the old column, the value is refused before any key is
+ // requested, because the stored column does not match.
+ let result =
+ String::decrypt_from_with_context(migrated.clone(), &keyset, column("customers", "email"))
+ .await;
+ assert!(
+ matches!(result, Err(Error::ContextMismatch { .. })),
+ "{:?}",
+ result.err()
+ );
+
+ // Change the stored column back to the old one. The ciphertext still
+ // fails to open, because it is bound to the new column, not to the label.
+ let mut relabelled = migrated;
+ relabelled.i.c = "email".to_owned();
+ let result = String::decrypt_from(relabelled, &keyset).await;
+ assert!(matches!(result, Err(Error::Aead)), "{:?}", result.err());
+}
+
+#[tokio::test]
+async fn an_empty_column_is_refused_before_any_key_is_requested() {
+ let (cipher, calls) = common::cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let source = V3Plaintext {
+ i: identifier("customers", ""),
+ p: "alan@example.com".to_owned(),
+ };
+ let result = source.encrypt_into::, _>(&keyset).await;
+ assert!(matches!(result, Err(Error::Other(_))));
+ assert!(
+ calls.lock().unwrap().generate.is_empty(),
+ "no key was requested"
+ );
+}
diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md
index 9551e84d4..acb151898 100644
--- a/packages/stack-encrypt/CHANGELOG.md
+++ b/packages/stack-encrypt/CHANGELOG.md
@@ -71,6 +71,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **A target can take its context from a field of the plaintext:
+ `Encryption::context_from`** (#1586). Some values arrive together with the
+ context they are stored under. For example, a value decrypted from an older
+ format comes with its column. Such a value now encrypts with no context
+ argument.
+ - A type that wraps the target names the field once, in its
+ `EncryptTarget` declaration. The type declares `NoCallerContext`.
+ - The target must store its context in the row, so its context input must
+ be a typed `NonEmpty`. Records with a `context_field`, and EQL types,
+ qualify. Opening a value never needs its plaintext.
+ - The field is picked by reference, so a context cannot be computed from
+ the sealed value.
+ - An empty context fails the encryption. No key is requested.
+ - The picked field is sent to ZeroKMS and stored in the clear. The field
+ must not be secret.
- **Optional values through the owned API: `Option`** (#1585). An
`Option` encrypts into an `Option` for any owned target `T`, and
decrypts back.
diff --git a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md
index 111da4472..f87632adc 100644
--- a/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md
+++ b/packages/stack-encrypt/docs/adr/0004-one-context-per-target-threaded-through-the-declaration-tree.md
@@ -113,6 +113,9 @@ literal `"users/age"`. A column is the pair now, which renders the ZeroKMS
descriptor `users/age`; the joined literal is one part and renders escaped.
See ADR-0006.)
+A third way, `context_from`, reads the context from a field of the plaintext.
+See the amendment of 2026-11-09 below.
+
Two further combinators change nothing about *which* context reaches a
subtree, only its type at the root. `accepting` converts the context a record
declares its caller supplies into the one its operations need, once, at the
@@ -354,3 +357,38 @@ language allows; Go-1). The dynamic-to-static dispatch itself is unchanged.
Decisions 1 to 4, 7 and 8 do not change. Decision 2's `extend` is what
`Cipher.Extend` does from Go: a caller's context appends to the declared one,
and never replaces it.
+
+## Amended 2026-11-09 (#1586)
+
+Decision 2 gains a third way to set the context of a subtree:
+`Encryption::context_from`. `under` and `extend` do not cover one case: a
+value that arrives together with the context it is stored under. For
+example, a value decrypted from EQL v3 comes with its column. Re-encrypting
+it as EQL v4 should read that column, not ask the caller to supply it again.
+
+`context_from(pick)` reads the context from a field of the plaintext.
+`context_from` discharges the requirement into `NoCallerContext`, so the
+caller supplies no context. Three limits keep the reasons for decision 2:
+
+- **The target must store its context.** The context input of the target
+ must be a typed context, `NonEmpty`. A record that stores its context in
+ a `context_field` declares such an input. An EQL type declares one too, and
+ stores its column in `i`. So the context comes from the plaintext on the
+ way in, and from the row on the way out. Opening a value never needs its
+ plaintext.
+- **`pick` returns a reference to a field of the plaintext.** So `pick`
+ cannot compute a context from the plaintext. When the context type differs
+ from the type of the sealed value, the sealed field cannot be picked at
+ all.
+- **The declaration names the field.** `pick` is a plain function pointer,
+ visible at review, like the selector of `project`. A value supplied at the
+ call could reach one side of a target and not the other. A named field
+ cannot.
+
+`context_from` replaces the surrounding context, and does not extend it. A
+target that takes its context this way cannot be a field of a record that
+declares contexts, because its context input is `NoCallerContext`.
+
+Two halves of one target, zipped after two `context_from` calls, could each
+take a different field. Decision 1 already accepts that difference for
+contexts declared per side, because the declaration names each field.
diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs
index 1e08468a5..504f8ee0f 100644
--- a/packages/stack-encrypt/src/target/operations.rs
+++ b/packages/stack-encrypt/src/target/operations.rs
@@ -8,16 +8,18 @@
//! of [`Encryption`] (ADR-0004): a target cannot route the context it is
//! handed to one operation and something else to another, because there is
//! no argument to route. What a subtree may do is take a context of its own,
-//! by name, with [`Encryption::under`] or [`Encryption::extend`].
+//! by name, with [`Encryption::under`] or [`Encryption::extend`]. A subtree
+//! can also read its context from a named field of the plaintext, with
+//! [`Encryption::context_from`].
use super::context::{AeadContext, CallerContext, DeclaredContext, Extends};
use super::conversion::{DecryptSource, EncryptTarget};
use super::core::{encrypt_native, open_native, Term};
use super::index::{Index, IndexSpec, Json};
-use super::input::RequiredContext;
+use super::input::{AcceptsContext, NoCallerContext, RequiredContext};
use super::source::{Borrowed, ConsumeSource, Owned, ShareSource, SourceMode};
use super::{CipherScope, Pending};
use crate::json::{JsonDocument, JsonOptions, JsonTerms};
-use crate::{Error, IntoContext, KeysetCipher, NonEmpty, StackCipher, StackCipherText};
+use crate::{Error, IntoContext, KeysetCipher, MaybeEmpty, NonEmpty, StackCipher, StackCipherText};
use stack_kms::MaybeSend;
use std::fmt;
@@ -114,14 +116,17 @@ type Open = Box FnOnce(&'a StackCipher) -> Pending<'a, T, K
/// other. A ciphertext beside a term takes the term's context through
/// [`accepting`](Self::accepting): the [`AeadContext`] it seals under is
/// the AEAD half of that one value. A side given a context of its own,
-/// with [`under`](Self::under) or [`extend`](Self::extend), says so in the
-/// declaration; that is how a record names its fields' contexts, and the
-/// tree does not tell a record's fields from a target's halves.
+/// with [`under`](Self::under), [`extend`](Self::extend), or
+/// [`context_from`](Self::context_from), says so in the declaration; that
+/// is how a record names its fields' contexts, and the tree does not tell a
+/// record's fields from a target's halves.
/// - **A leaf still cannot be reached without a context.** An operation needs
-/// a real one. [`under`](Self::under) and [`extend`](Self::extend) are the
-/// only ways to change the context a subtree runs under, and only `under`
-/// discharges the requirement into a [`DeclaredContext`], which is what
-/// `()` may satisfy.
+/// a real one. [`under`](Self::under) and [`extend`](Self::extend) change
+/// the context that a subtree runs under. `under` discharges the
+/// requirement into a [`DeclaredContext`], and `()` may satisfy that.
+/// [`context_from`](Self::context_from) also discharges the requirement,
+/// into [`NoCallerContext`], with the context read from a named field of
+/// the plaintext.
///
/// `M` is how the description is handed its plaintext ([`SourceMode`]):
/// by reference in the default [`Borrowed`] mode, which every
@@ -349,6 +354,124 @@ impl<'s, S: 's, T: 'static, K: 'static, Ctx: 's> Encryption<'s, S, T, K, Ctx> {
build: Box::new(move |source, cipher, cx| (self.build)(select(source), cipher, cx)),
}
}
+ /// Takes the context from a field of the plaintext. The caller then
+ /// supplies no context.
+ ///
+ /// Use it for a value that arrives together with its context. For example,
+ /// a value decrypted from an older format comes with the column it was
+ /// stored in. A type that wraps the target names the field that contains
+ /// the context, once. Callers of that type then encrypt without a context
+ /// argument.
+ ///
+ /// The target must store its context in the row. In the types, its context
+ /// input must be a typed context, `NonEmpty`. A record with a
+ /// `#[stash(context_field)]` has such an input. An EQL type has one too,
+ /// and stores its column in `i`. So the context is read from the plaintext
+ /// on the way in, and from the row on the way out.
+ ///
+ /// Opening a value never needs its plaintext.
+ ///
+ /// `pick` returns a reference to a field of the plaintext. So `pick` can
+ /// only pass on a value that the plaintext contains. It cannot compute a
+ /// context from the plaintext. `pick` is a plain function pointer, like the
+ /// selector of [`project`](Self::project), so it cannot reach a cipher.
+ ///
+ /// The context must not be empty. If it is empty, the description fails.
+ /// No key is requested.
+ ///
+ /// # Security
+ ///
+ /// The picked field is not secret. ZeroKMS receives it as the descriptor
+ /// of the data key. A target that stores its context also writes it in
+ /// the clear.
+ ///
+ /// Pick a field that says where the value belongs, such as its column.
+ /// Never pick a field that is also sealed: its plaintext would then be
+ /// stored outside the ciphertext. The compiler refuses that mistake when
+ /// the context type differs from the type of the sealed value, as
+ /// `Identifier` differs from `String`.
+ ///
+ /// ```
+ /// use stack_encrypt::sem::EqualityTerm;
+ /// use stack_encrypt::target::{EncryptTarget, Encryption, NoCallerContext};
+ /// use stack_encrypt::{EncryptInto, NonEmpty, StackCipher};
+ /// use stack_encrypt::kms::FakeDataKeySource;
+ /// use zeroize::Zeroize;
+ ///
+ /// /// A value and the column it belongs to.
+ /// struct Placed {
+ /// column: String,
+ /// value: String,
+ /// }
+ /// impl Zeroize for Placed {
+ /// // The column is not secret.
+ /// fn zeroize(&mut self) {
+ /// self.value.zeroize();
+ /// }
+ /// }
+ ///
+ /// /// The stored form: the column in the clear, and the term of the value.
+ /// #[derive(EncryptTarget)]
+ /// #[stash(plaintext = String)]
+ /// struct Stored {
+ /// #[stash(context_field)]
+ /// column: String,
+ /// hm: EqualityTerm,
+ /// }
+ ///
+ /// /// `Stored`, encrypted from a `Placed`, under the column in the `Placed`.
+ /// struct FromPlaced(Stored);
+ /// impl EncryptTarget for FromPlaced {
+ /// type ContextInput = NoCallerContext;
+ /// fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> {
+ /// >::encryption()
+ /// .project(|placed: &Placed| &placed.value)
+ /// .context_from(|placed: &Placed| &placed.column)
+ /// .map(FromPlaced)
+ /// }
+ /// }
+ ///
+ /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(async {
+ /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?;
+ /// let keyset = cipher.default_keyset();
+ /// let placed = Placed { column: "users/email".into(), value: "ada@example.com".into() };
+ /// // No context argument: the context comes from `placed.column`.
+ /// let FromPlaced(stored) = placed.encrypt_into(&keyset).await?;
+ /// assert_eq!(stored.column, "users/email");
+ ///
+ /// // The same term as passing the column as the context.
+ /// let direct: Stored = "ada@example.com"
+ /// .to_owned()
+ /// .encrypt_into_with_context(&keyset, NonEmpty::new("users/email".to_owned())?)
+ /// .await?;
+ /// assert_eq!(stored.hm, direct.hm);
+ /// # Ok::<(), Box>(())
+ /// # }).unwrap();
+ /// ```
+ ///
+ /// `context_from` works in the [`Borrowed`] mode. Every
+ /// `EncryptTarget::encryption` runs in that mode. The owned form of such a
+ /// target wraps the plaintext and runs the borrowed form, so the owned
+ /// form needs nothing more.
+ pub fn context_from(
+ self,
+ pick: for<'borrow> fn(&'borrow S) -> &'borrow C,
+ ) -> Encryption<'s, S, T, K, NoCallerContext>
+ where
+ Ctx: AcceptsContext>,
+ C: Clone + MaybeEmpty + 's,
+ {
+ Encryption {
+ build: Box::new(
+ move |source, cipher, _: NoCallerContext| match super::context::nonempty(
+ pick(source).clone(),
+ ) {
+ Ok(context) => (self.build)(source, cipher, Ctx::supplied(context)),
+ Err(error) => Pending::failed(cipher, error),
+ },
+ ),
+ }
+ }
/// [`under`](Encryption::under) for a context read out of the source
/// itself, as a plan whose context is a field of the value does: `own`
/// reads the label from the borrowed source, or fails, in which case the
diff --git a/packages/stack-encrypt/tests/context_from.rs b/packages/stack-encrypt/tests/context_from.rs
new file mode 100644
index 000000000..3afaf3379
--- /dev/null
+++ b/packages/stack-encrypt/tests/context_from.rs
@@ -0,0 +1,160 @@
+//! Tests for a target that takes its context from a field of the plaintext,
+//! through `Encryption::context_from`.
+//!
+//! The caller supplies no context. The plaintext contains the context it is
+//! stored under, such as the column of a value decrypted from an older
+//! format. The target stores the context in the row, so opening the value
+//! never needs its plaintext. These tests need no service credentials.
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::Arc;
+
+use stack_encrypt::sem::EqualityTerm;
+use stack_encrypt::target::{DecryptSource, EncryptTarget, Encryption, NoCallerContext};
+use stack_encrypt::{DecryptFrom, EncryptInto, Error, NonEmpty, StackCipherText};
+use zeroize::Zeroize;
+mod common;
+
+/// A value and the column it belongs to. Records when its value is wiped.
+struct Placed {
+ column: String,
+ value: String,
+ wiped: Arc,
+}
+impl Zeroize for Placed {
+ // The column is not secret.
+ fn zeroize(&mut self) {
+ self.value.zeroize();
+ self.wiped.store(true, Ordering::SeqCst);
+ }
+}
+fn placed(column: &str, value: &str) -> (Placed, Arc) {
+ let wiped = Arc::new(AtomicBool::new(false));
+ let placed = Placed {
+ column: column.to_owned(),
+ value: value.to_owned(),
+ wiped: wiped.clone(),
+ };
+ (placed, wiped)
+}
+
+/// The stored form: the column in the clear, the ciphertext, and its term.
+#[derive(EncryptTarget, DecryptSource)]
+#[stash(plaintext = String)]
+struct Stored {
+ #[stash(context_field)]
+ column: String,
+ c: StackCipherText,
+ hm: EqualityTerm,
+}
+
+/// `Stored`, encrypted from a `Placed`, under the column in the `Placed`.
+struct FromPlaced(Stored);
+impl EncryptTarget for FromPlaced {
+ type ContextInput = NoCallerContext;
+ fn encryption<'s, K: 'static>() -> Encryption<'s, Placed, Self, K, NoCallerContext> {
+ >::encryption()
+ .project(|placed: &Placed| &placed.value)
+ .context_from(|placed: &Placed| &placed.column)
+ .map(FromPlaced)
+ }
+}
+
+fn column(name: &str) -> NonEmpty {
+ NonEmpty::new(name.to_owned()).unwrap()
+}
+
+#[tokio::test]
+async fn the_value_is_stored_under_the_column_it_carries() {
+ let cipher = common::stack_cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let (source, wiped) = placed("users/email", "ada@example.com");
+ let FromPlaced(stored) = source.encrypt_into(&keyset).await.unwrap();
+ assert!(wiped.load(Ordering::SeqCst), "the plaintext is wiped");
+ assert_eq!(stored.column, "users/email", "the column is stored");
+
+ let direct: Stored = "ada@example.com"
+ .to_owned()
+ .encrypt_into_with_context(&keyset, column("users/email"))
+ .await
+ .unwrap();
+ assert_eq!(stored.hm, direct.hm, "the same term as passing the column");
+
+ // The value opens from the row alone, and under the column the row names.
+ let opened = String::decrypt_from_with_context(stored, &keyset, column("users/email"))
+ .await
+ .unwrap();
+ assert_eq!(opened, "ada@example.com");
+ let opened = String::decrypt_from(direct, &keyset).await.unwrap();
+ assert_eq!(opened, "ada@example.com");
+}
+
+#[tokio::test]
+async fn another_column_derives_another_term_and_does_not_open_as_the_first() {
+ let cipher = common::stack_cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let (email, _) = placed("users/email", "ada@example.com");
+ let (name, _) = placed("users/name", "ada@example.com");
+ let FromPlaced(email) = email.encrypt_into(&keyset).await.unwrap();
+ let FromPlaced(name) = name.encrypt_into(&keyset).await.unwrap();
+ assert_ne!(email.hm, name.hm);
+
+ let result = String::decrypt_from_with_context(name, &keyset, column("users/email")).await;
+ assert!(
+ matches!(result, Err(Error::ContextMismatch { .. })),
+ "{:?}",
+ result.err()
+ );
+}
+
+#[tokio::test]
+async fn an_empty_column_is_refused_before_any_key_is_requested() {
+ let (cipher, generates, _) = common::counting_cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let (source, wiped) = placed("", "ada@example.com");
+ let result = source.encrypt_into::(&keyset).await;
+ assert!(matches!(result, Err(Error::Other(_))));
+ assert_eq!(generates.load(Ordering::SeqCst), 0, "no key was requested");
+ assert!(
+ wiped.load(Ordering::SeqCst),
+ "the refused plaintext is wiped"
+ );
+}
+
+// Each row has its own column. The rows are encrypted in one batch.
+#[tokio::test]
+async fn each_row_of_a_column_is_sealed_under_its_own_column() {
+ let (cipher, sent) = common::recording_cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let rows: Vec = vec![
+ placed("email", "ada@example.com").0,
+ placed("name", "Ada").0,
+ ]
+ .encrypt_into(&keyset)
+ .await
+ .unwrap();
+ let sent = sent.lock().unwrap().clone();
+ assert_eq!(sent.generate.len(), 1, "one batch");
+ assert_eq!(sent.generated(), ["email", "name"]);
+
+ let columns: Vec<&str> = rows.iter().map(|FromPlaced(s)| s.column.as_str()).collect();
+ assert_eq!(columns, ["email", "name"]);
+}
+
+#[tokio::test]
+async fn one_row_with_an_empty_column_refuses_the_whole_column() {
+ let (cipher, generates, _) = common::counting_cipher().await;
+ let keyset = cipher.default_keyset();
+
+ let (first, first_wiped) = placed("users/email", "ada@example.com");
+ let (second, second_wiped) = placed("", "Ada");
+ let result = vec![first, second]
+ .encrypt_into::, _>(&keyset)
+ .await;
+ assert!(matches!(result, Err(Error::Other(_))));
+ assert_eq!(generates.load(Ordering::SeqCst), 0, "no key was requested");
+ assert!(first_wiped.load(Ordering::SeqCst) && second_wiped.load(Ordering::SeqCst));
+}