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)); +}