From 3d183c5cada93eda84196c775708f9f3d2a2fa67 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 4 Oct 2026 16:58:36 -0700 Subject: [PATCH 1/7] refactor(stack-encrypt)!: rename MatchTerm to MatchTerms A match index produces a set of terms, not one: every token of the value contributes k Bloom filter positions, and a query matches when its positions are a subset of the stored ones. The singular name read like EqualityTerm and OreTerm, which are each one comparand, and the plan builder is about to name index outputs in its own API (an Index's associated Term), where the difference shows. MatchTerms keeps the family's prefix, so it still sorts and reads beside EqualityTerm, OreTerm and OpeTerm, and the plural says what the value is. The decode error variant follows (OddMatchTermsLength). The constructor stays `matching()` and the transport bytes are unchanged; this is a name change only. stack-encrypt 0.2.0 on crates.io exports both old names, so the old type name stays as a deprecated alias and the CHANGELOG records the break: the next release is 0.3.0. BREAKING CHANGE: sem::MatchTerm is renamed MatchTerms; MatchTerm remains as a deprecated type alias, so code naming the type still compiles with a warning. TermBytesError::OddMatchTermLength is renamed OddMatchTermsLength, with no alias (an enum variant cannot have one). --- docs/fuzzing.md | 2 +- packages/stack-encrypt/CHANGELOG.md | 4 ++ .../stack-encrypt/examples/search_terms.rs | 10 +-- .../fuzz/fuzz_targets/term_decode.rs | 4 +- packages/stack-encrypt/src/sem/mod.rs | 69 +++++++++++-------- .../stack-encrypt/src/target/operations.rs | 4 +- .../stack-encrypt/src/target/transcode.rs | 6 +- packages/stack-encrypt/tests/derive.rs | 8 +-- packages/stack-encrypt/tests/frozen_bytes.rs | 28 ++++---- packages/stack-encrypt/tests/sem_terms.rs | 4 +- packages/stack-encrypt/tests/source_mode.rs | 14 ++-- packages/stack-encrypt/tests/target.rs | 18 ++--- .../ui/decrypt_field_not_decryptable.stderr | 4 +- .../tests/ui/pass/owned_without_clone.rs | 4 +- 14 files changed, 97 insertions(+), 82 deletions(-) diff --git a/docs/fuzzing.md b/docs/fuzzing.md index c206f6431..0c168aac6 100644 --- a/docs/fuzzing.md +++ b/docs/fuzzing.md @@ -25,7 +25,7 @@ Current targets: | `fuzz:jwt-decode` | `stack-auth` | `jwt_decode` | JWT claims (`Token::fuzz_decode_claims`) | | `fuzz:client-key` | `stack-kms` | `client_key_encoded` | `ClientKey::from_encoded_v1` (hex or base64) | | `fuzz:sealed-value` | `stack-encrypt` | `sealed_value_decode` | `SealedValue::from_bytes` (frozen v1 leaf); accepted input must re-encode to itself | -| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerm`, `OreTerm`, `OpeTerm` `from_bytes`) | +| `fuzz:term-decode` | `stack-encrypt` | `term_decode` | the SEM term decoders (`EqualityTerm`, `MatchTerms`, `OreTerm`, `OpeTerm` `from_bytes`) | | `fuzz:check-record` | `stack-encrypt` | `check_record` | `dynamic::record::check_record` over an `Arbitrary`-derived plan and tree, against a model of the record rules (structure-aware) | Each target is a few lines — `libfuzzer-sys` hands a `&str` (or `&[u8]` diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 59e404604..43d17b512 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -19,6 +19,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 whose result type does not fix the mode must name it; `Borrowed` is the old behaviour. In return `ciphertext`, `equality`, `ore` and `ope` no longer ask `S: Clone` themselves: only borrowed mode does. +- `sem::MatchTerm` is renamed `MatchTerms`: a match index produces a set of + terms, not one. `MatchTerm` remains as a deprecated alias. +- `TermBytesError::OddMatchTermLength` is renamed `OddMatchTermsLength`. An + enum variant cannot be aliased, so a `match` that names it must change. ### Added diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index 74c556b96..514cca254 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -18,7 +18,7 @@ //! `CS_WORKSPACE_CRN` and `CS_CLIENT_ID` / `CS_CLIENT_KEY` instead (see the //! `zerokms_auth` example for the lookup order). -use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; use stack_encrypt::target::EncryptInto; use stack_encrypt::{nonempty, EncryptFrom, StackCipher, StackCipherText}; @@ -72,13 +72,13 @@ async fn main() -> Result<(), Box> { // its positions are a subset of the stored term's (Bloom semantics: false // positives possible, false negatives not). - let bio: MatchTerm = "alice, senior cryptography engineer" + let bio: MatchTerms = "alice, senior cryptography engineer" .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("bio")) .await?; for query in ["crypto", "engineer", "plumber"] { - let probe: MatchTerm = query + let probe: MatchTerms = query .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("bio")) .await?; @@ -132,7 +132,7 @@ async fn main() -> Result<(), Box> { struct SearchableEmail { c: StackCipherText, eq: EqualityTerm, - text: MatchTerm, + text: MatchTerms, ord: OreTerm, } @@ -140,7 +140,7 @@ async fn main() -> Result<(), Box> { .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("email")) .await?; - let probe: MatchTerm = "example" + let probe: MatchTerms = "example" .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("email")) .await?; diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs index 45d7c0b0e..7a5334331 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/term_decode.rs @@ -1,7 +1,7 @@ #![no_main] use libfuzzer_sys::fuzz_target; -use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; +use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerms, OpeTerm, OreTerm}; // Fuzz the SEM index-term byte decoders: stored terms come back through these // before comparison, so the bytes are attacker-controlled and decoding must @@ -19,7 +19,7 @@ fuzz_target!(|bytes: &[u8]| { "EqualityTerm decode is not lossless" ); } - let _ = MatchTerm::::from_bytes(bytes); + let _ = MatchTerms::::from_bytes(bytes); // One fixed-width and one variable-width CLLW output each for ORE and OPE. if let Ok(term) = OreTerm::::from_bytes(bytes) { assert_eq!( diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 9e00a8d74..8c3336990 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -11,7 +11,7 @@ //! ``` //! //! * [`EqualityTerm`] — a PRF of the whole value; exact-match queries. -//! * [`MatchTerm`] — the value is tokenized locally, each token is PRF'd, and +//! * [`MatchTerms`] — the value is tokenized locally, each token is PRF'd, and //! the outputs fold into Bloom-filter bit positions; full-text match //! queries. Tokenizer/filter parameters are a *type-level* config //! ([`MatchConfig`]) so write-time and query-time terms agree by @@ -87,9 +87,9 @@ //! ([`as_bytes`](EqualityTerm::as_bytes) / //! [`to_bytes`](EqualityTerm::to_bytes) / //! [`from_bytes`](EqualityTerm::from_bytes)). -//! * [`MatchTerm`] — the sorted, de-duplicated bit positions, each a -//! little-endian `u16` ([`to_bytes`](MatchTerm::to_bytes) / -//! [`from_bytes`](MatchTerm::from_bytes)). +//! * [`MatchTerms`] — the sorted, de-duplicated bit positions, each a +//! little-endian `u16` ([`to_bytes`](MatchTerms::to_bytes) / +//! [`from_bytes`](MatchTerms::from_bytes)). //! * [`OreTerm`] / [`OpeTerm`] — the raw CLLW ciphertext bytes, unframed //! ([`as_bytes`](OreTerm::as_bytes) / [`to_bytes`](OreTerm::to_bytes) / //! [`from_bytes`](OreTerm::from_bytes)). @@ -98,7 +98,7 @@ //! [`TermBytesError`]; [`EqualityTerm`] additionally keeps an infallible //! [`from_bytes`](EqualityTerm::from_bytes) over a `[u8; 32]`. `as_bytes` //! exists only where the term *is* a contiguous buffer (equality, ORE, OPE); -//! a [`MatchTerm`] is canonically a position list, so it has none. +//! a [`MatchTerms`] is canonically a position list, so it has none. //! //! For equality and ORE/OPE the transport bytes are also the stored form, //! and they share the *shape* of the v1 / `cipherstash-client` encodings — a @@ -106,7 +106,7 @@ //! hex-encodes into its `hm` / `oc` / `op` fields with its hex and JSON //! framing sitting *above* them. A match term is the exception: what is //! stored and queried is the position list -//! ([`positions`](MatchTerm::positions)), which maps to an integer-array +//! ([`positions`](MatchTerms::positions)), which maps to an integer-array //! column (EQL sends `bf` as a JSON integer array) — no column holds the //! `u16` byte string, which is stack-encrypt's own shape and exists so a //! binding can carry the term across the boundary without inventing a @@ -214,7 +214,7 @@ pub enum TermBytesError { /// Match-term bytes are not a whole number of little-endian `u16` /// positions. #[error("match-term bytes must be little-endian u16 positions, got an odd length of {0}")] - OddMatchTermLength(usize), + OddMatchTermsLength(usize), /// A decoded position lies outside the Bloom filter the term's /// [`MatchConfig`] fixes. Genuine positions are always masked into /// `0..m`, so an out-of-range one means the bytes were not written by @@ -416,7 +416,7 @@ impl MatchOptions { } } -/// Type-level match configuration: the [`MatchOptions`] a [`MatchTerm`] +/// Type-level match configuration: the [`MatchOptions`] a [`MatchTerms`] /// is generated with. Putting the configuration on the *type* means a record /// field and the query probing it agree on tokenizer and filter parameters by /// construction. Define your own by implementing this on a marker type. @@ -434,15 +434,24 @@ impl MatchConfig for DefaultMatch { } } -/// A match (full-text) index term: the set bit positions of a Bloom filter over -/// the PRF outputs of the value's tokens, generated under the [`MatchConfig`] -/// `O`. Positions are sorted and de-duplicated. -pub struct MatchTerm { +/// The match (full-text) terms of a value: the set bit positions of a Bloom +/// filter over the PRF outputs of the value's tokens, generated under the +/// [`MatchConfig`] `O`. Positions are sorted and de-duplicated. +/// +/// The name is plural because the value is a *set*: every token contributes +/// `k` positions, and a query matches when its positions are a subset of the +/// stored ones ([`contains`](Self::contains)). An equality or ORE term is one +/// comparand; this is many. (It was named `MatchTerm` in stack-encrypt 0.2.) +pub struct MatchTerms { positions: Vec, _config: PhantomData O>, } -impl MatchTerm { +/// Renamed to [`MatchTerms`]: the value is a set of terms, not one. +#[deprecated(since = "0.3.0", note = "renamed to `MatchTerms`")] +pub type MatchTerm = MatchTerms; + +impl MatchTerms { /// Wrap positions that are already known to be in range — sorting and /// de-duplicating them into the canonical order. Private because nothing /// outside can know the range holds: the generator's positions are masked @@ -491,7 +500,7 @@ impl MatchTerm { /// query. Term generation already refuses to build such a term /// ([`TermError::EmptyTermText`]); this guards any other /// (e.g. deserialized) source of an empty term. - pub fn contains(&self, query: &MatchTerm) -> bool { + pub fn contains(&self, query: &MatchTerms) -> bool { !query.positions.is_empty() && query .positions @@ -503,7 +512,7 @@ impl MatchTerm { /// Rebuilding a term needs the [`MatchConfig`]: it fixes the filter size `m` /// every genuine position is below, and a position outside it is a decoding /// bug rather than a term. -impl MatchTerm { +impl MatchTerms { /// Rebuild a term from stored positions — the inverse of /// [`positions`](Self::positions) / /// [`into_positions`](Self::into_positions), for terms persisted @@ -534,7 +543,7 @@ impl MatchTerm { /// never matches. Rejects an odd-length buffer. pub fn from_bytes(bytes: &[u8]) -> Result { if !bytes.len().is_multiple_of(2) { - return Err(TermBytesError::OddMatchTermLength(bytes.len())); + return Err(TermBytesError::OddMatchTermsLength(bytes.len())); } Self::from_positions( bytes @@ -545,8 +554,8 @@ impl MatchTerm { } } -/// [`MatchTerm::from_bytes`] as a std conversion — the same decoder. -impl TryFrom<&[u8]> for MatchTerm { +/// [`MatchTerms::from_bytes`] as a std conversion — the same decoder. +impl TryFrom<&[u8]> for MatchTerms { type Error = TermBytesError; fn try_from(bytes: &[u8]) -> Result { @@ -554,27 +563,27 @@ impl TryFrom<&[u8]> for MatchTerm { } } -impl fmt::Debug for MatchTerm { +impl fmt::Debug for MatchTerms { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("MatchTerm") + f.debug_struct("MatchTerms") .field("positions", &self.positions) .finish() } } -impl Clone for MatchTerm { +impl Clone for MatchTerms { fn clone(&self) -> Self { Self::normalised(self.positions.clone()) } } -impl PartialEq for MatchTerm { +impl PartialEq for MatchTerms { fn eq(&self, other: &Self) -> bool { self.positions == other.positions } } -impl Eq for MatchTerm {} +impl Eq for MatchTerms {} /// A [`PrfVisitor`] that folds a sequence of per-token PRF blocks into /// Bloom-filter bit positions: `k` little-endian 2-byte slices of each block, @@ -604,7 +613,7 @@ impl PrfVisitor<[u8; 32], P> for BloomVisitor { // `k <= 16` (checked in `MatchOptions::validate`), so the `k` // 2-byte slices are disjoint; masking to `m - 1` maps each u16 into // the filter's `m` positions. The same token in a query text hits - // the same `k` positions, which is what `MatchTerm::contains` tests. + // the same `k` positions, which is what `MatchTerms::contains` tests. for i in 0..self.k { let chunk = [block[2 * i], block[2 * i + 1]]; positions.push(u16::from_le_bytes(chunk) & self.mask); @@ -630,7 +639,7 @@ fn match_term( text: &str, context: Context<'_>, options: MatchOptions, -) -> Result, TermError> { +) -> Result, TermError> { let mask = options.validate()?; let tokens = tokenize::tokenize(text, options.tokenizer, options.downcase); if tokens.is_empty() { @@ -644,14 +653,14 @@ fn match_term( .map_err(TermError::from_prf)?; // Every position came out of the visitor masked to `m - 1`, so the range // check `from_positions` applies is already satisfied by construction. - Ok(MatchTerm::normalised(positions)) + Ok(MatchTerms::normalised(positions)) } /// A match term of any text source, generated under `O`'s options. Under /// the local HMAC backend, derived during the synchronous build — the /// returned [`Pending`] carries no requests (tokenize makes the one /// necessary copy of the text). -impl<'c, S, K, O, T> Term> for MatchTerm +impl<'c, S, K, O, T> Term> for MatchTerms where S: AsRef, O: MatchConfig, @@ -708,7 +717,7 @@ macro_rules! index_term { } index_term!(EqualityTerm); -index_term!(MatchTerm, O: MatchConfig); +index_term!(MatchTerms, O: MatchConfig); index_term!(OreTerm, T: CllwOreEncrypt); index_term!(OpeTerm, T: CllwOpeEncrypt); @@ -1060,7 +1069,7 @@ impl KeysetCipher<'_, K> { /// define a marker type implementing [`MatchConfig`]. /// /// The same call serves both write time (index the stored text) and query - /// time (index the probe text, then test [`MatchTerm::contains`] + /// time (index the probe text, then test [`MatchTerms::contains`] /// server-side). /// /// Fails with [`TermError::EmptyTermText`] — as @@ -1072,7 +1081,7 @@ impl KeysetCipher<'_, K> { &self, text: &str, descriptor: NonEmpty>, - ) -> Pending<'_, MatchTerm, K> + ) -> Pending<'_, MatchTerms, K> where O: MatchConfig + MaybeSend, { diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index fc9bca95c..bd171cbc9 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -422,7 +422,7 @@ term_operation!( term_operation!( /// The match term of any text `S` under the context the tree hands it, /// tokenised and hashed as `O` declares. - matching, crate::sem::MatchTerm, view, [O: crate::sem::MatchConfig + 'static], [S: AsRef] + matching, crate::sem::MatchTerms, view, [O: crate::sem::MatchConfig + 'static], [S: AsRef] ); term_operation!( /// The order-revealing term of `S` under the context the tree hands it. @@ -735,7 +735,7 @@ impl EncryptFrom for crate::sem::EqualityT } } impl, O: crate::sem::MatchConfig + 'static> EncryptFrom - for crate::sem::MatchTerm + for crate::sem::MatchTerms { type Context = CallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> diff --git a/packages/stack-encrypt/src/target/transcode.rs b/packages/stack-encrypt/src/target/transcode.rs index 53949a1f2..c99071d09 100644 --- a/packages/stack-encrypt/src/target/transcode.rs +++ b/packages/stack-encrypt/src/target/transcode.rs @@ -9,7 +9,7 @@ //! What a reader hands over is what the cipher produced, no more: a sealed //! leaf or marker is authenticated ciphertext, but passthrough metadata and //! terms are not, and a visitor must not present them as such. -use crate::sem::{EqualityTerm, MatchConfig, MatchTerm, OpeTerm, OreTerm}; +use crate::sem::{EqualityTerm, MatchConfig, MatchTerms, OpeTerm, OreTerm}; use crate::{BoxedPassthrough, CipherText, Error, SealedValue, StackCipherText}; /// An encrypted output that can drive a destination visitor. @@ -107,7 +107,7 @@ pub trait Visitor: Sized { /// # Errors /// /// Refuses with [`Error::UnsupportedShape`] unless overridden. - fn matching(self, _: MatchTerm) -> Result { + fn matching(self, _: MatchTerms) -> Result { Err(Error::UnsupportedShape) } /// An order-revealing term. One-way, and not authenticated. @@ -195,7 +195,7 @@ impl Reader for EqualityTerm { visitor.equality(self) } } -impl Reader for MatchTerm { +impl Reader for MatchTerms { fn read(self, visitor: V) -> Result { visitor.matching(self) } diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 375190fa4..9f7ab97a1 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -10,7 +10,7 @@ use std::sync::atomic::Ordering as AtomicOrdering; use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, stack_cipher}; -use stack_encrypt::sem::{EqualityTerm, MatchTerm, OreTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms, OreTerm}; use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto}; use stack_encrypt::{ nonempty, ContextPiece, DecryptField, DecryptInto, Decryptable, EncryptFrom, Error, @@ -63,13 +63,13 @@ async fn a_derived_record_is_the_hand_written_one() { } /// No `plaintext`: one impl generic over it, accepting whatever every leaf -/// accepts — here any text type, since `MatchTerm` wants `AsRef` — and +/// accepts — here any text type, since `MatchTerms` wants `AsRef` — and /// decrypting to whatever the ciphertext field opens to. #[derive(EncryptFrom, DecryptInto)] struct SearchableText { c: StackCipherText, hm: EqualityTerm, - m: MatchTerm, + m: MatchTerms, } /// Tuple structs assign by index. @@ -127,7 +127,7 @@ async fn a_generic_plaintext_record_accepts_what_its_leaves_accept() { .encrypt_into_with_context(&generator, nonempty!("users/name")) .await .unwrap(); - let m: MatchTerm = "alice" + let m: MatchTerms = "alice" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/name")) .await diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index b5833b27b..f930043a0 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -22,7 +22,9 @@ use std::borrow::Cow; use stack_encrypt::nonempty; -use stack_encrypt::sem::{DefaultMatch, EqualityTerm, MatchTerm, OpeTerm, OreTerm, TermBytesError}; +use stack_encrypt::sem::{ + DefaultMatch, EqualityTerm, MatchTerms, OpeTerm, OreTerm, TermBytesError, +}; use stack_encrypt::target::EncryptInto; use stack_encrypt::{CipherText, Error, LeafBytesError, SealedValue, StackCipher}; use stack_kms::{ @@ -379,12 +381,12 @@ async fn match_term_bytes_are_pinned() { "040005000a000d000e001e002700350038003d0055005e005f0064006f007d007f009c00ad00bc00bd00ca00d000e000e500ef00" ); assert_eq!( - MatchTerm::::from_bytes(&bytes).expect("decode match term"), + MatchTerms::::from_bytes(&bytes).expect("decode match term"), term ); // The std conversion is the same decoder. assert_eq!( - MatchTerm::::try_from(bytes.as_slice()).expect("TryFrom decode"), + MatchTerms::::try_from(bytes.as_slice()).expect("TryFrom decode"), term ); } @@ -393,10 +395,10 @@ async fn match_term_bytes_are_pinned() { /// else. #[test] fn match_term_debug_is_its_positions() { - let term = MatchTerm::::from_positions(vec![17, 3]).expect("in range"); + let term = MatchTerms::::from_positions(vec![17, 3]).expect("in range"); assert_eq!( format!("{term:?}"), - "MatchTerm { positions: [3, 17] }", + "MatchTerms { positions: [3, 17] }", "Debug should show only the sorted positions" ); } @@ -404,8 +406,8 @@ fn match_term_debug_is_its_positions() { #[test] fn match_term_from_bytes_rejects_odd_length() { assert_eq!( - MatchTerm::::from_bytes(&[0x21]), - Err(TermBytesError::OddMatchTermLength(1)) + MatchTerms::::from_bytes(&[0x21]), + Err(TermBytesError::OddMatchTermsLength(1)) ); } @@ -416,14 +418,14 @@ fn match_term_from_bytes_rejects_positions_outside_the_filter() { // and the 0xffff a wrong-endian decoder produces, are both rejected — // they would otherwise decode cleanly and then silently never match. assert_eq!( - MatchTerm::::from_bytes(&[0x00, 0x01]), + MatchTerms::::from_bytes(&[0x00, 0x01]), Err(TermBytesError::MatchPositionOutOfRange { position: 256, filter_size: 256, }) ); assert_eq!( - MatchTerm::::from_bytes(&[0xff, 0xff]), + MatchTerms::::from_bytes(&[0xff, 0xff]), Err(TermBytesError::MatchPositionOutOfRange { position: 0xffff, filter_size: 256, @@ -432,20 +434,20 @@ fn match_term_from_bytes_rejects_positions_outside_the_filter() { // Byte-swapping a genuine term is exactly that failure: position 0x21 // becomes 0x2100. assert!(matches!( - MatchTerm::::from_bytes(&[0x00, 0x21]), + MatchTerms::::from_bytes(&[0x00, 0x21]), Err(TermBytesError::MatchPositionOutOfRange { .. }) )); // In-range positions round-trip, through both constructors. let positions = vec![0u16, 1, 255]; - let term = MatchTerm::::from_positions(positions.clone()).expect("in range"); + let term = MatchTerms::::from_positions(positions.clone()).expect("in range"); assert_eq!(term.positions(), positions.as_slice()); assert_eq!( - MatchTerm::::from_bytes(&term.to_bytes()).expect("decode"), + MatchTerms::::from_bytes(&term.to_bytes()).expect("decode"), term ); assert_eq!( - MatchTerm::::from_positions(vec![256]), + MatchTerms::::from_positions(vec![256]), Err(TermBytesError::MatchPositionOutOfRange { position: 256, filter_size: 256, diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index aebd1ad2f..1ce6b799f 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -4,7 +4,7 @@ use std::cmp::Ordering; use stack_encrypt::nonempty; -use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, MatchTerm, Tokenizer}; +use stack_encrypt::sem::{DefaultMatch, MatchConfig, MatchOptions, MatchTerms, Tokenizer}; use stack_encrypt::{Error, StackCipher}; use stack_kms::{FakeDataKeySource, IdentifiedBy}; use uuid::Uuid; @@ -152,7 +152,7 @@ async fn match_query_terms_are_contained_in_stored_terms() { #[test] fn match_containment_needs_every_query_position() { let term = |positions: &[u16]| { - MatchTerm::::from_positions(positions.to_vec()) + MatchTerms::::from_positions(positions.to_vec()) .expect("positions inside the default filter") }; let stored = term(&[3, 17, 200]); diff --git a/packages/stack-encrypt/tests/source_mode.rs b/packages/stack-encrypt/tests/source_mode.rs index 1b2d0f80f..0489248b0 100644 --- a/packages/stack-encrypt/tests/source_mode.rs +++ b/packages/stack-encrypt/tests/source_mode.rs @@ -17,7 +17,7 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; -use stack_encrypt::sem::{EqualityTerm, MatchTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchTerms}; use stack_encrypt::target::{ ciphertext, equality, matching, AeadContext, Borrowed, CallerContext, Encryption, Owned, }; @@ -169,12 +169,12 @@ async fn a_plaintext_that_is_not_clone_derives_a_match_term_alone() { let cipher = stack_cipher().await; let keyset = cipher.default_keyset(); - let term: MatchTerm = keyset + let term: MatchTerms = keyset .run(matching::<_, _, Owned, _>(), Secret::new(NUMBER), caller()) .await .unwrap(); - let expected: MatchTerm = keyset.match_terms(NUMBER, context()).await.unwrap(); + let expected: MatchTerms = keyset.match_terms(NUMBER, context()).await.unwrap(); assert_eq!( term, expected, "an owned match term should be byte-identical to the same text's term" @@ -310,11 +310,11 @@ async fn a_match_term_never_copies_its_text() { let keyset = cipher.default_keyset(); let (value, clones) = Counted::new(NUMBER); - let _: MatchTerm = keyset + let _: MatchTerms = keyset .run(matching::<_, _, Borrowed, _>(), &value, caller()) .await .unwrap(); - let _: MatchTerm = keyset + let _: MatchTerms = keyset .run(matching::<_, _, Owned, _>(), value, caller()) .await .unwrap(); @@ -430,12 +430,12 @@ async fn an_owned_plaintext_is_dropped_before_its_key_request_is_sent() { 1, "matching: dropped before run returns" ); - let term: MatchTerm = pending.await.unwrap(); + let term: MatchTerms = pending.await.unwrap(); assert_eq!( drops.load(Ordering::SeqCst), 1, "matching: dropped exactly once" ); - let expected: MatchTerm = keyset.match_terms(NUMBER, context()).await.unwrap(); + let expected: MatchTerms = keyset.match_terms(NUMBER, context()).await.unwrap(); assert_eq!(term, expected, "the term is the text's term"); } diff --git a/packages/stack-encrypt/tests/target.rs b/packages/stack-encrypt/tests/target.rs index 419fce494..dba4be098 100644 --- a/packages/stack-encrypt/tests/target.rs +++ b/packages/stack-encrypt/tests/target.rs @@ -8,7 +8,7 @@ use std::cmp::Ordering; use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering}; use std::sync::Arc; -use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerm, OreTerm}; +use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OreTerm}; use stack_encrypt::target::{ ciphertext, equality, CallerContext, DeclaredContext, DecryptFrom, DecryptInto, Decryption, EncryptFrom, EncryptInto, Encryption, Pending, Request, @@ -164,12 +164,12 @@ async fn match_leaf_supports_containment_queries() { let generator = generator().await; let generator = generator.default_keyset(); - let stored: MatchTerm = "alice wonderland" + let stored: MatchTerms = "alice wonderland" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); - let query: MatchTerm = "wonder" + let query: MatchTerms = "wonder" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -195,7 +195,7 @@ async fn match_leaf_config_is_type_level() { let generator = generator().await; let generator = generator.default_keyset(); - let term: MatchTerm = "a longer piece of text" + let term: MatchTerms = "a longer piece of text" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -204,7 +204,7 @@ async fn match_leaf_config_is_type_level() { // The same text under the default config is a different (larger-filter) // term — and a different type, so the two cannot be compared by mistake. - let default_term: MatchTerm = "a longer piece of text" + let default_term: MatchTerms = "a longer piece of text" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -712,7 +712,7 @@ async fn a_failed_field_fails_the_record_before_any_kms_call() { let cipher = cipher.default_keyset(); let b = "b".to_string(); - let zipped = MatchTerm::::encrypt_from(&"", &cipher, nonempty!("users/x")) + let zipped = MatchTerms::::encrypt_from(&"", &cipher, nonempty!("users/x")) .zip(StackCipherText::encrypt_from( &b, &cipher, @@ -862,12 +862,12 @@ async fn terms_rehydrate_from_persisted_parts() { let rehydrated = EqualityTerm::from_bytes(eq.clone().into_bytes()); assert_eq!(eq, rehydrated); - let stored: MatchTerm = "alice wonderland" + let stored: MatchTerms = "alice wonderland" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await .unwrap(); - let query: MatchTerm = "wonder" + let query: MatchTerms = "wonder" .to_string() .encrypt_into_with_context(&generator, nonempty!("users/bio")) .await @@ -876,7 +876,7 @@ async fn terms_rehydrate_from_persisted_parts() { // range-checks against the config's filter size). let mut positions = stored.clone().into_positions(); positions.reverse(); - let rehydrated: MatchTerm = MatchTerm::from_positions(positions).unwrap(); + let rehydrated: MatchTerms = MatchTerms::from_positions(positions).unwrap(); assert_eq!(stored, rehydrated); assert!(rehydrated.contains(&query)); } diff --git a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr index e2e9fda35..3f56416c7 100644 --- a/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr +++ b/packages/stack-encrypt/tests/ui/decrypt_field_not_decryptable.stderr @@ -12,7 +12,7 @@ help: the trait `DecryptField` is not implemented for `Opaqu = help: the following other types implement trait `DecryptField`: `CipherText>` implements `DecryptField` `EqualityTerm` implements `DecryptField` - `MatchTerm` implements `DecryptField` + `MatchTerms` implements `DecryptField` `OpeTerm` implements `DecryptField` `Option` implements `DecryptField, Ctx>` `OreTerm` implements `DecryptField` @@ -35,7 +35,7 @@ help: the trait `Decryptable` is not implemented for `Opaque` = help: the following other types implement trait `Decryptable`: CipherText> EqualityTerm - MatchTerm + MatchTerms OpeTerm Option OreTerm diff --git a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs index ea6331265..4ae01d79f 100644 --- a/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs +++ b/packages/stack-encrypt/tests/ui/pass/owned_without_clone.rs @@ -1,7 +1,7 @@ //! A single operation in owned mode asks nothing of the plaintext beyond the //! operation's own capability: no `Clone`, so a value that is moved and //! wiped can be sealed or indexed. -use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, EqualityTerm, MatchTerm, OpeTerm, OreTerm}; +use stack_encrypt::sem::{CllwOpeEncrypt, CllwOreEncrypt, EqualityTerm, MatchTerms, OpeTerm, OreTerm}; use stack_encrypt::target::{ ciphertext, equality, matching, ope, ore, CallerContext, Owned, Pending, }; @@ -23,7 +23,7 @@ fn index<'a, S: vitaminc_prf::PrfValue>( fn search<'a, S: AsRef>( keyset: &'a KeysetCipher<'_, FakeDataKeySource>, value: S, -) -> Pending<'a, MatchTerm, FakeDataKeySource> { +) -> Pending<'a, MatchTerms, FakeDataKeySource> { keyset.run(matching::<_, _, Owned, _>(), value, CallerContext::from(nonempty!("column"))) } // Bounded only by the scheme's own trait: `CllwOreEncrypt` and From f44cc3274e062ea0b792ff7b9b905679e14e04e7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 4 Oct 2026 16:59:24 -0700 Subject: [PATCH 2/7] feat(stack-encrypt): indexes as types, passthrough and run_decryption The plan builder is meant to be a thin lowering onto the combinators, and three things it needs were missing. Indexes. Every caller (the derive, dynamic::record) spelled out ciphertext().accepting().zip(equality()).zip(..).map(..) itself, with the nested-pair bookkeeping. Index makes one index a type, implemented only for the plaintexts its scheme is defined over, so a match index on an integer does not compile. Indexes is one index or a tuple of two to four, and deliberately not (), so an indexed field with no index is a compile error rather than a quiet ciphertext-only field. indexed(idx) does the composition once and returns Encrypted: the ciphertext and the terms as a tuple, read by destructuring. spec() lowers an index to data (IndexSpec) for the FFI and saved plans, with a match index's options; Indexes::select picks one index for the query side. The bytes are the hand composition's and the derive's, which the tests pin. passthrough(). A plan can carry a field unsealed. The engine had no operation that just returns the source; this is it, moving the value in owned mode and cloning it in borrowed mode, ignoring the context. Its rustdoc says it is not authenticated and what to use instead. run_decryption(). KeysetCipher::run already executes an Encryption held in a variable; this is the Decryption counterpart, on the keyset (which refuses a foreign leaf) and on the client. --- packages/stack-encrypt/src/lib.rs | 3 +- packages/stack-encrypt/src/target/index.rs | 460 ++++++++++++++++ packages/stack-encrypt/src/target/mod.rs | 8 +- .../stack-encrypt/src/target/operations.rs | 69 +++ packages/stack-encrypt/tests/index.rs | 519 ++++++++++++++++++ .../tests/ui/indexes_empty_set.rs | 8 + .../tests/ui/indexes_empty_set.stderr | 59 ++ .../tests/ui/match_in_a_tuple_on_integer.rs | 8 + .../ui/match_in_a_tuple_on_integer.stderr | 12 + .../tests/ui/match_on_integer.rs | 8 + .../tests/ui/match_on_integer.stderr | 12 + .../stack-encrypt/tests/ui/pass/indexes.rs | 26 + 12 files changed, 1189 insertions(+), 3 deletions(-) create mode 100644 packages/stack-encrypt/src/target/index.rs create mode 100644 packages/stack-encrypt/tests/index.rs create mode 100644 packages/stack-encrypt/tests/ui/indexes_empty_set.rs create mode 100644 packages/stack-encrypt/tests/ui/indexes_empty_set.stderr create mode 100644 packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs create mode 100644 packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr create mode 100644 packages/stack-encrypt/tests/ui/match_on_integer.rs create mode 100644 packages/stack-encrypt/tests/ui/match_on_integer.stderr create mode 100644 packages/stack-encrypt/tests/ui/pass/indexes.rs diff --git a/packages/stack-encrypt/src/lib.rs b/packages/stack-encrypt/src/lib.rs index 10c4d17e7..7485d8176 100644 --- a/packages/stack-encrypt/src/lib.rs +++ b/packages/stack-encrypt/src/lib.rs @@ -292,7 +292,8 @@ pub use keyset::KeysetCipher; pub use stack_kms as kms; pub use target::{ CallerContext, CipherScope, DecryptField, DecryptFrom, DecryptInto, Decryptable, Decryption, - EncryptFrom, EncryptInto, Encryption, Pending, PendingFuture, Request, Responses, + EncryptFrom, EncryptInto, Encrypted, Encryption, Equality, Index, IndexSpec, Indexes, Match, + Ope, Ore, Pending, PendingFuture, Request, Responses, }; // Re-export the vitaminc AEAD surface callers need to drive the cipher, so they diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs new file mode 100644 index 000000000..d8b680acc --- /dev/null +++ b/packages/stack-encrypt/src/target/index.rs @@ -0,0 +1,460 @@ +//! Indexes as types: what a field is searchable by, composed once in the +//! engine instead of once per caller. +//! +//! An *index* is an operation that derives a search term beside a +//! ciphertext: [`Equality`], [`Match`], [`Ore`] or [`Ope`]. Each is a type +//! implementing [`Index`] for exactly the plaintexts its scheme is defined +//! over, so an index that does not apply does not compile: `Match` is text +//! only, so a match index on an integer is a type error, not a runtime one. +//! +//! A field's indexes are one value: a single index, or a tuple of them +//! ([`Indexes`]). `()` is deliberately not a set of indexes, so asking for +//! an indexed field with no index is a compile error rather than a quiet +//! ciphertext-only field; a field with no index is [`ciphertext`] alone. +//! +//! [`indexed`] does the composition every caller used to spell by hand — +//! `ciphertext().accepting().zip(equality()).zip(ore()).map(..)`, with its +//! nested-pair bookkeeping — and produces an [`Encrypted`]: the +//! ciphertext, and the terms as a tuple read by destructuring. +//! +//! ``` +//! # async fn example() -> Result<(), stack_encrypt::Error> { +//! use stack_encrypt::kms::FakeDataKeySource; +//! use stack_encrypt::sem::{EqualityTerm, OreTerm}; +//! use stack_encrypt::target::{indexed, Borrowed, CallerContext, Encrypted, Equality, Ore}; +//! use stack_encrypt::{nonempty, StackCipher}; +//! +//! let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; +//! let keyset = cipher.default_keyset(); +//! +//! let age = 34u32; +//! let out: Encrypted<(EqualityTerm, OreTerm)> = keyset +//! .run( +//! indexed::((Equality, Ore)), +//! &age, +//! CallerContext::from(nonempty!("users/age")), +//! ) +//! .await?; +//! let (eq, ore) = out.terms; +//! # let _ = (eq, ore, out.ciphertext); +//! # Ok(()) +//! # } +//! # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); +//! ``` +//! +//! On the query side a field's indexes answer one at a time, with no +//! ciphertext: [`Indexes::select`] picks the index by type and +//! [`Index::operation`] is its term alone. +//! +//! For the FFI and for saved plans an index lowers to data, +//! [`IndexSpec`], through [`Index::spec`]. Parameters live on the index +//! (a [`Match`]'s tokenizer and filter size, through its [`MatchConfig`]) +//! and lower with it. +use std::fmt; +use std::marker::PhantomData; + +use super::context::{AeadContext, CallerContext}; +use super::operations::{ciphertext, equality, matching, ope, open, ore}; +use super::source::{ConsumeSource, ShareSource}; +use super::{DecryptInto, Decryption, Encryption}; +use crate::sem::{ + DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, +}; +use crate::StackCipherText; + +/// An index lowered to data: what crosses the FFI boundary and what a saved +/// plan holds. The Rust side keeps the type ([`Index`]); a binding, which +/// has no type to name, speaks this. +/// +/// The [`key`](Self::key) strings are the output keys of the `dynamic` record +/// format (`"eq"`, `"match"`, `"ore"`, `"ope"`), so they are wire format; +/// that is why this enum is exhaustive, as `dynamic::TermKind` is: a new +/// index is something every binding has to be taught. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum IndexSpec { + /// Equality (exact match). + Equality, + /// Full-text match, with the options its terms are generated under. + Match(MatchOptions), + /// Order-revealing comparison. + Ore, + /// Order-preserving comparison. + Ope, +} + +impl IndexSpec { + /// The output key this index's term rides under in a record, and the + /// string a binding spells it as. + pub fn key(&self) -> &'static str { + match self { + IndexSpec::Equality => "eq", + IndexSpec::Match(_) => "match", + IndexSpec::Ore => "ore", + IndexSpec::Ope => "ope", + } + } +} + +impl fmt::Display for IndexSpec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.key()) + } +} + +/// An operation that derives one search term of `S`. +/// +/// Implemented for exactly the plaintexts the index's scheme is defined +/// over, which is what makes an inapplicable index a compile error: there is +/// no `Index` for [`Match`], because match is defined over text. +/// +/// An index does not seal anything. [`indexed`] puts a ciphertext beside the +/// indexes of a field; [`operation`](Self::operation) on its own is the term +/// alone, which is the query side. +/// +/// The trait is open: an index defined in another crate implements it by +/// composing this module's public operations, as the four here do. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not an index of `{S}`", + label = "this index is not defined over `{S}`", + note = "an index applies only to the plaintexts its scheme is defined over: `Match` takes text, and `Equality`, `Ore` and `Ope` take the types their schemes encode" +)] +pub trait Index { + /// The term this index derives. + type Term: 'static; + /// This index as data, with its parameters. + fn spec(&self) -> IndexSpec; + /// The description that derives this index's term of `S`, under the + /// [`CallerContext`] it is handed when it runs. + /// + /// A fresh description per call: a description is single-use, and an + /// index is not, so a saved plan asks again each time it runs. + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's; +} + +/// The equality (exact-match) index: one PRF block over the whole value. +/// Defined for every `S` with a PRF encoding. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Equality; + +/// The order-revealing index: a CLLW ORE ciphertext of the value, under a +/// key derived from the field's context. Defined for every `S` the scheme +/// can order (text and integers among them). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Ore; + +/// The order-preserving index; see [`Ore`]. Defined for the same `S`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub struct Ope; + +/// The full-text match index, over text only, with the tokenizer and filter +/// parameters its [`MatchConfig`] `O` fixes. +/// +/// `Match::default()` is the default configuration ([`DefaultMatch`]); +/// `Match::::new()` is another. The configuration is on the type so +/// that a stored field and the query probing it agree on it by construction. +pub struct Match(PhantomData O>); + +impl Match { + /// The match index under the configuration `O`. + pub const fn new() -> Self { + Self(PhantomData) + } +} +impl Default for Match { + fn default() -> Self { + Self::new() + } +} +// By hand, so `O` (a marker type) need not be `Clone`, `Debug` or `PartialEq`. +impl Clone for Match { + fn clone(&self) -> Self { + *self + } +} +impl Copy for Match {} +impl fmt::Debug for Match { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("Match") + } +} + +impl Index for Equality { + type Term = EqualityTerm; + fn spec(&self) -> IndexSpec { + IndexSpec::Equality + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + equality() + } +} +impl, O: MatchConfig + 'static> Index for Match { + type Term = MatchTerms; + fn spec(&self) -> IndexSpec { + IndexSpec::Match(O::options()) + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + matching() + } +} +impl Index for Ore +where + S: cllw_ore::CllwOreEncrypt + Send + 'static, + S::Output: Send + 'static, +{ + type Term = OreTerm; + fn spec(&self) -> IndexSpec { + IndexSpec::Ore + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + ore() + } +} +impl Index for Ope +where + S: cllw_ore::CllwOpeEncrypt + Send + 'static, + S::Output: Send + 'static, +{ + type Term = OpeTerm; + fn spec(&self) -> IndexSpec { + IndexSpec::Ope + } + fn operation<'s, K: 'static, M: ConsumeSource<'s, S>>( + &self, + ) -> Encryption<'s, S, Self::Term, K, CallerContext, M> + where + S: 's, + { + ope() + } +} + +/// A non-empty set of indexes over `S`: one [`Index`], or a tuple of two to +/// four of them. +/// +/// Not implemented for `()`. An indexed field with no index would be a +/// ciphertext-only field that says otherwise; that field is [`ciphertext`] +/// alone, and asking [`indexed`] for one does not compile. +/// +/// The terms come out in the order the indexes are named: one index's +/// [`Terms`](Self::Terms) is its term, a tuple's is the tuple of its terms. +#[diagnostic::on_unimplemented( + message = "`{Self}` is not a set of indexes over `{S}`", + label = "expected one index, or a tuple of two to four, each defined over `{S}`", + note = "`()` is not a set of indexes: a field with no index is `ciphertext()` alone" +)] +pub trait Indexes { + /// What the indexes derive: one term, or a tuple of terms in order. + type Terms: 'static; + /// The indexes as data, in order. + fn specs(&self) -> Vec; + /// The description that derives every term, under the one + /// [`CallerContext`] it is handed, settling them in one batch. + /// + /// Several indexes over one owned plaintext share it, so in + /// [`Owned`](super::Owned) mode `S` must be `Clone` (see [`ShareSource`]). + fn operations<'s, K: 'static, M>(&self) -> Encryption<'s, S, Self::Terms, K, CallerContext, M> + where + S: 's, + M: ConsumeSource<'s, S> + ShareSource<'s, S>; + /// The one index of type `I` in this set, for the query side, where a + /// query asks one index for one term and needs no ciphertext; its + /// [`operation`](Index::operation) is that term alone. + /// + /// `At` is where `I` sits and is inferred; leave it `_`. Asking for an + /// index the set does not hold does not compile, and neither does asking + /// for one the set holds twice, since its place is then ambiguous. + /// + /// In code generic over `X: Indexes`, as the plan builder is, this is + /// `indexes.select::()`. A concrete tuple is a set of indexes + /// over many plaintexts, so there the plaintext is named: + /// + /// ``` + /// use stack_encrypt::target::{Equality, Index, IndexSpec, Indexes, Ore}; + /// + /// let indexes = (Equality, Ore); + /// let ore = Indexes::::select::(&indexes); + /// assert_eq!(Index::::spec(ore), IndexSpec::Ore); + /// ``` + fn select, At>(&self) -> &I + where + Self: Select, + { + Select::get(self) + } +} + +/// Where an index sits in a set of indexes, for [`Indexes::select`]: the +/// set *is* the index ([`Whole`]), or holds it at position `N` ([`At`]). +/// Implemented for every index and every tuple position; the position is +/// inferred, so a caller never names it. +pub trait Select { + /// The index at that position. + fn get(&self) -> &I; +} + +/// [`Select`]'s position for a set that is one index. +#[derive(Debug)] +pub enum Whole {} + +/// [`Select`]'s position for the `N`th index of a tuple, counting from zero. +#[derive(Debug)] +pub struct At; + +impl Select for I { + fn get(&self) -> &I { + self + } +} + +/// One index is a set of one. Implemented per index rather than as a +/// blanket over every `Index`, which would overlap the tuple impls below. +macro_rules! single_index { + ($([$($generics:tt)*] $index:ty),+ $(,)?) => {$( + impl Indexes for $index + where + $index: Index, + { + type Terms = <$index as Index>::Term; + fn specs(&self) -> Vec { + vec![self.spec()] + } + fn operations<'s, K: 'static, M>( + &self, + ) -> Encryption<'s, S, Self::Terms, K, CallerContext, M> + where + S: 's, + M: ConsumeSource<'s, S> + ShareSource<'s, S>, + { + self.operation() + } + } + )+}; +} +single_index!([] Equality, [O] Match, [] Ore, [] Ope); + +/// A tuple of indexes: each index's operation, zipped left to right, and the +/// nested pairs `zip` builds flattened back into one tuple of terms. +macro_rules! tuple_of_indexes { + ($(($($name:ident $at:tt),+) => $nested:pat,)+) => {$( + impl),+> Indexes for ($($name,)+) { + type Terms = ($($name::Term,)+); + fn specs(&self) -> Vec { + vec![$(self.$at.spec()),+] + } + fn operations<'s, K: 'static, M>( + &self, + ) -> Encryption<'s, S, Self::Terms, K, CallerContext, M> + where + S: 's, + M: ConsumeSource<'s, S> + ShareSource<'s, S>, + { + tuple_of_indexes!(@zip self, $($at),+) + .map(|$nested| ($($name,)+)) + } + } + tuple_of_indexes!(@select [$($name),+] $($name $at),+); + )+}; + (@zip $self:ident, $first:tt $(, $rest:tt)*) => { + $self.$first.operation()$(.zip($self.$rest.operation()))* + }; + // One `Select` impl per position, each over the whole tuple. + (@select [$($all:ident),+] $name:ident $at:tt $(, $rest:ident $rest_at:tt)*) => { + impl<$($all),+> Select<$name, At<$at>> for ($($all,)+) { + fn get(&self) -> &$name { + &self.$at + } + } + tuple_of_indexes!(@select [$($all),+] $($rest $rest_at),*); + }; + (@select [$($all:ident),+]) => {}; +} +// The closure's pattern re-binds each term under its index's type name, so +// the body can list them in order. +#[allow(non_snake_case)] +mod tuples { + use super::*; + tuple_of_indexes! { + (A 0, B 1) => (A, B), + (A 0, B 1, C 2) => ((A, B), C), + (A 0, B 1, C 2, D 3) => (((A, B), C), D), + } +} + +/// One value, sealed, with the terms of its indexes beside it: what +/// [`indexed`] produces. +/// +/// The terms are a tuple in the order the indexes were named, and are read +/// by destructuring: +/// +/// ```text +/// let (eq, ore) = out.terms; +/// ``` +/// +/// There are deliberately no named accessors (`out.ore()`): they would need +/// a type-level search of the tuple to serve a case that barely exists. A +/// struct with named fields is what `#[derive(EncryptFrom)]` is for. +/// +/// Decrypting it opens the ciphertext: the terms are one-way. +#[derive(Debug)] +pub struct Encrypted { + /// The sealed value. + pub ciphertext: StackCipherText, + /// The terms, one per index, in order. + pub terms: Terms, +} + +impl + 'static, Terms> DecryptInto

for Encrypted { + type Context = AeadContext; + fn decryption(self, context: Self::Context) -> Decryption { + open(self.ciphertext, context) + } +} + +/// Seal `S` and derive the terms of `indexes` beside it, under the one +/// [`CallerContext`] the description is handed: the ciphertext takes its +/// AEAD half, every term the whole. +/// +/// This is `ciphertext().accepting().zip(..).map(..)` composed once, here, +/// so a field with indexes is one line and its output is an +/// [`Encrypted`]. The bytes are exactly the hand composition's. +/// +/// The ciphertext and every term share the plaintext, so in +/// [`Owned`](super::Owned) mode `S` must be `Clone`; a plaintext that must +/// never be copied can still be sealed alone ([`ciphertext`]) or indexed +/// alone ([`Index::operation`]). +/// +/// The type parameters are in the order [`ciphertext`]'s are, with the +/// indexes last: `indexed::((Equality, Ore))`. +pub fn indexed<'s, S, K, M, X>( + indexes: X, +) -> Encryption<'s, S, Encrypted, K, CallerContext, M> +where + S: crate::Encrypt + 's, + K: 'static, + M: ConsumeSource<'s, S> + ShareSource<'s, S>, + X: Indexes, +{ + ciphertext::() + .accepting::() + .zip(indexes.operations()) + .map(|(ciphertext, terms)| Encrypted { ciphertext, terms }) +} diff --git a/packages/stack-encrypt/src/target/mod.rs b/packages/stack-encrypt/src/target/mod.rs index 038b92c79..9dbffd417 100644 --- a/packages/stack-encrypt/src/target/mod.rs +++ b/packages/stack-encrypt/src/target/mod.rs @@ -87,6 +87,7 @@ //! `EncryptTarget` and `DecryptTarget` are no longer extension points. mod context; pub(crate) mod core; +mod index; mod operations; mod pending; mod request; @@ -95,9 +96,12 @@ pub mod transcode; pub(crate) use self::core::{decipher_pending, seal_pending}; pub use context::{AeadContext, CallerContext, DeclaredContext, ExpectedContext, Extends}; +pub use index::{ + indexed, At, Encrypted, Equality, Index, IndexSpec, Indexes, Match, Ope, Ore, Select, Whole, +}; pub use operations::{ - ciphertext, equality, matching, ope, open, ore, DecryptField, DecryptFrom, DecryptInto, - Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, + ciphertext, equality, matching, ope, open, ore, passthrough, DecryptField, DecryptFrom, + DecryptInto, Decryptable, Decryption, EncryptFrom, EncryptInto, Encryption, }; pub use pending::{CipherScope, Pending, PendingFuture}; pub use request::{Request, Responses}; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index bd171cbc9..32b386fac 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -367,6 +367,32 @@ pub fn ciphertext<'s, S: crate::Encrypt + 's, K: 'static, M: ConsumeSource<'s, S ), } } +/// Carry the plaintext through unchanged: the output is the source itself, +/// and the context the description is handed is ignored. +/// +/// This is a field that is present in a record's output so the record is +/// whole, and is **not encrypted and not authenticated**: nothing binds it +/// to the ciphertexts beside it, so whoever can write the stored record can +/// change it undetected. A field that must stay readable but be +/// tamper-evident is not a passthrough: seal it, with an equality index +/// beside it for lookup ([`indexed`](super::indexed) with +/// [`Equality`](super::Equality)). +/// +/// In [`Owned`](super::Owned) mode the value is moved through; in the +/// default [`Borrowed`] mode it is cloned once, which is what +/// `M: ConsumeSource<'s, S>` asks. `Ctx` is whatever the surrounding tree +/// hands its fields, so a passthrough zips beside any of them. +pub fn passthrough<'s, S, K, M, Ctx>() -> Encryption<'s, S, S, K, Ctx, M> +where + S: MaybeSend + 'static, + K: 'static, + M: ConsumeSource<'s, S>, + Ctx: 's, +{ + Encryption { + build: Box::new(move |source, cipher, _| Pending::ready(cipher, Ok(M::take(source)))), + } +} /// A term operation: `$function` produces `$output` from any `S` satisfying /// the bounds, under the [`CallerContext`] the tree hands it. /// @@ -585,6 +611,11 @@ impl KeysetCipher<'_, K> { /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(f) /// # } /// ``` + /// + /// The opening side is [`run_decryption`](Self::run_decryption): an + /// [`Encryption`] is run with its source and context, a [`Decryption`] + /// already holds both. Either is single-use; a saved plan builds a fresh + /// one per call. pub fn run<'a, 's, S: 's, T: 'static, Ctx, M: SourceMode<'s, S>>( &'a self, encryption: Encryption<'s, S, T, K, Ctx, M>, @@ -593,6 +624,35 @@ impl KeysetCipher<'_, K> { ) -> Pending<'a, T, K> { (encryption.build)(source, self, context) } + /// Run a [`Decryption`] held in a variable under this keyset: the + /// counterpart of [`run`](Self::run), and what + /// [`decrypt_as`](Self::decrypt_as) does with a type's declaration. A + /// leaf sealed under another keyset is refused + /// ([`Error::ForeignKeyset`]) before any key is retrieved. + /// + /// ``` + /// # async fn example() -> Result<(), stack_encrypt::Error> { + /// use stack_encrypt::kms::FakeDataKeySource; + /// use stack_encrypt::target::{self, AeadContext, Decryption}; + /// use stack_encrypt::{nonempty, StackCipher, StackCipherText}; + /// + /// let cipher = StackCipher::builder().kms(FakeDataKeySource::new()).init().await?; + /// let keyset = cipher.default_keyset(); + /// let context = || AeadContext::from(nonempty!("users/email")); + /// let sealed: StackCipherText = keyset.encrypt_as(&"bob@example.com".to_string(), context()).await?; + /// + /// let opening: Decryption = target::open(sealed, context()); + /// assert_eq!(keyset.run_decryption(opening).await?, "bob@example.com"); + /// # Ok(()) + /// # } + /// # tokio::runtime::Builder::new_current_thread().enable_all().build().unwrap().block_on(example()).unwrap(); + /// ``` + pub fn run_decryption<'a, P: 'static>( + &'a self, + decryption: Decryption, + ) -> Pending<'a, P, K> { + decryption.open_in(self) + } /// Recover `P` from `source`, as its declaration describes. A leaf sealed /// under another keyset is refused ([`Error::ForeignKeyset`]) before any /// key is retrieved. @@ -608,6 +668,15 @@ impl KeysetCipher<'_, K> { } } impl StackCipher { + /// Run a [`Decryption`] held in a variable through the client: leaves + /// from any of its keysets open here. See + /// [`KeysetCipher::run_decryption`], which refuses a foreign one. + pub fn run_decryption<'a, P: 'static>( + &'a self, + decryption: Decryption, + ) -> Pending<'a, P, K> { + decryption.open_in(self) + } /// Recover `P` from `source`, as its declaration describes. Leaves from /// any of the client's keysets open here. pub fn decrypt_as<'a, P: 'static, T>( diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs new file mode 100644 index 000000000..6ba6f04d8 --- /dev/null +++ b/packages/stack-encrypt/tests/index.rs @@ -0,0 +1,519 @@ +//! The engine pieces the plan builder lowers onto: indexes as types +//! (`Index`, `Indexes`, `indexed`, `Encrypted`), `passthrough`, and +//! running a description held in a variable (`run`, `run_decryption`). +//! +//! The load-bearing claim is byte identity: `indexed` is the hand +//! composition `ciphertext().accepting().zip(..)` and what the derive emits, +//! composed once — same terms, a ciphertext either opens — so a field +//! written one way is found and opened the other. + +mod common; + +use std::sync::atomic::Ordering; + +use common::{counting_cipher, stack_cipher}; +use stack_encrypt::sem::{ + DefaultMatch, EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OpeTerm, OreTerm, Tokenizer, +}; +use stack_encrypt::target::{ + ciphertext, equality, indexed, matching, ope, ore, passthrough, AeadContext, Borrowed, + CallerContext, DecryptFrom, Decryption, Encrypted, Encryption, Equality, Index, IndexSpec, + Indexes, Match, Ope, Ore, Owned, +}; +use stack_encrypt::{nonempty, DecryptInto, EncryptFrom, Error, StackCipherText}; +use stack_kms::{FakeDataKeySource, IdentifiedBy}; +use vitaminc_protected::{Controlled, Protected}; + +fn caller() -> CallerContext { + CallerContext::from(nonempty!("users/email")) +} +fn aead() -> AeadContext { + AeadContext::from(nonempty!("users/email")) +} + +/// What the derive emits today for a field with equality and ORE beside its +/// ciphertext: the struct `indexed::((Equality, Ore))` replaces. +#[derive(EncryptFrom, DecryptInto)] +#[stash(plaintext = String)] +struct DerivedEmail { + c: StackCipherText, + hm: EqualityTerm, + ob: OreTerm, +} + +mod given_equality_and_ore_over_a_string { + use super::*; + + #[tokio::test] + async fn indexed_derives_the_terms_the_hand_composition_and_the_derive_derive() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let email = "bob@example.com".to_string(); + + let out: Encrypted<(EqualityTerm, OreTerm)> = keyset + .run( + indexed::((Equality, Ore)), + &email, + caller(), + ) + .await + .expect("indexed"); + + let hand = ciphertext::() + .accepting::() + .zip(equality::()) + .zip(ore::()); + let ((hand_c, hand_eq), hand_ore) = keyset.run(hand, &email, caller()).await.expect("hand"); + + let derived: DerivedEmail = keyset.encrypt_as(&email, caller()).await.expect("derived"); + + let (eq, ore) = out.terms; + assert_eq!(eq, hand_eq, "equality term matches the hand composition"); + assert_eq!(ore, hand_ore, "ORE term matches the hand composition"); + assert_eq!(eq, derived.hm, "equality term matches the derive"); + assert_eq!(ore, derived.ob, "ORE term matches the derive"); + + // A ciphertext is sealed under a fresh data key, so two seals are + // never byte-equal; what must agree is that each opens under the + // same context to the same value, by the other path's reader. + let as_derived = DerivedEmail { + c: out.ciphertext, + hm: eq, + ob: ore, + }; + let opened: String = as_derived + .decrypt_into(&cipher, caller()) + .await + .expect("the derive's reader opens indexed's ciphertext"); + assert_eq!(opened, email); + let opened: String = Encrypted { + ciphertext: derived.c, + terms: (), + } + .decrypt_into(&cipher, aead()) + .await + .expect("Encrypted's reader opens the derive's ciphertext"); + assert_eq!(opened, email); + let opened: String = keyset + .decrypt_as(hand_c, aead()) + .await + .expect("the hand composition's ciphertext opens"); + assert_eq!(opened, email); + } + + #[tokio::test] + async fn owned_mode_derives_the_same_terms_and_seals_from_one_key_request() { + let (cipher, generates, _) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let email = "bob@example.com".to_string(); + + let owned = keyset + .run( + indexed::((Equality, Ore)), + email.clone(), + caller(), + ) + .await + .expect("owned"); + assert_eq!(generates.load(Ordering::SeqCst), 1, "one batched request"); + let borrowed = keyset + .run( + indexed::((Equality, Ore)), + &email, + caller(), + ) + .await + .expect("borrowed"); + + assert_eq!( + owned.terms, borrowed.terms, + "the mode does not change a term" + ); + let opened: String = owned.decrypt_into(&cipher, aead()).await.expect("open"); + assert_eq!(opened, email); + } +} + +mod given_a_set_of_indexes { + use super::*; + + /// A configuration other than the default, so a spec that dropped the + /// options, or a term derived under the default ones, would show. + struct Words; + impl MatchConfig for Words { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: Tokenizer::Standard, + downcase: false, + k: 4, + m: 512, + } + } + } + + #[test] + fn specs_list_each_index_in_order_with_its_parameters() { + assert_eq!( + >::specs(&Equality), + [IndexSpec::Equality] + ); + assert_eq!(>::specs(&Ore), [IndexSpec::Ore]); + assert_eq!(>::specs(&Ope), [IndexSpec::Ope]); + assert_eq!( + >::specs(&Match::default()), + [IndexSpec::Match(MatchOptions::default())] + ); + assert_eq!( + as Indexes>::specs(&Match::::new()), + [IndexSpec::Match(Words::options())], + "a match index lowers its own configuration" + ); + assert_eq!( + <(Ore, Equality) as Indexes>::specs(&(Ore, Equality)), + [IndexSpec::Ore, IndexSpec::Equality] + ); + assert_eq!( + <(Ope, Ore, Equality) as Indexes>::specs(&(Ope, Ore, Equality)), + [IndexSpec::Ope, IndexSpec::Ore, IndexSpec::Equality] + ); + assert_eq!( + <(Match, Ope, Ore, Equality) as Indexes>::specs(&( + Match::::new(), + Ope, + Ore, + Equality + )), + [ + IndexSpec::Match(Words::options()), + IndexSpec::Ope, + IndexSpec::Ore, + IndexSpec::Equality + ] + ); + } + + #[test] + fn a_match_index_debugs_by_name_whatever_its_configuration() { + assert_eq!(format!("{:?}", Match::default()), "Match"); + assert_eq!(format!("{:?}", Match::::new()), "Match"); + let copied = Match::::new(); + let again = copied; + assert_eq!( + as Index>::spec(&copied), + as Index>::spec(&again), + "a match index is a plain value: copying it keeps its configuration" + ); + } + + #[test] + fn a_spec_is_keyed_and_displayed_as_the_record_format_spells_it() { + for (spec, key) in [ + (IndexSpec::Equality, "eq"), + (IndexSpec::Match(MatchOptions::default()), "match"), + (IndexSpec::Ore, "ore"), + (IndexSpec::Ope, "ope"), + ] { + assert_eq!(spec.key(), key); + assert_eq!(spec.to_string(), key); + } + } + + /// Each index's term is the standalone operation's, in the order the + /// indexes were named, for every tuple size. + #[tokio::test] + async fn every_tuple_yields_the_standalone_terms_in_order() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let text = "Alice Smith".to_string(); + + let eq: EqualityTerm = keyset + .run(equality::(), &text, caller()) + .await + .unwrap(); + let ore_t: OreTerm = keyset + .run(ore::(), &text, caller()) + .await + .unwrap(); + let ope_t: OpeTerm = keyset + .run(ope::(), &text, caller()) + .await + .unwrap(); + let words: MatchTerms = keyset + .run(matching::(), &text, caller()) + .await + .unwrap(); + let default_match: MatchTerms = keyset + .run( + matching::(), + &text, + caller(), + ) + .await + .unwrap(); + assert_ne!( + words.positions(), + default_match.positions(), + "the two configurations derive different terms, so the test can tell them apart" + ); + + let one = keyset + .run(indexed::(Ope), &text, caller()) + .await + .unwrap(); + assert_eq!(one.terms, ope_t, "one index's terms are its term"); + + let one = keyset + .run( + indexed::(Match::::new()), + &text, + caller(), + ) + .await + .unwrap(); + assert_eq!( + one.terms, words, + "a match index derives under its own configuration" + ); + + let two = keyset + .run( + indexed::((Ore, Equality)), + &text, + caller(), + ) + .await + .unwrap(); + assert_eq!(two.terms, (ore_t.clone(), eq.clone())); + + let three = keyset + .run( + indexed::((Ope, Equality, Ore)), + &text, + caller(), + ) + .await + .unwrap(); + assert_eq!(three.terms, (ope_t.clone(), eq.clone(), ore_t.clone())); + + let four = keyset + .run( + indexed::((Match::::new(), Ore, Ope, Equality)), + &text, + caller(), + ) + .await + .unwrap(); + let (m, o, p, e) = four.terms; + assert_eq!((m, o, p, e), (words, ore_t, ope_t, eq)); + let opened: String = four.ciphertext.decrypt_into(&cipher, aead()).await.unwrap(); + assert_eq!(opened, text); + } + + /// The query side: one index, picked by type, its term alone. + #[tokio::test] + async fn select_picks_each_index_and_its_operation_is_the_term_alone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let age = 34u32; + let indexes = (Equality, Ore, Ope); + let stored = keyset + .run(indexed::(indexes), &age, caller()) + .await + .unwrap(); + let (eq, ore_t, ope_t) = stored.terms; + + let q: EqualityTerm = keyset + .run( + Index::::operation::<_, Borrowed>(Indexes::::select::( + &indexes, + )), + &age, + caller(), + ) + .await + .unwrap(); + assert_eq!(q, eq, "position 0"); + let q: OreTerm = keyset + .run( + Index::::operation::<_, Borrowed>(Indexes::::select::(&indexes)), + &age, + caller(), + ) + .await + .unwrap(); + assert_eq!(q, ore_t, "position 1"); + let q: OpeTerm = keyset + .run( + Index::::operation::<_, Borrowed>(Indexes::::select::(&indexes)), + &age, + caller(), + ) + .await + .unwrap(); + assert_eq!(q, ope_t, "position 2"); + + let four = (Equality, Ore, Ope, Match::default()); + assert_eq!( + Index::::spec(Indexes::::select::(&four)), + IndexSpec::Match(MatchOptions::default()), + "position 3" + ); + assert_eq!( + Index::::spec(>::select::(&Ore)), + IndexSpec::Ore, + "one index selects itself" + ); + assert_eq!( + Index::::spec(<(Ope, Ore) as Indexes>::select::(&(Ope, Ore))), + IndexSpec::Ope, + "a pair's first" + ); + assert_eq!( + Index::::spec(<(Ope, Ore) as Indexes>::select::(&(Ope, Ore))), + IndexSpec::Ore, + "a pair's second" + ); + } + + /// The query side needs no copy: a term over an owned plaintext that is + /// not `Clone` runs from the selected index alone. + #[tokio::test] + async fn a_selected_index_runs_over_an_owned_plaintext_without_clone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let q: EqualityTerm = keyset + .run( + Index::>::operation::<_, Owned>(&Equality), + Protected::new("bob".to_string()), + caller(), + ) + .await + .unwrap(); + let typed: EqualityTerm = keyset + .run( + equality::(), + &"bob".to_string(), + caller(), + ) + .await + .unwrap(); + assert_eq!(q, typed); + } +} + +mod given_a_passthrough { + use super::*; + + /// A value that is moved, never copied. + struct Unclonable(Protected); + + #[tokio::test] + async fn borrowed_hands_back_a_copy_with_no_key_request_whatever_the_context() { + let (cipher, generates, retrieves) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let id = "row-7".to_string(); + // `()` is a context no operation can run under, so a passthrough + // that consulted its context could not run here at all. + let out: String = keyset + .run(passthrough::(), &id, ()) + .await + .expect("passthrough"); + assert_eq!(out, id); + assert_eq!(generates.load(Ordering::SeqCst), 0, "nothing is sealed"); + assert_eq!(retrieves.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn owned_moves_the_value_through_without_clone() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let out = keyset + .run( + passthrough::(), + Unclonable(Protected::new("row-7".to_string())), + caller(), + ) + .await + .expect("passthrough"); + assert_eq!(out.0.risky_ref(), "row-7"); + } + + /// Beside a sealed field it rides in the same run, which seals only the + /// ciphertext: one key request, and the carried value is the source. + #[tokio::test] + async fn beside_a_sealed_field_it_is_carried_unsealed() { + let (cipher, generates, _) = counting_cipher().await; + let keyset = cipher.default_keyset(); + let value = "carried".to_string(); + let both: Encryption<'_, String, (StackCipherText, String), _, AeadContext> = + ciphertext::().zip(passthrough::()); + let (sealed, carried) = keyset.run(both, &value, aead()).await.expect("run"); + assert_eq!(carried, value); + assert_eq!( + generates.load(Ordering::SeqCst), + 1, + "one batch, for the ciphertext" + ); + let opened: String = keyset.decrypt_as(sealed, aead()).await.expect("open"); + assert_eq!(opened, value); + } +} + +mod given_a_decryption_held_in_a_variable { + use super::*; + + #[tokio::test] + async fn run_decryption_opens_it_through_a_keyset_or_the_client() { + let cipher = stack_cipher().await; + let keyset = cipher.default_keyset(); + let sealed: StackCipherText = keyset + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + let again: StackCipherText = keyset + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + + let opening: Decryption = + stack_encrypt::target::open(sealed, aead()); + assert_eq!(keyset.run_decryption(opening).await.unwrap(), "secret"); + let opening: Decryption = + stack_encrypt::target::open(again, aead()); + assert_eq!(cipher.run_decryption(opening).await.unwrap(), "secret"); + } + + #[tokio::test] + async fn a_keyset_refuses_another_keysets_leaf_and_the_client_opens_it() { + let cipher = stack_cipher().await; + let acme = cipher + .keyset(IdentifiedBy::Name("acme".to_string().into())) + .await + .unwrap(); + let globex = cipher + .keyset(IdentifiedBy::Name("globex".to_string().into())) + .await + .unwrap(); + let sealed: StackCipherText = acme + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + let again: StackCipherText = acme + .encrypt_as(&"secret".to_string(), aead()) + .await + .unwrap(); + + let result = globex + .run_decryption(stack_encrypt::target::open::(sealed, aead())) + .await; + assert!( + matches!(result, Err(Error::ForeignKeyset { .. })), + "{result:?}" + ); + let opened = cipher + .run_decryption(stack_encrypt::target::open::(again, aead())) + .await + .unwrap(); + assert_eq!(opened, "secret"); + } +} diff --git a/packages/stack-encrypt/tests/ui/indexes_empty_set.rs b/packages/stack-encrypt/tests/ui/indexes_empty_set.rs new file mode 100644 index 000000000..f724dbc76 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/indexes_empty_set.rs @@ -0,0 +1,8 @@ +//! `()` is not a set of indexes: an indexed field with no index would be a +//! ciphertext-only field that says otherwise. That field is `ciphertext()`. +use stack_encrypt::target::{indexed, Borrowed}; +use stack_kms::FakeDataKeySource; + +fn main() { + let _ = indexed::(()); +} diff --git a/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr b/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr new file mode 100644 index 000000000..9cdc9ca92 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/indexes_empty_set.stderr @@ -0,0 +1,59 @@ +error[E0277]: `()` is not a set of indexes over `u32` + --> tests/ui/indexes_empty_set.rs:7:57 + | +7 | let _ = indexed::(()); + | ^ expected one index, or a tuple of two to four, each defined over `u32` + | + = help: the trait `Indexes` is not implemented for `()` + = note: `()` is not a set of indexes: a field with no index is `ciphertext()` alone +help: the following other types implement trait `Indexes` + --> src/target/index.rs + | + | impl),+> Indexes for ($($name,)+) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | | + | `(A, B)` + | `(A, B, C)` + | `(A, B, C, D)` +... + | / tuple_of_indexes! { + | | (A 0, B 1) => (A, B), + | | (A 0, B 1, C 2) => ((A, B), C), + | | (A 0, B 1, C 2, D 3) => (((A, B), C), D), + | | } + | |_____- in this macro invocation +note: required by a bound in `indexed` + --> src/target/index.rs + | + | pub fn indexed<'s, S, K, M, X>( + | ------- required by a bound in this function +... + | X: Indexes, + | ^^^^^^^^^^ required by this bound in `indexed` + = note: this error originates in the macro `tuple_of_indexes` (in Nightly builds, run with -Z macro-backtrace for more info) + +error[E0277]: `()` is not a set of indexes over `u32` + --> tests/ui/indexes_empty_set.rs:7:13 + | +7 | let _ = indexed::(()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ expected one index, or a tuple of two to four, each defined over `u32` + | + = help: the trait `Indexes` is not implemented for `()` + = note: `()` is not a set of indexes: a field with no index is `ciphertext()` alone +help: the following other types implement trait `Indexes` + --> src/target/index.rs + | + | impl),+> Indexes for ($($name,)+) { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + | | + | `(A, B)` + | `(A, B, C)` + | `(A, B, C, D)` +... + | / tuple_of_indexes! { + | | (A 0, B 1) => (A, B), + | | (A 0, B 1, C 2) => ((A, B), C), + | | (A 0, B 1, C 2, D 3) => (((A, B), C), D), + | | } + | |_____- in this macro invocation + = note: this error originates in the macro `tuple_of_indexes` (in Nightly builds, run with -Z macro-backtrace for more info) diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs new file mode 100644 index 000000000..6b363cb74 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.rs @@ -0,0 +1,8 @@ +//! One inapplicable index makes the whole set inapplicable: equality is +//! defined over `u32`, match is not. +use stack_encrypt::target::{indexed, Borrowed, Equality, Match}; +use stack_kms::FakeDataKeySource; + +fn main() { + let _ = indexed::((Equality, Match::default())); +} diff --git a/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr new file mode 100644 index 000000000..cb38e177b --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_in_a_tuple_on_integer.stderr @@ -0,0 +1,12 @@ +error[E0277]: the trait bound `u32: AsRef` is not satisfied + --> tests/ui/match_in_a_tuple_on_integer.rs:7:13 + | +7 | let _ = indexed::((Equality, Match::default())); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` + | +help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` + --> src/target/index.rs + | + | impl, O: MatchConfig + 'static> Index for Match { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: required for `stack_encrypt::Match` to implement `stack_encrypt::Index` diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.rs b/packages/stack-encrypt/tests/ui/match_on_integer.rs new file mode 100644 index 000000000..37298c7ee --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_on_integer.rs @@ -0,0 +1,8 @@ +//! Match is defined over text, so a match index on an integer does not +//! compile. +use stack_encrypt::target::{indexed, Borrowed, Match}; +use stack_kms::FakeDataKeySource; + +fn main() { + let _ = indexed::(Match::default()); +} diff --git a/packages/stack-encrypt/tests/ui/match_on_integer.stderr b/packages/stack-encrypt/tests/ui/match_on_integer.stderr new file mode 100644 index 000000000..309411cc5 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/match_on_integer.stderr @@ -0,0 +1,12 @@ +error[E0277]: the trait bound `u32: AsRef` is not satisfied + --> tests/ui/match_on_integer.rs:7:13 + | +7 | let _ = indexed::(Match::default()); + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `AsRef` is not implemented for `u32` + | +help: the trait `stack_encrypt::Index` is implemented for `stack_encrypt::Match` + --> src/target/index.rs + | + | impl, O: MatchConfig + 'static> Index for Match { + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + = note: required for `stack_encrypt::Match` to implement `stack_encrypt::Index` diff --git a/packages/stack-encrypt/tests/ui/pass/indexes.rs b/packages/stack-encrypt/tests/ui/pass/indexes.rs new file mode 100644 index 000000000..1b0ca1cd7 --- /dev/null +++ b/packages/stack-encrypt/tests/ui/pass/indexes.rs @@ -0,0 +1,26 @@ +//! The index sets that must compile: one index, tuples of two to four, a +//! default and a configured match index, over the plaintexts each is +//! defined for, in either source mode. +use stack_encrypt::sem::{MatchConfig, MatchOptions}; +use stack_encrypt::target::{indexed, Borrowed, Equality, Match, Ope, Ore, Owned}; +use stack_kms::FakeDataKeySource; + +struct Words; +impl MatchConfig for Words { + fn options() -> MatchOptions { + MatchOptions::default() + } +} + +fn main() { + let _ = indexed::(Equality); + let _ = indexed::((Equality, Ore)); + let _ = indexed::((Equality, Ore, Ope)); + let _ = indexed::(Match::default()); + let _ = indexed::(( + Equality, + Match::::new(), + Ore, + Ope, + )); +} From 88da0014326e4bf2391ba570f6e6ad9c9e28a650 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 4 Oct 2026 16:59:42 -0700 Subject: [PATCH 3/7] feat(stack-encrypt): a declared type per field in the dynamic plan A typed host knows what 34 is; JavaScript, PHP and Ruby do not, and index semantics depend on it: ORE on the integer 34 and the float 34.0 differ, a JavaScript number is a float, and match is defined over text alone. So a plan field may now say what its values are, as `"type": ""`. The vocabulary is vitaminc's frozen leaf-tag table, one FieldType per scalar tag, plus the two composite kinds the transport frames (array, object); not new names. The engine uses it three times: - building a plan refuses an unknown type name, and an index the type is not defined for (match on an integer, equality on a float, any index on a composite); - encrypt refuses a value whose tag is not the declared type, before any key is minted, rather than trusting the binding's tagging, and decrypt refuses a field that opens to another type, so a typeless host can rely on the declaration for what it gets back; - FieldType::read reads a query value as the field's type, converting a number only when the conversion is exact (a JavaScript 34.0 is the u64 34 for a uint64 field). The key is optional. A field without it is dispatched on each value's own tag, as every field was before, so the plans existing bindings send (the Go guest's) stay valid with no change on their side. No error variant is added: the failures are Plan, Source and Record, which bindings already classify as caller input. TermKind also lowers to the typed side's IndexSpec, with the default match options a dynamic term derives under. --- .../stack-encrypt/src/dynamic/field_type.rs | 553 ++++++++++++++++++ packages/stack-encrypt/src/dynamic/mod.rs | 27 +- packages/stack-encrypt/src/dynamic/record.rs | 354 ++++++++++- packages/stack-encrypt/src/dynamic/term.rs | 37 +- 4 files changed, 947 insertions(+), 24 deletions(-) create mode 100644 packages/stack-encrypt/src/dynamic/field_type.rs diff --git a/packages/stack-encrypt/src/dynamic/field_type.rs b/packages/stack-encrypt/src/dynamic/field_type.rs new file mode 100644 index 000000000..3e4e9e192 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/field_type.rs @@ -0,0 +1,553 @@ +//! The type a plan declares for a field. +//! +//! A typed host knows what `34` is: a Rust `u32`, a Go `int64`. A dynamically +//! typed host (JavaScript, PHP, Ruby) does not, and index semantics depend on +//! it: an ORE term of the integer `34` and of the float `34.0` differ, a +//! JavaScript number is a float, and match is defined over text alone. So a +//! plan field may say what its values are, and the engine uses that three +//! times: +//! +//! - **encrypt** admits only the indexes the type is defined for (when the +//! plan is built), and refuses a value of any other type (when it runs), +//! so the term bytes are the declared type's and no binding is trusted to +//! have tagged the value right; +//! - **query** reads the query value *as* the field's type +//! ([`FieldType::read`]): `34` against a `uint64` field is the `u64` term, +//! whatever number type the host handed over; +//! - **decrypt** refuses an opened value of any other type, so a host with no +//! types of its own can rely on the declaration for what it gets back (an +//! integer, not a float; bytes, not a string). +//! +//! The vocabulary is not new: it is vitaminc's frozen leaf-tag table +//! (`vitaminc_aead_value::tags`), one type per scalar tag, plus the two +//! composite kinds the transport frames (`array`, `object`). `null` and +//! `undefined` are tags but not field types: a type with one value says +//! nothing a field can be declared as. +use std::fmt; + +use vitaminc_aead_value::{tags, FfiValue}; + +use super::{Error, TermKind}; + +/// The type of a plan field's values. +/// +/// The [`name`](Self::name) strings are wire format, spelled by a binding in +/// a plan's `"type"` key, so this enum is exhaustive for the reason +/// [`TermKind`] is (see the [module docs](super#stability)). +#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +pub enum FieldType { + /// `"bool"`: [`FfiValue::Bool`], tags `BOOL_FALSE` and `BOOL_TRUE`. + Bool, + /// `"int32"`: [`FfiValue::Int32`], tag `INT32`. + Int32, + /// `"int64"`: [`FfiValue::Int64`], tag `INT64`. + Int64, + /// `"uint32"`: [`FfiValue::UInt32`], tag `UINT32`. + UInt32, + /// `"uint64"`: [`FfiValue::UInt64`], tag `UINT64`. + UInt64, + /// `"float32"`: [`FfiValue::Float32`], tag `FLOAT32`. + Float32, + /// `"float64"`: [`FfiValue::Float64`], tag `FLOAT64`. + Float64, + /// `"string"`: [`FfiValue::String`], tag `STRING`. + String, + /// `"bytes"`: [`FfiValue::Bytes`], tag `BYTES`. + Bytes, + /// `"array"`: [`FfiValue::Array`], sealed in the cipher's sequence mode. + /// Its elements are not typed by the declaration. + Array, + /// `"object"`: [`FfiValue::Object`], sealed in the cipher's map mode. + /// Its entries are not typed by the declaration. + Object, +} + +/// Every field type, in declaration order. +const ALL: [FieldType; 11] = [ + FieldType::Bool, + FieldType::Int32, + FieldType::Int64, + FieldType::UInt32, + FieldType::UInt64, + FieldType::Float32, + FieldType::Float64, + FieldType::String, + FieldType::Bytes, + FieldType::Array, + FieldType::Object, +]; + +impl FieldType { + /// Every field type. + pub fn all() -> [FieldType; 11] { + ALL + } + + /// The type a plan's `"type"` names, or `None` for a name that is not + /// one. Names are matched exactly: `"uint64"`, not `"UInt64"` or `"u64"`. + pub fn parse(name: &str) -> Option { + ALL.into_iter().find(|ty| ty.name() == name) + } + + /// How a plan spells this type. + pub fn name(self) -> &'static str { + match self { + FieldType::Bool => "bool", + FieldType::Int32 => "int32", + FieldType::Int64 => "int64", + FieldType::UInt32 => "uint32", + FieldType::UInt64 => "uint64", + FieldType::Float32 => "float32", + FieldType::Float64 => "float64", + FieldType::String => "string", + FieldType::Bytes => "bytes", + FieldType::Array => "array", + FieldType::Object => "object", + } + } + + /// The vitaminc leaf tags a value of this type seals under: one per + /// scalar type (two for `bool`, whose value is its tag), none for a + /// composite, which seals as structure. + pub fn tags(self) -> &'static [u8] { + match self { + FieldType::Bool => &[tags::BOOL_FALSE, tags::BOOL_TRUE], + FieldType::Int32 => &[tags::INT32], + FieldType::Int64 => &[tags::INT64], + FieldType::UInt32 => &[tags::UINT32], + FieldType::UInt64 => &[tags::UINT64], + FieldType::Float32 => &[tags::FLOAT32], + FieldType::Float64 => &[tags::FLOAT64], + FieldType::String => &[tags::STRING], + FieldType::Bytes => &[tags::BYTES], + FieldType::Array | FieldType::Object => &[], + } + } + + /// The type of a value, or `None` for one no field can be declared as: + /// null, undefined or a passthrough. + pub fn of(value: &FfiValue) -> Option { + Some(match value { + FfiValue::Bool(_) => FieldType::Bool, + FfiValue::Int32(_) => FieldType::Int32, + FfiValue::Int64(_) => FieldType::Int64, + FfiValue::UInt32(_) => FieldType::UInt32, + FfiValue::UInt64(_) => FieldType::UInt64, + FfiValue::Float32(_) => FieldType::Float32, + FfiValue::Float64(_) => FieldType::Float64, + FfiValue::String(_) => FieldType::String, + FfiValue::Bytes(_) => FieldType::Bytes, + FfiValue::Array(_) => FieldType::Array, + FfiValue::Object(_) => FieldType::Object, + _ => return None, + }) + } + + /// Whether `value` is of this type: what the engine checks of every + /// value it seals into, and every value it opens from, a typed field. + pub fn holds(self, value: &FfiValue) -> bool { + Self::of(value) == Some(self) + } + + /// Whether the scheme defines a `kind` term for values of this type. + /// + /// The same table as [`TermKind::supports`], stated over types instead + /// of values, so a plan can be refused when it is built rather than when + /// its first value arrives: equality over every integer, text and bytes + /// (no floats, no booleans); match over text alone; ORE and OPE over + /// every scalar. A composite has no term. + pub fn admits(self, kind: TermKind) -> bool { + use FieldType::*; + match kind { + TermKind::Equality => matches!(self, Int32 | Int64 | UInt32 | UInt64 | String | Bytes), + TermKind::Match => self == String, + TermKind::Ore | TermKind::Ope => !matches!(self, Array | Object), + } + } + + /// Read a query value as this type. + /// + /// A value already of this type is returned as it is. A number of + /// another width or kind is converted when the conversion is exact: an + /// integer in range, or a float with no fractional part in range, for an + /// integer type (a JavaScript `34` arrives as a float); an integer or + /// float the target float type represents exactly, for a float type. + /// Nothing else converts: a string is never parsed as a number, and a + /// number never becomes a string. + /// + /// This is for a query: what a host hands over to search with. A value + /// being sealed is not converted; it must already be of the field's + /// type, and is refused otherwise. + /// + /// # Errors + /// + /// [`Error::Source`] if the value cannot be read as this type exactly. + pub fn read(self, value: FfiValue) -> Result { + if self.holds(&value) { + return Ok(value); + } + let number = Number::of(&value).ok_or(Error::Source)?; + let read = match self { + FieldType::Int32 => number + .integer() + .and_then(|i| i32::try_from(i).ok()) + .map(FfiValue::Int32), + FieldType::Int64 => number + .integer() + .and_then(|i| i64::try_from(i).ok()) + .map(FfiValue::Int64), + FieldType::UInt32 => number + .integer() + .and_then(|i| u32::try_from(i).ok()) + .map(FfiValue::UInt32), + FieldType::UInt64 => number + .integer() + .and_then(|i| u64::try_from(i).ok()) + .map(FfiValue::UInt64), + FieldType::Float64 => number.exact_f64().map(FfiValue::Float64), + FieldType::Float32 => number.exact_f32().map(FfiValue::Float32), + _ => None, + }; + read.ok_or(Error::Source) + } +} + +impl fmt::Display for FieldType { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.name()) + } +} + +/// A numeric leaf, widened without loss: every integer variant fits an +/// `i128`, and an `f32` widens to an `f64` exactly. +#[derive(Clone, Copy)] +enum Number { + Integer(i128), + Float(f64), +} + +impl Number { + fn of(value: &FfiValue) -> Option { + Some(match value { + FfiValue::Int32(v) => Number::Integer((*v).into()), + FfiValue::Int64(v) => Number::Integer((*v).into()), + FfiValue::UInt32(v) => Number::Integer((*v).into()), + FfiValue::UInt64(v) => Number::Integer((*v).into()), + FfiValue::Float32(v) => Number::Float((*v).into()), + FfiValue::Float64(v) => Number::Float(*v), + _ => return None, + }) + } + + /// The integer this number is exactly, if it is one. A float outside + /// `i128` saturates, which no target integer type then accepts. + fn integer(self) -> Option { + match self { + Number::Integer(i) => Some(i), + Number::Float(f) if f.is_finite() && f.fract() == 0.0 => Some(f as i128), + Number::Float(_) => None, + } + } + + /// The `f64` this number is exactly, if it is one: an integer whose + /// conversion does not round. + fn exact_f64(self) -> Option { + match self { + Number::Float(f) => Some(f), + Number::Integer(i) => { + let f = i as f64; + (f as i128 == i).then_some(f) + } + } + } + + /// The `f32` this number is exactly, if it is one. A NaN is never + /// exact, because it does not equal itself. + fn exact_f32(self) -> Option { + let f = self.exact_f64()?; + let narrowed = f as f32; + (f64::from(narrowed) == f).then_some(narrowed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::Scalar; + use vitaminc_protected::Protected; + + /// One value of every field type. + fn sample(ty: FieldType) -> FfiValue { + match ty { + FieldType::Bool => FfiValue::Bool(true), + FieldType::Int32 => FfiValue::Int32(-3), + FieldType::Int64 => FfiValue::Int64(-4), + FieldType::UInt32 => FfiValue::UInt32(34), + FieldType::UInt64 => FfiValue::UInt64(35), + FieldType::Float32 => FfiValue::Float32(1.5), + FieldType::Float64 => FfiValue::Float64(2.5), + FieldType::String => FfiValue::String("alice".into()), + FieldType::Bytes => FfiValue::Bytes(Protected::new(b"ab".to_vec())), + FieldType::Array => FfiValue::Array(vec![FfiValue::UInt32(1)]), + FieldType::Object => FfiValue::Object(vec![("k".to_string(), FfiValue::UInt32(1))]), + } + } + + const KINDS: [TermKind; 4] = [ + TermKind::Equality, + TermKind::Match, + TermKind::Ore, + TermKind::Ope, + ]; + + #[test] + fn every_name_parses_back_to_its_type_and_nothing_else_parses() { + let names: Vec<_> = FieldType::all().iter().map(|ty| ty.name()).collect(); + assert_eq!( + names, + [ + "bool", "int32", "int64", "uint32", "uint64", "float32", "float64", "string", + "bytes", "array", "object" + ], + "the names are wire format" + ); + for ty in FieldType::all() { + assert_eq!(FieldType::parse(ty.name()), Some(ty)); + assert_eq!(ty.to_string(), ty.name()); + } + for not_a_type in [ + "", + "UInt64", + "u64", + "int", + "number", + "null", + "undefined", + "text", + ] { + assert_eq!(FieldType::parse(not_a_type), None, "{not_a_type:?}"); + } + } + + /// The vocabulary is the tag table: every scalar tag but null and + /// undefined belongs to exactly one type, and the composites to none. + #[test] + fn the_scalar_types_are_vitaminc_tag_table() { + let mut claimed: Vec = FieldType::all() + .iter() + .flat_map(|ty| ty.tags().iter().copied()) + .collect(); + claimed.sort_unstable(); + assert_eq!( + claimed, + [ + tags::BOOL_FALSE, + tags::BOOL_TRUE, + tags::INT32, + tags::INT64, + tags::UINT32, + tags::UINT64, + tags::FLOAT32, + tags::FLOAT64, + tags::STRING, + tags::BYTES + ] + ); + assert_eq!(FieldType::Bool.tags(), [tags::BOOL_FALSE, tags::BOOL_TRUE]); + assert_eq!(FieldType::UInt64.tags(), [tags::UINT64]); + assert_eq!(FieldType::Int64.tags(), [tags::INT64]); + assert!(FieldType::Array.tags().is_empty()); + assert!(FieldType::Object.tags().is_empty()); + } + + #[test] + fn a_value_is_of_exactly_one_type_and_held_by_it_alone() { + for ty in FieldType::all() { + let value = sample(ty); + assert_eq!(FieldType::of(&value), Some(ty)); + for other in FieldType::all() { + assert_eq!(other.holds(&value), other == ty, "{other} holds a {ty}?"); + } + } + for untyped in [ + FfiValue::Null, + FfiValue::Undefined, + FfiValue::Passthrough(Box::new(FfiValue::UInt32(1))), + ] { + assert_eq!(FieldType::of(&untyped), None); + assert!(FieldType::all().iter().all(|ty| !ty.holds(&untyped))); + } + } + + /// `admits` is `TermKind::supports` stated over types: the two tables + /// cannot disagree about any scalar, and a composite admits nothing. + #[test] + fn admits_agrees_with_supports_for_every_scalar_type() { + for ty in FieldType::all() { + for kind in KINDS { + match Scalar::of(&sample(ty), kind) { + Ok(scalar) => { + assert_eq!(ty.admits(kind), kind.supports(&scalar), "{ty} and {kind}") + } + Err(_) => assert!(!ty.admits(kind), "{ty} is not a scalar"), + } + } + } + // Spelled out, so the table reads without the cross-check. + assert!(FieldType::UInt64.admits(TermKind::Equality)); + assert!(!FieldType::Float64.admits(TermKind::Equality)); + assert!(!FieldType::Bool.admits(TermKind::Equality)); + assert!(FieldType::String.admits(TermKind::Match)); + assert!(!FieldType::Bytes.admits(TermKind::Match)); + assert!(FieldType::Float64.admits(TermKind::Ore)); + assert!(!FieldType::Object.admits(TermKind::Ope)); + assert!(!FieldType::Array.admits(TermKind::Ore)); + } + + fn read(ty: FieldType, value: FfiValue) -> Option { + ty.read(value).ok() + } + + #[test] + fn read_returns_a_value_of_the_type_as_it_is() { + for ty in FieldType::all() { + let read = read(ty, sample(ty)).expect("its own type"); + assert!(ty.holds(&read)); + } + assert!( + matches!(read(FieldType::Float32, FfiValue::Float32(f32::NAN)), Some(FfiValue::Float32(f)) if f.is_nan()) + ); + } + + #[test] + fn read_converts_an_exact_number_to_an_integer_type() { + // Every numeric variant is a source, each read into another width. + assert!(matches!( + read(FieldType::Int64, FfiValue::Int32(-5)), + Some(FfiValue::Int64(-5)) + )); + assert!(matches!( + read(FieldType::Int32, FfiValue::UInt64(5)), + Some(FfiValue::Int32(5)) + )); + assert!( + matches!(read(FieldType::Float64, FfiValue::Int32(-5)), Some(FfiValue::Float64(f)) if f == -5.0) + ); + // A JavaScript number is a float. + assert!(matches!( + read(FieldType::UInt64, FfiValue::Float64(34.0)), + Some(FfiValue::UInt64(34)) + )); + assert!(matches!( + read(FieldType::Int32, FfiValue::Float32(-2.0)), + Some(FfiValue::Int32(-2)) + )); + assert!(matches!( + read(FieldType::Int64, FfiValue::UInt32(7)), + Some(FfiValue::Int64(7)) + )); + assert!(matches!( + read(FieldType::UInt32, FfiValue::Int64(7)), + Some(FfiValue::UInt32(7)) + )); + assert!(matches!( + read(FieldType::Int64, FfiValue::Float64(-0.0)), + Some(FfiValue::Int64(0)) + )); + assert!(matches!( + read(FieldType::UInt64, FfiValue::Int64(i64::MAX)), + Some(FfiValue::UInt64(v)) if v == i64::MAX as u64 + )); + assert!(matches!( + read(FieldType::Int32, FfiValue::Int64(i32::MIN.into())), + Some(FfiValue::Int32(i32::MIN)) + )); + assert!(matches!( + read(FieldType::UInt32, FfiValue::UInt64(u32::MAX.into())), + Some(FfiValue::UInt32(u32::MAX)) + )); + } + + #[test] + fn read_refuses_an_inexact_or_out_of_range_integer() { + let refused = [ + (FieldType::UInt64, FfiValue::Float64(34.5)), + (FieldType::Int64, FfiValue::Float64(f64::NAN)), + (FieldType::Int64, FfiValue::Float64(f64::INFINITY)), + (FieldType::Int64, FfiValue::Float64(1e30)), + (FieldType::UInt64, FfiValue::Int64(-1)), + (FieldType::UInt32, FfiValue::UInt64(u64::from(u32::MAX) + 1)), + (FieldType::Int32, FfiValue::Int64(i64::from(i32::MAX) + 1)), + (FieldType::Int32, FfiValue::Int64(i64::from(i32::MIN) - 1)), + (FieldType::Int64, FfiValue::UInt64(u64::MAX)), + ]; + for (at, (ty, value)) in refused.into_iter().enumerate() { + assert!( + matches!(ty.read(value), Err(Error::Source)), + "case {at}, as {ty}" + ); + } + } + + #[test] + fn read_converts_an_exactly_representable_number_to_a_float_type() { + assert!( + matches!(read(FieldType::Float64, FfiValue::Int64(34)), Some(FfiValue::Float64(f)) if f == 34.0) + ); + assert!( + matches!(read(FieldType::Float64, FfiValue::Float32(1.5)), Some(FfiValue::Float64(f)) if f == 1.5) + ); + assert!( + matches!(read(FieldType::Float32, FfiValue::Float64(1.5)), Some(FfiValue::Float32(f)) if f == 1.5) + ); + assert!( + matches!(read(FieldType::Float32, FfiValue::UInt32(16_777_216)), Some(FfiValue::Float32(f)) if f == 16_777_216.0) + ); + assert!(matches!( + read(FieldType::Float64, FfiValue::UInt64(1 << 53)), + Some(FfiValue::Float64(f)) if f == 9_007_199_254_740_992.0 + )); + } + + #[test] + fn read_refuses_a_number_a_float_type_would_round() { + let refused = [ + (FieldType::Float64, FfiValue::UInt64((1 << 53) + 1)), + (FieldType::Float64, FfiValue::UInt64(u64::MAX)), + (FieldType::Float32, FfiValue::UInt32(16_777_217)), + (FieldType::Float32, FfiValue::Float64(0.1)), + (FieldType::Float32, FfiValue::Float64(f64::NAN)), + ]; + for (at, (ty, value)) in refused.into_iter().enumerate() { + assert!( + matches!(ty.read(value), Err(Error::Source)), + "case {at}, as {ty}" + ); + } + } + + #[test] + fn read_never_converts_across_kinds() { + let refused = [ + (FieldType::UInt64, FfiValue::String("34".into())), + (FieldType::String, FfiValue::UInt64(34)), + (FieldType::Bool, FfiValue::UInt32(1)), + (FieldType::UInt32, FfiValue::Bool(true)), + (FieldType::Bytes, FfiValue::String("ab".into())), + ( + FieldType::String, + FfiValue::Bytes(Protected::new(b"ab".to_vec())), + ), + (FieldType::Array, FfiValue::Object(vec![])), + (FieldType::Object, FfiValue::Array(vec![])), + (FieldType::UInt32, FfiValue::Null), + (FieldType::Bool, FfiValue::Float64(1.0)), + ]; + for (at, (ty, value)) in refused.into_iter().enumerate() { + assert!( + matches!(ty.read(value), Err(Error::Source)), + "case {at}, as {ty}" + ); + } + } +} diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 5d7ccab83..7652377a7 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -41,19 +41,25 @@ //! them. Their long-term home is beside vitaminc's frozen tag table, which //! already owns this class of constant. //! -//! For the same reason the enums that spell them — [`Output`] and -//! [`TermKind`] — are *not* `#[non_exhaustive]`, against this workspace's +//! A plan field's `"type"` names ([`FieldType`]: `"int64"`, `"string"`, …) +//! are wire format in the same way: a binding spells them, and a stored row +//! opens only under the type it was sealed as. +//! +//! For the same reason the enums that spell them — [`Output`], +//! [`TermKind`] and [`FieldType`] — are *not* `#[non_exhaustive]`, against this workspace's //! usual rule for public enums: a new output is a wire-format addition every //! binding has to be taught, and an exhaustive match is how the compiler //! tells a binding author that. [`Scope`] is exhaustive for a different //! reason, given on the type. mod context; +mod field_type; pub mod record; mod term; use std::fmt; pub use context::{borrowed, context}; +pub use field_type::FieldType; pub use record::{FieldPlan, Output, Plan}; pub use term::{term, Scalar, TermKind}; /// vitaminc's language-neutral value tree — the runtime value every binding @@ -113,7 +119,10 @@ fn utf8(s: &vitaminc_aead_value::Utf8String) -> Option<&str> { /// The split that matters to a caller is malformed input versus something /// else: every variant but [`Cipher`](Error::Cipher) and /// [`Internal`](Error::Internal) is a statement about the value or the -/// request, decided before any key is minted or retrieved. `Cipher` is the +/// request, decided before any key is minted or retrieved — save one: a +/// typed field's opened value is checked against its declared type once it +/// is open (the type tag is inside the AEAD envelope), and a mismatch is +/// still [`Record`](Error::Record), a statement about the stored data. `Cipher` is the /// operation failing; `Internal` is this module's own bug. A binding maps /// them to its own status codes on those lines, and must not report /// `Internal` as the caller's fault. @@ -140,15 +149,18 @@ pub enum Error { }, /// A record plan is malformed: not an object of field specs, empty, - /// missing or duplicating an output, or carrying a key that is not - /// `"context"` or `"outputs"`. + /// missing or duplicating an output, carrying a key that is not + /// `"context"`, `"outputs"` or `"type"`, naming a type that is not one, + /// or asking for an index its declared type is not defined for. #[error("record plan is malformed")] Plan, /// A record source does not fit its plan: not an object (or an array of /// them), a field the plan does not name, a plan field the source does /// not carry or carries twice, or a passthrough or a repeated map key - /// under a field the plan seals. + /// under a field the plan seals, or a value of another type than its + /// field declares. Also a query value that cannot be read as its field's + /// type ([`FieldType::read`]). #[error("record source does not fit the plan")] Source, @@ -156,7 +168,8 @@ pub enum Error { /// them), a ciphertext-bearing field that is absent or given twice, or /// has no `"c"` node or two of them, a repeated map key under `"c"`, or /// a passthrough under `"c"` — which would hand back unauthenticated - /// bytes as if they had been opened. + /// bytes as if they had been opened — or a typed field that opens to a + /// value of another type than it declares. #[error("stored record does not fit the plan")] Record, diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index f7474897d..04ed6db33 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -53,7 +53,7 @@ use stack_kms::DataKeySource; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Protected; -use super::{borrowed, term, utf8, Error, Scalar, Scope, TermKind}; +use super::{borrowed, term, utf8, Error, FieldType, Scalar, Scope, TermKind}; use crate::target::Pending; use crate::{ BoxedPassthrough, CipherText, ContextPiece, Encrypt, KeysetCipher, NonEmpty, StackCipherText, @@ -95,12 +95,21 @@ impl Output { } /// One field of a record plan: what to call it, what context to bind it -/// under, and what to produce for it. +/// under, what to produce for it, and, optionally, what type its values are. +/// +/// A field with a declared [`FieldType`] admits only the indexes that type +/// is defined for (checked when the plan is built), seals only values of +/// that type and opens only to one (checked per value), so the engine +/// verifies what a binding hands it rather than trusting the binding's +/// tagging. A field with no declared type is dispatched on each value's own +/// type, as every field was before types existed; that keeps the plans +/// existing bindings send valid. #[derive(Clone, Debug)] pub struct FieldPlan { name: String, context: NonEmpty>, outputs: Vec, + field_type: Option, } impl FieldPlan { @@ -133,9 +142,29 @@ impl FieldPlan { name: name.into(), context, outputs, + field_type: None, }) } + /// Declare the type of this field's values. + /// + /// # Errors + /// + /// [`Error::Plan`] if the field asks for an index the type is not + /// defined for ([`FieldType::admits`]): match on an integer, equality on + /// a float, any index on a composite. + pub fn with_type(mut self, field_type: FieldType) -> Result { + for output in &self.outputs { + if let Output::Term(kind) = output { + if !field_type.admits(*kind) { + return Err(Error::Plan); + } + } + } + self.field_type = Some(field_type); + Ok(self) + } + /// The field's name — its key in the source and in the result. pub fn name(&self) -> &str { &self.name @@ -151,6 +180,13 @@ impl FieldPlan { &self.outputs } + /// 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. + pub fn field_type(&self) -> Option { + self.field_type + } + /// Whether the field has a ciphertext to seal and open. pub fn has_ciphertext(&self) -> bool { self.outputs.contains(&Output::Ciphertext) @@ -212,9 +248,14 @@ impl Plan { /// The plan is an [`FfiValue::Object`]: /// /// ```text -/// { : { "context": , "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ] }, ... } +/// { : { "context": , "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ], "type": }, ... } /// ``` /// +/// `"type"` is optional, and names a [`FieldType`] (`"int64"`, `"string"`, +/// …; see [`FieldType::name`]). Declared, it is checked against the field's +/// outputs here and against every value sealed into or opened from the +/// field; absent, each value is dispatched on its own type. +/// /// `` is defined once, in [`super::context`](super::context()): a /// string, bytes, an integer, or a list of those, with what each spells in /// Rust and the emptiness rule. @@ -259,9 +300,11 @@ impl Plan { /// /// [`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"` and `"outputs"` or with either given twice or missing, or -/// an output list that is not a list of known output names, is empty, or -/// names an output twice. [`Error::Context`] for a `"context"` that is +/// `"context"`, `"outputs"` and `"type"` or with one given twice, missing +/// `"context"` or `"outputs"`, an output list that is not a list of known +/// output names, is empty, or names an output twice, a `"type"` that is not +/// a string naming a [`FieldType`], or a type that does not admit one of +/// the field's index outputs. [`Error::Context`] for a `"context"` that is /// present but is not a context, or renders empty. /// /// The transport codec refuses duplicate object keys before a binding's @@ -279,6 +322,7 @@ pub fn plan(value: FfiValue) -> Result { }; let mut context: Option>> = None; let mut outputs: 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)?), @@ -296,15 +340,26 @@ pub fn plan(value: FfiValue) -> Result { } outputs = Some(parsed); } - // An unknown key, or one of the two given twice. + "type" if field_type.is_none() => { + let FfiValue::String(s) = &value else { + return Err(Error::Plan); + }; + let name = utf8(s).ok_or(Error::Plan)?; + field_type = Some(FieldType::parse(name).ok_or(Error::Plan)?); + } + // An unknown key, or one of the three given twice. _ => return Err(Error::Plan), } } - fields.push(FieldPlan::new( + let field = FieldPlan::new( name, context.ok_or(Error::Plan)?, outputs.ok_or(Error::Plan)?, - )?); + )?; + fields.push(match field_type { + Some(field_type) => field.with_type(field_type)?, + None => field, + }); } // The whole-plan rules — non-empty, no name twice — are `Plan::new`'s, // so a parsed plan and a hand-built one are refused alike. @@ -467,11 +522,14 @@ where K: DataKeySource + Sync + 'static, { let Rows { rows, batched } = record_leaves(record, plan)?; - let contexts = plan + let opened = plan .fields .iter() .filter(|field| field.has_ciphertext()) - .map(FieldPlan::view) + .collect::>(); + let contexts = opened + .iter() + .map(|field| field.view()) .collect::, Error>>()?; // Per row, per ciphertext-bearing plan field (in plan order, as @@ -507,7 +565,17 @@ where .map(|row_names| { let entries = row_names .into_iter() - .map(|name| Ok((name, values.next()?))) + .zip(&opened) + .map(|(name, field)| { + let value = values.next()?; + // The tag is authenticated, so a mismatch is not + // tampering: the row was sealed as another type than + // the plan now declares. + match field.field_type { + Some(declared) if !declared.holds(&value) => Err(Error::Record), + _ => Ok((name, value)), + } + }) .collect::, Error>>()?; Ok(FfiValue::Object(entries)) }) @@ -539,6 +607,9 @@ pub fn check_source(source: FfiValue, plan: &Plan) -> Result<(), Error> { /// Check a stored record against a plan without opening it — everything /// [`decrypt`] checks before it consults the cipher. See [`check_source`]. /// +/// A typed field's declared type is not among them: the type is the sealed +/// leaf's tag, inside the AEAD envelope, so only [`decrypt`] can check it. +/// /// # Errors /// /// As [`decrypt`], minus the cipher. @@ -784,11 +855,17 @@ fn source_rows(source: FfiValue, plan: &Plan) -> Result>, Err }) } -/// A source value against its plan field: every term output needs a scalar -/// the scheme defines the term for ([`TermKind::supports`]), and a -/// ciphertext output refuses a passthrough, or a repeated map key, anywhere -/// in the value ([`check_tree`]). +/// A source value against its plan field: a typed field needs a value of +/// its type, every term output needs a scalar the scheme defines the term +/// for ([`TermKind::supports`]), and a ciphertext output refuses a +/// passthrough, or a repeated map key, anywhere in the value +/// ([`check_tree`]). fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { + if let Some(declared) = field.field_type { + if !declared.holds(value) { + return Err(Error::Source); + } + } for output in &field.outputs { match output { Output::Ciphertext => check_tree(value)?, @@ -2045,4 +2122,249 @@ mod tests { "a bare plan string is the typed literal" ); } + + mod given_a_typed_field { + use super::*; + + fn typed(context: &str, outputs: &[&str], ty: &str) -> FfiValue { + obj(vec![ + ("context", s(context)), + ("outputs", strings(outputs)), + ("type", s(ty)), + ]) + } + + fn age_plan(ty: &str) -> Plan { + plan(obj(vec![( + "age", + typed("users/age", &["c", "eq", "ore"], ty), + )])) + .expect("a typed plan parses") + } + + #[test] + fn the_plan_parses_the_type_and_an_untyped_field_has_none() { + let parsed = plan(obj(vec![ + ("age", typed("users/age", &["c", "ore"], "uint64")), + ("bio", typed("users/bio", &["c", "match"], "string")), + ("notes", spec(s("users/notes"), &["c"])), + ])) + .expect("parses"); + let types: Vec<_> = parsed.fields().iter().map(FieldPlan::field_type).collect(); + assert_eq!( + types, + [Some(FieldType::UInt64), Some(FieldType::String), None] + ); + } + + #[test] + fn the_plan_refuses_an_unresolvable_type_or_one_that_does_not_admit_an_index() { + let refused: [(&str, FfiValue); 8] = [ + ("an unknown type", typed("users/x", &["c"], "u64")), + ( + "a type in the wrong case", + typed("users/x", &["c"], "UInt64"), + ), + ( + "a type that is not a string", + obj(vec![ + ("context", s("users/x")), + ("outputs", strings(&["c"])), + ("type", FfiValue::UInt32(6)), + ]), + ), + ( + "a type given twice", + obj(vec![ + ("context", s("users/x")), + ("outputs", strings(&["c"])), + ("type", s("string")), + ("type", s("string")), + ]), + ), + ( + "match on an integer", + typed("users/x", &["c", "match"], "int64"), + ), + ( + "equality on a float", + typed("users/x", &["c", "eq"], "float64"), + ), + ("equality on a bool", typed("users/x", &["eq"], "bool")), + ( + "order on a composite", + typed("users/x", &["c", "ore"], "object"), + ), + ]; + for (label, field) in refused { + let result = plan(obj(vec![("x", field)])); + assert!(matches!(result, Err(Error::Plan)), "{label}: {result:?}"); + } + } + + #[test] + fn a_hand_built_field_takes_a_type_its_indexes_admit_and_refuses_one_they_do_not() { + let field = FieldPlan::new( + "age", + context(s("users/age")).expect("context"), + vec![Output::Ciphertext, Output::Term(TermKind::Equality)], + ) + .expect("field"); + assert_eq!(field.field_type(), None, "untyped until declared"); + let typed = field + .clone() + .with_type(FieldType::UInt32) + .expect("equality admits a u32"); + assert_eq!(typed.field_type(), Some(FieldType::UInt32)); + assert!(matches!( + field.with_type(FieldType::Float32), + Err(Error::Plan) + )); + let sealed_only = FieldPlan::new( + "doc", + context(s("users/doc")).expect("context"), + vec![Output::Ciphertext], + ) + .expect("field") + .with_type(FieldType::Object) + .expect("a composite with no index is a plain sealed field"); + assert_eq!(sealed_only.field_type(), Some(FieldType::Object)); + } + + /// The engine verifies the tag rather than trusting the binding: a + /// `u32` is not a `uint64`, however small. Refused before any key + /// is minted, by `check_source` alike. + #[tokio::test] + async fn encrypt_refuses_a_value_of_another_type_with_no_key_request() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = age_plan("uint64"); + let check = check_source(obj(vec![("age", FfiValue::UInt32(34))]), &plan); + assert!( + matches!(check, Err(Error::Source)), + "check_source refuses it too" + ); + for (label, value) in [ + ("a u32 for a uint64 field", FfiValue::UInt32(34)), + ("a float for a uint64 field", FfiValue::Float64(34.0)), + ("null for a uint64 field", FfiValue::Null), + ] { + let result = encrypt(&keyset, obj(vec![("age", value)]), &plan).await; + assert!(matches!(result, Err(Error::Source)), "{label}: {result:?}"); + } + assert_eq!(generates(&cipher), 0, "refused before any key request"); + } + + #[tokio::test] + async fn a_value_of_the_type_round_trips_and_its_terms_are_the_typed_terms() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = age_plan("uint64"); + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt64(34))]), &plan) + .await + .expect("seal"); + let mut row = map(sealed); + let mut age = map(node(&mut row, "age")); + let eq = term_bytes(&node(&mut age, "eq")); + let typed = keyset + .equality_term(34u64, nonempty!("users/age")) + .await + .expect("typed"); + assert_eq!(eq, typed.into_bytes().to_vec(), "the u64 term"); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt64(34))]), &plan) + .await + .expect("seal"); + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("open"); + let fields = object(opened); + assert!(matches!(&fields[..], [(name, FfiValue::UInt64(34))] if name == "age")); + } + + /// A row sealed as one type and read under a plan declaring another + /// is refused, not handed back as the wrong type. The tag is inside + /// the AEAD envelope, so this is a plan disagreeing with its data, + /// never tampering, and it is caught once the leaf is open. + #[tokio::test] + async fn decrypt_refuses_a_value_that_opens_to_another_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let untyped = plan(obj(vec![("age", spec(s("users/age"), &["c"]))])).expect("plan"); + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + let as_uint64 = + plan(obj(vec![("age", typed("users/age", &["c"], "uint64"))])).expect("plan"); + let result = decrypt(Scope::Client(&cipher), sealed, &as_uint64).await; + assert!(matches!(result, Err(Error::Record)), "{:?}", result.err()); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + let as_uint32 = + plan(obj(vec![("age", typed("users/age", &["c"], "uint32"))])).expect("plan"); + let opened = decrypt(Scope::Client(&cipher), sealed, &as_uint32) + .await + .expect("the declared type opens"); + assert_eq!(u32_of(&object(opened)[0].1), 34); + } + + /// In a batch, each opened value is checked against its own field, + /// not the first field's. + #[tokio::test] + async fn decrypt_checks_each_field_against_its_own_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(obj(vec![ + ("age", typed("users/age", &["c"], "uint32")), + ("name", typed("users/name", &["c"], "string")), + ])) + .expect("plan"); + let rows = FfiValue::Array(vec![ + obj(vec![("age", FfiValue::UInt32(1)), ("name", s("a"))]), + obj(vec![("age", FfiValue::UInt32(2)), ("name", s("b"))]), + ]); + let sealed = encrypt(&keyset, rows, &plan).await.expect("seal"); + let opened = array( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("open"), + ); + assert_eq!(opened.len(), 2); + let second = object(opened.into_iter().nth(1).expect("row")); + assert_eq!(keys(&second), ["age", "name"]); + assert_eq!(u32_of(&second[0].1), 2); + assert_eq!(text_of(&second[1].1), "b"); + } + + /// The query side: a host's `34` (a float, from JavaScript) read as + /// the field's type derives the term the field stores. + #[tokio::test] + async fn a_query_value_read_as_the_field_type_finds_the_stored_term() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = age_plan("uint64"); + let field = &plan.fields()[0]; + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt64(34))]), &plan) + .await + .expect("seal"); + let mut row = map(sealed); + let mut age = map(node(&mut row, "age")); + let stored = term_bytes(&node(&mut age, "eq")); + + let declared = field.field_type().expect("typed"); + let value = declared.read(FfiValue::Float64(34.0)).expect("an exact 34"); + let scalar = Scalar::of(&value, TermKind::Equality).expect("scalar"); + let probe = term( + &keyset, + scalar, + TermKind::Equality, + field.view().expect("view"), + ) + .await + .expect("probe"); + assert_eq!(probe, stored); + } + } } diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 5a6e75d08..3f7d9a846 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -22,7 +22,7 @@ use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroizing; use super::{utf8, Error}; -use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch}; +use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchConfig}; use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; /// Which index term to derive. @@ -73,6 +73,22 @@ impl TermKind { } } +/// A term kind as the index it names: the data form of the typed +/// [`Index`](crate::target::Index) set. A dynamic term derives under the +/// default match configuration, so that is what [`TermKind::Match`] lowers +/// to. +impl From for crate::target::IndexSpec { + fn from(kind: TermKind) -> Self { + use crate::target::IndexSpec; + match kind { + TermKind::Equality => IndexSpec::Equality, + TermKind::Match => IndexSpec::Match(::options()), + TermKind::Ore => IndexSpec::Ore, + TermKind::Ope => IndexSpec::Ope, + } + } +} + impl fmt::Display for TermKind { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.key()) @@ -584,6 +600,25 @@ mod tests { mod given_a_term_kind { use super::*; + #[test] + fn it_lowers_to_the_index_spec_with_the_same_key() { + use crate::target::IndexSpec; + let cases = [ + (TermKind::Equality, IndexSpec::Equality), + ( + TermKind::Match, + IndexSpec::Match(crate::sem::MatchOptions::default()), + ), + (TermKind::Ore, IndexSpec::Ore), + (TermKind::Ope, IndexSpec::Ope), + ]; + for (kind, spec) in cases { + let lowered = IndexSpec::from(kind); + assert_eq!(lowered.key(), kind.key(), "{kind} keeps its key"); + assert_eq!(lowered, spec, "{kind} lowers to its index"); + } + } + #[test] fn its_key_is_how_a_plan_spells_it() { for kind in [ From 2731c6a3c980d1a05b975c093d065dd420236ef7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Sun, 4 Oct 2026 23:49:18 -0700 Subject: [PATCH 4/7] refactor(stack-encrypt)!: IndexSpec is the one data form of an index The engine had two enums for the same thing: dynamic::TermKind, which the record plan, term derivation and Go guest spoke, and IndexSpec, which the typed Index set lowers to, joined by a From conversion. Indexes are types in Rust and one data form at the FFI boundary, so TermKind goes and IndexSpec takes its place everywhere: Output::Term, Error::Term, Scalar::of, term, FieldType::admits (kind only; options do not change what an index applies to) and the guest's term ABI. Folding them gives match options a wire form instead of making them impossible to send. An index in a plan's outputs list is still its key string, and a bare "match" still means the default options, so every plan the Go binding writes today parses to the same thing. A match index under other options is a one-entry object, {"match": {tokenizer, downcase, k, m}}, each option optional and checked against the scheme's bounds. The term derivation now honours those options. A field still names each output key once, so two match indexes under different options are refused rather than written under one key twice. TermKind was public in stack-encrypt 0.2.0 on crates.io, so this is a break: the CHANGELOG's Unreleased section records it, with the additions this pull request makes to the target layer and the plan grammar. BREAKING CHANGE: dynamic::TermKind is removed; target::IndexSpec takes its place. Output::Term holds an IndexSpec and Output is no longer Copy; Error::Term's kind is an IndexSpec; Scalar::of and dynamic::term take &IndexSpec where they took a TermKind by value. A plan's wire form is unchanged. --- .../golang/stackencrypt/guest/src/ops.rs | 26 +- .../golang/stackencrypt/guest/src/status.rs | 6 +- packages/stack-encrypt/CHANGELOG.md | 14 + .../stack-encrypt/src/dynamic/field_type.rs | 78 ++- packages/stack-encrypt/src/dynamic/mod.rs | 11 +- packages/stack-encrypt/src/dynamic/record.rs | 292 +++++++-- packages/stack-encrypt/src/dynamic/term.rs | 581 ++++++++++++++---- packages/stack-encrypt/src/sem/mod.rs | 30 +- packages/stack-encrypt/src/sem/tokenize.rs | 2 +- packages/stack-encrypt/src/target/index.rs | 21 +- 10 files changed, 821 insertions(+), 240 deletions(-) diff --git a/languages/golang/stackencrypt/guest/src/ops.rs b/languages/golang/stackencrypt/guest/src/ops.rs index da2dce0e1..c294d796a 100644 --- a/languages/golang/stackencrypt/guest/src/ops.rs +++ b/languages/golang/stackencrypt/guest/src/ops.rs @@ -31,7 +31,9 @@ //! ABI's numeric term kinds, and the mapping from a library error to a //! status code. -use stack_encrypt::dynamic::{self, Scalar, Scope, TermKind}; +use stack_encrypt::dynamic::{self, Scalar, Scope}; +use stack_encrypt::sem::MatchOptions; +use stack_encrypt::target::IndexSpec; use stack_encrypt::{ BoxedPassthrough, CipherText, Element, Encrypt, KeysetCipher, SealedValue, StackCipherText, }; @@ -173,25 +175,29 @@ where // `STATUS_ENCODING` here, before any derivation. let context = dynamic::context(decode_value(context)?).map_err(|e| status_for_dynamic(&e))?; let (scalar, kind) = parse_term(decode_value(value)?, kind)?; - dynamic::term(cipher, scalar, kind, context) + dynamic::term(cipher, scalar, &kind, context) .await .map_err(|e| status_for_dynamic(&e)) } /// The static half of a term: the kind is one of the ABI's table, the value /// is a scalar, and the scheme defines the pair -/// ([`TermKind::supports`]). Shared by [`term`] and [`validate::term`] so +/// ([`IndexSpec::supports`]). Shared by [`term`] and [`validate::term`] so /// the ABI refuses exactly what the operation would, before any keyset is /// resolved. -fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, TermKind), u32> { +/// +/// The ABI's kind codes carry no options, so [`TERM_MATCH`] is the match +/// index under the default options, the same index a plan's bare `"match"` +/// names. +fn parse_term(value: FfiValue, kind: u32) -> Result<(Scalar, IndexSpec), u32> { let kind = match kind { - TERM_EQUALITY => TermKind::Equality, - TERM_MATCH => TermKind::Match, - TERM_ORE => TermKind::Ore, - TERM_OPE => TermKind::Ope, + TERM_EQUALITY => IndexSpec::Equality, + TERM_MATCH => IndexSpec::Match(MatchOptions::default()), + TERM_ORE => IndexSpec::Ore, + TERM_OPE => IndexSpec::Ope, _ => return Err(STATUS_ENCODING), }; - let scalar = Scalar::of(&value, kind).map_err(|e| status_for_dynamic(&e))?; + let scalar = Scalar::of(&value, &kind).map_err(|e| status_for_dynamic(&e))?; if !kind.supports(&scalar) { return Err(STATUS_ENCODING); } @@ -283,7 +289,7 @@ pub mod validate { /// A term's inputs, as [`term`] takes them: the context decodes and is /// non-empty, the kind is one of [`TERM_EQUALITY`] .. [`TERM_OPE`], and /// the value is a scalar the scheme defines that term for - /// ([`TermKind::supports`]). + /// ([`IndexSpec::supports`]). pub fn term(value: &[u8], context: &[u8], kind: u32) -> Result<(), u32> { dynamic::context(decode_value(context)?) .map(drop) diff --git a/languages/golang/stackencrypt/guest/src/status.rs b/languages/golang/stackencrypt/guest/src/status.rs index 700c009ce..b985c0434 100644 --- a/languages/golang/stackencrypt/guest/src/status.rs +++ b/languages/golang/stackencrypt/guest/src/status.rs @@ -318,13 +318,15 @@ mod tests { #[test] fn dynamic_input_errors_are_encoding_and_a_library_bug_is_internal() { - use stack_encrypt::dynamic::{Error, TermKind}; + use stack_encrypt::dynamic::Error; + use stack_encrypt::sem::MatchOptions; + use stack_encrypt::target::IndexSpec; for (label, err) in [ ("a bad context", Error::Context), ( "a bad term request", Error::Term { - kind: TermKind::Match, + kind: IndexSpec::Match(MatchOptions::default()), }, ), ("a bad plan", Error::Plan), diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 43d17b512..2bf39ad65 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -23,6 +23,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 terms, not one. `MatchTerm` remains as a deprecated alias. - `TermBytesError::OddMatchTermLength` is renamed `OddMatchTermsLength`. An enum variant cannot be aliased, so a `match` that names it must change. +- `dynamic::TermKind` is gone; `target::IndexSpec` is the one data form of + an index. `Output::Term`, `Error::Term`'s `kind`, `Scalar::of` and + `dynamic::term` take an `IndexSpec` (the last two by reference), and + `Output` is no longer `Copy`. A plan's wire form is unchanged: a bare + `"match"` still means the default options. ### Added @@ -32,6 +37,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Owned` mode a description is handed the plaintext by value, so a single operation consumes it with no copy and a plaintext that is not `Clone` (a zeroizing FFI value) can be sealed or indexed. The traits are sealed. +- Indexes as types: `target::{Index, Indexes, Equality, Match, Ore, Ope}`, + `indexed`, `Encrypted`, `Select` / `At` / `Whole`. A match index on an + integer does not compile, and an index set is one index or a tuple of two + to four, never `()`. `Index::spec` lowers an index to its `IndexSpec`. +- `target::passthrough`: a field carried unsealed and unauthenticated. +- `KeysetCipher::run_decryption` and `StackCipher::run_decryption`: run a + `Decryption` held in a variable. +- A dynamic plan's match index can carry options, as + `{"match": {"tokenizer", "downcase", "k", "m"}}`. ## [0.2.0] - 2026-10-04 diff --git a/packages/stack-encrypt/src/dynamic/field_type.rs b/packages/stack-encrypt/src/dynamic/field_type.rs index 3e4e9e192..a43b4cba1 100644 --- a/packages/stack-encrypt/src/dynamic/field_type.rs +++ b/packages/stack-encrypt/src/dynamic/field_type.rs @@ -27,13 +27,14 @@ use std::fmt; use vitaminc_aead_value::{tags, FfiValue}; -use super::{Error, TermKind}; +use super::Error; +use crate::target::IndexSpec; /// The type of a plan field's values. /// /// The [`name`](Self::name) strings are wire format, spelled by a binding in /// a plan's `"type"` key, so this enum is exhaustive for the reason -/// [`TermKind`] is (see the [module docs](super#stability)). +/// [`IndexSpec`] is (see the [module docs](super#stability)). #[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] pub enum FieldType { /// `"bool"`: [`FfiValue::Bool`], tags `BOOL_FALSE` and `BOOL_TRUE`. @@ -151,17 +152,18 @@ impl FieldType { /// Whether the scheme defines a `kind` term for values of this type. /// - /// The same table as [`TermKind::supports`], stated over types instead + /// The same table as [`IndexSpec::supports`], stated over types instead /// of values, so a plan can be refused when it is built rather than when /// its first value arrives: equality over every integer, text and bytes /// (no floats, no booleans); match over text alone; ORE and OPE over - /// every scalar. A composite has no term. - pub fn admits(self, kind: TermKind) -> bool { + /// every scalar. A composite has no term. Only the index's kind + /// matters: a match index's options do not change what it applies to. + pub fn admits(self, kind: &IndexSpec) -> bool { use FieldType::*; match kind { - TermKind::Equality => matches!(self, Int32 | Int64 | UInt32 | UInt64 | String | Bytes), - TermKind::Match => self == String, - TermKind::Ore | TermKind::Ope => !matches!(self, Array | Object), + IndexSpec::Equality => matches!(self, Int32 | Int64 | UInt32 | UInt64 | String | Bytes), + IndexSpec::Match(_) => self == String, + IndexSpec::Ore | IndexSpec::Ope => !matches!(self, Array | Object), } } @@ -274,6 +276,7 @@ impl Number { mod tests { use super::*; use crate::dynamic::Scalar; + use crate::sem::{MatchOptions, Tokenizer}; use vitaminc_protected::Protected; /// One value of every field type. @@ -293,12 +296,14 @@ mod tests { } } - const KINDS: [TermKind; 4] = [ - TermKind::Equality, - TermKind::Match, - TermKind::Ore, - TermKind::Ope, - ]; + fn kinds() -> [IndexSpec; 4] { + [ + IndexSpec::Equality, + IndexSpec::Match(MatchOptions::default()), + IndexSpec::Ore, + IndexSpec::Ope, + ] + } #[test] fn every_name_parses_back_to_its_type_and_nothing_else_parses() { @@ -379,29 +384,48 @@ mod tests { } } - /// `admits` is `TermKind::supports` stated over types: the two tables + /// `admits` is `IndexSpec::supports` stated over types: the two tables /// cannot disagree about any scalar, and a composite admits nothing. #[test] fn admits_agrees_with_supports_for_every_scalar_type() { for ty in FieldType::all() { - for kind in KINDS { - match Scalar::of(&sample(ty), kind) { + for kind in kinds() { + match Scalar::of(&sample(ty), &kind) { Ok(scalar) => { - assert_eq!(ty.admits(kind), kind.supports(&scalar), "{ty} and {kind}") + assert_eq!(ty.admits(&kind), kind.supports(&scalar), "{ty} and {kind}") } - Err(_) => assert!(!ty.admits(kind), "{ty} is not a scalar"), + Err(_) => assert!(!ty.admits(&kind), "{ty} is not a scalar"), } } } // Spelled out, so the table reads without the cross-check. - assert!(FieldType::UInt64.admits(TermKind::Equality)); - assert!(!FieldType::Float64.admits(TermKind::Equality)); - assert!(!FieldType::Bool.admits(TermKind::Equality)); - assert!(FieldType::String.admits(TermKind::Match)); - assert!(!FieldType::Bytes.admits(TermKind::Match)); - assert!(FieldType::Float64.admits(TermKind::Ore)); - assert!(!FieldType::Object.admits(TermKind::Ope)); - assert!(!FieldType::Array.admits(TermKind::Ore)); + assert!(FieldType::UInt64.admits(&IndexSpec::Equality)); + assert!(!FieldType::Float64.admits(&IndexSpec::Equality)); + assert!(!FieldType::Bool.admits(&IndexSpec::Equality)); + assert!(FieldType::String.admits(&IndexSpec::Match(MatchOptions::default()))); + assert!(!FieldType::Bytes.admits(&IndexSpec::Match(MatchOptions::default()))); + assert!(FieldType::Float64.admits(&IndexSpec::Ore)); + assert!(!FieldType::Object.admits(&IndexSpec::Ope)); + assert!(!FieldType::Array.admits(&IndexSpec::Ore)); + } + + /// A match index's options are not part of what it applies to: text + /// admits it under any options, and nothing else does. + #[test] + fn admits_ignores_match_options() { + let wide = IndexSpec::Match(MatchOptions { + tokenizer: Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + }); + for ty in FieldType::all() { + assert_eq!( + ty.admits(&wide), + ty == FieldType::String, + "{ty} admits a non-default match exactly when it is text" + ); + } } fn read(ty: FieldType, value: FfiValue) -> Option { diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 7652377a7..f3c24b15a 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -46,7 +46,7 @@ //! opens only under the type it was sealed as. //! //! For the same reason the enums that spell them — [`Output`], -//! [`TermKind`] and [`FieldType`] — are *not* `#[non_exhaustive]`, against this workspace's +//! [`IndexSpec`] and [`FieldType`] — are *not* `#[non_exhaustive]`, against this workspace's //! usual rule for public enums: a new output is a wire-format addition every //! binding has to be taught, and an exhaustive match is how the compiler //! tells a binding author that. [`Scope`] is exhaustive for a different @@ -61,13 +61,14 @@ use std::fmt; pub use context::{borrowed, context}; pub use field_type::FieldType; pub use record::{FieldPlan, Output, Plan}; -pub use term::{term, Scalar, TermKind}; +pub use term::{term, Scalar}; /// vitaminc's language-neutral value tree — the runtime value every binding /// funnels through. Its transport codec is `vitaminc_aead_value::transport`, /// which stays the binding's: this crate takes and returns values, never /// encoded bytes. pub use vitaminc_aead_value::FfiValue; +use crate::target::IndexSpec; use crate::{KeysetCipher, StackCipher}; /// Which cipher an opening operation decrypts through: the client, or one @@ -141,11 +142,11 @@ pub enum Error { /// container, null or passthrough (which have no term semantics at all), /// or a scalar outside the kind's domain — equality over a float or a /// boolean, match over anything but text. See - /// [`TermKind::supports`]. + /// [`IndexSpec::supports`]. #[error("no {kind} term is defined for this value")] Term { - /// The kind that was asked for. - kind: TermKind, + /// The index that was asked for. + kind: IndexSpec, }, /// A record plan is malformed: not an object of field specs, empty, diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 04ed6db33..7204dcbb4 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -53,40 +53,53 @@ use stack_kms::DataKeySource; use vitaminc_aead_value::FfiValue; use vitaminc_protected::Protected; -use super::{borrowed, term, utf8, Error, FieldType, Scalar, Scope, TermKind}; -use crate::target::Pending; +use super::{borrowed, term, utf8, Error, FieldType, Scalar, Scope}; +use crate::target::{IndexSpec, Pending}; use crate::{ BoxedPassthrough, CipherText, ContextPiece, Encrypt, KeysetCipher, NonEmpty, StackCipherText, }; /// What a plan field asks for. /// -/// The strings are wire format twice over: they are how a binding spells an +/// The keys are wire format twice over: they are how a binding spells an /// output, *and* the keys of the per-field output map in the stored result. /// That is why this enum is exhaustive — see the [module docs](super#stability). -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] +#[derive(Clone, PartialEq, Eq, Debug, Hash)] pub enum Output { /// `"c"` — the field's [`StackCipherText`]. Ciphertext, - /// An index term: `"eq"`, `"match"`, `"ore"` or `"ope"`. - Term(TermKind), + /// An index term, keyed `"eq"`, `"match"`, `"ore"` or `"ope"`: the + /// index, with its options, that derives it. + Term(IndexSpec), } impl Output { - /// The output a key names, or `None` for a key that is not one. + /// The output a bare key names, or `None` for a key that is not one. A + /// `"match"` key names the match index under default options; a plan + /// spells other options in the object form [`from_value`](Self::from_value) + /// reads. pub fn parse(s: &str) -> Option { - Some(match s { - "c" => Output::Ciphertext, - "eq" => Output::Term(TermKind::Equality), - "match" => Output::Term(TermKind::Match), - "ore" => Output::Term(TermKind::Ore), - "ope" => Output::Term(TermKind::Ope), - _ => return None, - }) + match s { + "c" => Some(Output::Ciphertext), + _ => IndexSpec::parse(s).map(Output::Term), + } + } + + /// Read one entry of a plan's `"outputs"` list: `"c"`, or an index in + /// its wire form ([`IndexSpec::from_value`]; see [`plan`]). + /// + /// # Errors + /// + /// [`Error::Plan`] for anything else. + pub fn from_value(value: &FfiValue) -> Result { + match value { + FfiValue::String(s) if utf8(s) == Some("c") => Ok(Output::Ciphertext), + _ => IndexSpec::from_value(value).map(Output::Term), + } } /// The map key this output rides under. - pub fn key(self) -> &'static str { + pub fn key(&self) -> &'static str { match self { Output::Ciphertext => "c", Output::Term(kind) => kind.key(), @@ -124,7 +137,10 @@ impl FieldPlan { /// /// # Errors /// - /// [`Error::Plan`] if `outputs` is empty or names an output twice. + /// [`Error::Plan`] if `outputs` is empty or names an output twice. Two + /// outputs with the same [key](Output::key) are the same output — two + /// match indexes under different options would both ride under + /// `"match"` — so they are refused too. pub fn new( name: impl Into, context: NonEmpty>, @@ -134,7 +150,10 @@ impl FieldPlan { return Err(Error::Plan); } for (at, output) in outputs.iter().enumerate() { - if outputs[..at].contains(output) { + if outputs[..at] + .iter() + .any(|prior| prior.key() == output.key()) + { return Err(Error::Plan); } } @@ -156,7 +175,7 @@ impl FieldPlan { pub fn with_type(mut self, field_type: FieldType) -> Result { for output in &self.outputs { if let Output::Term(kind) = output { - if !field_type.admits(*kind) { + if !field_type.admits(kind) { return Err(Error::Plan); } } @@ -248,9 +267,31 @@ impl Plan { /// The plan is an [`FfiValue::Object`]: /// /// ```text -/// { : { "context": , "outputs": [ "c" | "eq" | "match" | "ore" | "ope", ... ], "type": }, ... } +/// { : { "context": , "outputs": [ "c" | , ... ], "type": }, ... } +/// ``` +/// +/// `` 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: +/// +/// ```text +/// { "match": { "tokenizer": "standard" | { "ngram": }, +/// "downcase": , "k": , "m": } } /// ``` /// +/// A bare `"match"` is the default options ([`MatchOptions::default`]: +/// 3-grams, downcased, `k = 3`, `m = 256`), so a plan written before options +/// had a wire form means what it always meant. In the object form each +/// option is optional and defaults the same way; an unknown or repeated +/// option, or a set the match scheme refuses (`k` outside `3..=16`, `m` not +/// a power of two in `32..=65536`, a zero n-gram length), is refused. The +/// other three indexes have no options and no object form. A field names +/// each output key at most once, so it carries at most one match index. +/// [`IndexSpec::to_value`] writes this form and [`IndexSpec::from_value`] +/// reads it. +/// +/// [`MatchOptions::default`]: crate::sem::MatchOptions::default +/// /// `"type"` is optional, and names a [`FieldType`] (`"int64"`, `"string"`, /// …; see [`FieldType::name`]). Declared, it is checked against the field's /// outputs here and against every value sealed into or opened from the @@ -263,7 +304,8 @@ impl Plan { /// # Examples /// /// ``` -/// use stack_encrypt::dynamic::{record, FfiValue, Output, TermKind}; +/// use stack_encrypt::dynamic::{record, FfiValue, Output}; +/// use stack_encrypt::target::IndexSpec; /// /// // As a binding would decode it from its caller: seal `age` under the /// // pair ("users", "age") and index it for equality. @@ -291,7 +333,7 @@ impl Plan { /// assert_eq!(plan.fields()[0].name(), "age"); /// assert_eq!( /// plan.fields()[0].outputs(), -/// [Output::Ciphertext, Output::Term(TermKind::Equality)] +/// [Output::Ciphertext, Output::Term(IndexSpec::Equality)] /// ); /// # Ok::<(), stack_encrypt::dynamic::Error>(()) /// ``` @@ -301,8 +343,9 @@ impl Plan { /// [`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 known -/// output names, is empty, or names an output twice, a `"type"` that is not +/// `"context"` or `"outputs"`, an output list that is not a list of +/// outputs (`"c"` or an index in its wire form, above), is empty, or names +/// an output key twice, a `"type"` that is not /// a string naming a [`FieldType`], or a type that does not admit one of /// the field's index outputs. [`Error::Context`] for a `"context"` that is /// present but is not a context, or renders empty. @@ -330,14 +373,10 @@ pub fn plan(value: FfiValue) -> Result { let FfiValue::Array(items) = value else { return Err(Error::Plan); }; - let mut parsed = Vec::with_capacity(items.len()); - for item in &items { - let FfiValue::String(s) = item else { - return Err(Error::Plan); - }; - let key = utf8(s).ok_or(Error::Plan)?; - parsed.push(Output::parse(key).ok_or(Error::Plan)?); - } + let parsed = items + .iter() + .map(Output::from_value) + .collect::, _>>()?; outputs = Some(parsed); } "type" if field_type.is_none() => { @@ -857,7 +896,7 @@ fn source_rows(source: FfiValue, plan: &Plan) -> Result>, Err /// A source value against its plan field: a typed field needs a value of /// its type, every term output needs a scalar the scheme defines the term -/// for ([`TermKind::supports`]), and a ciphertext output refuses a +/// for ([`IndexSpec::supports`]), and a ciphertext output refuses a /// passthrough, or a repeated map key, anywhere in the value /// ([`check_tree`]). fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { @@ -870,9 +909,9 @@ fn check_field(value: &FfiValue, field: &FieldPlan) -> Result<(), Error> { match output { Output::Ciphertext => check_tree(value)?, Output::Term(kind) => { - let scalar = Scalar::of(value, *kind)?; + let scalar = Scalar::of(value, kind)?; if !kind.supports(&scalar) { - return Err(Error::Term { kind: *kind }); + return Err(Error::Term { kind: kind.clone() }); } } } @@ -949,7 +988,7 @@ where .outputs .iter() .find_map(|o| match o { - Output::Term(kind) => Some(*kind), + Output::Term(kind) => Some(kind), Output::Ciphertext => None, }) .map(|kind| Scalar::of(&value, kind)) @@ -964,7 +1003,7 @@ where let scalar = scalar.clone().ok_or(Error::Internal)?; outputs.push(( output.key(), - Slot::Term(term(cipher, scalar, *kind, context.clone()).await?), + Slot::Term(term(cipher, scalar, kind, context.clone()).await?), )); } @@ -1254,8 +1293,8 @@ mod tests { plan.fields()[0].outputs(), [ Output::Ciphertext, - Output::Term(TermKind::Equality), - Output::Term(TermKind::Ore) + Output::Term(IndexSpec::Equality), + Output::Term(IndexSpec::Ore) ], "outputs keep their spelled order" ); @@ -1266,7 +1305,9 @@ mod tests { ); assert_eq!( plan.fields()[2].outputs(), - [Output::Term(TermKind::Match)], + [Output::Term(IndexSpec::Match( + crate::sem::MatchOptions::default() + ))], "a field can be indexed and never sealed" ); assert!( @@ -1407,7 +1448,7 @@ mod tests { FieldPlan::new( "age", ctx.clone(), - vec![Output::Term(TermKind::Ore), Output::Term(TermKind::Ore)] + vec![Output::Term(IndexSpec::Ore), Output::Term(IndexSpec::Ore)] ), Err(Error::Plan) ), @@ -1419,6 +1460,101 @@ mod tests { ); } + /// A match index with non-default options is spelled in a plan as + /// an object, parses to the index carrying them, and still rides + /// under the `"match"` key; the bare key stays the defaults. + #[test] + fn a_match_index_with_options_parses_from_its_object_form() { + let wide = obj(vec![( + "match", + obj(vec![ + ("tokenizer", s("standard")), + ("downcase", FfiValue::Bool(false)), + ("k", FfiValue::UInt32(6)), + ("m", FfiValue::UInt32(1024)), + ]), + )]); + let plan = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ("outputs", FfiValue::Array(vec![s("c"), wide])), + ]), + )])) + .expect("parses"); + let options = crate::sem::MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + }; + assert_eq!( + plan.fields()[0].outputs(), + [Output::Ciphertext, Output::Term(IndexSpec::Match(options))] + ); + assert_eq!(plan.fields()[0].outputs()[1].key(), "match"); + } + + /// Two match indexes in one field would both ride under `"match"`, + /// so a field names an output key once whatever the options. + #[test] + fn a_field_refuses_two_match_indexes_under_different_options() { + let wide = obj(vec![("match", obj(vec![("k", FfiValue::UInt32(6))]))]); + let parsed = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ("outputs", FfiValue::Array(vec![s("match"), wide])), + ]), + )])); + assert!( + matches!(parsed, Err(Error::Plan)), + "two match outputs are one key twice" + ); + let ctx = context(s("users/nick")).expect("context"); + let by_hand = FieldPlan::new( + "nick", + ctx, + vec![ + Output::Term(IndexSpec::Match(crate::sem::MatchOptions::default())), + Output::Term(IndexSpec::Match(crate::sem::MatchOptions { + k: 6, + ..crate::sem::MatchOptions::default() + })), + ], + ); + assert!(matches!(by_hand, Err(Error::Plan)), "and by hand alike"); + } + + /// An output list entry is `"c"` or an index in its wire form, and + /// nothing else; a malformed match object is a plan error. + #[test] + fn an_output_that_is_not_one_is_refused() { + for (label, output) in [ + ("an unknown key", s("cc")), + ("a number", FfiValue::UInt32(1)), + ( + "an object for the ciphertext", + obj(vec![("c", obj(vec![]))]), + ), + ( + "match options out of bounds", + obj(vec![("match", obj(vec![("k", FfiValue::UInt32(2))]))]), + ), + ] { + let parsed = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ("outputs", FfiValue::Array(vec![output])), + ]), + )])); + assert!(matches!(parsed, Err(Error::Plan)), "{label}"); + } + assert_eq!(Output::parse("c"), Some(Output::Ciphertext)); + assert_eq!(Output::parse("cc"), None); + } + /// The whole-plan rules hold for a plan built by hand, not only for /// a parsed one: a hand-built plan reaches the same `encrypt` and /// `check_source`, which rely on them. @@ -1536,7 +1672,7 @@ mod tests { matches!( e, Error::Term { - kind: TermKind::Match + kind: IndexSpec::Match(_) } ) }, @@ -1552,7 +1688,7 @@ mod tests { matches!( e, Error::Term { - kind: TermKind::Match + kind: IndexSpec::Match(_) } ) }, @@ -1592,7 +1728,7 @@ mod tests { matches!( err, Some(Error::Term { - kind: TermKind::Match + kind: IndexSpec::Match(_) }) ), "encrypt refuses a value with no such term: {err:?}" @@ -1616,7 +1752,7 @@ mod tests { matches!( err, Some(Error::Term { - kind: TermKind::Equality + kind: IndexSpec::Equality }) ), "no PRF encoding exists for a float: {err:?}" @@ -1708,7 +1844,7 @@ mod tests { let eq = term( &keyset, Scalar::U32(34), - TermKind::Equality, + &IndexSpec::Equality, age_ctx.clone(), ) .await @@ -1718,7 +1854,7 @@ mod tests { eq, "the equality term is the standalone derivation under the plan context" ); - let ore = term(&keyset, Scalar::U32(34), TermKind::Ore, age_ctx) + let ore = term(&keyset, Scalar::U32(34), &IndexSpec::Ore, age_ctx) .await .expect("standalone ore term"); assert_eq!( @@ -1726,10 +1862,19 @@ mod tests { ore, "the ore term is the standalone derivation under the plan context" ); - let scalar = Scalar::of(&s("al smith"), TermKind::Match).expect("text"); - let matched = term(&keyset, scalar, TermKind::Match, nick_ctx) - .await - .expect("standalone match term"); + let scalar = Scalar::of( + &s("al smith"), + &IndexSpec::Match(crate::sem::MatchOptions::default()), + ) + .expect("text"); + let matched = term( + &keyset, + scalar, + &IndexSpec::Match(crate::sem::MatchOptions::default()), + nick_ctx, + ) + .await + .expect("standalone match term"); assert_eq!( term_bytes(&node(&mut nick, "match")), matched, @@ -1737,6 +1882,45 @@ mod tests { ); } + /// A plan's match options reach the term: the stored `"match"` + /// term is the derivation under those options, not the defaults. + #[tokio::test] + async fn a_match_term_derives_under_the_plan_options() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let options = crate::sem::MatchOptions { + k: 6, + m: 1024, + ..crate::sem::MatchOptions::default() + }; + let plan = plan(obj(vec![( + "nick", + obj(vec![ + ("context", s("users/nick")), + ( + "outputs", + FfiValue::Array(vec![IndexSpec::Match(options.clone()).to_value()]), + ), + ]), + )])) + .expect("parses"); + let ctx = plan.fields()[0].context().clone(); + let row = obj(vec![("nick", s("al smith"))]); + let mut fields = map(encrypt(&keyset, row, &plan).await.expect("encrypt")); + let mut nick = map(node(&mut fields, "nick")); + let stored = term_bytes(&node(&mut nick, "match")); + + let kind = IndexSpec::Match(options); + let scalar = Scalar::of(&s("al smith"), &kind).expect("text"); + let expected = term(&keyset, scalar.clone(), &kind, ctx.clone()) + .await + .expect("standalone"); + assert_eq!(stored, expected, "the term is the plan options' derivation"); + let default = IndexSpec::Match(crate::sem::MatchOptions::default()); + let under_default = term(&keyset, scalar, &default, ctx).await.expect("default"); + assert_ne!(stored, under_default, "and not the defaults'"); + } + #[tokio::test] async fn opens_back_to_its_ciphertext_bearing_fields_in_plan_order() { let cipher = cipher().await; @@ -2207,7 +2391,7 @@ mod tests { let field = FieldPlan::new( "age", context(s("users/age")).expect("context"), - vec![Output::Ciphertext, Output::Term(TermKind::Equality)], + vec![Output::Ciphertext, Output::Term(IndexSpec::Equality)], ) .expect("field"); assert_eq!(field.field_type(), None, "untyped until declared"); @@ -2355,11 +2539,11 @@ mod tests { let declared = field.field_type().expect("typed"); let value = declared.read(FfiValue::Float64(34.0)).expect("an exact 34"); - let scalar = Scalar::of(&value, TermKind::Equality).expect("scalar"); + let scalar = Scalar::of(&value, &IndexSpec::Equality).expect("scalar"); let probe = term( &keyset, scalar, - TermKind::Equality, + &IndexSpec::Equality, field.view().expect("view"), ) .await diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 3f7d9a846..9e6aa0a90 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -14,85 +14,169 @@ //! going to be **stored** should come from the record path, where it shares //! one context with the ciphertext beside it (ADR-0004). -use std::fmt; - use stack_kms::DataKeySource; use vitaminc_aead_value::FfiValue; use vitaminc_protected::{Controlled, OpaqueDebug, Protected}; use zeroize::Zeroizing; use super::{utf8, Error}; -use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchConfig}; +use crate::sem::{CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchOptions, Tokenizer}; +use crate::target::IndexSpec; use crate::{IntoPrfContext, KeysetCipher, NonEmpty}; -/// Which index term to derive. +/// The runtime half of [`IndexSpec`]: the domain table, and the index's wire +/// form in a record plan. /// -/// The `key` strings are wire format, and that is why this enum is -/// exhaustive — see the [module docs](super#stability). -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] -pub enum TermKind { - /// `"eq"` — equality (exact match). Raw 32 PRF bytes. - Equality, - /// `"match"` — full-text match under the default tokenizer config. LE - /// `u16` bit positions. - Match, - /// `"ore"` — order-revealing comparison. Raw CLLW bytes. - Ore, - /// `"ope"` — order-preserving comparison. Raw CLLW bytes. - Ope, -} +/// # Wire form +/// +/// An index is its key, a string: `"eq"`, `"match"`, `"ore"` or `"ope"`. +/// `"match"` is a match index under the default options +/// ([`MatchOptions::default`]), so every plan written before options had a +/// wire form reads as it always did. +/// +/// A match index under other options is a one-entry object, the key +/// `"match"` mapped to its options: +/// +/// ```text +/// { "match": { "tokenizer": "standard" | { "ngram": }, +/// "downcase": , "k": , "m": } } +/// ``` +/// +/// Every option is optional and defaults to [`MatchOptions::default`]'s +/// value; an unknown option, an option given twice, or options that fail +/// the match scheme's bounds (`k` in `3..=16`, `m` a power of two in +/// `[32, 65536]`, a non-zero n-gram length) are refused. The other three +/// indexes have no options and no object form. +/// +/// [`to_value`](Self::to_value) writes the string whenever the options are +/// the defaults and the object, with all four options, otherwise, so a +/// default plan is byte-for-byte what it was before options had a wire form. +impl IndexSpec { + /// The index a bare key names, under default options, or `None` for a + /// string that is not an index key. + pub fn parse(key: &str) -> Option { + Some(match key { + "eq" => IndexSpec::Equality, + "match" => IndexSpec::Match(MatchOptions::default()), + "ore" => IndexSpec::Ore, + "ope" => IndexSpec::Ope, + _ => return None, + }) + } -impl TermKind { - /// The map key this term rides under in a record, and the string a - /// binding spells it as. - pub fn key(self) -> &'static str { + /// Read an index from its wire form (see the [type docs](Self#wire-form)). + /// + /// # Errors + /// + /// [`Error::Plan`] for a value that is neither an index key nor a match + /// options object, or whose options are unknown, repeated, mistyped or + /// out of bounds. + pub fn from_value(value: &FfiValue) -> Result { + match value { + FfiValue::String(s) => Self::parse(utf8(s).ok_or(Error::Plan)?).ok_or(Error::Plan), + FfiValue::Object(entries) => match entries.as_slice() { + [(key, FfiValue::Object(options))] if key == "match" => { + Ok(IndexSpec::Match(match_options(options)?)) + } + _ => Err(Error::Plan), + }, + _ => Err(Error::Plan), + } + } + + /// Write this index in its wire form (see the [type docs](Self#wire-form)): + /// the key, or for a match index under non-default options, the object + /// carrying all four of them. [`from_value`](Self::from_value) reads it + /// back to an equal index. + pub fn to_value(&self) -> FfiValue { match self { - TermKind::Equality => "eq", - TermKind::Match => "match", - TermKind::Ore => "ore", - TermKind::Ope => "ope", + IndexSpec::Match(options) if *options != MatchOptions::default() => { + let tokenizer = match options.tokenizer { + Tokenizer::Standard => FfiValue::String("standard".into()), + Tokenizer::Ngram { length } => FfiValue::Object(vec![( + "ngram".to_string(), + FfiValue::UInt64(length as u64), + )]), + }; + FfiValue::Object(vec![( + "match".to_string(), + FfiValue::Object(vec![ + ("tokenizer".to_string(), tokenizer), + ("downcase".to_string(), FfiValue::Bool(options.downcase)), + ("k".to_string(), FfiValue::UInt64(options.k as u64)), + ("m".to_string(), FfiValue::UInt64(u64::from(options.m))), + ]), + )]) + } + _ => FfiValue::String(self.key().into()), } } - /// Whether the scheme defines this term for `scalar`. + /// Whether the scheme defines this index's term for `scalar`. /// /// No PRF encoding exists for floats (equality on IEEE-754 values is a /// modelling error) or booleans; match is text-only; the ordering /// schemes take every scalar. This is the one table — [`term`]'s arms /// mirror it and are unreachable for a pair it refuses — and it is /// consulted before any cipher work, so a binding can reject a bad - /// request at its boundary without minting anything. - pub fn supports(self, scalar: &Scalar) -> bool { + /// request at its boundary without minting anything. A match index's + /// options do not change its domain. + pub fn supports(&self, scalar: &Scalar) -> bool { match self { - TermKind::Equality => { + IndexSpec::Equality => { !matches!(scalar, Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_)) } - TermKind::Match => matches!(scalar, Scalar::Text(_)), - TermKind::Ore | TermKind::Ope => true, + IndexSpec::Match(_) => matches!(scalar, Scalar::Text(_)), + IndexSpec::Ore | IndexSpec::Ope => true, } } } -/// A term kind as the index it names: the data form of the typed -/// [`Index`](crate::target::Index) set. A dynamic term derives under the -/// default match configuration, so that is what [`TermKind::Match`] lowers -/// to. -impl From for crate::target::IndexSpec { - fn from(kind: TermKind) -> Self { - use crate::target::IndexSpec; - match kind { - TermKind::Equality => IndexSpec::Equality, - TermKind::Match => IndexSpec::Match(::options()), - TermKind::Ore => IndexSpec::Ore, - TermKind::Ope => IndexSpec::Ope, +/// The options object of a match index's wire form: each key at most once, +/// each defaulting, and the whole checked against the scheme's bounds. +fn match_options(entries: &[(String, FfiValue)]) -> Result { + let mut options = MatchOptions::default(); + let mut seen: Vec<&str> = Vec::with_capacity(entries.len()); + for (key, value) in entries { + if seen.contains(&key.as_str()) { + return Err(Error::Plan); + } + seen.push(key); + match (key.as_str(), value) { + ("tokenizer", FfiValue::String(s)) if utf8(s) == Some("standard") => { + options.tokenizer = Tokenizer::Standard; + } + ("tokenizer", FfiValue::Object(tokenizer)) => match tokenizer.as_slice() { + [(name, length)] if name == "ngram" => { + options.tokenizer = Tokenizer::Ngram { + length: integer(length)?, + }; + } + _ => return Err(Error::Plan), + }, + ("downcase", FfiValue::Bool(downcase)) => options.downcase = *downcase, + ("k", k) => options.k = integer(k)?, + ("m", m) => options.m = integer(m)?, + _ => return Err(Error::Plan), } } + if options.validate().is_err() { + return Err(Error::Plan); + } + Ok(options) } -impl fmt::Display for TermKind { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.key()) - } +/// A non-negative integer leaf of any width, as the target type, or +/// [`Error::Plan`]. +fn integer>(value: &FfiValue) -> Result { + let wide = match value { + FfiValue::Int32(v) => u64::try_from(*v).ok(), + FfiValue::Int64(v) => u64::try_from(*v).ok(), + FfiValue::UInt32(v) => Some(u64::from(*v)), + FfiValue::UInt64(v) => Some(*v), + _ => None, + }; + wide.and_then(|v| T::try_from(v).ok()).ok_or(Error::Plan) } /// A term-able scalar lifted out of an [`FfiValue`] leaf. @@ -134,8 +218,8 @@ impl Scalar { /// [`Error::Term`] for a container, null, undefined or passthrough: /// those have no term semantics at all, whatever the kind. `kind` names /// the term the caller was after, for the error only — whether that kind - /// is defined for the scalar is [`TermKind::supports`]. - pub fn of(value: &FfiValue, kind: TermKind) -> Result { + /// is defined for the scalar is [`IndexSpec::supports`]. + pub fn of(value: &FfiValue, kind: &IndexSpec) -> Result { Ok(match value { FfiValue::Bool(v) => Scalar::Bool(*v), FfiValue::Int32(v) => Scalar::I32(*v), @@ -145,11 +229,13 @@ impl Scalar { FfiValue::Float32(v) => Scalar::F32(*v), FfiValue::Float64(v) => Scalar::F64(*v), FfiValue::String(s) => Scalar::Text(Zeroizing::new( - utf8(s).ok_or(Error::Term { kind })?.to_string(), + utf8(s) + .ok_or_else(|| Error::Term { kind: kind.clone() })? + .to_string(), )), FfiValue::Bytes(b) => Scalar::Bytes(Zeroizing::new(b.risky_ref().to_vec())), // Containers, nulls and passthroughs have no term semantics. - _ => return Err(Error::Term { kind }), + _ => return Err(Error::Term { kind: kind.clone() }), }) } } @@ -162,7 +248,8 @@ impl Scalar { /// derives for the same value under the same context: /// /// ``` -/// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar, TermKind}; +/// use stack_encrypt::dynamic::{context, term, FfiValue, Scalar}; +/// use stack_encrypt::target::IndexSpec; /// use stack_encrypt::StackCipher; /// use stack_encrypt::kms::FakeDataKeySource; /// @@ -177,7 +264,7 @@ impl Scalar { /// FfiValue::String("users".into()), /// FfiValue::String("age".into()), /// ]))?; -/// let probe = term(&keyset, Scalar::U32(34), TermKind::Equality, ctx.clone()).await?; +/// let probe = term(&keyset, Scalar::U32(34), &IndexSpec::Equality, ctx.clone()).await?; /// let typed = keyset.equality_term(34u32, ctx).await?; /// assert_eq!(probe, typed.into_bytes().to_vec()); /// # Ok::<(), stack_encrypt::dynamic::Error>(()) @@ -187,13 +274,13 @@ impl Scalar { /// # Errors /// /// [`Error::Term`] if the scheme defines no such term for the scalar -/// ([`TermKind::supports`] is the table, and checking it first is how a +/// ([`IndexSpec::supports`] is the table, and checking it first is how a /// binding turns this into a boundary rejection). [`Error::Cipher`] if the /// derivation itself fails. pub async fn term<'c, K, D>( cipher: &KeysetCipher<'_, K>, scalar: Scalar, - kind: TermKind, + kind: &IndexSpec, context: NonEmpty, ) -> Result, Error> where @@ -201,14 +288,14 @@ where D: IntoPrfContext<'c>, { match kind { - TermKind::Equality => equality(cipher, scalar, context).await, - TermKind::Match => match_term(cipher, scalar, context).await, - TermKind::Ore => ore_of(cipher, scalar, context).await, - TermKind::Ope => ope_of(cipher, scalar, context).await, + IndexSpec::Equality => equality(cipher, scalar, context).await, + IndexSpec::Match(options) => match_term(cipher, scalar, options, context).await, + IndexSpec::Ore => ore_of(cipher, scalar, context).await, + IndexSpec::Ope => ope_of(cipher, scalar, context).await, } } -/// [`TermKind::Equality`] per scalar: one PRF block over the value, for +/// [`IndexSpec::Equality`] per scalar: one PRF block over the value, for /// every integer width, text and bytes. async fn equality<'c, K, D>( cipher: &KeysetCipher<'_, K>, @@ -234,17 +321,21 @@ where // values is a modelling error) or booleans. Scalar::Bool(_) | Scalar::F32(_) | Scalar::F64(_) => { return Err(Error::Term { - kind: TermKind::Equality, + kind: IndexSpec::Equality, }) } }?; Ok(term.into_bytes().to_vec()) } -/// [`TermKind::Match`] per scalar: text only. +/// [`IndexSpec::Match`] per scalar: text only, under the index's options. +/// Under the default options the bytes are the typed +/// `match_terms::`'s; under others, those of a +/// [`MatchConfig`](crate::sem::MatchConfig) returning the same options. async fn match_term<'c, K, D>( cipher: &KeysetCipher<'_, K>, scalar: Scalar, + options: &MatchOptions, context: NonEmpty, ) -> Result, Error> where @@ -253,16 +344,16 @@ where { match scalar { Scalar::Text(t) => Ok(cipher - .match_terms::(&t, context) + .match_terms_under::(&t, context, options.clone()) .await .map(|t| t.to_bytes())?), _ => Err(Error::Term { - kind: TermKind::Match, + kind: IndexSpec::Match(options.clone()), }), } } -/// [`TermKind::Ore`] per scalar: every scalar has an ORE encoding. +/// [`IndexSpec::Ore`] per scalar: every scalar has an ORE encoding. /// /// The text and bytes arms hand the encryptor the `Zeroizing` operand /// itself, not a bare clone of its contents: the CLLW encryptors take @@ -292,7 +383,7 @@ where } } -/// [`TermKind::Ope`] per scalar; see [`ore_of`] for why the text and bytes +/// [`IndexSpec::Ope`] per scalar; see [`ore_of`] for why the text and bytes /// arms pass the wrapper. async fn ope_of<'c, K, D>( cipher: &KeysetCipher<'_, K>, @@ -368,6 +459,12 @@ mod tests { .expect("build cipher") } + /// A match index under the default options: what a plan's bare + /// `"match"` names. + fn default_match() -> IndexSpec { + IndexSpec::Match(MatchOptions::default()) + } + fn s(value: &str) -> FfiValue { FfiValue::String(value.into()) } @@ -401,16 +498,18 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let ctx = context(s("users/x")).expect("context"); - let dynamic = |value: &FfiValue, kind: TermKind| { - let scalar = Scalar::of(value, kind).expect("a scalar"); - term(&keyset, scalar, kind, ctx.clone()) + let ks = &keyset; + let dynamic = |value: &FfiValue, kind: IndexSpec| { + let scalar = Scalar::of(value, &kind).expect("a scalar"); + let ctx = ctx.clone(); + async move { term(ks, scalar, &kind, ctx).await } }; let eq = |t: crate::sem::EqualityTerm| t.into_bytes().to_vec(); // Equality, per PRF-encodable variant. let typed = keyset.equality_term(-3i32, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::Int32(-3), TermKind::Equality) + dynamic(&FfiValue::Int32(-3), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -418,7 +517,7 @@ mod tests { ); let typed = keyset.equality_term(-4i64, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::Int64(-4), TermKind::Equality) + dynamic(&FfiValue::Int64(-4), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -426,7 +525,7 @@ mod tests { ); let typed = keyset.equality_term(34u32, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::UInt32(34), TermKind::Equality) + dynamic(&FfiValue::UInt32(34), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -434,7 +533,7 @@ mod tests { ); let typed = keyset.equality_term(35u64, nonempty!("users/x")).await; assert_eq!( - dynamic(&FfiValue::UInt64(35), TermKind::Equality) + dynamic(&FfiValue::UInt64(35), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -444,7 +543,7 @@ mod tests { .equality_term("alice".to_string(), nonempty!("users/x")) .await; assert_eq!( - dynamic(&s("alice"), TermKind::Equality).await.expect("eq"), + dynamic(&s("alice"), IndexSpec::Equality).await.expect("eq"), eq(typed.expect("typed")), "text equality" ); @@ -452,7 +551,7 @@ mod tests { .equality_term(Protected::new(b"ab".to_vec()), nonempty!("users/x")) .await; assert_eq!( - dynamic(&bytes(b"ab"), TermKind::Equality) + dynamic(&bytes(b"ab"), IndexSpec::Equality) .await .expect("eq"), eq(typed.expect("typed")), @@ -465,7 +564,7 @@ mod tests { .await .expect("typed"); assert_eq!( - dynamic(&s("alice smith"), TermKind::Match) + dynamic(&s("alice smith"), default_match()) .await .expect("match"), typed.to_bytes(), @@ -478,13 +577,13 @@ mod tests { ($value:expr, $leaf:expr, $label:literal) => { let typed = keyset.ore_term($value, nonempty!("users/x")).await; assert_eq!( - dynamic(&$leaf, TermKind::Ore).await.expect("ore"), + dynamic(&$leaf, IndexSpec::Ore).await.expect("ore"), typed.expect("typed").as_ref().to_vec(), concat!($label, " ore") ); let typed = keyset.ope_term($value, nonempty!("users/x")).await; assert_eq!( - dynamic(&$leaf, TermKind::Ope).await.expect("ope"), + dynamic(&$leaf, IndexSpec::Ope).await.expect("ope"), typed.expect("typed").as_ref().to_vec(), concat!($label, " ope") ); @@ -504,23 +603,29 @@ mod tests { #[test] fn supports_is_true() { for (label, leaf) in every_scalar() { - let scalar = Scalar::of(&leaf, TermKind::Ore).expect("a scalar"); - assert!(TermKind::Ore.supports(&scalar), "{label} takes an ore term"); - assert!(TermKind::Ope.supports(&scalar), "{label} takes an ope term"); + let scalar = Scalar::of(&leaf, &IndexSpec::Ore).expect("a scalar"); + assert!( + IndexSpec::Ore.supports(&scalar), + "{label} takes an ore term" + ); + assert!( + IndexSpec::Ope.supports(&scalar), + "{label} takes an ope term" + ); } for (label, leaf) in every_scalar() { - let scalar = Scalar::of(&leaf, TermKind::Equality).expect("a scalar"); + let scalar = Scalar::of(&leaf, &IndexSpec::Equality).expect("a scalar"); let prf_encodable = !matches!( leaf, FfiValue::Bool(_) | FfiValue::Float32(_) | FfiValue::Float64(_) ); assert_eq!( - TermKind::Equality.supports(&scalar), + IndexSpec::Equality.supports(&scalar), prf_encodable, "{label} takes an equality term exactly when it has a PRF encoding" ); assert_eq!( - TermKind::Match.supports(&scalar), + default_match().supports(&scalar), matches!(leaf, FfiValue::String(_)), "{label} takes a match term exactly when it is text" ); @@ -540,22 +645,22 @@ mod tests { let keyset = cipher.default_keyset(); let ctx = context(s("users/x")).expect("context"); let refused = [ - ("a bool", FfiValue::Bool(true), TermKind::Equality), - ("an f32", FfiValue::Float32(1.5), TermKind::Equality), - ("an f64", FfiValue::Float64(2.5), TermKind::Equality), - ("a bool", FfiValue::Bool(true), TermKind::Match), - ("a u32", FfiValue::UInt32(34), TermKind::Match), - ("bytes", bytes(b"ab"), TermKind::Match), + ("a bool", FfiValue::Bool(true), IndexSpec::Equality), + ("an f32", FfiValue::Float32(1.5), IndexSpec::Equality), + ("an f64", FfiValue::Float64(2.5), IndexSpec::Equality), + ("a bool", FfiValue::Bool(true), default_match()), + ("a u32", FfiValue::UInt32(34), default_match()), + ("bytes", bytes(b"ab"), default_match()), ]; for (label, leaf, kind) in refused { - let scalar = Scalar::of(&leaf, kind).expect("a scalar"); + let scalar = Scalar::of(&leaf, &kind).expect("a scalar"); assert!( !kind.supports(&scalar), "{label} must not take a {kind} term" ); - let result = term(&keyset, scalar, kind, ctx.clone()).await; + let result = term(&keyset, scalar, &kind, ctx.clone()).await; assert!( - matches!(result, Err(Error::Term { kind: k }) if k == kind), + matches!(&result, Err(Error::Term { kind: k }) if *k == kind), "{label} asked for a {kind} term must be refused as that kind: {result:?}" ); } @@ -582,14 +687,14 @@ mod tests { ]; for (label, value) in not_scalars { for kind in [ - TermKind::Equality, - TermKind::Match, - TermKind::Ore, - TermKind::Ope, + IndexSpec::Equality, + default_match(), + IndexSpec::Ore, + IndexSpec::Ope, ] { - let result = Scalar::of(&value, kind); + let result = Scalar::of(&value, &kind); assert!( - matches!(result, Err(Error::Term { kind: k }) if k == kind), + matches!(&result, Err(Error::Term { kind: k }) if *k == kind), "{label} has no {kind} term: {result:?}" ); } @@ -597,47 +702,269 @@ mod tests { } } - mod given_a_term_kind { + mod given_an_index_spec { use super::*; + fn non_default_match() -> MatchOptions { + MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + } + } + + /// The four keys are wire format: a plan spells each index by + /// exactly this string, and the stored record keys its term by it. #[test] - fn it_lowers_to_the_index_spec_with_the_same_key() { - use crate::target::IndexSpec; + fn its_keys_are_the_four_wire_strings() { let cases = [ - (TermKind::Equality, IndexSpec::Equality), - ( - TermKind::Match, - IndexSpec::Match(crate::sem::MatchOptions::default()), - ), - (TermKind::Ore, IndexSpec::Ore), - (TermKind::Ope, IndexSpec::Ope), + (IndexSpec::Equality, "eq"), + (default_match(), "match"), + (IndexSpec::Match(non_default_match()), "match"), + (IndexSpec::Ore, "ore"), + (IndexSpec::Ope, "ope"), ]; - for (kind, spec) in cases { - let lowered = IndexSpec::from(kind); - assert_eq!(lowered.key(), kind.key(), "{kind} keeps its key"); - assert_eq!(lowered, spec, "{kind} lowers to its index"); + for (spec, key) in cases { + assert_eq!(spec.key(), key, "{spec:?} rides under {key}"); + assert_eq!( + spec.to_string(), + key, + "the display form is the key, for error messages" + ); } } + /// Every index under default options writes as its bare key and + /// reads back from it, so a plan written before options had a wire + /// form means what it always meant. #[test] - fn its_key_is_how_a_plan_spells_it() { - for kind in [ - TermKind::Equality, - TermKind::Match, - TermKind::Ore, - TermKind::Ope, + fn a_default_index_round_trips_through_its_bare_key() { + for spec in [ + IndexSpec::Equality, + default_match(), + IndexSpec::Ore, + IndexSpec::Ope, ] { + let wire = spec.to_value(); + assert!( + matches!(&wire, FfiValue::String(k) if utf8(k) == Some(spec.key())), + "{spec} writes as its bare key" + ); assert_eq!( - Output::parse(kind.key()), - Some(Output::Term(kind)), - "a plan spelling {kind} by its key names that term" + IndexSpec::from_value(&wire).expect("reads back"), + spec, + "{spec} reads back from its key" ); + assert_eq!(IndexSpec::parse(spec.key()), Some(spec.clone())); assert_eq!( - kind.to_string(), - kind.key(), - "the display form is the key, for error messages" + Output::parse(spec.key()), + Some(Output::Term(spec.clone())), + "a plan spelling {spec} by its key names that index" ); } + assert_eq!(IndexSpec::parse("c"), None, "the ciphertext is no index"); + assert_eq!(IndexSpec::parse("Match"), None, "keys are case-sensitive"); + } + + /// Non-default match options have a wire form, and it is lossless: + /// each option written, each read back. + #[test] + fn non_default_match_options_round_trip() { + let cases = [ + non_default_match(), + MatchOptions { + tokenizer: crate::sem::Tokenizer::Ngram { length: 4 }, + ..MatchOptions::default() + }, + MatchOptions { + downcase: false, + ..MatchOptions::default() + }, + MatchOptions { + k: 4, + ..MatchOptions::default() + }, + MatchOptions { + m: 512, + ..MatchOptions::default() + }, + ]; + for options in cases { + let spec = IndexSpec::Match(options.clone()); + let wire = spec.to_value(); + assert!( + matches!(&wire, FfiValue::Object(_)), + "{options:?} is not the default, so it writes as an object" + ); + assert_eq!( + IndexSpec::from_value(&wire).expect("reads back"), + spec, + "{options:?} round-trips" + ); + } + } + + /// The object form spells each option by name and defaults the + /// rest; the exact shape here is what a binding writes. + #[test] + fn the_object_form_defaults_omitted_options() { + let obj = |entries: Vec<(&str, FfiValue)>| { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + }; + let wire = obj(vec![( + "match", + obj(vec![ + ("tokenizer", obj(vec![("ngram", FfiValue::UInt32(4))])), + ("k", FfiValue::Int64(5)), + ("m", FfiValue::Int32(64)), + ]), + )]); + assert_eq!( + IndexSpec::from_value(&wire).expect("reads"), + IndexSpec::Match(MatchOptions { + tokenizer: crate::sem::Tokenizer::Ngram { length: 4 }, + k: 5, + m: 64, + ..MatchOptions::default() + }) + ); + let wire = obj(vec![( + "match", + obj(vec![ + ("tokenizer", s("standard")), + ("downcase", FfiValue::Bool(false)), + ("m", FfiValue::UInt64(2048)), + ]), + )]); + assert_eq!( + IndexSpec::from_value(&wire).expect("reads"), + IndexSpec::Match(MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + m: 2048, + ..MatchOptions::default() + }) + ); + // An empty options object is the defaults, and reads as the + // bare key does. + assert_eq!( + IndexSpec::from_value(&obj(vec![("match", obj(vec![]))])).expect("reads"), + default_match() + ); + } + + #[test] + fn a_malformed_wire_form_is_error_plan() { + let obj = |entries: Vec<(&str, FfiValue)>| { + FfiValue::Object( + entries + .into_iter() + .map(|(k, v)| (k.to_string(), v)) + .collect(), + ) + }; + let opts = |entries: Vec<(&str, FfiValue)>| obj(vec![("match", obj(entries))]); + let refused = [ + ("an unknown key", s("eqq")), + ("the ciphertext key", s("c")), + ("a number", FfiValue::UInt32(1)), + ("an object for eq", obj(vec![("eq", obj(vec![]))])), + ( + "match options that are not an object", + obj(vec![("match", s("x"))]), + ), + ( + "two entries", + obj(vec![("match", obj(vec![])), ("ore", obj(vec![]))]), + ), + ("an empty object", obj(vec![])), + ("an unknown option", opts(vec![("q", FfiValue::UInt32(1))])), + ( + "an option twice", + opts(vec![("k", FfiValue::UInt32(4)), ("k", FfiValue::UInt32(5))]), + ), + ( + "an unknown tokenizer", + opts(vec![("tokenizer", s("words"))]), + ), + ( + "a tokenizer object that is not ngram", + opts(vec![( + "tokenizer", + obj(vec![("words", FfiValue::UInt32(3))]), + )]), + ), + ( + "a zero n-gram", + opts(vec![( + "tokenizer", + obj(vec![("ngram", FfiValue::UInt32(0))]), + )]), + ), + ("a mistyped downcase", opts(vec![("downcase", s("yes"))])), + ("a negative k", opts(vec![("k", FfiValue::Int32(-3))])), + ("a float k", opts(vec![("k", FfiValue::Float64(3.0))])), + ("k out of bounds", opts(vec![("k", FfiValue::UInt32(17))])), + ( + "m not a power of two", + opts(vec![("m", FfiValue::UInt32(300))]), + ), + ( + "m too wide for u16", + opts(vec![("m", FfiValue::UInt64(1 << 17))]), + ), + ]; + for (label, wire) in refused { + assert!( + matches!(IndexSpec::from_value(&wire), Err(Error::Plan)), + "{label} is not an index" + ); + } + } + + /// A match index's options reach the derivation: the default is the + /// typed default's bytes, and other options derive other bytes — + /// those of a typed config naming the same options. + #[tokio::test] + async fn match_options_drive_the_derivation() { + struct Wide; + impl crate::sem::MatchConfig for Wide { + fn options() -> MatchOptions { + MatchOptions { + tokenizer: crate::sem::Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + } + } + } + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let ctx = context(s("users/x")).expect("context"); + let text = || Scalar::of(&s("Alice Smith"), &default_match()).expect("text"); + let wide = term( + &keyset, + text(), + &IndexSpec::Match(non_default_match()), + ctx.clone(), + ) + .await + .expect("wide"); + let typed = keyset + .match_terms::("Alice Smith", nonempty!("users/x")) + .await + .expect("typed"); + assert_eq!(wide, typed.to_bytes(), "the options are the typed config's"); + let default = term(&keyset, text(), &default_match(), ctx) + .await + .expect("default"); + assert_ne!(wide, default, "other options derive other terms"); } } @@ -648,7 +975,7 @@ mod tests { fn debug_prints_none_of_it() { let rendered = format!( "{:?}", - Scalar::of(&s("hunter2"), TermKind::Equality).expect("a scalar") + Scalar::of(&s("hunter2"), &IndexSpec::Equality).expect("a scalar") ); assert!( !rendered.contains("hunter2"), diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index 8c3336990..bde08aae7 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -367,7 +367,7 @@ where /// Options controlling match-term generation. The defaults mirror the existing /// match indexer: 3-gram tokens, downcased, `k = 3` hash slices into an /// `m = 256`-bit filter. -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, Hash)] pub struct MatchOptions { /// How text splits into tokens. pub tokenizer: Tokenizer, @@ -397,7 +397,7 @@ impl MatchOptions { // Bounds mirror the v1 match indexer (`cipherstash-core`'s // `bloom_filter`: K_MIN/K_MAX/M_MIN/M_MAX) so the same configuration // validates identically across the two stacks. - fn validate(&self) -> Result { + pub(crate) fn validate(&self) -> Result { if let Tokenizer::Ngram { length: 0 } = self.tokenizer { return Err(TermError::InvalidOptions( "n-gram length must be at least 1", @@ -1085,12 +1085,26 @@ impl KeysetCipher<'_, K> { where O: MatchConfig + MaybeSend, { - let term = match_term( - self.prf(), - text, - descriptor.into_prf_context(), - O::options(), - ); + self.match_terms_under(text, descriptor, O::options()) + } + + /// [`match_terms`](Self::match_terms) under runtime options, for the + /// `dynamic` path, whose plan carries a match index's options as data + /// (an `IndexSpec::Match`). Not public: the typed API keeps the options + /// on the type so a stored field and its probe cannot disagree, and a + /// binding reaches this only through a plan that names the options for + /// both. + #[cfg_attr(not(feature = "dynamic"), allow(dead_code))] + pub(crate) fn match_terms_under<'c, O>( + &self, + text: &str, + descriptor: NonEmpty>, + options: MatchOptions, + ) -> Pending<'_, MatchTerms, K> + where + O: MatchConfig + MaybeSend, + { + let term = match_term(self.prf(), text, descriptor.into_prf_context(), options); Pending::ready(self, term.map_err(Error::from)) } diff --git a/packages/stack-encrypt/src/sem/tokenize.rs b/packages/stack-encrypt/src/sem/tokenize.rs index 19ac3c119..1d5986fe5 100644 --- a/packages/stack-encrypt/src/sem/tokenize.rs +++ b/packages/stack-encrypt/src/sem/tokenize.rs @@ -11,7 +11,7 @@ //! split produces, which only add noise bits to every filter). /// How text is split into tokens before each token is run through the PRF. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum Tokenizer { /// Sliding character n-grams of the given length over the whole text /// (whitespace included). Text shorter than `length` yields **no tokens**, diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index d8b680acc..ef33d1b9a 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -64,13 +64,18 @@ use crate::StackCipherText; /// An index lowered to data: what crosses the FFI boundary and what a saved /// plan holds. The Rust side keeps the type ([`Index`]); a binding, which -/// has no type to name, speaks this. +/// has no type to name, speaks this. It is the one data form of an index: +/// the `dynamic` record plan, its term derivation and the Go guest all +/// spell an index as an `IndexSpec`, options included. /// /// The [`key`](Self::key) strings are the output keys of the `dynamic` record /// format (`"eq"`, `"match"`, `"ore"`, `"ope"`), so they are wire format; -/// that is why this enum is exhaustive, as `dynamic::TermKind` is: a new -/// index is something every binding has to be taught. -#[derive(Clone, Debug, PartialEq, Eq)] +/// that is why this enum is exhaustive: a new index is something every +/// binding has to be taught. With the `dynamic` feature, a plan spells an +/// index as its key, or, for a match index with non-default options, as an +/// object carrying them; `dynamic::record::plan` documents that shape, and +/// `IndexSpec::from_value` / `IndexSpec::to_value` read and write it. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub enum IndexSpec { /// Equality (exact match). Equality, @@ -83,8 +88,12 @@ pub enum IndexSpec { } impl IndexSpec { - /// The output key this index's term rides under in a record, and the - /// string a binding spells it as. + /// The output key this index's term rides under in a record. + /// + /// This is the kind alone. It is the whole wire form of every index but + /// a match index with non-default options, whose options it drops; to + /// write an index to a plan, use the serialiser (`IndexSpec::to_value`, + /// with the `dynamic` feature), which keeps them. pub fn key(&self) -> &'static str { match self { IndexSpec::Equality => "eq", From 40ccd40cb8f661616d2cc5057e9d1d2e1442c7da Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 5 Oct 2026 00:37:55 -0700 Subject: [PATCH 5/7] refactor(stack-encrypt)!: the field type of a plan is vitaminc's ValueKind The previous commit added dynamic::FieldType: an enum of the eleven value kinds a plan field can declare, with its names, tags, parser and the test that it covers vitaminc's tag table. vitaminc owns that vocabulary (the FfiValue model and the frozen tag table are its), and vitaminc 0.5.1 now publishes it as ValueKind, with name, FromStr, tags, holds and FfiValue::kind. A second enum in stack-encrypt was a copy that could drift from the one bindings decode against. So FieldType is gone and ValueKind is re-exported from stack_encrypt::dynamic. FieldPlan::with_type takes a ValueKind and field_type returns one; the plan parser reads "type" with ValueKind's FromStr, mapping its error to Error::Plan; encrypt and decrypt check a value with ValueKind::holds. The two rules vitaminc deliberately leaves to the declaring layer stay here, as free functions beside term and context: admits(kind, &IndexSpec), which indexes a kind is defined for, and read(kind, value), which reads a query value as a kind. The module is renamed field_type -> kind, and its docs now say the vocabulary is vitaminc's. The tests that checked the names, the tag table and holds are vitaminc's job now (it has its own, including that every kinded leaf seals under a tag its kind names) and are dropped; the admits and read tests are ported. Every vitaminc crate in the root workspace and in the Go stackencrypt guest (a detached workspace) moves to 0.5.1, so the family resolves to one version: a split would duplicate the aead crate and fail the FfiValue: Decrypt bound. The workspaces that build a root crate by path inherit that requirement, so their lockfiles move too, vitaminc entries only: packages/eql (eql-bindings' stack-encrypt feature, which test:encryption checks with --locked), the three fuzz crates and the stackauth guest. Their manifests are unchanged. BREAKING CHANGE: dynamic::FieldType (added earlier in this change set, never released) is replaced by vitaminc_aead_value::ValueKind, re-exported as dynamic::ValueKind. FieldType::admits and FieldType::read become the free functions dynamic::admits(kind, &index) and dynamic::read(kind, value). FieldPlan::with_type takes, and FieldPlan::field_type returns, a ValueKind. stack-encrypt now requires vitaminc 0.5.1. --- Cargo.lock | 52 +- Cargo.toml | 15 +- languages/golang/stackauth/guest/Cargo.lock | 40 +- .../golang/stackencrypt/guest/Cargo.lock | 52 +- .../golang/stackencrypt/guest/Cargo.toml | 4 +- packages/eql/Cargo.lock | 100 +-- packages/stack-auth/fuzz/Cargo.lock | 40 +- packages/stack-encrypt/CHANGELOG.md | 7 + packages/stack-encrypt/Cargo.toml | 2 +- packages/stack-encrypt/fuzz/Cargo.lock | 52 +- .../stack-encrypt/src/dynamic/field_type.rs | 577 ------------------ packages/stack-encrypt/src/dynamic/kind.rs | 410 +++++++++++++ packages/stack-encrypt/src/dynamic/mod.rs | 21 +- packages/stack-encrypt/src/dynamic/record.rs | 64 +- packages/stack-kms/fuzz/Cargo.lock | 40 +- 15 files changed, 663 insertions(+), 813 deletions(-) delete mode 100644 packages/stack-encrypt/src/dynamic/field_type.rs create mode 100644 packages/stack-encrypt/src/dynamic/kind.rs diff --git a/Cargo.lock b/Cargo.lock index ba86403c9..44242e2a1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4071,9 +4071,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -4085,9 +4085,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -4100,9 +4100,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -4111,9 +4111,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", "vitaminc-protected", @@ -4122,9 +4122,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -4132,9 +4132,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -4146,9 +4146,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac", "sha2 0.11.0", @@ -4159,9 +4159,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.18", @@ -4171,9 +4171,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -4188,9 +4188,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -4199,9 +4199,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -4214,9 +4214,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -4225,9 +4225,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/Cargo.toml b/Cargo.toml index 1d4fb9c76..4ad564ff7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -120,12 +120,13 @@ web-time = "1.1" zeroize = { version = "1.8.1", features = ["derive"] } # This is needed because lock_api 0.4.6 was breaking things and the branch forces the use of 0.4.12 temp-env = { git = "https://github.com/cipherstash/temp-env", branch = "main" } -vitaminc = { version = "0.5.0", features = ["random", "protected", "encrypt"] } -vitaminc-aead = "0.5.0" +vitaminc = { version = "0.5.1", features = ["random", "protected", "encrypt"] } +vitaminc-aead = "0.5.1" # The dynamic value model (`FfiValue`) and its FFI transport codec, shared by # every language binding. Optional in stack-encrypt (the `dynamic` feature). -vitaminc-aead-value = "0.5.0" -vitaminc-encrypt = "0.5.0" -vitaminc-hmac = "0.5.0" -vitaminc-prf = "0.5.0" -vitaminc-protected = "0.5.0" +# 0.5.1 adds `ValueKind`, the field-type vocabulary a dynamic plan declares. +vitaminc-aead-value = "0.5.1" +vitaminc-encrypt = "0.5.1" +vitaminc-hmac = "0.5.1" +vitaminc-prf = "0.5.1" +vitaminc-protected = "0.5.1" diff --git a/languages/golang/stackauth/guest/Cargo.lock b/languages/golang/stackauth/guest/Cargo.lock index eee72b24b..2f72f6198 100644 --- a/languages/golang/stackauth/guest/Cargo.lock +++ b/languages/golang/stackauth/guest/Cargo.lock @@ -2230,9 +2230,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2244,9 +2244,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2259,9 +2259,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2281,9 +2281,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2291,9 +2291,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2305,9 +2305,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2322,9 +2322,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2333,9 +2333,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2348,9 +2348,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2359,9 +2359,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/languages/golang/stackencrypt/guest/Cargo.lock b/languages/golang/stackencrypt/guest/Cargo.lock index 7488fea75..152221f21 100644 --- a/languages/golang/stackencrypt/guest/Cargo.lock +++ b/languages/golang/stackencrypt/guest/Cargo.lock @@ -2444,9 +2444,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2458,9 +2458,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2473,9 +2473,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2484,9 +2484,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", "vitaminc-protected", @@ -2495,9 +2495,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2505,9 +2505,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2519,9 +2519,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac", "sha2 0.11.0", @@ -2532,9 +2532,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.20", @@ -2544,9 +2544,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2561,9 +2561,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2572,9 +2572,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -2587,9 +2587,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2598,9 +2598,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/languages/golang/stackencrypt/guest/Cargo.toml b/languages/golang/stackencrypt/guest/Cargo.toml index 69f82059e..e676076a6 100644 --- a/languages/golang/stackencrypt/guest/Cargo.toml +++ b/languages/golang/stackencrypt/guest/Cargo.toml @@ -42,8 +42,8 @@ zerokms-protocol = "=0.12.31" # (see the comment in packages/stack-encrypt/Cargo.toml). A different # version here fails at the `FfiValue: Decrypt` bound, since the aead crate # would be duplicated. -vitaminc-aead-value = "0.5.0" -vitaminc-protected = "0.5.0" +vitaminc-aead-value = "0.5.1" +vitaminc-protected = "0.5.1" futures = { version = "0.3", default-features = false, features = ["executor"] } serde = "1" diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 003abc66e..0c8971758 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -955,7 +955,7 @@ dependencies = [ "url", "utoipa", "uuid", - "vitaminc 0.5.0", + "vitaminc 0.5.1", ] [[package]] @@ -4212,8 +4212,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.5.0", - "vitaminc-protected 0.5.0", + "vitaminc 0.5.1", + "vitaminc-protected 0.5.1", "web-time", "zeroize", "zerokms-protocol", @@ -4230,11 +4230,11 @@ dependencies = [ "stack-kms", "thiserror 1.0.69", "uuid", - "vitaminc-aead 0.5.0", - "vitaminc-encrypt 0.5.0", + "vitaminc-aead 0.5.1", + "vitaminc-encrypt 0.5.1", "vitaminc-hmac", "vitaminc-prf", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", "zeroize", ] @@ -4269,8 +4269,8 @@ dependencies = [ "tracing", "url", "uuid", - "vitaminc 0.5.0", - "vitaminc-protected 0.5.0", + "vitaminc 0.5.1", + "vitaminc-protected 0.5.1", "zeroize", "zerokms-protocol", ] @@ -5020,16 +5020,16 @@ dependencies = [ [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ - "vitaminc-aead 0.5.0", + "vitaminc-aead 0.5.1", "vitaminc-context", - "vitaminc-encrypt 0.5.0", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", - "vitaminc-traits 0.5.0", + "vitaminc-encrypt 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", + "vitaminc-traits 0.5.1", ] [[package]] @@ -5047,24 +5047,24 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", "vitaminc-aead-derive", "vitaminc-context", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -5073,12 +5073,12 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", ] [[package]] @@ -5097,41 +5097,41 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", - "vitaminc-aead 0.5.0", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", + "vitaminc-aead 0.5.1", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac 0.13.0", "sha2 0.11.0", "vitaminc-prf", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", "zeroize", ] [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.20", "vitaminc-context", - "vitaminc-protected 0.5.0", + "vitaminc-protected 0.5.1", ] [[package]] @@ -5151,9 +5151,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -5162,7 +5162,7 @@ dependencies = [ "serde_bytes", "subtle", "thiserror 2.0.20", - "vitaminc-protected-derive 0.5.0", + "vitaminc-protected-derive 0.5.1", "zeroize", ] @@ -5179,9 +5179,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -5204,16 +5204,16 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", "rand 0.10.1", "thiserror 2.0.20", - "vitaminc-protected 0.5.0", - "vitaminc-random-derives 0.5.0", + "vitaminc-protected 0.5.1", + "vitaminc-random-derives 0.5.1", "zeroize", ] @@ -5230,9 +5230,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -5257,17 +5257,17 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", "rmp-serde", "serde", "thiserror 2.0.20", - "vitaminc-protected 0.5.0", - "vitaminc-random 0.5.0", + "vitaminc-protected 0.5.1", + "vitaminc-random 0.5.1", "zeroize", ] diff --git a/packages/stack-auth/fuzz/Cargo.lock b/packages/stack-auth/fuzz/Cargo.lock index 756d51a69..7aa19f3a5 100644 --- a/packages/stack-auth/fuzz/Cargo.lock +++ b/packages/stack-auth/fuzz/Cargo.lock @@ -3112,9 +3112,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -3126,9 +3126,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -3141,9 +3141,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -3152,9 +3152,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -3162,9 +3162,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -3176,9 +3176,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -3193,9 +3193,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -3204,9 +3204,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -3219,9 +3219,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -3230,9 +3230,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 2bf39ad65..66084d5b0 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -46,6 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `Decryption` held in a variable. - A dynamic plan's match index can carry options, as `{"match": {"tokenizer", "downcase", "k", "m"}}`. +- A dynamic plan field may declare its type, as `"type": ""`. The + vocabulary is vitaminc's `ValueKind` (re-exported as + `dynamic::ValueKind`), not a new enum, so this crate now needs vitaminc + 0.5.1. A declared type refuses an index it is not defined for + (`dynamic::admits`) and a value of another kind, on encrypt and on + decrypt. `dynamic::read` reads a query value as a kind; + `FieldPlan::with_type` and `field_type` set and read the declaration. ## [0.2.0] - 2026-10-04 diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index eaf3e1425..ea9c805d6 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -24,7 +24,7 @@ stack-auth = { workspace = true, optional = true } # `#[derive(EncryptFrom)]` / `#[derive(DecryptInto)]`, re-exported from `target`. stack-encrypt-derive = { path = "../stack-encrypt-derive", version = "0.2.0" } -# The workspace vitaminc (0.5.0): one canonical context encoding shared by +# The workspace vitaminc (0.5.1): one canonical context encoding shared by # the AEAD and PRF derivations (`IntoContext`, cipherstash/vitaminc#339), # `NonEmpty::with`, `From for NonEmpty` (#314) and the parts view # (#318), all of which this crate relies on. All five must share one diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 9a2612b77..ca039cf5c 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -2468,9 +2468,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2482,9 +2482,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2497,9 +2497,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2508,9 +2508,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-value" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b63326e8bf21f695080c50d8d849324fa92e4cf6ce7257bef198b1ff2eeea0d" +checksum = "5d78cdadbc6374c3bd7c19fb4d99abca693b96953b18fef8c608efbe092cf3ea" dependencies = [ "vitaminc-aead", "vitaminc-protected", @@ -2519,9 +2519,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2529,9 +2529,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2543,9 +2543,9 @@ dependencies = [ [[package]] name = "vitaminc-hmac" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccebde615f15197146a3fe4b3ae32ff00a6ae489f88ae9ee0b71e1cbdd286d94" +checksum = "15fe558a5e92d4f2b3f811c5b67d863d9255da5f66d08a71d4f0dd3d0011c563" dependencies = [ "hmac", "sha2 0.11.0", @@ -2556,9 +2556,9 @@ dependencies = [ [[package]] name = "vitaminc-prf" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0e6242717d2a5b3f0fdbdaf74340b60eb07713de537d5f82508065a8bfd7ef3" +checksum = "712d31d46d76b38c6b62deb35f516fc5636a35f90f39c0213d0ca9a052b4400b" dependencies = [ "mutants", "thiserror 2.0.18", @@ -2568,9 +2568,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2585,9 +2585,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2596,9 +2596,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -2611,9 +2611,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2622,9 +2622,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", diff --git a/packages/stack-encrypt/src/dynamic/field_type.rs b/packages/stack-encrypt/src/dynamic/field_type.rs deleted file mode 100644 index a43b4cba1..000000000 --- a/packages/stack-encrypt/src/dynamic/field_type.rs +++ /dev/null @@ -1,577 +0,0 @@ -//! The type a plan declares for a field. -//! -//! A typed host knows what `34` is: a Rust `u32`, a Go `int64`. A dynamically -//! typed host (JavaScript, PHP, Ruby) does not, and index semantics depend on -//! it: an ORE term of the integer `34` and of the float `34.0` differ, a -//! JavaScript number is a float, and match is defined over text alone. So a -//! plan field may say what its values are, and the engine uses that three -//! times: -//! -//! - **encrypt** admits only the indexes the type is defined for (when the -//! plan is built), and refuses a value of any other type (when it runs), -//! so the term bytes are the declared type's and no binding is trusted to -//! have tagged the value right; -//! - **query** reads the query value *as* the field's type -//! ([`FieldType::read`]): `34` against a `uint64` field is the `u64` term, -//! whatever number type the host handed over; -//! - **decrypt** refuses an opened value of any other type, so a host with no -//! types of its own can rely on the declaration for what it gets back (an -//! integer, not a float; bytes, not a string). -//! -//! The vocabulary is not new: it is vitaminc's frozen leaf-tag table -//! (`vitaminc_aead_value::tags`), one type per scalar tag, plus the two -//! composite kinds the transport frames (`array`, `object`). `null` and -//! `undefined` are tags but not field types: a type with one value says -//! nothing a field can be declared as. -use std::fmt; - -use vitaminc_aead_value::{tags, FfiValue}; - -use super::Error; -use crate::target::IndexSpec; - -/// The type of a plan field's values. -/// -/// The [`name`](Self::name) strings are wire format, spelled by a binding in -/// a plan's `"type"` key, so this enum is exhaustive for the reason -/// [`IndexSpec`] is (see the [module docs](super#stability)). -#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)] -pub enum FieldType { - /// `"bool"`: [`FfiValue::Bool`], tags `BOOL_FALSE` and `BOOL_TRUE`. - Bool, - /// `"int32"`: [`FfiValue::Int32`], tag `INT32`. - Int32, - /// `"int64"`: [`FfiValue::Int64`], tag `INT64`. - Int64, - /// `"uint32"`: [`FfiValue::UInt32`], tag `UINT32`. - UInt32, - /// `"uint64"`: [`FfiValue::UInt64`], tag `UINT64`. - UInt64, - /// `"float32"`: [`FfiValue::Float32`], tag `FLOAT32`. - Float32, - /// `"float64"`: [`FfiValue::Float64`], tag `FLOAT64`. - Float64, - /// `"string"`: [`FfiValue::String`], tag `STRING`. - String, - /// `"bytes"`: [`FfiValue::Bytes`], tag `BYTES`. - Bytes, - /// `"array"`: [`FfiValue::Array`], sealed in the cipher's sequence mode. - /// Its elements are not typed by the declaration. - Array, - /// `"object"`: [`FfiValue::Object`], sealed in the cipher's map mode. - /// Its entries are not typed by the declaration. - Object, -} - -/// Every field type, in declaration order. -const ALL: [FieldType; 11] = [ - FieldType::Bool, - FieldType::Int32, - FieldType::Int64, - FieldType::UInt32, - FieldType::UInt64, - FieldType::Float32, - FieldType::Float64, - FieldType::String, - FieldType::Bytes, - FieldType::Array, - FieldType::Object, -]; - -impl FieldType { - /// Every field type. - pub fn all() -> [FieldType; 11] { - ALL - } - - /// The type a plan's `"type"` names, or `None` for a name that is not - /// one. Names are matched exactly: `"uint64"`, not `"UInt64"` or `"u64"`. - pub fn parse(name: &str) -> Option { - ALL.into_iter().find(|ty| ty.name() == name) - } - - /// How a plan spells this type. - pub fn name(self) -> &'static str { - match self { - FieldType::Bool => "bool", - FieldType::Int32 => "int32", - FieldType::Int64 => "int64", - FieldType::UInt32 => "uint32", - FieldType::UInt64 => "uint64", - FieldType::Float32 => "float32", - FieldType::Float64 => "float64", - FieldType::String => "string", - FieldType::Bytes => "bytes", - FieldType::Array => "array", - FieldType::Object => "object", - } - } - - /// The vitaminc leaf tags a value of this type seals under: one per - /// scalar type (two for `bool`, whose value is its tag), none for a - /// composite, which seals as structure. - pub fn tags(self) -> &'static [u8] { - match self { - FieldType::Bool => &[tags::BOOL_FALSE, tags::BOOL_TRUE], - FieldType::Int32 => &[tags::INT32], - FieldType::Int64 => &[tags::INT64], - FieldType::UInt32 => &[tags::UINT32], - FieldType::UInt64 => &[tags::UINT64], - FieldType::Float32 => &[tags::FLOAT32], - FieldType::Float64 => &[tags::FLOAT64], - FieldType::String => &[tags::STRING], - FieldType::Bytes => &[tags::BYTES], - FieldType::Array | FieldType::Object => &[], - } - } - - /// The type of a value, or `None` for one no field can be declared as: - /// null, undefined or a passthrough. - pub fn of(value: &FfiValue) -> Option { - Some(match value { - FfiValue::Bool(_) => FieldType::Bool, - FfiValue::Int32(_) => FieldType::Int32, - FfiValue::Int64(_) => FieldType::Int64, - FfiValue::UInt32(_) => FieldType::UInt32, - FfiValue::UInt64(_) => FieldType::UInt64, - FfiValue::Float32(_) => FieldType::Float32, - FfiValue::Float64(_) => FieldType::Float64, - FfiValue::String(_) => FieldType::String, - FfiValue::Bytes(_) => FieldType::Bytes, - FfiValue::Array(_) => FieldType::Array, - FfiValue::Object(_) => FieldType::Object, - _ => return None, - }) - } - - /// Whether `value` is of this type: what the engine checks of every - /// value it seals into, and every value it opens from, a typed field. - pub fn holds(self, value: &FfiValue) -> bool { - Self::of(value) == Some(self) - } - - /// Whether the scheme defines a `kind` term for values of this type. - /// - /// The same table as [`IndexSpec::supports`], stated over types instead - /// of values, so a plan can be refused when it is built rather than when - /// its first value arrives: equality over every integer, text and bytes - /// (no floats, no booleans); match over text alone; ORE and OPE over - /// every scalar. A composite has no term. Only the index's kind - /// matters: a match index's options do not change what it applies to. - pub fn admits(self, kind: &IndexSpec) -> bool { - use FieldType::*; - match kind { - IndexSpec::Equality => matches!(self, Int32 | Int64 | UInt32 | UInt64 | String | Bytes), - IndexSpec::Match(_) => self == String, - IndexSpec::Ore | IndexSpec::Ope => !matches!(self, Array | Object), - } - } - - /// Read a query value as this type. - /// - /// A value already of this type is returned as it is. A number of - /// another width or kind is converted when the conversion is exact: an - /// integer in range, or a float with no fractional part in range, for an - /// integer type (a JavaScript `34` arrives as a float); an integer or - /// float the target float type represents exactly, for a float type. - /// Nothing else converts: a string is never parsed as a number, and a - /// number never becomes a string. - /// - /// This is for a query: what a host hands over to search with. A value - /// being sealed is not converted; it must already be of the field's - /// type, and is refused otherwise. - /// - /// # Errors - /// - /// [`Error::Source`] if the value cannot be read as this type exactly. - pub fn read(self, value: FfiValue) -> Result { - if self.holds(&value) { - return Ok(value); - } - let number = Number::of(&value).ok_or(Error::Source)?; - let read = match self { - FieldType::Int32 => number - .integer() - .and_then(|i| i32::try_from(i).ok()) - .map(FfiValue::Int32), - FieldType::Int64 => number - .integer() - .and_then(|i| i64::try_from(i).ok()) - .map(FfiValue::Int64), - FieldType::UInt32 => number - .integer() - .and_then(|i| u32::try_from(i).ok()) - .map(FfiValue::UInt32), - FieldType::UInt64 => number - .integer() - .and_then(|i| u64::try_from(i).ok()) - .map(FfiValue::UInt64), - FieldType::Float64 => number.exact_f64().map(FfiValue::Float64), - FieldType::Float32 => number.exact_f32().map(FfiValue::Float32), - _ => None, - }; - read.ok_or(Error::Source) - } -} - -impl fmt::Display for FieldType { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(self.name()) - } -} - -/// A numeric leaf, widened without loss: every integer variant fits an -/// `i128`, and an `f32` widens to an `f64` exactly. -#[derive(Clone, Copy)] -enum Number { - Integer(i128), - Float(f64), -} - -impl Number { - fn of(value: &FfiValue) -> Option { - Some(match value { - FfiValue::Int32(v) => Number::Integer((*v).into()), - FfiValue::Int64(v) => Number::Integer((*v).into()), - FfiValue::UInt32(v) => Number::Integer((*v).into()), - FfiValue::UInt64(v) => Number::Integer((*v).into()), - FfiValue::Float32(v) => Number::Float((*v).into()), - FfiValue::Float64(v) => Number::Float(*v), - _ => return None, - }) - } - - /// The integer this number is exactly, if it is one. A float outside - /// `i128` saturates, which no target integer type then accepts. - fn integer(self) -> Option { - match self { - Number::Integer(i) => Some(i), - Number::Float(f) if f.is_finite() && f.fract() == 0.0 => Some(f as i128), - Number::Float(_) => None, - } - } - - /// The `f64` this number is exactly, if it is one: an integer whose - /// conversion does not round. - fn exact_f64(self) -> Option { - match self { - Number::Float(f) => Some(f), - Number::Integer(i) => { - let f = i as f64; - (f as i128 == i).then_some(f) - } - } - } - - /// The `f32` this number is exactly, if it is one. A NaN is never - /// exact, because it does not equal itself. - fn exact_f32(self) -> Option { - let f = self.exact_f64()?; - let narrowed = f as f32; - (f64::from(narrowed) == f).then_some(narrowed) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::dynamic::Scalar; - use crate::sem::{MatchOptions, Tokenizer}; - use vitaminc_protected::Protected; - - /// One value of every field type. - fn sample(ty: FieldType) -> FfiValue { - match ty { - FieldType::Bool => FfiValue::Bool(true), - FieldType::Int32 => FfiValue::Int32(-3), - FieldType::Int64 => FfiValue::Int64(-4), - FieldType::UInt32 => FfiValue::UInt32(34), - FieldType::UInt64 => FfiValue::UInt64(35), - FieldType::Float32 => FfiValue::Float32(1.5), - FieldType::Float64 => FfiValue::Float64(2.5), - FieldType::String => FfiValue::String("alice".into()), - FieldType::Bytes => FfiValue::Bytes(Protected::new(b"ab".to_vec())), - FieldType::Array => FfiValue::Array(vec![FfiValue::UInt32(1)]), - FieldType::Object => FfiValue::Object(vec![("k".to_string(), FfiValue::UInt32(1))]), - } - } - - fn kinds() -> [IndexSpec; 4] { - [ - IndexSpec::Equality, - IndexSpec::Match(MatchOptions::default()), - IndexSpec::Ore, - IndexSpec::Ope, - ] - } - - #[test] - fn every_name_parses_back_to_its_type_and_nothing_else_parses() { - let names: Vec<_> = FieldType::all().iter().map(|ty| ty.name()).collect(); - assert_eq!( - names, - [ - "bool", "int32", "int64", "uint32", "uint64", "float32", "float64", "string", - "bytes", "array", "object" - ], - "the names are wire format" - ); - for ty in FieldType::all() { - assert_eq!(FieldType::parse(ty.name()), Some(ty)); - assert_eq!(ty.to_string(), ty.name()); - } - for not_a_type in [ - "", - "UInt64", - "u64", - "int", - "number", - "null", - "undefined", - "text", - ] { - assert_eq!(FieldType::parse(not_a_type), None, "{not_a_type:?}"); - } - } - - /// The vocabulary is the tag table: every scalar tag but null and - /// undefined belongs to exactly one type, and the composites to none. - #[test] - fn the_scalar_types_are_vitaminc_tag_table() { - let mut claimed: Vec = FieldType::all() - .iter() - .flat_map(|ty| ty.tags().iter().copied()) - .collect(); - claimed.sort_unstable(); - assert_eq!( - claimed, - [ - tags::BOOL_FALSE, - tags::BOOL_TRUE, - tags::INT32, - tags::INT64, - tags::UINT32, - tags::UINT64, - tags::FLOAT32, - tags::FLOAT64, - tags::STRING, - tags::BYTES - ] - ); - assert_eq!(FieldType::Bool.tags(), [tags::BOOL_FALSE, tags::BOOL_TRUE]); - assert_eq!(FieldType::UInt64.tags(), [tags::UINT64]); - assert_eq!(FieldType::Int64.tags(), [tags::INT64]); - assert!(FieldType::Array.tags().is_empty()); - assert!(FieldType::Object.tags().is_empty()); - } - - #[test] - fn a_value_is_of_exactly_one_type_and_held_by_it_alone() { - for ty in FieldType::all() { - let value = sample(ty); - assert_eq!(FieldType::of(&value), Some(ty)); - for other in FieldType::all() { - assert_eq!(other.holds(&value), other == ty, "{other} holds a {ty}?"); - } - } - for untyped in [ - FfiValue::Null, - FfiValue::Undefined, - FfiValue::Passthrough(Box::new(FfiValue::UInt32(1))), - ] { - assert_eq!(FieldType::of(&untyped), None); - assert!(FieldType::all().iter().all(|ty| !ty.holds(&untyped))); - } - } - - /// `admits` is `IndexSpec::supports` stated over types: the two tables - /// cannot disagree about any scalar, and a composite admits nothing. - #[test] - fn admits_agrees_with_supports_for_every_scalar_type() { - for ty in FieldType::all() { - for kind in kinds() { - match Scalar::of(&sample(ty), &kind) { - Ok(scalar) => { - assert_eq!(ty.admits(&kind), kind.supports(&scalar), "{ty} and {kind}") - } - Err(_) => assert!(!ty.admits(&kind), "{ty} is not a scalar"), - } - } - } - // Spelled out, so the table reads without the cross-check. - assert!(FieldType::UInt64.admits(&IndexSpec::Equality)); - assert!(!FieldType::Float64.admits(&IndexSpec::Equality)); - assert!(!FieldType::Bool.admits(&IndexSpec::Equality)); - assert!(FieldType::String.admits(&IndexSpec::Match(MatchOptions::default()))); - assert!(!FieldType::Bytes.admits(&IndexSpec::Match(MatchOptions::default()))); - assert!(FieldType::Float64.admits(&IndexSpec::Ore)); - assert!(!FieldType::Object.admits(&IndexSpec::Ope)); - assert!(!FieldType::Array.admits(&IndexSpec::Ore)); - } - - /// A match index's options are not part of what it applies to: text - /// admits it under any options, and nothing else does. - #[test] - fn admits_ignores_match_options() { - let wide = IndexSpec::Match(MatchOptions { - tokenizer: Tokenizer::Standard, - downcase: false, - k: 6, - m: 1024, - }); - for ty in FieldType::all() { - assert_eq!( - ty.admits(&wide), - ty == FieldType::String, - "{ty} admits a non-default match exactly when it is text" - ); - } - } - - fn read(ty: FieldType, value: FfiValue) -> Option { - ty.read(value).ok() - } - - #[test] - fn read_returns_a_value_of_the_type_as_it_is() { - for ty in FieldType::all() { - let read = read(ty, sample(ty)).expect("its own type"); - assert!(ty.holds(&read)); - } - assert!( - matches!(read(FieldType::Float32, FfiValue::Float32(f32::NAN)), Some(FfiValue::Float32(f)) if f.is_nan()) - ); - } - - #[test] - fn read_converts_an_exact_number_to_an_integer_type() { - // Every numeric variant is a source, each read into another width. - assert!(matches!( - read(FieldType::Int64, FfiValue::Int32(-5)), - Some(FfiValue::Int64(-5)) - )); - assert!(matches!( - read(FieldType::Int32, FfiValue::UInt64(5)), - Some(FfiValue::Int32(5)) - )); - assert!( - matches!(read(FieldType::Float64, FfiValue::Int32(-5)), Some(FfiValue::Float64(f)) if f == -5.0) - ); - // A JavaScript number is a float. - assert!(matches!( - read(FieldType::UInt64, FfiValue::Float64(34.0)), - Some(FfiValue::UInt64(34)) - )); - assert!(matches!( - read(FieldType::Int32, FfiValue::Float32(-2.0)), - Some(FfiValue::Int32(-2)) - )); - assert!(matches!( - read(FieldType::Int64, FfiValue::UInt32(7)), - Some(FfiValue::Int64(7)) - )); - assert!(matches!( - read(FieldType::UInt32, FfiValue::Int64(7)), - Some(FfiValue::UInt32(7)) - )); - assert!(matches!( - read(FieldType::Int64, FfiValue::Float64(-0.0)), - Some(FfiValue::Int64(0)) - )); - assert!(matches!( - read(FieldType::UInt64, FfiValue::Int64(i64::MAX)), - Some(FfiValue::UInt64(v)) if v == i64::MAX as u64 - )); - assert!(matches!( - read(FieldType::Int32, FfiValue::Int64(i32::MIN.into())), - Some(FfiValue::Int32(i32::MIN)) - )); - assert!(matches!( - read(FieldType::UInt32, FfiValue::UInt64(u32::MAX.into())), - Some(FfiValue::UInt32(u32::MAX)) - )); - } - - #[test] - fn read_refuses_an_inexact_or_out_of_range_integer() { - let refused = [ - (FieldType::UInt64, FfiValue::Float64(34.5)), - (FieldType::Int64, FfiValue::Float64(f64::NAN)), - (FieldType::Int64, FfiValue::Float64(f64::INFINITY)), - (FieldType::Int64, FfiValue::Float64(1e30)), - (FieldType::UInt64, FfiValue::Int64(-1)), - (FieldType::UInt32, FfiValue::UInt64(u64::from(u32::MAX) + 1)), - (FieldType::Int32, FfiValue::Int64(i64::from(i32::MAX) + 1)), - (FieldType::Int32, FfiValue::Int64(i64::from(i32::MIN) - 1)), - (FieldType::Int64, FfiValue::UInt64(u64::MAX)), - ]; - for (at, (ty, value)) in refused.into_iter().enumerate() { - assert!( - matches!(ty.read(value), Err(Error::Source)), - "case {at}, as {ty}" - ); - } - } - - #[test] - fn read_converts_an_exactly_representable_number_to_a_float_type() { - assert!( - matches!(read(FieldType::Float64, FfiValue::Int64(34)), Some(FfiValue::Float64(f)) if f == 34.0) - ); - assert!( - matches!(read(FieldType::Float64, FfiValue::Float32(1.5)), Some(FfiValue::Float64(f)) if f == 1.5) - ); - assert!( - matches!(read(FieldType::Float32, FfiValue::Float64(1.5)), Some(FfiValue::Float32(f)) if f == 1.5) - ); - assert!( - matches!(read(FieldType::Float32, FfiValue::UInt32(16_777_216)), Some(FfiValue::Float32(f)) if f == 16_777_216.0) - ); - assert!(matches!( - read(FieldType::Float64, FfiValue::UInt64(1 << 53)), - Some(FfiValue::Float64(f)) if f == 9_007_199_254_740_992.0 - )); - } - - #[test] - fn read_refuses_a_number_a_float_type_would_round() { - let refused = [ - (FieldType::Float64, FfiValue::UInt64((1 << 53) + 1)), - (FieldType::Float64, FfiValue::UInt64(u64::MAX)), - (FieldType::Float32, FfiValue::UInt32(16_777_217)), - (FieldType::Float32, FfiValue::Float64(0.1)), - (FieldType::Float32, FfiValue::Float64(f64::NAN)), - ]; - for (at, (ty, value)) in refused.into_iter().enumerate() { - assert!( - matches!(ty.read(value), Err(Error::Source)), - "case {at}, as {ty}" - ); - } - } - - #[test] - fn read_never_converts_across_kinds() { - let refused = [ - (FieldType::UInt64, FfiValue::String("34".into())), - (FieldType::String, FfiValue::UInt64(34)), - (FieldType::Bool, FfiValue::UInt32(1)), - (FieldType::UInt32, FfiValue::Bool(true)), - (FieldType::Bytes, FfiValue::String("ab".into())), - ( - FieldType::String, - FfiValue::Bytes(Protected::new(b"ab".to_vec())), - ), - (FieldType::Array, FfiValue::Object(vec![])), - (FieldType::Object, FfiValue::Array(vec![])), - (FieldType::UInt32, FfiValue::Null), - (FieldType::Bool, FfiValue::Float64(1.0)), - ]; - for (at, (ty, value)) in refused.into_iter().enumerate() { - assert!( - matches!(ty.read(value), Err(Error::Source)), - "case {at}, as {ty}" - ); - } - } -} diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs new file mode 100644 index 000000000..c923d1002 --- /dev/null +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -0,0 +1,410 @@ +//! What a plan's declared field type means to the engine. +//! +//! A typed host knows what `34` is: a Rust `u32`, a Go `int64`. A dynamically +//! typed host (JavaScript, PHP, Ruby) does not, and index semantics depend on +//! it: an ORE term of the integer `34` and of the float `34.0` differ, a +//! JavaScript number is a float, and match is defined over text alone. So a +//! plan field may say what its values are, in its `"type"` key. +//! +//! The vocabulary is vitaminc's, not this crate's: a declared type is a +//! [`ValueKind`] (`vitaminc_aead_value::ValueKind`, re-exported here), whose +//! [`name`](ValueKind::name)s (`"uint64"`, `"string"`, …, `"array"`, +//! `"object"`) are frozen wire format beside vitaminc's tag table, and whose +//! [`holds`](ValueKind::holds) and [`FfiValue::kind`] say whether a value is +//! of a kind. What vitaminc deliberately leaves to the declaring layer is the +//! two rules this module adds: +//! +//! - [`admits`]: which indexes a kind is defined for, so a plan asking for +//! match on an integer or equality on a float is refused when it is built; +//! - [`read`]: how a query value is read as a field's kind, converting a +//! number only when the conversion is exact. +//! +//! The engine then uses a declared kind three times: +//! +//! - **encrypt** admits only the indexes the kind is defined for (when the +//! plan is built), and refuses a value of any other kind (when it runs), +//! so the term bytes are the declared kind's and no binding is trusted to +//! have tagged the value right; +//! - **query** reads the query value *as* the field's kind ([`read`]): `34` +//! against a `uint64` field is the `u64` term, whatever number type the +//! host handed over; +//! - **decrypt** refuses an opened value of any other kind, so a host with no +//! types of its own can rely on the declaration for what it gets back (an +//! integer, not a float; bytes, not a string). +use vitaminc_aead_value::{FfiValue, ValueKind}; + +use super::Error; +use crate::target::IndexSpec; + +/// Whether the scheme defines an `index` term for values of `kind`. +/// +/// The same table as [`IndexSpec::supports`], stated over kinds instead of +/// values, so a plan can be refused when it is built rather than when its +/// first value arrives: equality over every integer, text and bytes (no +/// floats, no booleans); match over text alone; ORE and OPE over every +/// scalar. A composite (`array`, `object`) has no term. Only the index's +/// kind matters: a match index's options do not change what it applies to. +/// +/// ``` +/// use stack_encrypt::dynamic::{admits, ValueKind}; +/// use stack_encrypt::target::IndexSpec; +/// +/// assert!(admits(ValueKind::UInt64, &IndexSpec::Equality)); +/// assert!(!admits(ValueKind::Float64, &IndexSpec::Equality)); +/// assert!(!admits(ValueKind::Object, &IndexSpec::Ore)); +/// ``` +pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool { + use ValueKind::*; + match index { + IndexSpec::Equality => matches!(kind, Int32 | Int64 | UInt32 | UInt64 | String | Bytes), + IndexSpec::Match(_) => kind == String, + IndexSpec::Ore | IndexSpec::Ope => !matches!(kind, Array | Object), + } +} + +/// Read a query value as `kind`. +/// +/// A value already of this kind is returned as it is. A number of another +/// width or kind is converted when the conversion is exact: an integer in +/// range, or a float with no fractional part in range, for an integer kind +/// (a JavaScript `34` arrives as a float); an integer or float the target +/// float kind represents exactly, for a float kind. Nothing else converts: +/// a string is never parsed as a number, and a number never becomes a +/// string. +/// +/// This is for a query: what a host hands over to search with. A value +/// being sealed is not converted; it must already be of the field's kind, +/// and is refused otherwise. +/// +/// ``` +/// use stack_encrypt::dynamic::{read, FfiValue, ValueKind}; +/// +/// // A JavaScript number is a float; against a `uint64` field it is 34. +/// let value = read(ValueKind::UInt64, FfiValue::Float64(34.0))?; +/// assert!(matches!(value, FfiValue::UInt64(34))); +/// assert!(read(ValueKind::UInt64, FfiValue::Float64(34.5)).is_err()); +/// # Ok::<(), stack_encrypt::dynamic::Error>(()) +/// ``` +/// +/// # Errors +/// +/// [`Error::Source`] if the value cannot be read as `kind` exactly. +pub fn read(kind: ValueKind, value: FfiValue) -> Result { + if kind.holds(&value) { + return Ok(value); + } + let number = Number::of(&value).ok_or(Error::Source)?; + let read = match kind { + ValueKind::Int32 => number + .integer() + .and_then(|i| i32::try_from(i).ok()) + .map(FfiValue::Int32), + ValueKind::Int64 => number + .integer() + .and_then(|i| i64::try_from(i).ok()) + .map(FfiValue::Int64), + ValueKind::UInt32 => number + .integer() + .and_then(|i| u32::try_from(i).ok()) + .map(FfiValue::UInt32), + ValueKind::UInt64 => number + .integer() + .and_then(|i| u64::try_from(i).ok()) + .map(FfiValue::UInt64), + ValueKind::Float64 => number.exact_f64().map(FfiValue::Float64), + ValueKind::Float32 => number.exact_f32().map(FfiValue::Float32), + _ => None, + }; + read.ok_or(Error::Source) +} + +/// A numeric leaf, widened without loss: every integer variant fits an +/// `i128`, and an `f32` widens to an `f64` exactly. +#[derive(Clone, Copy)] +enum Number { + Integer(i128), + Float(f64), +} + +impl Number { + fn of(value: &FfiValue) -> Option { + Some(match value { + FfiValue::Int32(v) => Number::Integer((*v).into()), + FfiValue::Int64(v) => Number::Integer((*v).into()), + FfiValue::UInt32(v) => Number::Integer((*v).into()), + FfiValue::UInt64(v) => Number::Integer((*v).into()), + FfiValue::Float32(v) => Number::Float((*v).into()), + FfiValue::Float64(v) => Number::Float(*v), + _ => return None, + }) + } + + /// The integer this number is exactly, if it is one. A float outside + /// `i128` saturates, which no target integer type then accepts. + fn integer(self) -> Option { + match self { + Number::Integer(i) => Some(i), + Number::Float(f) if f.is_finite() && f.fract() == 0.0 => Some(f as i128), + Number::Float(_) => None, + } + } + + /// The `f64` this number is exactly, if it is one: an integer whose + /// conversion does not round. + fn exact_f64(self) -> Option { + match self { + Number::Float(f) => Some(f), + Number::Integer(i) => { + let f = i as f64; + (f as i128 == i).then_some(f) + } + } + } + + /// The `f32` this number is exactly, if it is one. A NaN is never + /// exact, because it does not equal itself. + fn exact_f32(self) -> Option { + let f = self.exact_f64()?; + let narrowed = f as f32; + (f64::from(narrowed) == f).then_some(narrowed) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::dynamic::Scalar; + use crate::sem::{MatchOptions, Tokenizer}; + use vitaminc_protected::Protected; + + // What a kind is, its name and its tags, and which values it holds are + // vitaminc's to test (`ValueKind`). These test the two rules this crate + // adds over the vocabulary. + + /// One value of every kind. + fn sample(kind: ValueKind) -> FfiValue { + match kind { + ValueKind::Bool => FfiValue::Bool(true), + ValueKind::Int32 => FfiValue::Int32(-3), + ValueKind::Int64 => FfiValue::Int64(-4), + ValueKind::UInt32 => FfiValue::UInt32(34), + ValueKind::UInt64 => FfiValue::UInt64(35), + ValueKind::Float32 => FfiValue::Float32(1.5), + ValueKind::Float64 => FfiValue::Float64(2.5), + ValueKind::String => FfiValue::String("alice".into()), + ValueKind::Bytes => FfiValue::Bytes(Protected::new(b"ab".to_vec())), + ValueKind::Array => FfiValue::Array(vec![FfiValue::UInt32(1)]), + ValueKind::Object => FfiValue::Object(vec![("k".to_string(), FfiValue::UInt32(1))]), + } + } + + fn indexes() -> [IndexSpec; 4] { + [ + IndexSpec::Equality, + IndexSpec::Match(MatchOptions::default()), + IndexSpec::Ore, + IndexSpec::Ope, + ] + } + + /// `admits` is `IndexSpec::supports` stated over kinds: the two tables + /// cannot disagree about any scalar, and a composite admits nothing. + #[test] + fn admits_agrees_with_supports_for_every_scalar_kind() { + for kind in ValueKind::ALL { + for index in indexes() { + match Scalar::of(&sample(kind), &index) { + Ok(scalar) => { + assert_eq!( + admits(kind, &index), + index.supports(&scalar), + "{kind} and {index}" + ) + } + Err(_) => assert!(!admits(kind, &index), "{kind} is not a scalar"), + } + } + } + // Spelled out, so the table reads without the cross-check. + assert!(admits(ValueKind::UInt64, &IndexSpec::Equality)); + assert!(!admits(ValueKind::Float64, &IndexSpec::Equality)); + assert!(!admits(ValueKind::Bool, &IndexSpec::Equality)); + assert!(admits( + ValueKind::String, + &IndexSpec::Match(MatchOptions::default()) + )); + assert!(!admits( + ValueKind::Bytes, + &IndexSpec::Match(MatchOptions::default()) + )); + assert!(admits(ValueKind::Float64, &IndexSpec::Ore)); + assert!(!admits(ValueKind::Object, &IndexSpec::Ope)); + assert!(!admits(ValueKind::Array, &IndexSpec::Ore)); + } + + /// A match index's options are not part of what it applies to: text + /// admits it under any options, and nothing else does. + #[test] + fn admits_ignores_match_options() { + let wide = IndexSpec::Match(MatchOptions { + tokenizer: Tokenizer::Standard, + downcase: false, + k: 6, + m: 1024, + }); + for kind in ValueKind::ALL { + assert_eq!( + admits(kind, &wide), + kind == ValueKind::String, + "{kind} admits a non-default match exactly when it is text" + ); + } + } + + fn read_ok(kind: ValueKind, value: FfiValue) -> Option { + read(kind, value).ok() + } + + #[test] + fn read_returns_a_value_of_the_kind_as_it_is() { + for kind in ValueKind::ALL { + let read = read_ok(kind, sample(kind)).expect("its own kind"); + assert!(kind.holds(&read)); + } + assert!( + matches!(read_ok(ValueKind::Float32, FfiValue::Float32(f32::NAN)), Some(FfiValue::Float32(f)) if f.is_nan()) + ); + } + + #[test] + fn read_converts_an_exact_number_to_an_integer_kind() { + // Every numeric variant is a source, each read into another width. + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::Int32(-5)), + Some(FfiValue::Int64(-5)) + )); + assert!(matches!( + read_ok(ValueKind::Int32, FfiValue::UInt64(5)), + Some(FfiValue::Int32(5)) + )); + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Int32(-5)), Some(FfiValue::Float64(f)) if f == -5.0) + ); + // A JavaScript number is a float. + assert!(matches!( + read_ok(ValueKind::UInt64, FfiValue::Float64(34.0)), + Some(FfiValue::UInt64(34)) + )); + assert!(matches!( + read_ok(ValueKind::Int32, FfiValue::Float32(-2.0)), + Some(FfiValue::Int32(-2)) + )); + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::UInt32(7)), + Some(FfiValue::Int64(7)) + )); + assert!(matches!( + read_ok(ValueKind::UInt32, FfiValue::Int64(7)), + Some(FfiValue::UInt32(7)) + )); + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::Float64(-0.0)), + Some(FfiValue::Int64(0)) + )); + assert!(matches!( + read_ok(ValueKind::UInt64, FfiValue::Int64(i64::MAX)), + Some(FfiValue::UInt64(v)) if v == i64::MAX as u64 + )); + assert!(matches!( + read_ok(ValueKind::Int32, FfiValue::Int64(i32::MIN.into())), + Some(FfiValue::Int32(i32::MIN)) + )); + assert!(matches!( + read_ok(ValueKind::UInt32, FfiValue::UInt64(u32::MAX.into())), + Some(FfiValue::UInt32(u32::MAX)) + )); + } + + #[test] + fn read_refuses_an_inexact_or_out_of_range_integer() { + let refused = [ + (ValueKind::UInt64, FfiValue::Float64(34.5)), + (ValueKind::Int64, FfiValue::Float64(f64::NAN)), + (ValueKind::Int64, FfiValue::Float64(f64::INFINITY)), + (ValueKind::Int64, FfiValue::Float64(1e30)), + (ValueKind::UInt64, FfiValue::Int64(-1)), + (ValueKind::UInt32, FfiValue::UInt64(u64::from(u32::MAX) + 1)), + (ValueKind::Int32, FfiValue::Int64(i64::from(i32::MAX) + 1)), + (ValueKind::Int32, FfiValue::Int64(i64::from(i32::MIN) - 1)), + (ValueKind::Int64, FfiValue::UInt64(u64::MAX)), + ]; + for (at, (kind, value)) in refused.into_iter().enumerate() { + assert!( + matches!(read(kind, value), Err(Error::Source)), + "case {at}, as {kind}" + ); + } + } + + #[test] + fn read_converts_an_exactly_representable_number_to_a_float_kind() { + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Int64(34)), Some(FfiValue::Float64(f)) if f == 34.0) + ); + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Float32(1.5)), Some(FfiValue::Float64(f)) if f == 1.5) + ); + assert!( + matches!(read_ok(ValueKind::Float32, FfiValue::Float64(1.5)), Some(FfiValue::Float32(f)) if f == 1.5) + ); + assert!( + matches!(read_ok(ValueKind::Float32, FfiValue::UInt32(16_777_216)), Some(FfiValue::Float32(f)) if f == 16_777_216.0) + ); + assert!(matches!( + read_ok(ValueKind::Float64, FfiValue::UInt64(1 << 53)), + Some(FfiValue::Float64(f)) if f == 9_007_199_254_740_992.0 + )); + } + + #[test] + fn read_refuses_a_number_a_float_kind_would_round() { + let refused = [ + (ValueKind::Float64, FfiValue::UInt64((1 << 53) + 1)), + (ValueKind::Float64, FfiValue::UInt64(u64::MAX)), + (ValueKind::Float32, FfiValue::UInt32(16_777_217)), + (ValueKind::Float32, FfiValue::Float64(0.1)), + (ValueKind::Float32, FfiValue::Float64(f64::NAN)), + ]; + for (at, (kind, value)) in refused.into_iter().enumerate() { + assert!( + matches!(read(kind, value), Err(Error::Source)), + "case {at}, as {kind}" + ); + } + } + + #[test] + fn read_never_converts_across_kinds() { + let refused = [ + (ValueKind::UInt64, FfiValue::String("34".into())), + (ValueKind::String, FfiValue::UInt64(34)), + (ValueKind::Bool, FfiValue::UInt32(1)), + (ValueKind::UInt32, FfiValue::Bool(true)), + (ValueKind::Bytes, FfiValue::String("ab".into())), + ( + ValueKind::String, + FfiValue::Bytes(Protected::new(b"ab".to_vec())), + ), + (ValueKind::Array, FfiValue::Object(vec![])), + (ValueKind::Object, FfiValue::Array(vec![])), + (ValueKind::UInt32, FfiValue::Null), + (ValueKind::Bool, FfiValue::Float64(1.0)), + ]; + for (at, (kind, value)) in refused.into_iter().enumerate() { + assert!( + matches!(read(kind, value), Err(Error::Source)), + "case {at}, as {kind}" + ); + } + } +} diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index f3c24b15a..3bcbcc55a 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -41,25 +41,28 @@ //! them. Their long-term home is beside vitaminc's frozen tag table, which //! already owns this class of constant. //! -//! A plan field's `"type"` names ([`FieldType`]: `"int64"`, `"string"`, …) -//! are wire format in the same way: a binding spells them, and a stored row -//! opens only under the type it was sealed as. +//! A plan field's `"type"` names (`"int64"`, `"string"`, …) are wire format +//! in the same way: a binding spells them, and a stored row opens only under +//! the type it was sealed as. They are not this crate's: a declared type is +//! vitaminc's [`ValueKind`], re-exported here, whose names vitaminc freezes +//! beside its tag table. This crate adds only what a kind means to an index +//! ([`admits`]) and to a query value ([`read`]). //! //! For the same reason the enums that spell them — [`Output`], -//! [`IndexSpec`] and [`FieldType`] — are *not* `#[non_exhaustive]`, against this workspace's +//! [`IndexSpec`] and [`ValueKind`] — are *not* `#[non_exhaustive]`, against this workspace's //! usual rule for public enums: a new output is a wire-format addition every //! binding has to be taught, and an exhaustive match is how the compiler //! tells a binding author that. [`Scope`] is exhaustive for a different //! reason, given on the type. mod context; -mod field_type; +mod kind; pub mod record; mod term; use std::fmt; pub use context::{borrowed, context}; -pub use field_type::FieldType; +pub use kind::{admits, read}; pub use record::{FieldPlan, Output, Plan}; pub use term::{term, Scalar}; /// vitaminc's language-neutral value tree — the runtime value every binding @@ -67,6 +70,10 @@ pub use term::{term, Scalar}; /// which stays the binding's: this crate takes and returns values, never /// encoded bytes. pub use vitaminc_aead_value::FfiValue; +/// vitaminc's value kinds: the type a plan field declares in its `"type"` +/// key. Its names are frozen wire format; see [`admits`] and [`read`] for +/// what a kind means to this engine. +pub use vitaminc_aead_value::ValueKind; use crate::target::IndexSpec; use crate::{KeysetCipher, StackCipher}; @@ -161,7 +168,7 @@ pub enum Error { /// not carry or carries twice, or a passthrough or a repeated map key /// under a field the plan seals, or a value of another type than its /// field declares. Also a query value that cannot be read as its field's - /// type ([`FieldType::read`]). + /// type ([`read`]). #[error("record source does not fit the plan")] Source, diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 7204dcbb4..20df616cc 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -50,10 +50,10 @@ //! plaintext and have it reported as a successful decrypt. use stack_kms::DataKeySource; -use vitaminc_aead_value::FfiValue; +use vitaminc_aead_value::{FfiValue, ValueKind}; use vitaminc_protected::Protected; -use super::{borrowed, term, utf8, Error, FieldType, Scalar, Scope}; +use super::{admits, borrowed, term, utf8, Error, Scalar, Scope}; use crate::target::{IndexSpec, Pending}; use crate::{ BoxedPassthrough, CipherText, ContextPiece, Encrypt, KeysetCipher, NonEmpty, StackCipherText, @@ -110,19 +110,19 @@ impl Output { /// One field of a record plan: what to call it, what context to bind it /// under, what to produce for it, and, optionally, what type its values are. /// -/// A field with a declared [`FieldType`] admits only the indexes that type -/// is defined for (checked when the plan is built), seals only values of -/// that type and opens only to one (checked per value), so the engine -/// verifies what a binding hands it rather than trusting the binding's -/// tagging. A field with no declared type is dispatched on each value's own -/// type, as every field was before types existed; that keeps the plans -/// existing bindings send valid. +/// A field with a declared type (a [`ValueKind`]) admits only the indexes +/// that kind is defined for ([`admits`], checked when the +/// plan is built), seals only values of that kind and opens only to one +/// (checked per value), so the engine verifies what a binding hands it +/// rather than trusting the binding's tagging. A field with no declared type +/// is dispatched on each value's own type, as every field was before types +/// existed; that keeps the plans existing bindings send valid. #[derive(Clone, Debug)] pub struct FieldPlan { name: String, context: NonEmpty>, outputs: Vec, - field_type: Option, + field_type: Option, } impl FieldPlan { @@ -169,13 +169,13 @@ impl FieldPlan { /// /// # Errors /// - /// [`Error::Plan`] if the field asks for an index the type is not - /// defined for ([`FieldType::admits`]): match on an integer, equality on - /// a float, any index on a composite. - pub fn with_type(mut self, field_type: FieldType) -> Result { + /// [`Error::Plan`] if the field asks for an index the kind is not + /// defined for ([`admits`]): match on an integer, + /// equality on a float, any index on a composite. + pub fn with_type(mut self, field_type: ValueKind) -> Result { for output in &self.outputs { - if let Output::Term(kind) = output { - if !field_type.admits(kind) { + if let Output::Term(index) = output { + if !admits(field_type, index) { return Err(Error::Plan); } } @@ -202,7 +202,7 @@ impl FieldPlan { /// 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. - pub fn field_type(&self) -> Option { + pub fn field_type(&self) -> Option { self.field_type } @@ -292,10 +292,11 @@ impl Plan { /// /// [`MatchOptions::default`]: crate::sem::MatchOptions::default /// -/// `"type"` is optional, and names a [`FieldType`] (`"int64"`, `"string"`, -/// …; see [`FieldType::name`]). Declared, it is checked against the field's -/// outputs here and against every value sealed into or opened from the -/// field; absent, each value is dispatched on its own type. +/// `"type"` is optional, and names a [`ValueKind`] (`"int64"`, `"string"`, +/// …; see [`ValueKind::name`]): vitaminc's vocabulary, not one of this +/// crate's. Declared, it is checked against the field's outputs here +/// ([`admits`]) and against every value sealed into or opened +/// from the field; absent, each value is dispatched on its own type. /// /// `` is defined once, in [`super::context`](super::context()): a /// string, bytes, an integer, or a list of those, with what each spells in @@ -346,7 +347,7 @@ impl Plan { /// `"context"` or `"outputs"`, an output list that is not a list of /// outputs (`"c"` or an index in its wire form, above), is empty, or names /// an output key twice, a `"type"` that is not -/// a string naming a [`FieldType`], or a type that does not admit one of +/// a string naming a [`ValueKind`], or a type that does not admit one of /// the field's index outputs. [`Error::Context`] for a `"context"` that is /// present but is not a context, or renders empty. /// @@ -365,7 +366,7 @@ pub fn plan(value: FfiValue) -> Result { }; let mut context: Option>> = None; let mut outputs: Option> = None; - let mut field_type: 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)?), @@ -384,7 +385,7 @@ pub fn plan(value: FfiValue) -> Result { return Err(Error::Plan); }; let name = utf8(s).ok_or(Error::Plan)?; - field_type = Some(FieldType::parse(name).ok_or(Error::Plan)?); + field_type = Some(name.parse().map_err(|_| Error::Plan)?); } // An unknown key, or one of the three given twice. _ => return Err(Error::Plan), @@ -2337,7 +2338,7 @@ mod tests { let types: Vec<_> = parsed.fields().iter().map(FieldPlan::field_type).collect(); assert_eq!( types, - [Some(FieldType::UInt64), Some(FieldType::String), None] + [Some(ValueKind::UInt64), Some(ValueKind::String), None] ); } @@ -2397,11 +2398,11 @@ mod tests { assert_eq!(field.field_type(), None, "untyped until declared"); let typed = field .clone() - .with_type(FieldType::UInt32) + .with_type(ValueKind::UInt32) .expect("equality admits a u32"); - assert_eq!(typed.field_type(), Some(FieldType::UInt32)); + assert_eq!(typed.field_type(), Some(ValueKind::UInt32)); assert!(matches!( - field.with_type(FieldType::Float32), + field.with_type(ValueKind::Float32), Err(Error::Plan) )); let sealed_only = FieldPlan::new( @@ -2410,9 +2411,9 @@ mod tests { vec![Output::Ciphertext], ) .expect("field") - .with_type(FieldType::Object) + .with_type(ValueKind::Object) .expect("a composite with no index is a plain sealed field"); - assert_eq!(sealed_only.field_type(), Some(FieldType::Object)); + assert_eq!(sealed_only.field_type(), Some(ValueKind::Object)); } /// The engine verifies the tag rather than trusting the binding: a @@ -2538,7 +2539,8 @@ mod tests { let stored = term_bytes(&node(&mut age, "eq")); let declared = field.field_type().expect("typed"); - let value = declared.read(FfiValue::Float64(34.0)).expect("an exact 34"); + let value = + crate::dynamic::read(declared, FfiValue::Float64(34.0)).expect("an exact 34"); let scalar = Scalar::of(&value, &IndexSpec::Equality).expect("scalar"); let probe = term( &keyset, diff --git a/packages/stack-kms/fuzz/Cargo.lock b/packages/stack-kms/fuzz/Cargo.lock index aaa4ad420..93e2a0d9b 100644 --- a/packages/stack-kms/fuzz/Cargo.lock +++ b/packages/stack-kms/fuzz/Cargo.lock @@ -2356,9 +2356,9 @@ checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "vitaminc" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f371b877e1586acb8ffc9efd0bd6a7c7ff7d8c4b1529c1f46d34b1df3478699" +checksum = "35042fb7a81d72dc43c53fec835e61027f18b4d01cc0f7e12d6c6a6ffbf3bfb9" dependencies = [ "vitaminc-aead", "vitaminc-context", @@ -2370,9 +2370,9 @@ dependencies = [ [[package]] name = "vitaminc-aead" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0238d5b77821fcd46d2f72b082532209d625892c69e40008e403b709a1dd0fa" +checksum = "62cacd4dae485e98cfe5407e919187a8468dd933ea4234be5115b7d13a30f3c6" dependencies = [ "bytes", "serde", @@ -2385,9 +2385,9 @@ dependencies = [ [[package]] name = "vitaminc-aead-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a81524f1e096bc45a1e7901186546bc0cf5545a9a86e978581a7146c54a5c7" +checksum = "8ff34056b70a8417ad3bf9db73b2bd3be97f448794566c6de2080bd8a7cd9b6b" dependencies = [ "proc-macro2", "quote", @@ -2396,9 +2396,9 @@ dependencies = [ [[package]] name = "vitaminc-context" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e7b3e0602ad490039680222ba23217264eab0f400495c072ec11e1a2235b304" +checksum = "a7df52501fe33cdb3ee9e155b5242fac184c037d147e58c96e5143ed011252a8" dependencies = [ "mutants", "vitaminc-protected", @@ -2406,9 +2406,9 @@ dependencies = [ [[package]] name = "vitaminc-encrypt" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b969f689aebd40637a6ba9243618a62057ee9c76a8b70c6661c9b0c28ba20275" +checksum = "9fbd0e1748055e381e02033e08af385de879c4dcf32e0f6739ac0c0cf0c6f13d" dependencies = [ "aes-gcm", "aws-lc-rs", @@ -2420,9 +2420,9 @@ dependencies = [ [[package]] name = "vitaminc-protected" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a13b616fa4e7093f4970dacd6f44ca76a6929bee1c72612134353a69bb52c62" +checksum = "c7b514f605a63f88874ba46ffea81775fad64994475ab239cb19da159e884b7b" dependencies = [ "bitvec", "digest 0.11.3", @@ -2437,9 +2437,9 @@ dependencies = [ [[package]] name = "vitaminc-protected-derive" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47c3f8db2eff8108d078a77125f20974448b9a66950bdc120e35b0b53eda4c98" +checksum = "9cb0a3d11afbe2c909f0cfc9996fe37fd89b1196eb0b8d89a66e28716360ee09" dependencies = [ "proc-macro2", "quote", @@ -2448,9 +2448,9 @@ dependencies = [ [[package]] name = "vitaminc-random" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8320b63fad9560026b036cb6eb9b85efb1d666af52862af923a2c75972c4282" +checksum = "fa7b1bfab35ee322de37269c6fd94d8cd94047a66f97cf6722e53e3321482167" dependencies = [ "chacha20", "getrandom 0.4.2", @@ -2463,9 +2463,9 @@ dependencies = [ [[package]] name = "vitaminc-random-derives" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdbc48a9e3893406046f51f51c3d2bb1dffe70d6ca3b19920ca12d231ed12b68" +checksum = "0915e2bfb417ce11e23123fb7ec122af5d27d8cfd22d6ad6a2cf7a1c2dbb4712" dependencies = [ "proc-macro2", "quote", @@ -2474,9 +2474,9 @@ dependencies = [ [[package]] name = "vitaminc-traits" -version = "0.5.0" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c53613363efb0c47aba19ea2bfe0142cba8b2d0fe1bc27d43b0ea3390f6efe7" +checksum = "b31ec300e351a7a59e64899d0a2d7e7227cfa74ac0514f4e745acb2cc9335cee" dependencies = [ "anyhow", "bytes", From 77877da52200b5cc2d7826c1702ee52a89c2ae45 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 5 Oct 2026 00:39:22 -0700 Subject: [PATCH 6/7] docs(stack-encrypt): say what is transitional and what the engine does not call Three places where the docs promised more than the code does. An indexed plan field without "type" is still dispatched on each value's own tag, so for that field the engine trusts the binding's tagging: a 34 sent once as a Float64 and once as an Int64 under one "ore" field stores two different terms. That keeps the Go binding's current plans valid, and it is meant to end once that binding fills "type" from its struct types. The plan() rustdoc, FieldPlan and the dynamic module docs now say so, and point at #1082, which tracks making "type" required on every field with a term output. dynamic::read is the whole query-side type story, and nothing in the engine calls it: term() takes a Scalar and an IndexSpec and never sees the field's declared kind. Its rustdoc now says that plainly, that a binding deriving a query term for a typed field must call it first, and that wiring it into query(v).using(&plan) is tracked in #1057. Indexes::select said generic code over X: Indexes can call indexes.select::(), which does not compile without a Select bound. The sentence is replaced by a doctest that shows the bound, and the method's position parameter is renamed At -> P, so the rendered signature no longer reads as if it named the At struct. --- packages/stack-encrypt/src/dynamic/kind.rs | 19 ++++++++++-- packages/stack-encrypt/src/dynamic/mod.rs | 4 ++- packages/stack-encrypt/src/dynamic/record.rs | 14 +++++++-- packages/stack-encrypt/src/target/index.rs | 32 +++++++++++++++----- 4 files changed, 55 insertions(+), 14 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs index c923d1002..e87beac4a 100644 --- a/packages/stack-encrypt/src/dynamic/kind.rs +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -25,12 +25,16 @@ //! plan is built), and refuses a value of any other kind (when it runs), //! so the term bytes are the declared kind's and no binding is trusted to //! have tagged the value right; -//! - **query** reads the query value *as* the field's kind ([`read`]): `34` -//! against a `uint64` field is the `u64` term, whatever number type the -//! host handed over; +//! - **query** is meant to read the query value *as* the field's kind +//! ([`read`]): `34` against a `uint64` field is the `u64` term, whatever +//! number type the host handed over. The engine does not do this for the +//! caller yet; see [`read`]; //! - **decrypt** refuses an opened value of any other kind, so a host with no //! types of its own can rely on the declaration for what it gets back (an //! integer, not a float; bytes, not a string). +//! +//! A field with no `"type"` is dispatched on each value's own tag. That is +//! transitional; see [`record::plan`](super::record::plan()). use vitaminc_aead_value::{FfiValue, ValueKind}; use super::Error; @@ -76,6 +80,15 @@ pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool { /// being sealed is not converted; it must already be of the field's kind, /// and is refused otherwise. /// +/// **The engine does not call this.** [`term`](super::term()) takes a +/// [`Scalar`](super::Scalar) and an [`IndexSpec`] and never sees the field's +/// declared kind, so a binding deriving a query term for a typed field must +/// call `read` first, with [`FieldPlan::field_type`](super::FieldPlan::field_type). +/// One that skips it hands `Float64(34.0)` to a `uint64` field, derives a +/// float term where the stored one is the `u64` term, and matches nothing, +/// with no error. Wiring this into the plan's query path +/// (`query(v).using(&plan)`) is tracked in #1057. +/// /// ``` /// use stack_encrypt::dynamic::{read, FfiValue, ValueKind}; /// diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index 3bcbcc55a..e0891c3a9 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -46,7 +46,9 @@ //! the type it was sealed as. They are not this crate's: a declared type is //! vitaminc's [`ValueKind`], re-exported here, whose names vitaminc freezes //! beside its tag table. This crate adds only what a kind means to an index -//! ([`admits`]) and to a query value ([`read`]). +//! ([`admits`]) and to a query value ([`read`]). A field without `"type"` is +//! dispatched on each value's own tag; that is transitional, and +//! [`record::plan`] says until when. //! //! For the same reason the enums that spell them — [`Output`], //! [`IndexSpec`] and [`ValueKind`] — are *not* `#[non_exhaustive]`, against this workspace's diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 20df616cc..a6d493c0f 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -116,7 +116,8 @@ impl Output { /// (checked per value), so the engine verifies what a binding hands it /// rather than trusting the binding's tagging. A field with no declared type /// is dispatched on each value's own type, as every field was before types -/// existed; that keeps the plans existing bindings send valid. +/// existed; that keeps the plans existing bindings send valid, and is +/// transitional (see [`plan`]). #[derive(Clone, Debug)] pub struct FieldPlan { name: String, @@ -296,7 +297,16 @@ impl Plan { /// …; see [`ValueKind::name`]): vitaminc's vocabulary, not one of this /// crate's. Declared, it is checked against the field's outputs here /// ([`admits`]) and against every value sealed into or opened -/// from the field; absent, each value is dispatched on its own type. +/// from the field. +/// +/// **An indexed field without `"type"` is dispatched on each value's own +/// tag**, so for that field the engine trusts the binding to tag every value +/// the same way: a `34` sent once as a `Float64` and once as an `Int64` under +/// one `"ore"` field is accepted both times and stores two different terms. +/// This is transitional. It keeps the plans the Go binding sends today, which +/// carry no `"type"`, valid until that binding fills `"type"` from its struct +/// types; then `"type"` becomes required on every field with a term output +/// (#1082). /// /// `` is defined once, in [`super::context`](super::context()): a /// string, bytes, an integer, or a list of those, with what each spells in diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index ef33d1b9a..bb0117bb5 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -288,13 +288,13 @@ pub trait Indexes { /// query asks one index for one term and needs no ciphertext; its /// [`operation`](Index::operation) is that term alone. /// - /// `At` is where `I` sits and is inferred; leave it `_`. Asking for an - /// index the set does not hold does not compile, and neither does asking - /// for one the set holds twice, since its place is then ambiguous. + /// `P` is where `I` sits (the position [`Select`] names) and is inferred; + /// leave it `_`. Asking for an index the set does not hold does not + /// compile, and neither does asking for one the set holds twice, since + /// its place is then ambiguous. /// - /// In code generic over `X: Indexes`, as the plan builder is, this is - /// `indexes.select::()`. A concrete tuple is a set of indexes - /// over many plaintexts, so there the plaintext is named: + /// A concrete tuple is a set of indexes over many plaintexts, so there + /// the plaintext is named: /// /// ``` /// use stack_encrypt::target::{Equality, Index, IndexSpec, Indexes, Ore}; @@ -303,9 +303,25 @@ pub trait Indexes { /// let ore = Indexes::::select::(&indexes); /// assert_eq!(Index::::spec(ore), IndexSpec::Ore); /// ``` - fn select, At>(&self) -> &I + /// + /// In code generic over the set, the position is a type parameter too, + /// and the set must be bounded by [`Select`] for it: `X: Indexes` + /// alone does not let the call compile. + /// + /// ``` + /// use stack_encrypt::target::{Equality, Index, IndexSpec, Indexes, Ore, Select}; + /// + /// fn ore_spec(indexes: &X) -> IndexSpec + /// where + /// X: Indexes + Select, + /// { + /// Index::::spec(indexes.select::()) + /// } + /// assert_eq!(ore_spec(&(Equality, Ore)), IndexSpec::Ore); + /// ``` + fn select, P>(&self) -> &I where - Self: Select, + Self: Select, { Select::get(self) } From 27fe04215d9c544a6f3b46e7f003e7f655007bc4 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Mon, 5 Oct 2026 00:39:52 -0700 Subject: [PATCH 7/7] fix(stack-encrypt): refuse a NaN either way in read, and test the typed-field gaps dynamic::read treated a NaN differently in its two float directions: read(Float64, Float32(NaN)) returned Float64(NaN), while read(Float32, Float64(NaN)) refused it, and the doc said "a NaN is never exact". Converting a NaN is never exact, since it equals nothing, so both directions now refuse it. A NaN already of the field's own float kind is still returned as it is, like any value of the kind. Tests for paths review found unpinned: - decrypt with an index-only field before a typed field. Opened values are paired with the fields that have a ciphertext, not with every plan field; pairing with every field makes this test fail (checked), where every existing test passed. - a typed "object" or "array" field round-trips, empty or not, and opens as its declared kind. - check_record accepts a record sealed as another type (it cannot open the leaf to see the tag), and decrypt refuses it. - a field spec with "type" before "outputs" parses and is checked the same way. - read at the first float past each integer range (2^63, 2^64, 2^32) is refused and the last value inside converts exactly, so a saturating `f as u64` cast would fail. - read refuses a NaN in both float directions. The query-term test's variable is renamed from probe to query, the glossary's word. --- packages/stack-encrypt/src/dynamic/kind.rs | 64 ++++++++-- packages/stack-encrypt/src/dynamic/record.rs | 120 ++++++++++++++++++- 2 files changed, 173 insertions(+), 11 deletions(-) diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs index e87beac4a..e3cc30869 100644 --- a/packages/stack-encrypt/src/dynamic/kind.rs +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -72,9 +72,10 @@ pub fn admits(kind: ValueKind, index: &IndexSpec) -> bool { /// width or kind is converted when the conversion is exact: an integer in /// range, or a float with no fractional part in range, for an integer kind /// (a JavaScript `34` arrives as a float); an integer or float the target -/// float kind represents exactly, for a float kind. Nothing else converts: -/// a string is never parsed as a number, and a number never becomes a -/// string. +/// float kind represents exactly, for a float kind. A NaN is never +/// converted, in either float direction: it equals nothing, so no +/// conversion of it is exact. Nothing else converts: a string is never +/// parsed as a number, and a number never becomes a string. /// /// This is for a query: what a host hands over to search with. A value /// being sealed is not converted; it must already be of the field's kind, @@ -163,10 +164,11 @@ impl Number { } /// The `f64` this number is exactly, if it is one: an integer whose - /// conversion does not round. + /// conversion does not round, or a float that is not a NaN. A NaN is + /// never exact, because it does not equal itself. fn exact_f64(self) -> Option { match self { - Number::Float(f) => Some(f), + Number::Float(f) => (!f.is_nan()).then_some(f), Number::Integer(i) => { let f = i as f64; (f as i128 == i).then_some(f) @@ -174,8 +176,8 @@ impl Number { } } - /// The `f32` this number is exactly, if it is one. A NaN is never - /// exact, because it does not equal itself. + /// The `f32` this number is exactly, if it is one. A NaN is refused by + /// `exact_f64` before it gets here. fn exact_f32(self) -> Option { let f = self.exact_f64()?; let narrowed = f as f32; @@ -284,9 +286,14 @@ mod tests { let read = read_ok(kind, sample(kind)).expect("its own kind"); assert!(kind.holds(&read)); } + // Already of the kind: returned as it is, not converted, so a NaN + // the host sent as the field's own float kind is not refused here. assert!( matches!(read_ok(ValueKind::Float32, FfiValue::Float32(f32::NAN)), Some(FfiValue::Float32(f)) if f.is_nan()) ); + assert!( + matches!(read_ok(ValueKind::Float64, FfiValue::Float64(f64::NAN)), Some(FfiValue::Float64(f)) if f.is_nan()) + ); } #[test] @@ -359,6 +366,34 @@ mod tests { } } + /// The first float past each integer range is refused, and the last + /// one inside it converts exactly. A float cast straight to the integer + /// type (`f as u64`) would saturate instead: a JavaScript 2^64 would + /// read as `u64::MAX` and derive a term for a different number. + #[test] + fn read_refuses_a_float_at_the_first_value_past_an_integer_range() { + let two_63 = 9_223_372_036_854_775_808.0_f64; + let two_64 = 18_446_744_073_709_551_616.0_f64; + assert!(read_ok(ValueKind::Int64, FfiValue::Float64(two_63)).is_none()); + assert!(matches!( + read_ok(ValueKind::Int64, FfiValue::Float64(-two_63)), + Some(FfiValue::Int64(i64::MIN)) + )); + assert!(read_ok(ValueKind::UInt64, FfiValue::Float64(two_64)).is_none()); + assert!(matches!( + read_ok( + ValueKind::UInt64, + FfiValue::Float64(18_446_744_073_709_549_568.0) + ), + Some(FfiValue::UInt64(18_446_744_073_709_549_568)) + )); + assert!(read_ok(ValueKind::UInt32, FfiValue::Float64(4_294_967_296.0)).is_none()); + assert!(matches!( + read_ok(ValueKind::UInt32, FfiValue::Float64(4_294_967_295.0)), + Some(FfiValue::UInt32(u32::MAX)) + )); + } + #[test] fn read_converts_an_exactly_representable_number_to_a_float_kind() { assert!( @@ -386,7 +421,6 @@ mod tests { (ValueKind::Float64, FfiValue::UInt64(u64::MAX)), (ValueKind::Float32, FfiValue::UInt32(16_777_217)), (ValueKind::Float32, FfiValue::Float64(0.1)), - (ValueKind::Float32, FfiValue::Float64(f64::NAN)), ]; for (at, (kind, value)) in refused.into_iter().enumerate() { assert!( @@ -396,6 +430,20 @@ mod tests { } } + /// A NaN converts in neither float direction: it equals nothing, so no + /// conversion of it is exact, and a query for one could match nothing. + #[test] + fn read_refuses_a_nan_in_both_float_directions() { + assert!(matches!( + read(ValueKind::Float32, FfiValue::Float64(f64::NAN)), + Err(Error::Source) + )); + assert!(matches!( + read(ValueKind::Float64, FfiValue::Float32(f32::NAN)), + Err(Error::Source) + )); + } + #[test] fn read_never_converts_across_kinds() { let refused = [ diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index a6d493c0f..6040a57ae 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -2352,6 +2352,32 @@ mod tests { ); } + /// The parser applies `"type"` after it has read every key, so a + /// spec that declares its type before its outputs is checked the + /// same way: admitted when the type admits the indexes, refused + /// when it does not. + #[test] + fn the_plan_reads_a_type_given_before_the_outputs() { + let early = |ty: &str| { + obj(vec![ + ("type", s(ty)), + ("outputs", strings(&["c", "eq"])), + ("context", s("users/age")), + ]) + }; + let parsed = plan(obj(vec![("age", early("uint64"))])).expect("parses"); + assert_eq!(parsed.fields()[0].field_type(), Some(ValueKind::UInt64)); + assert_eq!( + parsed.fields()[0].outputs(), + [Output::Ciphertext, Output::Term(IndexSpec::Equality)] + ); + let refused = plan(obj(vec![("age", early("float64"))])); + assert!( + matches!(refused, Err(Error::Plan)), + "equality on a float is refused whatever the key order: {refused:?}" + ); + } + #[test] fn the_plan_refuses_an_unresolvable_type_or_one_that_does_not_admit_an_index() { let refused: [(&str, FfiValue); 8] = [ @@ -2552,15 +2578,103 @@ mod tests { let value = crate::dynamic::read(declared, FfiValue::Float64(34.0)).expect("an exact 34"); let scalar = Scalar::of(&value, &IndexSpec::Equality).expect("scalar"); - let probe = term( + let query = term( &keyset, scalar, &IndexSpec::Equality, field.view().expect("view"), ) .await - .expect("probe"); - assert_eq!(probe, stored); + .expect("query term"); + assert_eq!(query, stored); + } + + /// An index-only field has no ciphertext, so it is not among the + /// opened values. The typed field after it is checked against its + /// own type, not against the index-only field's. + #[tokio::test] + async fn decrypt_skips_an_index_only_field_when_it_checks_types() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let plan = plan(obj(vec![ + ("age", typed("users/age", &["eq"], "uint64")), + ("name", typed("users/name", &["c"], "string")), + ])) + .expect("plan"); + let sealed = encrypt( + &keyset, + obj(vec![("age", FfiValue::UInt64(34)), ("name", s("bob"))]), + &plan, + ) + .await + .expect("seal"); + let opened = object( + decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("name is checked as a string, not as a uint64"), + ); + assert_eq!(keys(&opened), ["name"]); + assert_eq!(text_of(&opened[0].1), "bob"); + } + + /// A typed composite field opens as its declared kind, with its + /// entries, including when it is empty: an empty `[]` that opened + /// as `{}` would fail every read of an `"array"` field. + #[tokio::test] + async fn a_typed_composite_field_round_trips_even_when_empty() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + for (ty, value, len) in [ + ("object", obj(vec![("home", s("a@x"))]), 1), + ("object", FfiValue::Object(vec![]), 0), + ( + "array", + FfiValue::Array(vec![s("a"), FfiValue::UInt32(1)]), + 2, + ), + ("array", FfiValue::Array(vec![]), 0), + ] { + let plan = plan(obj(vec![("doc", typed("users/doc", &["c"], ty))])).expect("plan"); + let sealed = encrypt(&keyset, obj(vec![("doc", value)]), &plan) + .await + .expect("seal"); + let opened = decrypt(Scope::Client(&cipher), sealed, &plan) + .await + .expect("a composite opens as its declared kind"); + let (_, doc) = object(opened).into_iter().next().expect("doc"); + match (ty, doc) { + ("object", FfiValue::Object(entries)) => assert_eq!(entries.len(), len), + ("array", FfiValue::Array(items)) => assert_eq!(items.len(), len), + (ty, _) => panic!("a {ty} field opened as another kind"), + } + } + } + + /// `check_record` does not open anything, so it cannot see a + /// typed field's type: the tag is inside the AEAD envelope. A record + /// sealed as another type passes it, and only `decrypt` refuses it. + #[tokio::test] + async fn check_record_accepts_a_record_sealed_as_another_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let untyped = plan(obj(vec![("age", spec(s("users/age"), &["c"]))])).expect("plan"); + let as_uint64 = + plan(obj(vec![("age", typed("users/age", &["c"], "uint64"))])).expect("plan"); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + check_record(sealed, &as_uint64).expect("the type is not visible without opening"); + + let sealed = encrypt(&keyset, obj(vec![("age", FfiValue::UInt32(34))]), &untyped) + .await + .expect("seal"); + let result = decrypt(Scope::Client(&cipher), sealed, &as_uint64).await; + assert!( + matches!(result, Err(Error::Record)), + "decrypt is where the type is checked: {:?}", + result.err() + ); } } }