diff --git a/Cargo.lock b/Cargo.lock index 733953e93..d9fef6e89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -509,15 +509,17 @@ dependencies = [ [[package]] name = "cllw-ore" -version = "0.5.0" +version = "0.5.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +checksum = "24eb4a774d070a6c2b52175b98273cd7193c61e1bda39c1a98b50b7a2d91375a" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "unicode-normalization", + "vitaminc-ore", + "vitaminc-protected 0.6.1", "zeroize", ] @@ -3286,6 +3288,7 @@ dependencies = [ "vitaminc-encrypt 0.6.1", "vitaminc-hmac", "vitaminc-normalize", + "vitaminc-ore", "vitaminc-prf", "vitaminc-protected 0.6.1", "zeroize", @@ -4254,6 +4257,21 @@ dependencies = [ "vitaminc-protected 0.6.1", ] +[[package]] +name = "vitaminc-ore" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed88c54b5f06f2177ecbfb7666812adf645406f6fb555f4e2f0fd7fa218e71c4" +dependencies = [ + "thiserror 2.0.18", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.1", + "vitaminc-context 0.6.1", + "vitaminc-normalize", + "vitaminc-protected 0.6.1", +] + [[package]] name = "vitaminc-prf" version = "0.6.1" diff --git a/Cargo.toml b/Cargo.toml index 0629e45a6..17bec471a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -81,7 +81,10 @@ stack-profile = { path = "./packages/stack-profile", version = "0.43.0" } cts-common = { version = "0.43.0", default-features = false } zerokms-protocol = "0.12.31" recipher = "=0.3.1" -cllw-ore = { version = "=0.5.0", default-features = false } +# cllw-ore's `vitaminc` feature implements vitaminc-ore's `OrderScheme`, +# which Stack Encrypt's order terms are encrypted through. It first ships +# in a pre-release, and Cargo resolves a pre-release only when named. +cllw-ore = { version = "=0.5.2-rc.1", default-features = false, features = ["vitaminc"] } # External dependencies, with the suite's feature lists. base64 = "0.22.0" @@ -130,5 +133,8 @@ vitaminc-hmac = "0.6.1" # Normalized equality terms (Unicode 16 NFC text, one integer encoding for # every width), which `vitaminc-prf`'s `normalize` feature derives over. vitaminc-normalize = "0.6.1" +# The text order key: the system ADR-0002's fold, pinned to Unicode 16, +# which text ORE and OPE terms are derived over (ADR-0009). +vitaminc-ore = "0.6.1" vitaminc-prf = { version = "0.6.1", features = ["normalize"] } vitaminc-protected = "0.6.1" diff --git a/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md b/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md index 17fb6920f..01926da4f 100644 --- a/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md +++ b/docs/adr/0002-one-value-model-three-encodings-and-eql-v4-by-producer.md @@ -203,9 +203,18 @@ transform and its version is named in the domain label, for example UTC calendar day. - **Text fold, v1**: NFD, then full Unicode case folding (`CaseFolding.txt` statuses C and F, so `ß` becomes `ss`), then NFD again, then removal of - every code point with General_Category `Mn`, then NFC. The order of the - steps is part of the transform, because folding can produce text that is - no longer normalised and the steps do not commute. + every accent, then NFC. An accent is a mark (General_Category `Mn`, `Mc` + or `Me`) with a non-zero canonical combining class: the acute in `é`, the + umlaut in `ö`, a Devanagari virama. A mark of class zero is a letter of + its own in its script, such as a Thai or Devanagari vowel sign (U+0E31, + U+0901), and is kept. The order of the steps is part of the transform, + because folding can produce text that is no longer normalised and the + steps do not commute. + *Amended 2026-10-19:* this step said "every code point with + General_Category `Mn`", which would also drop class-zero vowel signs and + so tie different words. The fold that ships, `vitaminc-ore`'s + `vitaminc/ore/value/text-folded/unicode-16/v1`, keeps them, and this + bullet now describes it. - **Prefix truncation**: a number of code points kept after any other transform. Alphabet packing, if ever built, is a transform of the same kind. @@ -219,6 +228,13 @@ write. The cost is that two strings using characters added after the pin, which a later Unicode would treat as equivalent, may not match. Moving the pin is a new label: existing columns keep the label they were written under and stay queryable, and a column moves only by re-encryption. +*Amended 2026-10-18:* vitaminc 0.6 refuses a code point unassigned in the +pinned version instead of passing it through. `vitaminc-normalize` (equality +and match terms) and `vitaminc-ore`'s fold (order terms) both return +`UnassignedCodePoint`, so text holding one has no term, and writing it to an +indexed column or querying with it is refused. The refusal is the same on +every path and in every language, so the transform is still a fixed +function of the text it accepts, and moving the pin is still a new label. Order terms fold case and accents because code-point order puts `é` after `z`; a fixed fold approximates the first level of Unicode collation without diff --git a/languages/golang/CHANGELOG.md b/languages/golang/CHANGELOG.md index 9a20b3106..c2fc099f1 100644 --- a/languages/golang/CHANGELOG.md +++ b/languages/golang/CHANGELOG.md @@ -35,6 +35,20 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. implemented outside this module (a wrapper, or a fake in a generator's tests) changes its `Check` to return them; `enginetest.Static` shows the table. +- **An `int8`, `uint8` or `byte` field takes no `ore` or `ope` index; drop + the index and run stashgen again.** With 256 values, an order term ranks + every row among 256, which a few known rows resolve to the values, the + leak a `bool`'s order term is already refused for. The engine refuses the + declaration as it refuses one on a `bool`, so stashgen stops at it. The + field keeps `equality` and `Eq`; a value that must order declares a wider + type. +- **Every ORE and OPE term changes bytes; encrypt indexed rows again.** + Order terms now encrypt through cllw-ore's vitaminc schemes, for every + type: integers of every width, floats, text and bytes. A term v0.1.0-alpha.1 + stored does not compare with one this release derives, and nothing + refuses it: `Lt`, `Between` or an `ORDER BY` over a column holding the + alpha's terms silently misses or misplaces rows. Re-derive a column's + order terms (encrypt its rows again) before querying it. ### Added @@ -47,8 +61,8 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. `Decimal` are vitaminc's `vcvalue` types, with `encrypt.DateOf`, `ParseInt128`, `ParseUint128` and `ParseDecimal` (which refuses NaN and the infinities with `ErrNonFiniteDecimal`). Equality applies to all of - them, ORE and OPE to every integer width. stashgen refuses an order index - on a decimal, date or timestamp, naming #1236. The value-kinds fixture + them, ORE and OPE to every integer width but 8 bits. stashgen refuses an + order index on a decimal, date or timestamp, naming #1236. The value-kinds fixture test opens a record the Rust lowering sealed and derives the same terms. - **Any built-in Go integer field takes an integer EQL type** (ADR-0002): an `int16` or a `uint16` into `Integer`, an `int` into `Bigint`. The @@ -74,9 +88,13 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`. type, range checked as for `Integer`) tagged `encrypt_into=` one of them seals at 16 bits; `SmallintEq` gets `Eq`, with the same equality term as an integer of the same value under the same column. +- **Ordering methods on text.** A string field with `index=ore` or + `index=ope` gets `Lt`, `Lte`, `Gt`, `Gte` and `Between`. Text orders by its + folded form (ADR-0002: case and accents folded, under Unicode 16), so + `Café` and `cafe` tie and `alice` orders before `Zoe`. - **stashgen notices an index no generated method queries**: an `ore` or - `ope` index on text, bytes or a float (the engine answers the orderings - over integers only), or the second of two order indexes on an integer. + `ope` index on bytes or a float (the engine answers the orderings over + integers and text only), or the second of two order indexes on a field. The index is still stored; the notice is on stderr and in the file. ### Changed diff --git a/languages/golang/cmd/stashgen/README.md b/languages/golang/cmd/stashgen/README.md index cdbfb8155..731ecbe90 100644 --- a/languages/golang/cmd/stashgen/README.md +++ b/languages/golang/cmd/stashgen/README.md @@ -124,7 +124,7 @@ For `-type User`, the file `user_stash.go` holds: - `Fields`, with one entry for each sealed field. An opaque struct, or a struct with no sealed field outside an opaque one, gets no `Fields`: nothing in it is sealed on its own, so nothing can be queried. An entry encrypts one value, and has one query method for each operator the engine answers on the field: `Eq`, `Lt`, `Lte`, `Gt`, `Gte`, `Between` or `Matches`. - stashgen asks the embedded engine which operators each field answers, and which index answers each one, so it holds no copy of those rules: an equality index answers `Eq`, a match index on a string `Matches`, and an ORE or OPE index on an integer the four orderings and `Between`, through ORE when the field declares both. + stashgen asks the embedded engine which operators each field answers, and which index answers each one, so it holds no copy of those rules: an equality index answers `Eq`, a match index on a string `Matches`, and an ORE or OPE index on an integer or a string the four orderings and `Between`, through ORE when the field declares both. A string orders by its folded form: case and accents folded, under Unicode 16. `encrypt_into=TextEq` answers `Eq`, which returns the type's query value, `eql.TextEqQuery`. `encrypt_into=TextMatch` answers `Matches`, which returns an `encrypt.EQLMatchQuery`: the `eql.TextMatchQuery` operand, with `Verify` and `TargetLimit`. The query of `Matches` carries `Verify` and `TargetLimit`, because its answer can hold rows that do not match; an exact operator's query carries neither. diff --git a/languages/golang/encrypt/README.md b/languages/golang/encrypt/README.md index 9618d2801..fba25aafb 100644 --- a/languages/golang/encrypt/README.md +++ b/languages/golang/encrypt/README.md @@ -229,20 +229,21 @@ or a type defined over one (`type Email string`): | Go field | Seals as | Indexes it can store | Query methods | |---|---|---|---| -| `string` | text | `equality`, `match`, `ore`, `ope` | `Eq`, `Matches` | +| `string` | text | `equality`, `match`, `ore`, `ope` | `Eq`, `Matches`, `Lt`, `Lte`, `Gt`, `Gte`, `Between` | | `[]byte` | bytes | `equality`, `ore`, `ope` | `Eq` | | `bool` | a boolean | none | none | -| `int8`, `int16`, `int32`, `int64`, `int` | an integer of the field's own width (`int` is 64-bit) | `equality`, `ore`, `ope` | `Eq`, `Lt`, `Lte`, `Gt`, `Gte`, `Between` | -| `uint8` (`byte`), `uint16`, `uint32`, `uint64`, `uint` | the same, unsigned | `equality`, `ore`, `ope` | the same | +| `int16`, `int32`, `int64`, `int` | an integer of the field's own width (`int` is 64-bit) | `equality`, `ore`, `ope` | `Eq`, `Lt`, `Lte`, `Gt`, `Gte`, `Between` | +| `uint16`, `uint32`, `uint64`, `uint` | the same, unsigned | `equality`, `ore`, `ope` | the same | +| `int8`, `uint8` (`byte`) | an 8-bit integer | `equality` | `Eq` | | `encrypt.Int128`, `encrypt.Uint128` | a 128-bit integer | `equality`, `ore`, `ope` | the same | | `float32`, `float64` | a float of its width | `equality`, `ore`, `ope` | `Eq` | | `encrypt.Decimal` | a decimal: a 96-bit coefficient and a scale up to 28 | `equality` | `Eq` | | `encrypt.Date` | a calendar date | `equality` | `Eq` | | `time.Time` | a timestamp, to the nanosecond, in UTC | `equality` | `Eq` | -Each query method needs its index declared. An `ore` or `ope` index on text, -bytes or a float stores its term, but the engine answers no ordering through -it yet, so such a field has no `Lt` (see [Queries](#queries)). +Each query method needs its index declared. An `ore` or `ope` index on bytes +or a float stores its term, but the engine answers no ordering through it +yet, so such a field has no `Lt` (see [Queries](#queries)). `encrypt.Date`, `Int128`, `Uint128` and `Decimal` are vitaminc's `vcvalue` types; build them with `encrypt.DateOf`, `ParseInt128`, `ParseUint128` and @@ -253,8 +254,11 @@ sealed with, but a zero has no sign: `-0.00` opens as `0.00`. The zero `ErrEncoding`, naming the field; so is a decimal or a `time.Time` outside vitaminc's range. A sealed field cannot be a pointer, so an optional date has no sealed form yet: keep it in an opaque struct, or use a sentinel date. Each integer type seals at its own -width, and each width is passed to the order scheme as itself, so an `int8` -field's terms are an `int8`'s. Equality follows vitaminc's normalisation: an +width, and each width is passed to the order scheme as itself, so an `int16` +field's terms are an `int16`'s. An `int8` or `uint8` field has no order +index: with 256 values, an order term would rank every row among 256, which +a few known rows resolve to the values, as a `bool`'s would say which rows +are true. Equality follows vitaminc's normalisation: an integer equals itself at every width, decimals compare at any scale (`1.50 = 1.5`), and a timestamp compares to the microsecond. An order index on a decimal, date or timestamp is refused for now: those order terms wait for @@ -298,17 +302,27 @@ rules. | The field declares | Methods | Each returns | |---|---|---| | `index=equality` | `Eq` | `encrypt.EqualityTerm` | -| `index=ore` or `index=ope`, on an integer of any width | `Lt`, `Lte`, `Gt`, `Gte` | `encrypt.OreTerm`, or `encrypt.OpeTerm` when the field declares no `ore` | +| `index=ore` or `index=ope`, on an integer of 16 bits or wider or a string | `Lt`, `Lte`, `Gt`, `Gte` | `encrypt.OreTerm`, or `encrypt.OpeTerm` when the field declares no `ore` | | the same | `Between(ctx, cipher, lo, hi)` | `encrypt.Range[encrypt.OreTerm]` or `encrypt.Range[encrypt.OpeTerm]`, the two bounds' terms | | `index=match`, on a string | `Matches` | `encrypt.MatchQuery[T]` | | `encrypt_into=TextEq` | `Eq` | `eql.TextEqQuery` | | `encrypt_into=TextMatch` | `Matches` | `encrypt.EQLMatchQuery[eql.TextMatchQuery, T]` | A method takes the field's own Go type, so `Fields.Age.Lt` on a `uint32` -field takes a `uint32`. An order index on text, bytes or a float stores its -term but answers no ordering operator yet, so such a field has no `Lt`; nor -does the `ope` index of a field that declares `ore` too, since `ore` answers -the orderings. stashgen prints a notice for each such index, on stderr and in +field takes a `uint32`. + +Text orders by its folded form (ADR-0002): case and accents are folded and +`ß` becomes `ss`, under Unicode 16. So `Café`, `cafe` and `CAFÉ` are one +value and tie, `alice` orders before `Zoe`, and `é` orders with `e`, not +after `z`. `Between` is inclusive at both ends and, as SQL's `BETWEEN`, +does not refuse a range whose low bound is above its high bound: such a +range matches nothing. So `Between("b", "A")` matches nothing, since `A` +folds to `a`, below `b`. Text holding a code point Unicode 16 does not assign has no +fold, and its query or write is refused with `encrypt.ErrTerm`. + +An order index on bytes or a float stores its term but answers no ordering +operator, so such a field has no `Lt`; nor does the `ope` index of a field +that declares `ore` too, since `ore` answers the orderings. stashgen prints a notice for each such index, on stderr and in the generated file: the terms are stored, and only your own code can compare them. A match index on an index-only field (`index=match` with no `encrypt`) is refused, because `Verify` checks a match query's rows by decrypting them. diff --git a/languages/golang/encrypt/doc.go b/languages/golang/encrypt/doc.go index ae207856c..ad71f813d 100644 --- a/languages/golang/encrypt/doc.go +++ b/languages/golang/encrypt/doc.go @@ -153,7 +153,10 @@ // // Eq returns an [EqualityTerm] (or, for a field with encrypt_into, the EQL // type's query value), an ordering an [OreTerm] or [OpeTerm], and Between a -// [Range] of two. Their answers are exact. Matches returns a [MatchQuery], +// [Range] of two. Their answers are exact. The orderings are answered on +// integers and on text, which orders by its folded form (case and accents +// folded, under Unicode 16), so Café and cafe tie and alice orders before +// Zoe. Matches returns a [MatchQuery], // whose answer can hold rows that do not match: size the fetch with // [MatchQuery.TargetLimit], then decrypt the rows and keep those // [MatchQuery.Verify] reports true, one call into the engine for the page. diff --git a/languages/golang/encrypt/guest/Cargo.lock b/languages/golang/encrypt/guest/Cargo.lock index 39920158b..649c9a375 100644 --- a/languages/golang/encrypt/guest/Cargo.lock +++ b/languages/golang/encrypt/guest/Cargo.lock @@ -338,15 +338,17 @@ dependencies = [ [[package]] name = "cllw-ore" -version = "0.5.0" +version = "0.5.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +checksum = "24eb4a774d070a6c2b52175b98273cd7193c61e1bda39c1a98b50b7a2d91375a" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "unicode-normalization", + "vitaminc-ore", + "vitaminc-protected 0.6.1", "zeroize", ] @@ -2087,6 +2089,7 @@ dependencies = [ "vitaminc-encrypt 0.6.1", "vitaminc-hmac", "vitaminc-normalize", + "vitaminc-ore", "vitaminc-prf", "vitaminc-protected 0.6.1", "zeroize", @@ -2780,6 +2783,21 @@ dependencies = [ "vitaminc-protected 0.6.1", ] +[[package]] +name = "vitaminc-ore" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed88c54b5f06f2177ecbfb7666812adf645406f6fb555f4e2f0fd7fa218e71c4" +dependencies = [ + "thiserror 2.0.20", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.1", + "vitaminc-context 0.6.1", + "vitaminc-normalize", + "vitaminc-protected 0.6.1", +] + [[package]] name = "vitaminc-prf" version = "0.6.1" diff --git a/languages/golang/encrypt/guest/src/abi.rs b/languages/golang/encrypt/guest/src/abi.rs index 1cd4ce19a..7c83622fd 100644 --- a/languages/golang/encrypt/guest/src/abi.rs +++ b/languages/golang/encrypt/guest/src/abi.rs @@ -465,8 +465,9 @@ pub unsafe extern "C" fn se_plan_check(plan_ptr: *const u8, plan_len: u32) -> u6 /// term's codec-encoded object out. An operator the field does not answer /// is `STATUS_ENCODING` naming the field, as a mistyped value is, before /// the cipher is consulted; the refusals the typed path makes before a term -/// exists (a range the wrong way round, match text that cuts no token or -/// does not normalize) are `STATUS_TERM`, also before the cipher. The input +/// exists (match text that cuts no token or does not normalize) are +/// `STATUS_TERM`, also before the cipher. A range the wrong way round is +/// not refused: it matches no row, as SQL's `BETWEEN` does. The input /// buffer is wiped in place. /// /// # Safety diff --git a/languages/golang/encrypt/guest/tests/native_ops.rs b/languages/golang/encrypt/guest/tests/native_ops.rs index f26214490..866c304e4 100644 --- a/languages/golang/encrypt/guest/tests/native_ops.rs +++ b/languages/golang/encrypt/guest/tests/native_ops.rs @@ -330,7 +330,8 @@ fn guest_terms_match_the_native_sem_derivations() { .expect("native"); assert_eq!(eq_bytes, native_bytes.as_bytes()); - // Variable-width CLLW output for strings. + // Variable-width CLLW output for strings: the text behind cllw-ore's + // one marker byte, eight bytes a plaintext byte. let ore_s = block_on(ops::term( &cipher.default_keyset(), &encode(s("alice")), @@ -338,7 +339,7 @@ fn guest_terms_match_the_native_sem_derivations() { TERM_ORE, )) .expect("string ore"); - assert_eq!(ore_s.len(), 5 * 8); + assert_eq!(ore_s.len(), 6 * 8); // No ZeroKMS traffic for any of it. assert_eq!(cipher.kms().generate_calls.load(Ordering::SeqCst), 0); @@ -1556,13 +1557,6 @@ fn query_term_validation_refuses_what_the_query_op_refuses() { "between", STATUS_ENCODING, ), - ( - "inverted range", - Tree::Array(vec![Tree::UInt32(65), Tree::UInt32(18)]), - "age", - "between", - STATUS_TERM, - ), ("no token", s("al"), "name", "matches", STATUS_TERM), ] { let input = encode(input); diff --git a/languages/golang/encrypt/internal/testusers/kinds.go b/languages/golang/encrypt/internal/testusers/kinds.go index 974e78d54..879a69055 100644 --- a/languages/golang/encrypt/internal/testusers/kinds.go +++ b/languages/golang/encrypt/internal/testusers/kinds.go @@ -45,7 +45,7 @@ type Kinds struct { S string `stash:"s,encrypt,index=equality;match"` E Email `stash:"e,encrypt,index=equality;match;ore"` Bo bool `stash:"bo,encrypt"` - I8 int8 `stash:"i8,encrypt,index=equality;ore"` + I8 int8 `stash:"i8,encrypt,index=equality"` I16 int16 `stash:"i16,encrypt,index=ope"` I32 int32 `stash:"i32,encrypt,index=equality"` I int `stash:"i,encrypt,index=ore"` @@ -79,13 +79,14 @@ type Kinds struct { // context, identities and indexes of the plan in // packages/stack-encrypt/tests/fixtures/value_kinds.json, so the generated // code opens the record Rust sealed there and derives the same terms. Every -// integer seals at its own width; the order indexes of a date, timestamp or -// decimal wait for #1236, so those three carry equality alone. +// integer seals at its own width; an 8-bit integer has no order term, and +// the order indexes of a date, timestamp or decimal wait for #1236, so +// those five carry equality alone. type ValueKinds struct { _ struct{} `stash:"context=value_kinds"` - I8 int8 `stash:"i8,encrypt,index=equality;ore"` + I8 int8 `stash:"i8,encrypt,index=equality"` I16 int16 `stash:"i16,encrypt,index=equality;ore"` - U8 uint8 `stash:"u8,encrypt,index=equality;ore"` + U8 uint8 `stash:"u8,encrypt,index=equality"` U16 uint16 `stash:"u16,encrypt,index=equality;ope"` I128 encrypt.Int128 `stash:"i128,encrypt,index=equality;ore"` U128 encrypt.Uint128 `stash:"u128,encrypt,index=equality;ope"` diff --git a/languages/golang/encrypt/internal/testusers/kinds_stash.go b/languages/golang/encrypt/internal/testusers/kinds_stash.go index ff92f3923..36ba4e8e9 100644 --- a/languages/golang/encrypt/internal/testusers/kinds_stash.go +++ b/languages/golang/encrypt/internal/testusers/kinds_stash.go @@ -18,15 +18,10 @@ const _ = gensupport.GeneratedVersion1 // Kinds prints its sealed fields in the clear: it has no String or LogValue // method. Write them, or run stashgen with -redact. -// E: stores ore terms that no generated method queries: the engine answers no -// query through the ore index on this field (Email): the orderings are answered -// over integers only. Drop the index unless your own code compares the stored -// terms. - // F64: stores ore terms that no generated method queries: the engine answers no // query through the ore index on this field (float64): the orderings are -// answered over integers only. Drop the index unless your own code compares the -// stored terms. +// answered over integers and text only. Drop the index unless your own code +// compares the stored terms. type EncryptedKinds struct { S EncryptedKindsS @@ -79,7 +74,6 @@ type EncryptedKindsBo struct { type EncryptedKindsI8 struct { Ciphertext encrypt.Ciphertext Equality encrypt.EqualityTerm - Ore encrypt.OreTerm } type EncryptedKindsI16 struct { @@ -205,7 +199,7 @@ var kindsDeclaration = gensupport.Declare("kinds"). EncryptIndex("s", gensupport.String, encrypt.Equality, encrypt.Match()). EncryptIndex("e", gensupport.String, encrypt.Equality, encrypt.Match(), encrypt.Ore). Encrypt("bo", gensupport.Bool). - EncryptIndex("i8", gensupport.Int8, encrypt.Equality, encrypt.Ore). + EncryptIndex("i8", gensupport.Int8, encrypt.Equality). EncryptIndex("i16", gensupport.Int16, encrypt.Ope). EncryptIndex("i32", gensupport.Int32, encrypt.Equality). EncryptIndex("i", gensupport.Int64, encrypt.Ore). @@ -309,7 +303,6 @@ var kindsCodec = gensupport.New(gensupport.Generated[Kinds, EncryptedKinds]{ e.I8 = EncryptedKindsI8{ Ciphertext: rec["i8"].Ciphertext, Equality: rec["i8"].Equality, - Ore: rec["i8"].Ore, } e.I16 = EncryptedKindsI16{ Ciphertext: rec["i16"].Ciphertext, @@ -368,7 +361,7 @@ var kindsCodec = gensupport.New(gensupport.Generated[Kinds, EncryptedKinds]{ "s": {Ciphertext: e.S.Ciphertext, Equality: e.S.Equality, Match: e.S.Match}, "e": {Ciphertext: e.E.Ciphertext, Equality: e.E.Equality, Match: e.E.Match, Ore: e.E.Ore}, "bo": {Ciphertext: e.Bo.Ciphertext}, - "i8": {Ciphertext: e.I8.Ciphertext, Equality: e.I8.Equality, Ore: e.I8.Ore}, + "i8": {Ciphertext: e.I8.Ciphertext, Equality: e.I8.Equality}, "i16": {Ciphertext: e.I16.Ciphertext, Ope: e.I16.Ope}, "i32": {Ciphertext: e.I32.Ciphertext, Equality: e.I32.Equality}, "i": {Ciphertext: e.I.Ciphertext, Ore: e.I.Ore}, @@ -593,6 +586,31 @@ func (f KindsEField) Eq(ctx context.Context, c *encrypt.Cipher, v Email) (encryp return encrypt.EqualityTerm(term), err } +func (f KindsEField) Lt(ctx context.Context, c *encrypt.Cipher, v Email) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lt", v) + return encrypt.OreTerm(term), err +} + +func (f KindsEField) Lte(ctx context.Context, c *encrypt.Cipher, v Email) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lte", v) + return encrypt.OreTerm(term), err +} + +func (f KindsEField) Gt(ctx context.Context, c *encrypt.Cipher, v Email) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gt", v) + return encrypt.OreTerm(term), err +} + +func (f KindsEField) Gte(ctx context.Context, c *encrypt.Cipher, v Email) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gte", v) + return encrypt.OreTerm(term), err +} + +func (f KindsEField) Between(ctx context.Context, c *encrypt.Cipher, lo, hi Email) (encrypt.Range[encrypt.OreTerm], error) { + low, high, err := f.field.Range(ctx, c, "between", lo, hi) + return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err +} + // Matches derives the matches query for v. Its answer can hold rows that do not // match: decrypt the rows it fetches and keep those Verify reports true. func (f KindsEField) Matches(ctx context.Context, c *encrypt.Cipher, v Email) (encrypt.MatchQuery[Email], error) { @@ -617,7 +635,7 @@ func (f KindsI8Field) Encrypt(ctx context.Context, c *encrypt.Cipher, v int8) (E if err != nil { return EncryptedKindsI8{}, err } - return EncryptedKindsI8{Ciphertext: out.Ciphertext, Equality: out.Equality, Ore: out.Ore}, nil + return EncryptedKindsI8{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil } func (f KindsI8Field) Eq(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.EqualityTerm, error) { @@ -625,31 +643,6 @@ func (f KindsI8Field) Eq(ctx context.Context, c *encrypt.Cipher, v int8) (encryp return encrypt.EqualityTerm(term), err } -func (f KindsI8Field) Lt(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "lt", v) - return encrypt.OreTerm(term), err -} - -func (f KindsI8Field) Lte(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "lte", v) - return encrypt.OreTerm(term), err -} - -func (f KindsI8Field) Gt(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "gt", v) - return encrypt.OreTerm(term), err -} - -func (f KindsI8Field) Gte(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "gte", v) - return encrypt.OreTerm(term), err -} - -func (f KindsI8Field) Between(ctx context.Context, c *encrypt.Cipher, lo, hi int8) (encrypt.Range[encrypt.OreTerm], error) { - low, high, err := f.field.Range(ctx, c, "between", lo, hi) - return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err -} - type KindsI16Field struct { field gensupport.Field[int16] } diff --git a/languages/golang/encrypt/internal/testusers/probe_stash.go b/languages/golang/encrypt/internal/testusers/probe_stash.go index d901ac220..3ab83bafc 100644 --- a/languages/golang/encrypt/internal/testusers/probe_stash.go +++ b/languages/golang/encrypt/internal/testusers/probe_stash.go @@ -31,25 +31,20 @@ const _ = gensupport.GeneratedVersion1 // answers lt and the other orderings. Drop the index unless your own code // compares the stored terms. -// S: stores ore terms that no generated method queries: the engine answers no -// query through the ore index on this field (string): the orderings are -// answered over integers only. Drop the index unless your own code compares the -// stored terms. - // S: stores ope terms that no generated method queries: the engine answers no -// query through the ope index on this field (string): the orderings are -// answered over integers only. Drop the index unless your own code compares the -// stored terms. +// query through the ope index on this field (string): the ore index already +// answers lt and the other orderings. Drop the index unless your own code +// compares the stored terms. // B: stores ore terms that no generated method queries: the engine answers no // query through the ore index on this field ([]byte): the orderings are -// answered over integers only. Drop the index unless your own code compares the -// stored terms. +// answered over integers and text only. Drop the index unless your own code +// compares the stored terms. // B: stores ope terms that no generated method queries: the engine answers no // query through the ope index on this field ([]byte): the orderings are -// answered over integers only. Drop the index unless your own code compares the -// stored terms. +// answered over integers and text only. Drop the index unless your own code +// compares the stored terms. type EncryptedProbe struct { U32 EncryptedProbeU32 @@ -339,6 +334,31 @@ func (f ProbeSField) Encrypt(ctx context.Context, c *encrypt.Cipher, v string) ( return EncryptedProbeS{Ciphertext: out.Ciphertext, Ore: out.Ore, Ope: out.Ope}, nil } +func (f ProbeSField) Lt(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lt", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeSField) Lte(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "lte", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeSField) Gt(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gt", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeSField) Gte(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OreTerm, error) { + term, err := f.field.Term(ctx, c, "gte", v) + return encrypt.OreTerm(term), err +} + +func (f ProbeSField) Between(ctx context.Context, c *encrypt.Cipher, lo, hi string) (encrypt.Range[encrypt.OreTerm], error) { + low, high, err := f.field.Range(ctx, c, "between", lo, hi) + return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err +} + type ProbeBField struct { field gensupport.Field[[]byte] } diff --git a/languages/golang/encrypt/internal/testusers/valuekinds_stash.go b/languages/golang/encrypt/internal/testusers/valuekinds_stash.go index 87e14184d..9ff1be22b 100644 --- a/languages/golang/encrypt/internal/testusers/valuekinds_stash.go +++ b/languages/golang/encrypt/internal/testusers/valuekinds_stash.go @@ -32,7 +32,6 @@ type EncryptedValueKinds struct { type EncryptedValueKindsI8 struct { Ciphertext encrypt.Ciphertext Equality encrypt.EqualityTerm - Ore encrypt.OreTerm } type EncryptedValueKindsI16 struct { @@ -44,7 +43,6 @@ type EncryptedValueKindsI16 struct { type EncryptedValueKindsU8 struct { Ciphertext encrypt.Ciphertext Equality encrypt.EqualityTerm - Ore encrypt.OreTerm } type EncryptedValueKindsU16 struct { @@ -105,9 +103,9 @@ type valueKindsShape struct { } var valueKindsDeclaration = gensupport.Declare("value_kinds"). - EncryptIndex("i8", gensupport.Int8, encrypt.Equality, encrypt.Ore). + EncryptIndex("i8", gensupport.Int8, encrypt.Equality). EncryptIndex("i16", gensupport.Int16, encrypt.Equality, encrypt.Ore). - EncryptIndex("u8", gensupport.Uint8, encrypt.Equality, encrypt.Ore). + EncryptIndex("u8", gensupport.Uint8, encrypt.Equality). EncryptIndex("u16", gensupport.Uint16, encrypt.Equality, encrypt.Ope). EncryptIndex("i128", gensupport.Int128, encrypt.Equality, encrypt.Ore). EncryptIndex("u128", gensupport.Uint128, encrypt.Equality, encrypt.Ope). @@ -137,7 +135,6 @@ var valueKindsCodec = gensupport.New(gensupport.Generated[ValueKinds, EncryptedV e.I8 = EncryptedValueKindsI8{ Ciphertext: rec["i8"].Ciphertext, Equality: rec["i8"].Equality, - Ore: rec["i8"].Ore, } e.I16 = EncryptedValueKindsI16{ Ciphertext: rec["i16"].Ciphertext, @@ -147,7 +144,6 @@ var valueKindsCodec = gensupport.New(gensupport.Generated[ValueKinds, EncryptedV e.U8 = EncryptedValueKindsU8{ Ciphertext: rec["u8"].Ciphertext, Equality: rec["u8"].Equality, - Ore: rec["u8"].Ore, } e.U16 = EncryptedValueKindsU16{ Ciphertext: rec["u16"].Ciphertext, @@ -180,9 +176,9 @@ var valueKindsCodec = gensupport.New(gensupport.Generated[ValueKinds, EncryptedV }, Open: func(e EncryptedValueKinds) gensupport.Record { return gensupport.Record{ - "i8": {Ciphertext: e.I8.Ciphertext, Equality: e.I8.Equality, Ore: e.I8.Ore}, + "i8": {Ciphertext: e.I8.Ciphertext, Equality: e.I8.Equality}, "i16": {Ciphertext: e.I16.Ciphertext, Equality: e.I16.Equality, Ore: e.I16.Ore}, - "u8": {Ciphertext: e.U8.Ciphertext, Equality: e.U8.Equality, Ore: e.U8.Ore}, + "u8": {Ciphertext: e.U8.Ciphertext, Equality: e.U8.Equality}, "u16": {Ciphertext: e.U16.Ciphertext, Equality: e.U16.Equality, Ope: e.U16.Ope}, "i128": {Ciphertext: e.I128.Ciphertext, Equality: e.I128.Equality, Ore: e.I128.Ore}, "u128": {Ciphertext: e.U128.Ciphertext, Equality: e.U128.Equality, Ope: e.U128.Ope}, @@ -269,7 +265,7 @@ func (f ValueKindsI8Field) Encrypt(ctx context.Context, c *encrypt.Cipher, v int if err != nil { return EncryptedValueKindsI8{}, err } - return EncryptedValueKindsI8{Ciphertext: out.Ciphertext, Equality: out.Equality, Ore: out.Ore}, nil + return EncryptedValueKindsI8{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil } func (f ValueKindsI8Field) Eq(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.EqualityTerm, error) { @@ -277,31 +273,6 @@ func (f ValueKindsI8Field) Eq(ctx context.Context, c *encrypt.Cipher, v int8) (e return encrypt.EqualityTerm(term), err } -func (f ValueKindsI8Field) Lt(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "lt", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsI8Field) Lte(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "lte", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsI8Field) Gt(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "gt", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsI8Field) Gte(ctx context.Context, c *encrypt.Cipher, v int8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "gte", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsI8Field) Between(ctx context.Context, c *encrypt.Cipher, lo, hi int8) (encrypt.Range[encrypt.OreTerm], error) { - low, high, err := f.field.Range(ctx, c, "between", lo, hi) - return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err -} - type ValueKindsI16Field struct { field gensupport.Field[int16] } @@ -353,7 +324,7 @@ func (f ValueKindsU8Field) Encrypt(ctx context.Context, c *encrypt.Cipher, v uin if err != nil { return EncryptedValueKindsU8{}, err } - return EncryptedValueKindsU8{Ciphertext: out.Ciphertext, Equality: out.Equality, Ore: out.Ore}, nil + return EncryptedValueKindsU8{Ciphertext: out.Ciphertext, Equality: out.Equality}, nil } func (f ValueKindsU8Field) Eq(ctx context.Context, c *encrypt.Cipher, v uint8) (encrypt.EqualityTerm, error) { @@ -361,31 +332,6 @@ func (f ValueKindsU8Field) Eq(ctx context.Context, c *encrypt.Cipher, v uint8) ( return encrypt.EqualityTerm(term), err } -func (f ValueKindsU8Field) Lt(ctx context.Context, c *encrypt.Cipher, v uint8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "lt", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsU8Field) Lte(ctx context.Context, c *encrypt.Cipher, v uint8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "lte", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsU8Field) Gt(ctx context.Context, c *encrypt.Cipher, v uint8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "gt", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsU8Field) Gte(ctx context.Context, c *encrypt.Cipher, v uint8) (encrypt.OreTerm, error) { - term, err := f.field.Term(ctx, c, "gte", v) - return encrypt.OreTerm(term), err -} - -func (f ValueKindsU8Field) Between(ctx context.Context, c *encrypt.Cipher, lo, hi uint8) (encrypt.Range[encrypt.OreTerm], error) { - low, high, err := f.field.Range(ctx, c, "between", lo, hi) - return encrypt.Range[encrypt.OreTerm]{Low: low, High: high}, err -} - type ValueKindsU16Field struct { field gensupport.Field[uint16] } diff --git a/languages/golang/encrypt/order_test.go b/languages/golang/encrypt/order_test.go index 19ad9df9a..78e439b69 100644 --- a/languages/golang/encrypt/order_test.go +++ b/languages/golang/encrypt/order_test.go @@ -6,6 +6,7 @@ import ( "context" "math/rand" "reflect" + "slices" "testing" "testing/quick" @@ -17,7 +18,7 @@ import ( // stores for them through the generated Probe fields' Encrypt, comparison // in Go. A query method derives the same bytes, but only for the index // that answers its operator (ORE, where a field declares both), and only -// over integers, so the stored terms reach every index and kind. ORE is +// over integers and text, so the stored terms reach every index and kind. ORE is // probabilistic — a wrong comparator or a wrong derivation still agrees // with plaintext order on many pairs — so a fixed vector set says little; // hundreds of random pairs per type say more. The index key is the @@ -121,24 +122,31 @@ func adjacentProperty[T any](t *testing.T, cipher *encrypt.Cipher, ope bool, der } } -// collatedAlphabet holds characters that the ORE and OPE string encodings -// keep as they are. Before deriving a term, cllw-ore's orderize_string -// decomposes each character canonically and drops anything that is not -// alphanumeric, whitespace or ASCII punctuation. So two strings order by -// their UTF-8 bytes only when that collation leaves both unchanged: a -// private-use character is dropped, and a precomposed Hangul syllable or -// an accented letter decomposes. The non-ASCII letters here have no -// canonical decomposition, so multi-byte UTF-8 ordering is still covered. -var collatedAlphabet = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~ßжω中") +// foldedAlphabet holds characters that text's order key keeps as they are. +// Text orders by its fold (ADR-0002): NFD, full case folding, NFD again, +// accents dropped, NFC. So two strings order by their UTF-8 bytes when the +// fold leaves both unchanged: lower-case letters with no accent, digits, +// space and ASCII punctuation. The non-ASCII letters here are already +// folded and have no decomposition, so multi-byte UTF-8 ordering is still +// covered. Upper case, accents and the sharp s are checked as ties below. +var foldedAlphabet = []rune("abcdefghijklmnopqrstuvwxyz0123456789 !\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~\u0436\u03c9\u4e2d") -func collatedString(r *rand.Rand) string { +func foldedString(r *rand.Rand) string { out := make([]rune, r.Intn(24)) for i := range out { - out[i] = collatedAlphabet[r.Intn(len(collatedAlphabet))] + out[i] = foldedAlphabet[r.Intn(len(foldedAlphabet))] } return string(out) } +// inOrder orders strings by their place in values, for a list written in +// the order the fold gives it. +func inOrder(values []string) func(a, b string) int { + return func(a, b string) int { + return cmp.Compare(slices.Index(values, a), slices.Index(values, b)) + } +} + func TestTermOrderIsPlaintextOrder(t *testing.T) { c := deterministicClient(t) cipher := c.DefaultKeyset() @@ -176,16 +184,20 @@ func TestTermOrderIsPlaintextOrder(t *testing.T) { d := storedTerm(f.S.Encrypt, ope, func(e testusers.EncryptedProbeS) []byte { return e.Ore }, func(e testusers.EncryptedProbeS) []byte { return e.Ope }) - // Strings order by the UTF-8 bytes of their collated form; a - // prefix orders before its extensions. See collatedAlphabet. - orderProperty(t, cipher, ope, d, byteOrder, collatedString) - adjacentProperty(t, cipher, ope, d, []string{"", "a", "aa", "ab", "b", "ba", "ß", "ßa", "中"}, byteOrder) - // Collation drops a control or private-use character, and - // strips the accent from a decomposed letter, so the terms - // cannot tell these pairs apart. - adjacentProperty(t, cipher, ope, d, []string{"", "\x7f"}, same) - adjacentProperty(t, cipher, ope, d, []string{"ab", "ab"}, same) - adjacentProperty(t, cipher, ope, d, []string{"e", "é"}, same) + // Strings order by the UTF-8 bytes of their folded form; a + // prefix orders before its extensions. See foldedAlphabet. + orderProperty(t, cipher, ope, d, byteOrder, foldedString) + adjacentProperty(t, cipher, ope, d, []string{"", "a", "aa", "ab", "b", "ba", "ж", "ω", "中"}, byteOrder) + // The fold drops case and accents and expands ß, so the terms + // cannot tell these apart. + adjacentProperty(t, cipher, ope, d, []string{"a", "A"}, same) + adjacentProperty(t, cipher, ope, d, []string{"e", "\u00e9", "\u00c9", "e\u0301"}, same) + adjacentProperty(t, cipher, ope, d, []string{"ss", "\u00df", "SS"}, same) + adjacentProperty(t, cipher, ope, d, []string{"caf\u00e9", "CAFE", "Cafe\u0301"}, same) + // Mixed case and accents order by the fold: Zoe after alice, + // and an accented letter beside its base letter. + names := []string{"alice", "Bob", "eclair", "\u00c9lan", "emu", "Zoe"} + adjacentProperty(t, cipher, ope, d, names, inOrder(names)) }) t.Run("bytes", func(t *testing.T) { d := storedTerm(f.B.Encrypt, ope, @@ -197,3 +209,49 @@ func TestTermOrderIsPlaintextOrder(t *testing.T) { }) } } + +// The generated ordering methods on a text field derive the term its +// Encrypt stores for the folded value, and Between orders its bounds by +// their folds. +func TestTextOrderingMethodsFoldTheValue(t *testing.T) { + c := deterministicClient(t) + cipher := c.DefaultKeyset() + ctx := context.Background() + f := testusers.ProbeFields + stored, err := f.S.Encrypt(ctx, cipher, "cafe") + if err != nil { + t.Fatal(err) + } + for _, v := range []string{"cafe", "Caf\u00e9", "CAFE", "cafe\u0301"} { + lt, err := f.S.Lt(ctx, cipher, v) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(lt, stored.Ore) { + t.Errorf("Lt(%q) is not the stored term of cafe", v) + } + } + r, err := f.S.Between(ctx, cipher, "a", "Zoe") + if err != nil { + t.Fatal(err) + } + if r.Low.Compare(r.High) >= 0 { + t.Errorf("a should order below Zoe") + } + // A range inverted once folded is not refused, as SQL's BETWEEN is + // not: it matches nothing. + inverted, err := f.S.Between(ctx, cipher, "b", "A") + if err != nil { + t.Fatalf("Between(b, A): %v", err) + } + if inverted.Low.Compare(inverted.High) <= 0 { + t.Errorf("Between(b, A): A folds to a, below b") + } + tie, err := f.S.Between(ctx, cipher, "A", "a") + if err != nil { + t.Fatal(err) + } + if tie.Low.Compare(tie.High) != 0 { + t.Errorf("Between(A, a) is one folded value") + } +} diff --git a/languages/golang/encrypt/query.go b/languages/golang/encrypt/query.go index d4953b4cf..8a90502b6 100644 --- a/languages/golang/encrypt/query.go +++ b/languages/golang/encrypt/query.go @@ -108,8 +108,9 @@ func (l Limit) FetchOr(page uint64) uint64 { // that answers the operator, and derives that index's term under the // field's context; for a field that names an EQL type it runs the type's // query. An operator the field does not answer, or one the engine does not -// know, is [ErrEncoding] naming the field, as a mistyped value is; a range -// the wrong way round, and match text that cuts no token, are [ErrTerm]. +// know, is [ErrEncoding] naming the field, as a mistyped value is; match +// text that cuts no token is [ErrTerm]. A range the wrong way round is not +// refused: it matches no row, as SQL's BETWEEN does. // No data key is minted. For generated code. func (cph *Cipher) QueryTerm(ctx context.Context, plan *record.Plan, field, operator string, input any) (QueryTerm, error) { p, err := cph.queryPlan(plan, field) diff --git a/languages/golang/encrypt/query_test.go b/languages/golang/encrypt/query_test.go index 4510cfc2e..a2c55eb26 100644 --- a/languages/golang/encrypt/query_test.go +++ b/languages/golang/encrypt/query_test.go @@ -317,8 +317,9 @@ func TestAQueryTheFieldDoesNotAnswerIsRefused(t *testing.T) { t.Fatalf("%s on %s: %v, want field %q and reason %q", tc.operator, tc.field, err, tc.field, tc.reason) } } - if _, err := cipher.QueryTerm(ctx, queryPlan, "age", "between", []any{uint32(65), uint32(18)}); !errors.Is(err, encrypt.ErrTerm) { - t.Fatalf("an inverted range: %v, want ErrTerm", err) + // An inverted range is not refused, as SQL's BETWEEN is not. + if _, err := cipher.QueryTerm(ctx, queryPlan, "age", "between", []any{uint32(65), uint32(18)}); err != nil { + t.Fatalf("an inverted range: %v", err) } if _, err := cipher.QueryTerm(ctx, queryPlan, "name", "matches", "al"); !errors.Is(err, encrypt.ErrTerm) { t.Fatalf("text that cuts no token: %v, want ErrTerm", err) @@ -499,9 +500,9 @@ func TestGeneratedQueriesAreTheRustFixtures(t *testing.T) { t.Errorf("%s.Value() = %T %v", label, got, err) } } - // An inverted range is the typed path's refusal, from the engine. - if _, err := fields.Age.Between(ctx, cipher, 65, 18); !errors.Is(err, encrypt.ErrTerm) { - t.Errorf("an inverted range: %v, want ErrTerm", err) + // An inverted range is not refused, as SQL's BETWEEN is not. + if _, err := fields.Age.Between(ctx, cipher, 65, 18); err != nil { + t.Errorf("an inverted range: %v", err) } } diff --git a/languages/golang/encrypt/value_kinds_test.go b/languages/golang/encrypt/value_kinds_test.go index bd63e6909..d0b299eae 100644 --- a/languages/golang/encrypt/value_kinds_test.go +++ b/languages/golang/encrypt/value_kinds_test.go @@ -164,9 +164,9 @@ func TestGeneratedCodeOpensTheRustValueKindsFixture(t *testing.T) { rec := f.Record stored := testusers.EncryptedValueKinds{ - I8: testusers.EncryptedValueKindsI8{Ciphertext: hexField(t, rec, "i8", "c"), Equality: hexField(t, rec, "i8", "eq"), Ore: hexField(t, rec, "i8", "ore")}, + I8: testusers.EncryptedValueKindsI8{Ciphertext: hexField(t, rec, "i8", "c"), Equality: hexField(t, rec, "i8", "eq")}, I16: testusers.EncryptedValueKindsI16{Ciphertext: hexField(t, rec, "i16", "c"), Equality: hexField(t, rec, "i16", "eq"), Ore: hexField(t, rec, "i16", "ore")}, - U8: testusers.EncryptedValueKindsU8{Ciphertext: hexField(t, rec, "u8", "c"), Equality: hexField(t, rec, "u8", "eq"), Ore: hexField(t, rec, "u8", "ore")}, + U8: testusers.EncryptedValueKindsU8{Ciphertext: hexField(t, rec, "u8", "c"), Equality: hexField(t, rec, "u8", "eq")}, U16: testusers.EncryptedValueKindsU16{Ciphertext: hexField(t, rec, "u16", "c"), Equality: hexField(t, rec, "u16", "eq"), Ope: hexField(t, rec, "u16", "ope")}, I128: testusers.EncryptedValueKindsI128{Ciphertext: hexField(t, rec, "i128", "c"), Equality: hexField(t, rec, "i128", "eq"), Ore: hexField(t, rec, "i128", "ore")}, U128: testusers.EncryptedValueKindsU128{Ciphertext: hexField(t, rec, "u128", "c"), Equality: hexField(t, rec, "u128", "eq"), Ope: hexField(t, rec, "u128", "ope")}, @@ -211,9 +211,7 @@ func TestGeneratedCodeOpensTheRustValueKindsFixture(t *testing.T) { } } orders := map[string]func() ([]byte, error){ - "i8:ore": func() ([]byte, error) { return fields.I8.Lt(ctx, cipher, want.I8) }, "i16:ore": func() ([]byte, error) { return fields.I16.Lt(ctx, cipher, want.I16) }, - "u8:ore": func() ([]byte, error) { return fields.U8.Lt(ctx, cipher, want.U8) }, "u16:ope": func() ([]byte, error) { return fields.U16.Lt(ctx, cipher, want.U16) }, "i128:ore": func() ([]byte, error) { return fields.I128.Lt(ctx, cipher, want.I128) }, "u128:ope": func() ([]byte, error) { return fields.U128.Lt(ctx, cipher, want.U128) }, @@ -304,9 +302,9 @@ func TestGeneratedCodeOpensTheRustValueKindsFixture(t *testing.T) { // fixture's output names. func valueKindsTerms(e testusers.EncryptedValueKinds) map[string]map[string][]byte { return map[string]map[string][]byte{ - "i8": {"eq": e.I8.Equality, "ore": e.I8.Ore}, + "i8": {"eq": e.I8.Equality}, "i16": {"eq": e.I16.Equality, "ore": e.I16.Ore}, - "u8": {"eq": e.U8.Equality, "ore": e.U8.Ore}, + "u8": {"eq": e.U8.Equality}, "u16": {"eq": e.U16.Equality, "ope": e.U16.Ope}, "i128": {"eq": e.I128.Equality, "ore": e.I128.Ore}, "u128": {"eq": e.U128.Equality, "ope": e.U128.Ope}, diff --git a/languages/golang/stashgen/emit.go b/languages/golang/stashgen/emit.go index 6a5a1f450..642556e4e 100644 --- a/languages/golang/stashgen/emit.go +++ b/languages/golang/stashgen/emit.go @@ -636,8 +636,8 @@ func planQueries(f *genFile) error { // // It returns a notice for each index the engine answers no operator // through on the field: the index stores a term no generated method can -// query. That is an ore or ope index on text, bytes or a float (the engine -// stores it and answers the orderings over integers only), or a second +// query. That is an ore or ope index on bytes or a float (the engine stores +// it and answers the orderings over integers and text only), or a second // order index beside the one the engine already answers the orderings // with. Storing it is allowed, since a program may compare the stored terms // itself, but an order term tells anyone who reads the column how its @@ -662,7 +662,7 @@ func indexesAnswer(g *genField) ([]string, error) { } why := "" if ix.Name == IndexOre || ix.Name == IndexOpe { - why = ": the orderings are answered over integers only" + why = ": the orderings are answered over integers and text only" for _, other := range []IndexName{IndexOre, IndexOpe} { if op, ok := answers[other]; ok && other != ix.Name { why = fmt.Sprintf(": the %s index already answers %s and the other orderings", other, op) diff --git a/languages/golang/stashgen/enginetest/enginetest.go b/languages/golang/stashgen/enginetest/enginetest.go index 6ef220953..fd7d948fa 100644 --- a/languages/golang/stashgen/enginetest/enginetest.go +++ b/languages/golang/stashgen/enginetest/enginetest.go @@ -182,8 +182,8 @@ func (e Static) operators(ctx context.Context, d stashgen.Declaration) (map[stri // answeredBy mirrors OpSpec::answered_by in stack-encrypt: eq through an // equality index, matches through a match index, and the orderings through -// ORE, or OPE when there is no ORE, over an integer alone; each only where -// the index applies to the type. +// ORE, or OPE when there is no ORE, over an integer or text; each only +// where the index applies to the type. func answeredBy(operator string, t stashgen.GoType, indexes []stashgen.IndexName) (stashgen.IndexName, bool) { declared := func(want stashgen.IndexName) bool { for _, idx := range indexes { @@ -199,7 +199,8 @@ func answeredBy(operator string, t stashgen.GoType, indexes []stashgen.IndexName case "matches": return stashgen.IndexMatch, declared(stashgen.IndexMatch) } - if !integer(t) { + orderable := (integer(t) && !eightBit(t)) || t.Kind == stashgen.KindString + if !orderable { return "", false } if declared(stashgen.IndexOre) { @@ -217,14 +218,25 @@ func integer(t stashgen.GoType) bool { return false } +// eightBit reports whether the type is an 8-bit integer (int8, uint8 or +// byte, or a type defined over one), which has no order term. +func eightBit(t stashgen.GoType) bool { + switch t.Basic { + case "int8", "uint8", "byte": + return true + } + return false +} + // indexApplies mirrors dynamic::admits in stack-encrypt: equality over // every kind vitaminc gives an equality domain (integers of every width, // floats, decimals, dates, timestamps, text and bytes; a boolean has none), -// match over text alone, ORE and OPE over integers of every width, floats, -// text and bytes. Not over a boolean, whose order term would reveal which -// rows are true, and not yet over a decimal, date or timestamp, whose order -// terms wait for new cllw-ore and ore-rs releases (#1236). The JSON index is -// not in the engine yet. +// match over text alone, ORE and OPE over integers of 16 bits and wider, +// floats, text and bytes. Not over a boolean or an 8-bit integer, whose +// order term would reveal which rows are true, or each row's rank among 256 +// values; and not yet over a decimal, date or timestamp, whose order terms +// wait for new cllw-ore and ore-rs releases (#1236). The JSON index is not +// in the engine yet. func indexApplies(name stashgen.IndexName, t stashgen.GoType) error { switch name { case stashgen.IndexEquality: @@ -247,7 +259,7 @@ func indexApplies(name stashgen.IndexName, t stashgen.GoType) error { case stashgen.KindDecimal, stashgen.KindDate, stashgen.KindTimestamp: return fmt.Errorf("an order index on a decimal, date or timestamp waits for new cllw-ore and ore-rs releases (#1236)") } - if integer(t) { + if integer(t) && !eightBit(t) { return nil } case stashgen.IndexJSON: diff --git a/languages/golang/stashgen/refusal_test.go b/languages/golang/stashgen/refusal_test.go index 6553c2477..e66142f89 100644 --- a/languages/golang/stashgen/refusal_test.go +++ b/languages/golang/stashgen/refusal_test.go @@ -273,6 +273,9 @@ func TestRefusalsHoldAgainstTheEmbeddedEngine(t *testing.T) { {"equality on a bool", "Done", "\tDone bool `stash:\"done,encrypt,index=equality\"`", "the engine refuses the declaration"}, {"order on a bool", "Done", "\tDone bool `stash:\"done,encrypt,index=ore\"`", "the engine refuses the declaration"}, {"ope on a bool", "Done", "\tDone bool `stash:\"done,encrypt,index=ope\"`", "the engine refuses the declaration"}, + {"order on an int8", "Rank", "\tRank int8 `stash:\"rank,encrypt,index=ore\"`", "the engine refuses the declaration"}, + {"ope on a uint8", "Rank", "\tRank uint8 `stash:\"rank,encrypt,index=ope\"`", "the engine refuses the declaration"}, + {"order on a byte", "Rank", "\tRank byte `stash:\"rank,encrypt,index=equality;ore\"`", "the engine refuses the declaration"}, {"match on an integer", "Age", "\tAge int32 `stash:\"age,encrypt,index=match\"`", "the engine refuses the declaration"}, {"encrypt_into, no EQL type in this build", "Email", "\tEmail string `stash:\"email,encrypt_into=TextEq\"`", "EQL types are not available yet"}, } { @@ -294,6 +297,9 @@ func TestRefusalsHoldAgainstTheEmbeddedEngine(t *testing.T) { for _, c := range []struct{ name, line string }{ {"equality on a bool", "\tDone bool `stash:\"done,encrypt,index=equality\"`"}, {"order on a bool", "\tDone bool `stash:\"done,encrypt,index=ore\"`"}, + {"order on an int8", "\tRank int8 `stash:\"rank,encrypt,index=ore\"`"}, + {"ope on a uint8", "\tRank uint8 `stash:\"rank,encrypt,index=ope\"`"}, + {"equality on an int8", "\tRank int8 `stash:\"rank,encrypt,index=equality\"`"}, {"equality on a float", "\tScore float64 `stash:\"score,encrypt,index=equality\"`"}, {"a declaration the engine runs", "\tAge uint32 `stash:\"age,encrypt,index=equality;ore\"`\n\tName string `stash:\"name,encrypt,index=match\"`"}, {"every index on an integer", "\tScore int64 `stash:\"score,encrypt,index=equality;ore;ope\"`\n\tRank int32 `stash:\"rank,index=ope\"`"}, @@ -341,17 +347,18 @@ func TestTheWholeDeclarationIsCheckedAgainstTheEmbeddedEngine(t *testing.T) { } // An index no generated method queries is stored, and the generator says -// so, on stderr and in the file: an order index on text, bytes or a float, -// and the second of two order indexes on an integer. +// so, on stderr and in the file: an order index on bytes or a float, and +// the second of two order indexes on an integer or text. An order index on +// text answers the orderings, so it gets methods and no notice. func TestAnIndexNoMethodQueriesGetsANotice(t *testing.T) { - src := user(ctx + "\tName string `stash:\"name,encrypt,index=match;ope\"`\n\tScore float64 `stash:\"score,encrypt,index=ore\"`\n\tAge int64 `stash:\"age,encrypt,index=ore;ope\"`\n\tRank int32 `stash:\"rank,encrypt,index=ope\"`") + src := user(ctx + "\tName string `stash:\"name,encrypt,index=match;ope\"`\n\tBlob []byte `stash:\"blob,encrypt,index=ope\"`\n\tScore float64 `stash:\"score,encrypt,index=ore\"`\n\tAge int64 `stash:\"age,encrypt,index=ore;ope\"`\n\tRank int32 `stash:\"rank,encrypt,index=ope\"`") file, err := generate(t, src, stashgen.Request{Type: "User"}) if err != nil { t.Fatal(err) } notices := strings.Join(file.Notices, "\n") for _, want := range []string{ - "stashgen: User.Name: stores ope terms that no generated method queries", + "stashgen: User.Blob: stores ope terms that no generated method queries", "stashgen: User.Score: stores ore terms that no generated method queries", "stashgen: User.Age: stores ope terms that no generated method queries: the engine answers no query through the ope index on this field (int64): the ore index already answers", } { @@ -359,10 +366,15 @@ func TestAnIndexNoMethodQueriesGetsANotice(t *testing.T) { t.Errorf("notices lack %q:\n%s", want, notices) } } - if strings.Contains(notices, "User.Rank") { - t.Errorf("an ope index that answers the orderings got a notice:\n%s", notices) + for _, field := range []string{"User.Name", "User.Rank"} { + if strings.Contains(notices, field) { + t.Errorf("an order index that answers the orderings got a notice (%s):\n%s", field, notices) + } } - if !strings.Contains(string(file.Content), "// Name: stores ope terms that no generated method queries") { + if !strings.Contains(string(file.Content), "// Blob: stores ope terms that no generated method queries") { t.Errorf("the file does not carry the notice:\n%s", file.Content) } + if !strings.Contains(string(file.Content), "func (f NameField) Lt(") { + t.Errorf("a text field with ope has no Lt:\n%s", file.Content) + } } diff --git a/languages/golang/stashgen/testdata/cases/orders/order_stash.go.golden b/languages/golang/stashgen/testdata/cases/orders/order_stash.go.golden index 773da8c8d..ba0c84bf4 100644 --- a/languages/golang/stashgen/testdata/cases/orders/order_stash.go.golden +++ b/languages/golang/stashgen/testdata/cases/orders/order_stash.go.golden @@ -16,11 +16,6 @@ const _ = gensupport.GeneratedVersion1 // Order prints its sealed fields in the clear: it has no String or LogValue // method. Write them, or run stashgen with -redact. -// Customer: stores ope terms that no generated method queries: the engine -// answers no query through the ope index on this field (string): the orderings -// are answered over integers only. Drop the index unless your own code compares -// the stored terms. - type EncryptedOrder struct { ID int64 `db:"id"` Amount EncryptedOrderAmount `db:"amount"` @@ -213,6 +208,31 @@ func (f OrderCustomerField) Encrypt(ctx context.Context, c *encrypt.Cipher, v st return EncryptedOrderCustomer{Ciphertext: out.Ciphertext, Match: out.Match, Ope: out.Ope}, nil } +func (f OrderCustomerField) Lt(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OpeTerm, error) { + term, err := f.field.Term(ctx, c, "lt", v) + return encrypt.OpeTerm(term), err +} + +func (f OrderCustomerField) Lte(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OpeTerm, error) { + term, err := f.field.Term(ctx, c, "lte", v) + return encrypt.OpeTerm(term), err +} + +func (f OrderCustomerField) Gt(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OpeTerm, error) { + term, err := f.field.Term(ctx, c, "gt", v) + return encrypt.OpeTerm(term), err +} + +func (f OrderCustomerField) Gte(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.OpeTerm, error) { + term, err := f.field.Term(ctx, c, "gte", v) + return encrypt.OpeTerm(term), err +} + +func (f OrderCustomerField) Between(ctx context.Context, c *encrypt.Cipher, lo, hi string) (encrypt.Range[encrypt.OpeTerm], error) { + low, high, err := f.field.Range(ctx, c, "between", lo, hi) + return encrypt.Range[encrypt.OpeTerm]{Low: low, High: high}, err +} + // Matches derives the matches query for v. Its answer can hold rows that do not // match: decrypt the rows it fetches and keep those Verify reports true. func (f OrderCustomerField) Matches(ctx context.Context, c *encrypt.Cipher, v string) (encrypt.MatchQuery[string], error) { diff --git a/packages/eql/Cargo.lock b/packages/eql/Cargo.lock index 28462c7ff..cc35cb4e8 100644 --- a/packages/eql/Cargo.lock +++ b/packages/eql/Cargo.lock @@ -659,15 +659,17 @@ dependencies = [ [[package]] name = "cllw-ore" -version = "0.5.0" +version = "0.5.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +checksum = "24eb4a774d070a6c2b52175b98273cd7193c61e1bda39c1a98b50b7a2d91375a" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "unicode-normalization", + "vitaminc-ore", + "vitaminc-protected 0.6.1", "zeroize", ] @@ -4230,7 +4232,7 @@ name = "stack-encrypt" version = "0.3.0" dependencies = [ "base64ct", - "cllw-ore 0.5.0", + "cllw-ore 0.5.2-rc.1", "miette", "serde", "serde_json", @@ -4243,6 +4245,7 @@ dependencies = [ "vitaminc-encrypt 0.6.1", "vitaminc-hmac", "vitaminc-normalize", + "vitaminc-ore", "vitaminc-prf", "vitaminc-protected 0.6.1", "zeroize", @@ -5223,6 +5226,21 @@ dependencies = [ "vitaminc-protected 0.6.1", ] +[[package]] +name = "vitaminc-ore" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed88c54b5f06f2177ecbfb7666812adf645406f6fb555f4e2f0fd7fa218e71c4" +dependencies = [ + "thiserror 2.0.20", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.1", + "vitaminc-context 0.6.1", + "vitaminc-normalize", + "vitaminc-protected 0.6.1", +] + [[package]] name = "vitaminc-prf" version = "0.6.1" diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index 30cbc94d4..a05bf1f4b 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -104,14 +104,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **The ordering operators, and `Between` as one term.** `op::Lt`, `Lte`, `Gt` and `Gte` take one value and `op::Between` a pair of bounds, `cipher.query(op::Between, &(18, 65)).on(&age_plan)`, inclusive at both - ends. They are answered over the integer types (`op::Ordered`) by the - `Ore` index by default and by `Ope` through `.via(Ope)`; an ordering on - text does not compile. A comparison's query term is the index's own term + ends. They are answered over the integer types and text (`op::Ordered`) + by the `Ore` index by default and by `Ope` through `.via(Ope)`; an + ordering on bytes or a float does not compile. A comparison's query term is the index's own term for the value (`OreTerm`, `OpeTerm`); a range's is one `sem::RangeQuery` carrying both bounds' terms, read with `lo()` and `hi()`, so no statement can pair the low bound of one query with the high bound of another. A - range whose low bound is above its high bound is refused when the query - is awaited, before either term is derived (`TermError::InvertedRange`). + range whose low bound is above its high bound is not refused: as SQL's + `BETWEEN`, it matches nothing. What a query takes is the operator's `op::Arity` (`op::One` or `op::Pair`), and `op::Input` relates the value given to the plaintext the plan is checked against (ADR-0009, decision 7). @@ -260,6 +260,53 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 from a raw frame), the record decrypt (with a nested field), `query_term` and `verify` under an allocator that fails the test on any freed block still holding plaintext. +- **Order terms encrypt through cllw-ore's vitaminc schemes, and text is + ordered.** An ORE or OPE term is now cllw-ore 0.5.2-rc.1's + `vitaminc::Ore`/`vitaminc::Ope` encryption of the value's `vitaminc-ore` + order encoding: the engine passes the value through and names its type's + target (`sem::Orderable`: integers at their own width, `f32`/`f64` as + `F64`, text as `Text`, bytes as `Bytes`), and cllw-ore does the encoding. + Text is ordered by the system ADR-0002's fold (Unicode 16 NFC with case + and accents folded), so `Café`, `cafe`, `CAFÉ` and `cafe` with a + combining accent are one term and tie, `alice` orders before `Zoe`, and + `é` orders with `e`, not after `z`. Every order term changes bytes: the + key labels move to `/v2`, the column salt binds the scheme and the + target, and text and bytes go in behind cllw-ore's marker byte. An order + term written by 0.1.0 or 0.2.0 (or by the Go module's v0.1.0-alpha.1) + does not compare with one this release derives, and nothing refuses it: + a `<`, `BETWEEN` or `ORDER BY` over a column holding the old terms + silently misses or misplaces rows. Re-derive a column's ORE and OPE terms + (encrypt its rows again) before querying it with this release. Every source type's term is one ciphertext type, + `OreCllw8VariableV1` or `OpeCllw8VariableV1`; `OreTerm` and + `OpeTerm` keep `T` to name the source, and `new`, `inner` and + `into_inner`, `KeysetCipher::ore_term` and `ope_term` take or return those + types. `sem::CllwOreEncrypt` and `sem::CllwOpeEncrypt` are no longer + re-exported, and the term bounds are `sem::Orderable`: a sealed trait + with one item, `const TARGET: OrderTarget`, the `vitaminc-ore` target a + type's values are encoded as. The value goes to the scheme as the + `vitaminc-aead` `Encrypt` source it is, by reference, and the engine + holds the one copy in a `Zeroizing` and wipes it; so an order term's + source is an owned `String` or `Vec`, and `&'static str`, + `&'static [u8]`, `Zeroizing` and `Zeroizing>` are no + longer sources. Text holding a code point Unicode 16 does not assign has + no order term (`TermError::Normalize`), on write and on query. The + ordering operators are answered on text, on the typed path + (`op::Ordered` for `String`) and on the data-plan path, so a binding + generates them too. +- **A `u8` and an `i8` have no order term, as a `bool` has none.** An order + term over 256 values ranks every row among 256, which a few known rows + resolve to the values, the leak the crate already refuses for a boolean's + two. `u8` and `i8` leave `op::Ordered` and `sem::Orderable`, so + `OreTerm`, `OpeTerm` and an `Ore` or `Ope` index on either do not + compile; `dynamic::admits` refuses ORE and OPE on `int8` and `uint8` + (`Reason::IndexNotAdmitted`, as for a boolean), and a data plan naming + one is refused when it is built. Both keep their equality term. The + refusal is a compile error now, so `TermError::NoOrderTerm` (code + `stack_encrypt::no_order_term`), which `bool` raised at run time, is + gone. The `value_kinds.json` fixture's `i8` and `u8` fields carry + equality alone, and the frozen `stored_rows_v1.json` loses its four + 8-bit order terms under the order-term exception; every other stored + byte is as it was. - **A binding's control data is read from its frame with serde, not decoded into an `FfiValue`** (`dynamic` feature). Each message is a typed @@ -341,11 +388,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Match text is tokenized in the same normal form, and refused the same way. Every equality and match term changes bytes (the domains move to `/v2`); ORE and OPE terms do not. No stored - term existed to keep; the record fixture is regenerated. ORE and OPE - text is still ordered as its raw UTF-8, not normalized, so on a column - with both an equality and an order index two forms of `café` are one - value to `=` and two to an ordering. This is a known gap, to be closed - with block ORE (#1140). + term existed to keep; the record fixture is regenerated. Text order + terms are encoded in the same normal form, folded further (see "Order + terms encrypt through cllw-ore's vitaminc schemes" above), so equality + and ordering agree on two forms of `café`. - **`dynamic::Value` and `dynamic::Scalar` are gone.** A field lowered from data holds an `FfiValue`, which is now `Clone` (a deep copy into fresh `Protected` payloads, as `Value`'s was) and reads as a context label. @@ -417,10 +463,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 it refuses text that cuts no token (`TermError::EmptyTermText`); and it compiles only for a configuration that implements `op::MatchCase`. A configuration without one needs that one-line impl to be queried. - - `query_index(Ore, text)` and `query_index(Ope, text)` have no - replacement: an ordering over text does not compile until the order of - text is settled (ADR-0009). A text column can still store ORE and OPE - terms. + - `query_index(Ore, text)` and `query_index(Ope, text)` are + `query(op::Lt, text)` and `query(op::Lt, text).via(Ope)` (or any + ordering), whose term is the text's folded order term (see "Order terms + encrypt through cllw-ore's vitaminc schemes" above). - **`op::Supports`, `op::Answers` and `op::DefaultIndex` are keyed by the query's input**, `Supports` with `In: Input`, in place of the plaintext; for every operator but `Between` the input is diff --git a/packages/stack-encrypt/Cargo.toml b/packages/stack-encrypt/Cargo.toml index 3c2b321b9..858816166 100644 --- a/packages/stack-encrypt/Cargo.toml +++ b/packages/stack-encrypt/Cargo.toml @@ -37,6 +37,9 @@ vitaminc-hmac = { workspace = true } # number whatever its width, text in Unicode 16 NFC. Match text is # normalized by the same crate before it is tokenized. vitaminc-normalize = { workspace = true } +# The order targets (`OrderTarget`) and the scheme traits cllw-ore's +# `vitaminc` feature implements, which order terms encrypt through. +vitaminc-ore = { workspace = true } vitaminc-prf = { workspace = true } vitaminc-protected = { workspace = true } # `dynamic` only: serde over the transport frame, which reads a binding's diff --git a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md index 73735079f..cea58c3d2 100644 --- a/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md +++ b/packages/stack-encrypt/docs/adr/0007-bindings-enter-through-a-plan-never-a-second-executor.md @@ -228,9 +228,10 @@ orderings through an ORE index over an integer, or OPE when the field declares no ORE. A field that does not answer the operator is refused with the same shape of error as a mistyped field: an input error naming the field, with the reason `operator_not_answered` (or `unknown_operator`, -`not_a_range`). The refusals the typed path makes before a term exists, a -range the wrong way round and match text that cuts no token, are refused the -same way here, before the cipher is consulted. +`not_a_range`). The refusal the typed path makes before a term exists, +match text that cuts no token, is made the same way here, before the cipher +is consulted. A range the wrong way round is not refused on either path: it +matches no row, as SQL's `BETWEEN` does. **The term comes back with its query domain.** The guest's `se_query_term(input, plan, field, operator, options)` returns one object: diff --git a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md index 53f19e25c..93771512b 100644 --- a/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md +++ b/packages/stack-encrypt/docs/adr/0009-operators-select-the-index-and-a-query-term-is-not-the-stored-term.md @@ -178,8 +178,8 @@ and `Matches` take the plaintext the index is declared over so the plan is checked against `S` either way, and the chain derives one term per plaintext under the one label. `check` is where a query is refused before any term exists: a match index whose declared case disagrees with its -`downcase` option, a `Between` whose bounds are the wrong way round, a match -query with no tokens. `AtPath` takes a path and `Op`'s input, and gains +`downcase` option, a match query with no tokens. A `Between` whose bounds +are the wrong way round is not refused: it matches no row, as SQL's does. `AtPath` takes a path and `Op`'s input, and gains its shape with the JSON index. The bound tightens per operator, through `TermFor`: any `QueryTerm` @@ -263,8 +263,7 @@ pub trait Ranked: QueryTerm { - A term that implements `QueryTerm` only is **exact**. The server's answer is the answer. HMAC `Eq`, ORE and OPE (`Lt`, `Between`) on numbers, JSON - `AtPath`, and ORE and OPE on text if the catalog keeps text ordering - (see "Text ordering" below). + `AtPath`, and ORE and OPE on text (see "Text ordering" below). - A `Probabilistic` term can return **false positives and no false negatives**. `verify` is the operator's own meaning, run on a decrypted value: it decides whether a row the server returned is a real match. Bloom @@ -501,10 +500,11 @@ key tie; that is what the column declared, and an earlier draft of this ADR was wrong to call it a false positive and put the term under `Probabilistic`. Its use is ordering and cursor paging on names (`ORDER BY name`, "the page after Nguyen"), and EQL v4 carries `query_text_ord` and `query_text_ord_ore` -for it. Whether the Rust library produces the term is a catalog question -(`TextOrdOre` is unproducible today, pending the system ADR-0002); nothing -else in this ADR depends on it, and if text ordering is dropped this paragraph -goes with it. +for it. Stack Encrypt produces the term: it hands the text to cllw-ore's +vitaminc schemes as the `Text` target, whose order key is vitaminc-ore's +text order encoding, the system ADR-0002's fold pinned to Unicode 16, and +the ORE and OPE indexes answer the orderings on text over it. `TextOrdOre` and `TextSearchOre` also need the block ORE scheme EQL +stores (#1132). A `Ranked` term's `target_limit` applies the index's configured recall factor and needs no `Stats`. @@ -572,12 +572,16 @@ the ranked loop is worth packaging; it is deferred, not refused. `cipher.query(op::Between, &(lo, hi)).on(&plan)` produces one term, `sem::RangeQuery`, carrying the index's term for each bound (the ORE left -ciphertexts, once block ORE lands), inclusive at both ends, and the client -refuses `lo > hi` (`TermError::InvertedRange`) before either term is -derived; `(v, v)` is one value. One call derives both bounds, under one +ciphertexts, once block ORE lands), inclusive at both ends; `(v, v)` is one +value. The client does not refuse `lo > hi`: as SQL's `BETWEEN`, such a +range matches nothing. Judging it would mean reproducing, before +encryption, the order the index compares in (text by its fold, floats by +their canonical encoding), which belongs to the ORE crates. One call +derives both bounds, under one context, so a caller cannot pair a bound from one column with a bound from -another. The ordering operators are answered over the integer types -(`op::Ordered`), by the ORE index by default and by OPE through `.via(Ope)`. +another. The ordering operators are answered over the integer types and +text (`op::Ordered`), by the ORE index by default and by OPE through +`.via(Ope)`. In SQL the term stays one operand. EQL v4 gains a function that takes the column and the range, and a range query domain beside each ordered query @@ -696,8 +700,8 @@ JSON column's query already selects it. A similarity index stores its vector separately, so a `Nearest` query must select the embedding, or the sketch, as well. -Text ordering, if the catalog keeps it, needs no special case: `Lt` on text -is an exact term over the order key, and what that key folds is the system +Text ordering needs no special case: `Lt` on text is an exact term over the +order key, and what that key folds is the system ADR-0002's decision (the value model, linked above), which keeps equality off the order term for the same reason. @@ -877,8 +881,7 @@ let rows: Vec = sqlx::query_as( .fetch_all(&pool).await?; ``` -`AtPath` on a text leaf, if the catalog keeps text ordering, is exact, -as `Lt` on a text column would be: the leaf's order term compares order +`AtPath` on a text leaf is exact, as `Lt` on a text column is: the leaf's order term compares order keys, and there is nothing to verify. **`Nearest`, ranked.** Stack Similarity has no EQL spelling yet, so this is diff --git a/packages/stack-encrypt/examples/search_terms.rs b/packages/stack-encrypt/examples/search_terms.rs index 514cca254..e6de54335 100644 --- a/packages/stack-encrypt/examples/search_terms.rs +++ b/packages/stack-encrypt/examples/search_terms.rs @@ -112,10 +112,12 @@ async fn main() -> Result<(), Box> { println!(" age 45 > 40 => {}", age_45 > query_40); // Strings order lexicographically. - let apple: OreTerm<&str> = "apple" + let apple: OreTerm = "apple" + .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("name")) .await?; - let banana: OreTerm<&str> = "banana" + let banana: OreTerm = "banana" + .to_string() .encrypt_into_with_context(&terms, nonempty!("users").with("name")) .await?; println!(" \"apple\" < \"banana\" => {}", apple < banana); diff --git a/packages/stack-encrypt/fuzz/Cargo.lock b/packages/stack-encrypt/fuzz/Cargo.lock index 5b005e247..e1111571c 100644 --- a/packages/stack-encrypt/fuzz/Cargo.lock +++ b/packages/stack-encrypt/fuzz/Cargo.lock @@ -347,15 +347,17 @@ dependencies = [ [[package]] name = "cllw-ore" -version = "0.5.0" +version = "0.5.2-rc.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "476f300d37a5029d3d9dd57145d4db50a23f40ae9b4d1374c44543978b906191" +checksum = "24eb4a774d070a6c2b52175b98273cd7193c61e1bda39c1a98b50b7a2d91375a" dependencies = [ "blake3", "hex", "subtle", "thiserror 1.0.69", "unicode-normalization", + "vitaminc-ore", + "vitaminc-protected 0.6.1", "zeroize", ] @@ -2004,6 +2006,7 @@ dependencies = [ "vitaminc-encrypt 0.6.1", "vitaminc-hmac", "vitaminc-normalize", + "vitaminc-ore", "vitaminc-prf", "vitaminc-protected 0.6.1", "zeroize", @@ -2643,6 +2646,21 @@ dependencies = [ "vitaminc-protected 0.6.1", ] +[[package]] +name = "vitaminc-ore" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed88c54b5f06f2177ecbfb7666812adf645406f6fb555f4e2f0fd7fa218e71c4" +dependencies = [ + "thiserror 2.0.18", + "unicode-general-category", + "unicode-normalization", + "vitaminc-aead 0.6.1", + "vitaminc-context 0.6.1", + "vitaminc-normalize", + "vitaminc-protected 0.6.1", +] + [[package]] name = "vitaminc-prf" version = "0.6.1" diff --git a/packages/stack-encrypt/src/codes.rs b/packages/stack-encrypt/src/codes.rs index 00cb4116d..3a03cc178 100644 --- a/packages/stack-encrypt/src/codes.rs +++ b/packages/stack-encrypt/src/codes.rs @@ -81,9 +81,7 @@ mod tests { TermError::Ore(_) => TermError::Ore(cllw_ore::Error), TermError::InvalidOptions(_) => TermError::InvalidOptions("k out of range"), TermError::EmptyTermText => TermError::EmptyTermText, - TermError::InvertedRange => TermError::InvertedRange, TermError::NoEqualityTerm => TermError::NoEqualityTerm, - TermError::NoOrderTerm => TermError::NoOrderTerm, TermError::Normalize(_) => { TermError::Normalize(vitaminc_normalize::NormalizeError::UnassignedCodePoint) }, @@ -396,9 +394,7 @@ mod tests { json!({ "len": 3 }), ), (Box::new(TermError::EmptyTermText), json!({})), - (Box::new(TermError::InvertedRange), json!({})), (Box::new(TermError::NoEqualityTerm), json!({})), - (Box::new(TermError::NoOrderTerm), json!({})), ( Box::new(TermError::Normalize( vitaminc_normalize::NormalizeError::UnassignedCodePoint, diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs index a2b975b2b..d127f98b0 100644 --- a/packages/stack-encrypt/src/dynamic/kind.rs +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -55,8 +55,10 @@ use crate::target::IndexSpec; /// `vitaminc-normalize` gives an equality domain, which is every integer /// of every width, both floats, a decimal, a date, a timestamp, text and /// bytes, and not a boolean. The rest are this crate's: match over text -/// alone, and ORE and OPE over every integer, both floats, text and bytes. -/// A boolean has no order term, which would reveal which rows are `true`. +/// alone, and ORE and OPE over the integers of 16 bits and wider, both +/// floats, text and bytes. A boolean and an 8-bit integer have no order +/// term: over so small a domain it would reveal which rows are `true`, or +/// each row's rank among 256 values. /// A decimal, a date and a timestamp have none yet: their order encodings /// wait for new cllw-ore and ore-rs releases (#1236), and [`not_admitted`] /// names that reason. A composite (`array`, `object`) has no term. Only the @@ -68,7 +70,9 @@ use crate::target::IndexSpec; /// use stack_encrypt::target::IndexSpec; /// /// assert!(admits(ValueKind::UInt64, &IndexSpec::Equality)); -/// assert!(admits(ValueKind::Int8, &IndexSpec::Ore)); +/// assert!(admits(ValueKind::Int16, &IndexSpec::Ore)); +/// assert!(admits(ValueKind::Int8, &IndexSpec::Equality)); +/// assert!(!admits(ValueKind::Int8, &IndexSpec::Ore)); /// assert!(admits(ValueKind::Date, &IndexSpec::Equality)); /// assert!(!admits(ValueKind::Date, &IndexSpec::Ore)); /// assert!(!admits(ValueKind::Bool, &IndexSpec::Equality)); @@ -657,15 +661,13 @@ mod tests { } /// The new kinds' rows, spelled out: equality on every one, order on - /// every integer width and on none of the decimal, date and timestamp, - /// match on none. + /// every integer width but 8 bits and on none of the decimal, date and + /// timestamp, match on none. #[test] fn admits_the_new_kinds_where_their_terms_exist() { let integers = [ - ValueKind::Int8, ValueKind::Int16, ValueKind::Int128, - ValueKind::UInt8, ValueKind::UInt16, ValueKind::UInt128, ]; @@ -677,6 +679,19 @@ mod tests { assert!(admits(kind, &IndexSpec::Ope), "{kind} ope"); assert!(!admits(kind, &matched), "{kind} match"); } + // An 8-bit integer keeps its equality term and, like a boolean, + // has no order term: 256 values are too few to rank. + for kind in [ValueKind::Int8, ValueKind::UInt8] { + assert!(admits(kind, &IndexSpec::Equality), "{kind} equality"); + assert!(!admits(kind, &IndexSpec::Ore), "{kind} ore"); + assert!(!admits(kind, &IndexSpec::Ope), "{kind} ope"); + assert!(!admits(kind, &matched), "{kind} match"); + assert_eq!( + not_admitted(kind, &IndexSpec::Ore), + Some(Reason::IndexNotAdmitted), + "{kind} ore is refused as a boolean's is, not as pending" + ); + } for kind in logical { assert!(admits(kind, &IndexSpec::Equality), "{kind} equality"); assert!(!admits(kind, &IndexSpec::Ore), "{kind} ore"); @@ -722,7 +737,11 @@ mod tests { not_admitted(ValueKind::Date, &IndexSpec::Match(MatchOptions::default())), Some(Reason::IndexNotAdmitted) ); - assert_eq!(not_admitted(ValueKind::Int8, &IndexSpec::Ore), None); + assert_eq!(not_admitted(ValueKind::Int16, &IndexSpec::Ore), None); + assert_eq!( + not_admitted(ValueKind::Int8, &IndexSpec::Ore), + Some(Reason::IndexNotAdmitted) + ); assert!(Reason::OrderTermPending.to_string().contains("#1236")); } diff --git a/packages/stack-encrypt/src/dynamic/query.rs b/packages/stack-encrypt/src/dynamic/query.rs index 36af7df4b..2b00f2e67 100644 --- a/packages/stack-encrypt/src/dynamic/query.rs +++ b/packages/stack-encrypt/src/dynamic/query.rs @@ -35,13 +35,15 @@ //! A field with indexes answers what [`OpSpec::answered_by`] says for its //! declared kind: `eq` through an equality index, `matches` through a match //! index over text, and the orderings (`lt`, `lte`, `gt`, `gte`, -//! `between`) through an ORE index over an integer, or an OPE index when -//! the field declares no ORE. The typed path's default for an ordering is -//! ORE, and OPE is the typed path's `.via(Ope)`; here a field that declares -//! only OPE is answered through it. An ordering over text, bytes or a float -//! is not answered, as `op::Ordered` covers the integers only (of every -//! width, `int8` to `uint128`). Nor is one over a decimal, date or -//! timestamp: a plan cannot declare an order index on one until #1236. +//! `between`) through an ORE index over an integer or text, or an OPE index +//! when the field declares no ORE. The typed path's default for an ordering +//! is ORE, and OPE is the typed path's `.via(Ope)`; here a field that +//! declares only OPE is answered through it. Text orders by its folded +//! order key (ADR-0002). An ordering over bytes or a float is not answered, +//! as `op::Ordered` covers the integers of 16 bits and wider (`int16` to +//! `uint128`) and text only. Nor is one over an 8-bit integer, which has +//! no order term, or a decimal, date or timestamp: +//! a plan cannot declare an order index on one yet. //! //! A field that names an EQL type answers what its //! [`TargetDescriptor::operators`] lists: the operators of a single value @@ -70,7 +72,6 @@ use serde::{Serialize, Serializer}; use super::WireBytes; -use std::cmp::Ordering; use std::fmt; use stack_kms::DataKeySource; @@ -172,7 +173,8 @@ impl OpSpec { /// /// let both = [IndexSpec::Ope, IndexSpec::Ore]; /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::UInt32, &both), Some(&IndexSpec::Ore)); - /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::String, &both), None); + /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::String, &both), Some(&IndexSpec::Ore)); + /// assert_eq!(OpSpec::Lt.answered_by(ValueKind::Bytes, &both), None); /// assert_eq!(OpSpec::Eq.answered_by(ValueKind::UInt32, &both), None); /// ``` pub fn answered_by(self, kind: ValueKind, indexes: &[IndexSpec]) -> Option<&IndexSpec> { @@ -199,22 +201,23 @@ impl fmt::Display for OpSpec { } } -/// The kinds the ordering operators are answered over: the integers of -/// every width, `int8` to `uint128`, as [`op::Ordered`](crate::op::Ordered) -/// covers them in the typed path. +/// The kinds the ordering operators are answered over: the integers of 16 +/// bits and wider, `int16` to `uint128`, and text, as +/// [`op::Ordered`](crate::op::Ordered) covers them in the typed path. Not +/// `int8` or `uint8`, which have no order term. Text orders by its order +/// key (ADR-0002's fold). fn ordered(kind: ValueKind) -> bool { matches!( kind, - ValueKind::Int8 - | ValueKind::Int16 + ValueKind::Int16 | ValueKind::Int32 | ValueKind::Int64 | ValueKind::Int128 - | ValueKind::UInt8 | ValueKind::UInt16 | ValueKind::UInt32 | ValueKind::UInt64 | ValueKind::UInt128 + | ValueKind::String ) } @@ -585,18 +588,24 @@ fn not_a_range(field: &str) -> Error { } /// The refusals the typed path makes when a query is awaited, before any -/// term exists ([`Supports::check`](crate::op::Supports::check)): a range -/// whose low bound is above its high bound, and match text the index -/// cannot normalize or that cuts no token. The match index's own -/// derivation refuses the last two again; asking here lets a binding -/// refuse at its boundary. +/// term exists ([`Supports::check`](crate::op::Supports::check)): match +/// text the index cannot normalize or that cuts no token, and equality +/// text the scheme cannot normalize. Each index's own derivation refuses +/// these again; asking here lets a binding refuse at its boundary. A range +/// whose low bound is above its high bound is not refused: as SQL's +/// `BETWEEN`, it matches nothing. fn check_input(index: &IndexSpec, input: &Input) -> Result<(), Error> { let refused = |error: TermError| Error::Cipher(error.into()); match (index, input) { - (_, Input::Pair(lo, hi)) => { - if integer_order(lo, hi) == Some(Ordering::Greater) { - return Err(refused(TermError::InvertedRange)); - } + // Order text the fold cannot take (a code point unassigned in + // Unicode 16) is refused here, before a term or a keyset, as + // derivation would refuse it after one. + (IndexSpec::Ore | IndexSpec::Ope, Input::One(value)) => { + order_text(value).map_err(refused)?; + } + (IndexSpec::Ore | IndexSpec::Ope, Input::Pair(lo, hi)) => { + order_text(lo).map_err(refused)?; + order_text(hi).map_err(refused)?; } (IndexSpec::Match(options), Input::One(value)) => { query_tokens(value, options).map(drop)?; @@ -612,31 +621,21 @@ fn check_input(index: &IndexSpec, input: &Input) -> Result<(), Error> { Ok(()) } -/// The order of two integers of one kind, or `None` for anything else. -fn integer_order(lo: &ValueFrame, hi: &ValueFrame) -> Option { - let (lo_kind, lo) = integer_of(lo)?; - let (hi_kind, hi) = integer_of(hi)?; - (lo_kind == hi_kind).then(|| lo.cmp(&hi)) -} - -/// An integer scalar's kind and value, or `None` for anything else. The -/// value is its sign and 128-bit two's-complement word (vitaminc's -/// [`Int::sign_and_word`](vitaminc_aead::plaintext::Int::sign_and_word)), -/// ordered negatives first, so one comparison covers every width from -/// `i128::MIN` to `u128::MAX`. -fn integer_of(value: &ValueFrame) -> Option<(ValueKind, (bool, u128))> { - let kind = value.kind()?; - let (negative, word) = value +/// Whether `value`, if it is order text, has a fold: `vitaminc-ore`'s +/// `Text` encoding, the one an order index encrypts, refuses a code point +/// Unicode 16 does not assign. Anything but text has nothing to refuse. +fn order_text(value: &ValueFrame) -> Result<(), TermError> { + value .visit(OneScalar::new( |ty: Option, value: Physical<'_>| match (ty, value) { - (None, Physical::Int(int)) => Ok(int.sign_and_word()), + (None, Physical::Text(text)) => Ok(vitaminc_ore::text(text) + .map(drop) + .map_err(TermError::Normalize)), _ => Err(None), }, )) - .ok()?; - // `!negative` so a negative sorts below every non-negative; within a - // sign the two's-complement words order as the values do. - Some((kind, (!negative, word))) + // Not one text scalar: nothing to refuse here. + .unwrap_or(Ok(())) } /// The tokens a match query of `value` keeps, under the index's options. @@ -730,10 +729,11 @@ pub fn check_query( /// field does not answer ([`Reason::OperatorNotAnswered`]), an input that /// cannot be read as the field's kind ([`Reason::FieldType`]) and a /// `between` input that is not two values ([`Reason::NotARange`]). -/// [`Error::Cipher`] for the query refusals the typed path makes: a range -/// whose low bound is above its high ([`TermError::InvertedRange`]), and -/// match text that cuts no token ([`TermError::EmptyTermText`]) or that -/// the index cannot normalize ([`TermError::Normalize`]). +/// [`Error::Cipher`] for the query refusals the typed path makes: match +/// text that cuts no token ([`TermError::EmptyTermText`]), and text the +/// index cannot normalize ([`TermError::Normalize`]). A range whose low +/// bound is above its high bound is not refused: as SQL's `BETWEEN`, it +/// matches nothing. /// [`Error::Target`] for what the resolver refuses. pub fn query_term<'a, K>( cipher: &'a KeysetCipher<'_, K>, @@ -1086,7 +1086,7 @@ mod tests { keyset: &KeysetCipher<'_, FakeDataKeySource>, name: &str, operator: OpSpec, - input: Tree, + input: impl Encrypt, ) -> Result { query_term(keyset, &users(), name, operator, input, &NoTargets)? .await @@ -1166,17 +1166,23 @@ mod tests { OpSpec::Gte, OpSpec::Between, ] { + for kind in [ValueKind::Int8, ValueKind::UInt8, ValueKind::Bool] { + assert_eq!( + answered(op, kind, &all), + None, + "{op} {kind}: an 8-bit integer has no order term, as a boolean has none" + ); + } for kind in [ - ValueKind::Int8, ValueKind::Int16, ValueKind::Int32, ValueKind::Int64, ValueKind::Int128, - ValueKind::UInt8, ValueKind::UInt16, ValueKind::UInt32, ValueKind::UInt64, ValueKind::UInt128, + ValueKind::String, ] { assert_eq!( answered(op, kind, &all), @@ -1195,7 +1201,6 @@ mod tests { ); } for kind in [ - ValueKind::String, ValueKind::Bytes, ValueKind::Float64, ValueKind::Bool, @@ -1206,7 +1211,7 @@ mod tests { assert_eq!( answered(op, kind, &all), None, - "{op} is answered over integers only: {kind}" + "{op} is answered over integers and text only: {kind}" ); } } @@ -1365,7 +1370,7 @@ mod tests { ), ( "nickname", - OpSpec::Lt, + OpSpec::Eq, text("bob"), Reason::OperatorNotAnswered, ), @@ -1417,27 +1422,47 @@ mod tests { async fn the_typed_paths_query_refusals_are_made_before_any_term() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let pair = |lo, hi| Tree::Array(vec![Tree::UInt32(lo), Tree::UInt32(hi)]); + let one = |text: &str| ValueFrame::of(text).expect("a value"); + let two = |lo: &str, hi: &str| ValueFrame::of(vec![lo, hi]).expect("a value"); let cases = [ - ("age", OpSpec::Between, pair(65, 18), "inverted"), - ("name", OpSpec::Matches, text("al"), "empty"), - ("name", OpSpec::Matches, text("\u{0378}abc"), "normalize"), - ("email", OpSpec::Eq, text("a\u{0378}bc"), "normalize"), + ("nickname", OpSpec::Lt, one("a\u{0378}"), "normalize"), + ( + "nickname", + OpSpec::Between, + two("a", "b\u{0378}"), + "normalize", + ), + ( + "nickname", + OpSpec::Between, + two("a\u{0378}", "b"), + "normalize", + ), + ("name", OpSpec::Matches, one("al"), "empty"), + ("name", OpSpec::Matches, one("\u{0378}abc"), "normalize"), + ("email", OpSpec::Eq, one("a\u{0378}bc"), "normalize"), ]; for (name, op, input, what) in cases { for error in [ - query(&keyset, name, op, input.clone()).await.unwrap_err(), - check_query(&users(), name, op, input, &NoTargets).unwrap_err(), + query(&keyset, name, op, &input).await.unwrap_err(), + check_query(&users(), name, op, &input, &NoTargets).unwrap_err(), ] { let term = term_error(&error); let expected = match what { - "inverted" => matches!(term, Some(TermError::InvertedRange)), "empty" => matches!(term, Some(TermError::EmptyTermText)), _ => matches!(term, Some(TermError::Normalize(_))), }; - assert!(expected, "{what}: {error:?}"); + assert!(expected, "{name} {what}: {error:?}"); } } + // The terms of folded equivalents are one term. + let cafe = query(&keyset, "nickname", OpSpec::Lt, "cafe") + .await + .unwrap(); + let folded = query(&keyset, "nickname", OpSpec::Lt, "CAF\u{c9}") + .await + .unwrap(); + assert_eq!(folded.operand, cafe.operand); } /// A plan whose context is a field of each record has no context a @@ -1843,7 +1868,7 @@ mod tests { let cipher = cipher().await; let keyset = cipher.default_keyset(); let plan = record::plan(&frame_of(&Tree::Object(vec![ - field("tiny", "int8", vec![text("c"), text("ore")]), + field("tiny", "int16", vec![text("c"), text("ore")]), field("huge", "int128", vec![text("c"), text("ope")]), field("day", "date", vec![text("c"), text("eq")]), field("at", "timestamp", vec![text("c"), text("eq")]), @@ -1865,10 +1890,10 @@ mod tests { }; let tiny = crate::Plan::context("users/tiny") - .with::(Ore) + .with::(Ore) .build() .unwrap(); - let typed = cipher.query(op::Lt, &-8i8).on(&tiny).await.unwrap(); + let typed = cipher.query(op::Lt, &-8i16).on(&tiny).await.unwrap(); for input in [ int(Int::I8(-8)), vf(&Tree::Int64(-8)), @@ -1877,10 +1902,10 @@ mod tests { let term = ask("tiny", OpSpec::Lt, input).await.unwrap(); assert_eq!(term.operand(), &Operand::One(typed.to_bytes())); } - let refused = ask("tiny", OpSpec::Lt, vf(&Tree::Int64(300))).await; + let refused = ask("tiny", OpSpec::Lt, vf(&Tree::Int64(70_000))).await; assert!( matches!(&refused, Err(error) if reason_of(error) == (Some("tiny"), Some(Reason::FieldType))), - "300 is no int8: {refused:?}" + "70000 is no int16: {refused:?}" ); let huge = crate::Plan::context("users/huge") @@ -1912,8 +1937,8 @@ mod tests { ]) .unwrap(); assert!( - ask("huge", OpSpec::Between, inverted).await.is_err(), - "an inverted 128-bit range is refused" + ask("huge", OpSpec::Between, inverted).await.is_ok(), + "an inverted 128-bit range is not refused" ); let stored = |value: &ValueFrame, name: &'static str| { @@ -1990,53 +2015,30 @@ mod tests { ); } - /// A range is refused the wrong way round for every integer kind a - /// field can declare, and two values of different kinds have no order - /// here: the input was read as one kind first. - #[test] - fn a_range_is_ordered_within_each_integer_kind() { - let cases = [ - (Tree::Int32(-1), Tree::Int32(1)), - (Tree::Int64(-1), Tree::Int64(1)), - (Tree::UInt32(1), Tree::UInt32(2)), - (Tree::UInt64(1), Tree::UInt64(2)), - ]; - for (lo, hi) in cases { - let (lo, hi) = (vf(&lo), vf(&hi)); - assert_eq!(integer_order(&lo, &hi), Some(Ordering::Less)); - assert_eq!(integer_order(&hi, &lo), Some(Ordering::Greater)); - assert_eq!(integer_order(&lo, &lo), Some(Ordering::Equal)); - } - assert_eq!( - integer_order(&vf(&Tree::UInt32(1)), &vf(&Tree::UInt64(2))), - None - ); - assert_eq!(integer_order(&vf(&text("a")), &vf(&text("b"))), None); - // Every width, the 128-bit edges included: a `u128` above - // `i128::MAX` and a negative `i128` order as the numbers do. - let int = |int: vitaminc_aead::plaintext::Int| { - ValueFrame::of(vitaminc_aead::plaintext::Raw( - None, - vitaminc_aead::plaintext::Physical::Int(int), - )) - .expect("a value") + /// A range whose low bound is above its high bound is not refused, as + /// SQL's `BETWEEN` is not: it is checked, and its terms derived, like + /// any other range, and matches nothing. + #[tokio::test] + async fn an_inverted_range_is_a_query_like_any_other() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + // `score` declares OPE alone, whose terms compare as bytes. + let inverted = Tree::Array(vec![Tree::Int64(65), Tree::Int64(18)]); + assert!(check_query( + &users(), + "score", + OpSpec::Between, + vf(&inverted), + &NoTargets + ) + .is_ok()); + let term = query(&keyset, "score", OpSpec::Between, inverted) + .await + .expect("derived"); + let Operand::Range { lo, hi } = term.operand() else { + panic!("a range"); }; - use vitaminc_aead::plaintext::Int; - for (lo, hi) in [ - (Int::I8(-1), Int::I8(1)), - (Int::I16(-1), Int::I16(1)), - (Int::I128(i128::MIN), Int::I128(i128::MAX)), - (Int::I128(-1), Int::I128(0)), - (Int::U8(1), Int::U8(2)), - (Int::U16(1), Int::U16(2)), - (Int::U128(i128::MAX as u128), Int::U128(u128::MAX)), - ] { - let (lo, hi) = (int(lo), int(hi)); - assert_eq!(integer_order(&lo, &hi), Some(Ordering::Less)); - assert_eq!(integer_order(&hi, &lo), Some(Ordering::Greater)); - assert_eq!(integer_order(&lo, &lo), Some(Ordering::Equal)); - } - assert_eq!(integer_order(&int(Int::I8(1)), &int(Int::I16(2))), None); + assert!(lo > hi, "the bounds as given, and the terms in their order"); } #[test] @@ -2235,7 +2237,17 @@ mod tests { (OpSpec::Between, IndexSpec::Ore), ], ), - ("nickname", "string", vec![]), + ( + "nickname", + "string", + vec![ + (OpSpec::Lt, IndexSpec::Ore), + (OpSpec::Lte, IndexSpec::Ore), + (OpSpec::Gt, IndexSpec::Ore), + (OpSpec::Gte, IndexSpec::Ore), + (OpSpec::Between, IndexSpec::Ore), + ], + ), ] { let listed = field_operators(&users(), name, &NoTargets).unwrap(); assert_eq!(listed, want, "{name}"); diff --git a/packages/stack-encrypt/src/dynamic/term.rs b/packages/stack-encrypt/src/dynamic/term.rs index 1cd9f5a75..2dc5e5903 100644 --- a/packages/stack-encrypt/src/dynamic/term.rs +++ b/packages/stack-encrypt/src/dynamic/term.rs @@ -15,11 +15,12 @@ //! compared in Unicode 16 NFC, and a date, timestamp, decimal or uuid has //! a domain of its own. A boolean has none. //! - **Match** takes text only, cut in Unicode 16 NFC. -//! - **ORE and OPE** take an integer of any width, a float, text or bytes, -//! each handed to cllw-ore as the Rust type the typed path would have -//! named: an `i8` as an `i8`, a `u128` as a `u128`. A boolean has no -//! order term: with two values, its order term would tell anyone holding -//! the column which rows are `true`. A decimal, a date and a timestamp +//! - **ORE and OPE** take an integer of 16 bits or wider, a float, text or +//! bytes, each handed to cllw-ore as the Rust type the typed path would +//! have named: an `i16` as an `i16`, a `u128` as a `u128`. A boolean, a +//! `u8` and an `i8` have no order term: over a domain that small (two +//! values, or 256) it would tell anyone holding the column which rows are +//! `true`, or rank every row among 256. A decimal, a date and a timestamp //! have none yet: cllw-ore 0.5 does not encode them the ADR-0002 way, and //! their order terms wait for new cllw-ore and ore-rs releases (#1236). //! @@ -39,14 +40,11 @@ use vitaminc_aead::plaintext::{ Error as SinkError, Float, Int, Logical, MapSink, Physical, SeqSink, Sink, SourceError, }; use vitaminc_normalize::{domain_of, normalize, NormalizeError}; -use zeroize::Zeroizing; use serde::{Deserialize, Serialize, Serializer}; use super::{Error, Reason}; -use crate::sem::{ - CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, MatchOptions, QueryTokens, TermError, Tokenizer, -}; +use crate::sem::{DefaultMatch, MatchOptions, Orderable, QueryTokens, TermError, Tokenizer}; use crate::target::{chosen, CallerContext, ConsumeSource, Encryption, Index, IndexSpec, Pending}; use crate::{Encrypt, IntoPrfContext, KeysetCipher, NonEmpty}; @@ -155,15 +153,17 @@ impl IndexSpec { } } -/// Whether CLLW ORE and OPE order this scalar: an integer of any width, a -/// float, text or bytes. Not a boolean, which would reveal which rows hold -/// `true`, and not a null. The caller has already refused a scalar under a -/// logical type: a decimal, date or timestamp's order term waits for -/// #1236. +/// Whether CLLW ORE and OPE order this scalar: an integer of 16 bits or +/// wider, a float, text or bytes. Not a boolean, and not an 8-bit integer: +/// an order term over so small a domain reveals too much, which rows hold +/// `true`, or each row's rank among 256 values, which a few known rows +/// resolve to the values. Not a null. The caller has already refused a +/// scalar under a logical type: a decimal, date or timestamp's order term +/// waits for a later release. fn ordered(value: &Physical<'_>) -> bool { match value { + Physical::Int(Int::I8(_) | Int::U8(_)) | Physical::Null | Physical::Bool(_) => false, Physical::Int(_) | Physical::Float(_) | Physical::Text(_) | Physical::Bytes(_) => true, - Physical::Null | Physical::Bool(_) => false, } } @@ -487,16 +487,16 @@ where /// Rust type the typed path would have named; `None` for a scalar it does /// not order ([`ordered`]). /// -/// Text and bytes are copied out of the walk into a `Zeroizing` wrapper, -/// and the encryptor is handed the wrapper itself: the CLLW encryptors take -/// their value by `'static` ownership (the visitor carries it), so a bare -/// `String` or `Vec` would be freed with the plaintext still in it, in -/// a guest's linear memory, where the host can read it. +/// Text and bytes are copied out of the walk into an owned `String` or +/// `Vec` and moved to the encryptor, which wraps the one copy in a +/// `Zeroizing` on receipt: the order term takes its value by ownership +/// (the visitor carries it) and wipes it, so nothing is freed with the +/// plaintext still in it, in a guest's linear memory, where the host can +/// read it. /// -/// Text is ordered as its raw UTF-8, not in the Unicode 16 NFC that -/// equality and match terms use: `café` written precomposed and with a -/// combining accent is one value to `=` and two to an ordering. A known -/// gap, to close with block ORE (#1140). +/// Each scalar is encoded at the target of the Rust type named here +/// ([`Orderable`]), as on the typed path: text by the system ADR-0002's +/// fold, so `café` precomposed or with a combining accent, and `Café`, tie. fn ore_of<'a, 'c, K: 'static, D>( cipher: &'a KeysetCipher<'_, K>, value: Physical<'_>, @@ -506,20 +506,18 @@ where D: IntoPrfContext<'c>, { Some(match value { - Physical::Int(Int::I8(v)) => ore(cipher, v, context), Physical::Int(Int::I16(v)) => ore(cipher, v, context), Physical::Int(Int::I32(v)) => ore(cipher, v, context), Physical::Int(Int::I64(v)) => ore(cipher, v, context), Physical::Int(Int::I128(v)) => ore(cipher, v, context), - Physical::Int(Int::U8(v)) => ore(cipher, v, context), Physical::Int(Int::U16(v)) => ore(cipher, v, context), Physical::Int(Int::U32(v)) => ore(cipher, v, context), Physical::Int(Int::U64(v)) => ore(cipher, v, context), Physical::Int(Int::U128(v)) => ore(cipher, v, context), Physical::Float(Float::F32(v)) => ore(cipher, v, context), Physical::Float(Float::F64(v)) => ore(cipher, v, context), - Physical::Text(t) => ore(cipher, Zeroizing::new(t.to_owned()), context), - Physical::Bytes(b) => ore(cipher, Zeroizing::new(b.to_vec()), context), + Physical::Text(t) => ore(cipher, t.to_owned(), context), + Physical::Bytes(b) => ore(cipher, b.to_vec(), context), _ => return None, }) } @@ -534,34 +532,30 @@ where D: IntoPrfContext<'c>, { Some(match value { - Physical::Int(Int::I8(v)) => ope(cipher, v, context), Physical::Int(Int::I16(v)) => ope(cipher, v, context), Physical::Int(Int::I32(v)) => ope(cipher, v, context), Physical::Int(Int::I64(v)) => ope(cipher, v, context), Physical::Int(Int::I128(v)) => ope(cipher, v, context), - Physical::Int(Int::U8(v)) => ope(cipher, v, context), Physical::Int(Int::U16(v)) => ope(cipher, v, context), Physical::Int(Int::U32(v)) => ope(cipher, v, context), Physical::Int(Int::U64(v)) => ope(cipher, v, context), Physical::Int(Int::U128(v)) => ope(cipher, v, context), Physical::Float(Float::F32(v)) => ope(cipher, v, context), Physical::Float(Float::F64(v)) => ope(cipher, v, context), - Physical::Text(t) => ope(cipher, Zeroizing::new(t.to_owned()), context), - Physical::Bytes(b) => ope(cipher, Zeroizing::new(b.to_vec()), context), + Physical::Text(t) => ope(cipher, t.to_owned(), context), + Physical::Bytes(b) => ope(cipher, b.to_vec(), context), _ => return None, }) } -/// The `AsRef<[u8]>` on the output is what turns the typed CLLW ciphertext -/// into the frozen raw-bytes encoding. +/// The term's raw bytes are its frozen encoding. fn ore<'a, 'c, K: 'static, T, D>( cipher: &'a KeysetCipher<'_, K>, value: T, context: NonEmpty, ) -> Pending<'a, TermBytes, K> where - T: CllwOreEncrypt + Send + 'static, - T::Output: AsRef<[u8]> + Send + 'static, + T: Orderable, D: IntoPrfContext<'c>, { cipher @@ -576,8 +570,7 @@ fn ope<'a, 'c, K: 'static, T, D>( context: NonEmpty, ) -> Pending<'a, TermBytes, K> where - T: CllwOpeEncrypt + Send + 'static, - T::Output: AsRef<[u8]> + Send + 'static, + T: Orderable, D: IntoPrfContext<'c>, { cipher @@ -990,6 +983,8 @@ mod tests { fn every_scalar() -> Vec<(&'static str, Tree)> { vec![ ("a bool", Tree::Bool(true)), + ("an i8", Tree::Int8(-8)), + ("a u8", Tree::UInt8(200)), ("an i32", Tree::Int32(-3)), ("an i64", Tree::Int64(-4)), ("a u32", Tree::UInt32(34)), @@ -1051,9 +1046,9 @@ mod tests { "text match" ); - // The ordering schemes take every scalar but a boolean; the - // typed side is spelled once per variant because each is its - // own type. + // The ordering schemes take every scalar but a boolean and an + // 8-bit integer; the typed side is spelled once per variant + // because each is its own type. macro_rules! ordered { ($value:expr, $leaf:expr, $label:literal) => { let typed = keyset.ore_term($value, nonempty!("users/x")).await; @@ -1176,33 +1171,15 @@ mod tests { ); }; } - typed!(-8i8, "i8"); typed!(-300i16, "i16"); typed!(300u16, "u16"); typed!(34u64, "u64"); typed!(i128::MIN, "i128"); typed!(u128::MAX, "u128"); // The `UINT8` tag and its byte: no Rust source walks a `u8` as an - // int, but a host's frame can carry one, and it is the `u8` term. + // int, but a host's frame can carry one. It has the `u8` + // equality term, which is every width's, and no order term. let frame = vec![vitaminc_aead::tags::UINT8, 200]; - assert_eq!( - derive(frame.clone(), IndexSpec::Ore).await, - keyset - .ore_term(200u8, nonempty!("users/x")) - .await - .expect("typed") - .as_ref() - .to_vec() - ); - assert_eq!( - derive(frame.clone(), IndexSpec::Ope).await, - keyset - .ope_term(200u8, nonempty!("users/x")) - .await - .expect("typed") - .as_ref() - .to_vec() - ); assert_eq!( derive(frame, IndexSpec::Equality).await, derive( @@ -1215,6 +1192,19 @@ mod tests { .await, "one equality term per number, whatever its width" ); + for (label, frame) in [ + ("u8", vec![vitaminc_aead::tags::UINT8, 200]), + ("i8", vec![vitaminc_aead::tags::INT8, 0xf8]), + ] { + for kind in [IndexSpec::Ore, IndexSpec::Ope] { + let frame = vitaminc_aead::Frame::parse(&frame).expect("a frame"); + let result = term(&keyset, frame, &kind, ctx.clone()).await; + assert!( + matches!(&result, Err(Error::Term { kind: k, .. }) if *k == kind), + "an {label} has no {kind} term: {result:?}" + ); + } + } // A date, a timestamp and a decimal have equality terms only, // the ones their frame's own value derives. for value in [date(19_782), instant(-1), decimal(-150, 2)] { @@ -1287,15 +1277,16 @@ mod tests { fn defines_is_true() { for (label, leaf) in every_scalar() { let not_bool = !matches!(leaf, Tree::Bool(_)); + let wide = not_bool && !matches!(leaf, Tree::Int8(_) | Tree::UInt8(_)); assert_eq!( IndexSpec::Ore.defines(&leaf), - not_bool, - "{label} takes an ore term exactly when it is not a boolean" + wide, + "{label} takes an ore term exactly when it is not a boolean or 8 bits wide" ); assert_eq!( IndexSpec::Ope.defines(&leaf), - not_bool, - "{label} takes an ope term exactly when it is not a boolean" + wide, + "{label} takes an ope term exactly when it is not a boolean or 8 bits wide" ); assert_eq!( IndexSpec::Equality.defines(&leaf), @@ -1328,6 +1319,8 @@ mod tests { ("bytes", bytes(b"ab"), default_match()), ("a bool", Tree::Bool(true), IndexSpec::Ore), ("a bool", Tree::Bool(false), IndexSpec::Ope), + ("an i8", Tree::Int8(-8), IndexSpec::Ore), + ("a u8", Tree::UInt8(200), IndexSpec::Ope), ]; for (label, leaf, kind) in refused { assert!(!kind.defines(&leaf), "{label} must not take a {kind} term"); diff --git a/packages/stack-encrypt/src/op/mod.rs b/packages/stack-encrypt/src/op/mod.rs index 83741743f..e7fa34100 100644 --- a/packages/stack-encrypt/src/op/mod.rs +++ b/packages/stack-encrypt/src/op/mod.rs @@ -55,22 +55,21 @@ //! does. //! //! What the types can see is checked at compile time: a match query on an -//! integer, an ordering on text, an operator a target type does not answer, +//! integer, an ordering on bytes, an operator a target type does not answer, //! and, on a one-value plan, an index the plan does not hold or a //! case-sensitive match against a case-folding index. A field of a fields //! plan is data, so there an index the field does not declare, or declares //! with other options, is refused when the query is awaited, before any //! key is requested. So is a match configuration whose [`MatchCase`] -//! disagrees with its options, and so are the two query values the library +//! disagrees with its options, and so is the one query value the library //! refuses: match text that cuts no token under the index's tokenizer, -//! whose filter would match every row ([`TermError::EmptyTermText`]), and -//! a `Between` whose low bound is above its high bound -//! ([`TermError::InvertedRange`]). The stored side has no such refusal: a -//! value with no tokens is stored as the empty filter, which no query -//! matches. +//! whose filter would match every row ([`TermError::EmptyTermText`]). The +//! stored side has no such refusal: a value with no tokens is stored as +//! the empty filter, which no query matches. A `Between` whose low bound is +//! above its high bound is not refused either: as SQL's `BETWEEN`, it +//! matches nothing. //! //! [`TermError::EmptyTermText`]: crate::sem::TermError::EmptyTermText -//! [`TermError::InvertedRange`]: crate::sem::TermError::InvertedRange //! [`RangeQuery`]: crate::sem::RangeQuery //! //! The module is meant to be used by path (`op::Eq`): an imported `Eq` @@ -87,8 +86,8 @@ pub(crate) mod stats; pub use stats::{Limit, Stats, DEFAULT_CONFIDENCE, MAX_ROWS}; use crate::sem::{ - CllwOpeEncrypt, CllwOreEncrypt, DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, - MatchTerms, OpeTerm, OreTerm, QueryTokens, RangeQuery, TermError, + DefaultMatch, EqualityTerm, MatchConfig, MatchQuery, MatchTerms, OpeTerm, Orderable, OreTerm, + QueryTokens, RangeQuery, }; use crate::target::{DeclaredContext, Equality, Index, Match, Ope, Ore, Owned, Pending}; use crate::{Error, KeysetCipher, Label, NonEmpty, PlanError}; @@ -133,10 +132,9 @@ pub struct Gte; /// at both ends. It takes a [`Pair`], `cipher.query(op::Between, &(lo, hi))`, /// derives both bounds under one context, and sends them as one term /// ([`RangeQuery`]), so no statement can pair the -/// low bound of one query with the high bound of another. A pair whose low -/// bound is above its high bound is refused when the query is awaited -/// ([`TermError::InvertedRange`]); one -/// value is `(v, v)`. +/// low bound of one query with the high bound of another. One value is +/// `(v, v)`. A pair whose low bound is above its high bound is not +/// refused: as SQL's `BETWEEN`, it matches nothing. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] pub struct Between; @@ -342,59 +340,42 @@ impl Input for (S, S) { } /// A plaintext the ordering operators ([`Lt`], [`Lte`], [`Gt`], [`Gte`], -/// [`Between`]) are answered over: the integer types, whose ORE and OPE -/// terms order as the values do and whose order key is the value itself. -/// Text is not one: its order term is a catalog question (ADR-0009, ADR-0002), -/// and an ordering on a text plan does not compile. +/// [`Between`]) are answered over: the integers of 16 bits and wider, and +/// text (`String`). Not `u8` or `i8`, which have no order term +/// ([`Orderable`] says why), as `bool` has none. +/// Each is encoded as its [`Orderable`] target and its terms order as +/// cllw-ore orders that encoding: integers as numbers, text by the system +/// ADR-0002's fold, Unicode 16 NFC with case and accents folded, so `Café`, +/// `cafe` and `CAFÉ` tie and `alice` orders before `Zoe` (ADR-0009). /// -/// Sealed: `Between` refuses an inverted range with the type's `PartialOrd` -/// while the server compares ORE terms, so a type whose two orders could -/// disagree (a newtype over folded text, say) must not be able to join. A -/// type that meets every other bound is still refused: +/// Sealed: a type joins only when the order its terms compare in is the +/// one its operators promise, so a type whose terms could order otherwise +/// (a newtype over folded text, say) must not be able to join. A type that +/// meets every other bound is still refused: /// /// ```compile_fail -/// # use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt, Error, Key}; /// use stack_encrypt::op::Ordered; /// -/// #[derive(PartialEq, PartialOrd)] /// struct Folded(u32); -/// # impl CllwOreEncrypt for Folded { -/// # type Output = ::Output; -/// # fn encrypt_with_salt(self, key: &Key, salt: Option<&[u8]>) -> Result { -/// # self.0.encrypt_with_salt(key, salt) -/// # } -/// # } -/// # impl CllwOpeEncrypt for Folded { -/// # type Output = ::Output; -/// # fn encrypt_ope_with_salt(self, key: &Key, salt: Option<&[u8]>) -> Result { -/// # self.0.encrypt_ope_with_salt(key, salt) -/// # } -/// # } /// /// impl Ordered for Folded {} // `Ordered` is sealed /// ``` +/// +/// [`Orderable`]: crate::sem::Orderable #[diagnostic::on_unimplemented( - message = "`{Self}` is not ordered: the ordering operators are answered over the integer types only", + message = "`{Self}` is not ordered: the ordering operators are answered over the integers of 16 bits and wider, and text", label = "no ordering operator over this type", - note = "`Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u8` to `u128` or `i8` to `i128`; the order of text is not settled (ADR-0009), so an ordering over text does not compile" + note = "`Lt`, `Lte`, `Gt`, `Gte` and `Between` take `u16` to `u128`, `i16` to `i128`, or a `String`; a `u8`, an `i8` and a `bool` have no order term" )] -pub trait Ordered: - sealed::Sealed - + PartialOrd - + CllwOreEncrypt - + CllwOpeEncrypt - + Send - + 'static -{ -} +pub trait Ordered: sealed::Sealed + Orderable {} macro_rules! ordered { - ($($int:ty),+ $(,)?) => {$( - impl sealed::Sealed for $int {} - impl Ordered for $int {} + ($($ty:ty),+ $(,)?) => {$( + impl sealed::Sealed for $ty {} + impl Ordered for $ty {} )+}; } -ordered!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128); +ordered!(u16, u32, u64, u128, i16, i32, i64, i128, String); /// Text search: rows whose text contains the query's tokens, under the /// match index's tokenizer. Not containment: a Bloom match is order- and @@ -530,8 +511,8 @@ pub trait QueryTerm {} impl QueryTerm for EqualityTerm {} impl QueryTerm for MatchQuery {} -impl QueryTerm for OreTerm {} -impl QueryTerm for OpeTerm {} +impl QueryTerm for OreTerm {} +impl QueryTerm for OpeTerm {} impl QueryTerm for RangeQuery {} /// A query term whose answer can hold **false positives and no false @@ -644,9 +625,8 @@ pub trait Supports>: Index { /// Why this index cannot answer `Op` as configured, or for `input`, if /// it cannot: a match index whose [`MatchCase`] disagrees with its /// options ([`PlanError::MatchCase`]), a match query of text that cuts - /// no token ([`TermError::EmptyTermText`]), whose filter would match - /// every row, or a range whose low bound is above its high bound - /// ([`TermError::InvertedRange`]). + /// no token ([`TermError::EmptyTermText`](crate::sem::TermError::EmptyTermText)), + /// whose filter would match every row. /// Run when the query is awaited, before any key is requested; an /// index that refuses nothing returns `Ok(())`. fn check(&self, input: &In) -> Result<(), Error>; @@ -744,12 +724,8 @@ macro_rules! orders { impl Supports for $index { type Query = RangeQuery<$term>; type Verifier = (); - fn check(&self, (lo, hi): &(S, S)) -> Result<(), Error> { - if lo > hi { - Err(TermError::InvertedRange.into()) - } else { - Ok(()) - } + fn check(&self, _: &(S, S)) -> Result<(), Error> { + Ok(()) } fn verifier(&self, _: &(S, S)) {} fn query((): (), (lo, hi): ($term, $term), _: QueryToken) -> Self::Query { @@ -940,19 +916,16 @@ mod tests { arity::, Pathed>(); } - /// The ordering operators default to ORE, and a range is refused only - /// when its bounds are the wrong way round: `(v, v)` is one value. + /// An ordering refuses no value: a range whose bounds are the wrong way + /// round matches nothing, as SQL's `BETWEEN` does. #[test] - fn a_range_is_checked_before_any_term_is_derived() { + fn an_ordering_refuses_no_value() { let ok = |pair: (u32, u32)| Supports::::check(&Ore, &pair).is_ok(); assert!(ok((1, 2))); assert!(ok((2, 2))); - assert!(!ok((3, 2))); - assert!(matches!( - Supports::::check(&Ope, &(1, -1)), - Err(Error::Term(TermError::InvertedRange)) - )); - assert!(Supports::::check(&Ore, &7).is_ok()); + assert!(ok((3, 2))); + assert!(Supports::::check(&Ope, &(1, -1)).is_ok()); + assert!(Supports::::check(&Ore, &7).is_ok()); assert!(Supports::::check(&Ope, &-7).is_ok()); } } diff --git a/packages/stack-encrypt/src/sem/mod.rs b/packages/stack-encrypt/src/sem/mod.rs index d13937a33..36da3b018 100644 --- a/packages/stack-encrypt/src/sem/mod.rs +++ b/packages/stack-encrypt/src/sem/mod.rs @@ -133,19 +133,21 @@ use std::collections::HashSet; use std::fmt; use std::marker::PhantomData; -// Re-exported because they appear in this module's public bounds -// ([`KeysetCipher::ore_term`], [`OreTerm`], ...): a caller writing a generic -// wrapper over the term APIs has to be able to name them without depending -// on `cllw-ore` directly. -pub use cllw_ore::{CllwOpeEncrypt, CllwOreEncrypt}; +// Re-exported because they appear in this module's public signatures +// ([`KeysetCipher::ore_term`], [`OreTerm::inner`], ...): a caller can name +// the ciphertext types without depending on `cllw-ore` directly. +use cllw_ore::vitaminc::{Ope, Ore}; +pub use cllw_ore::{OpeCllw8VariableV1, OreCllw8VariableV1}; use vitaminc_aead::Encrypt; use vitaminc_hmac::HmacSha256Prf; use vitaminc_normalize::{terms, NormalizeError, Normalized}; +use vitaminc_ore::{OrderColumn, OrderEncrypt, OrderError, OrderKeyInit, OrderScheme, OrderTarget}; use vitaminc_prf::{ BlockVisitor, Context, IntoPrfContext, MapAccess, PrfError, PrfValue, PrfVisitor, PrfVisitorError, SeqAccess, }; use vitaminc_protected::NonEmpty; +use vitaminc_protected::Protected; use zeroize::{Zeroize, Zeroizing}; use stack_kms::MaybeSend; @@ -171,10 +173,15 @@ const EQUALITY_DOMAIN: &[u8] = b"stack-encrypt/sem/equality/v2"; /// written. const MATCH_DOMAIN: &[u8] = b"stack-encrypt/sem/match/v2"; /// PAE domain for ORE key derivation. -const ORE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ore-key/v1"; +/// +/// `v2`: the key keys cllw-ore's vitaminc ORE scheme, which encrypts the +/// value's `vitaminc-ore` order encoding at its [`Orderable`] target, where +/// `v1` keyed cllw-ore's own per-type encryption. +const ORE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ore-key/v2"; /// PAE domain for OPE key derivation (distinct from ORE: OPE ciphertexts are -/// encrypt-only, so the two schemes must never share a key). -const OPE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ope-key/v1"; +/// encrypt-only, so the two schemes must never share a key). `v2` as for +/// [`ORE_KEY_DOMAIN`]. +const OPE_KEY_DOMAIN: &[u8] = b"stack-encrypt/sem/ope-key/v2"; /// Errors from SEM term generation. #[derive(Debug, thiserror::Error, miette::Diagnostic)] @@ -212,16 +219,6 @@ pub enum TermError { help("A match query needs text with at least one token: a query with none would match every row, and one shorter than the n-gram length could match none. Query on longer text, or leave the match condition out.") )] EmptyTermText, - /// A `Between` query's low bound is above its high bound, so the range - /// holds nothing. Refused when the query is awaited, before a term - /// exists. The range is inclusive at both ends, so equal bounds are one - /// value, not an error. - #[error("the range's low bound is above its high bound")] - #[diagnostic( - code(stack_encrypt::inverted_range), - help("Give `op::Between` its bounds as `(lo, hi)` with `lo <= hi`. The range is inclusive at both ends, so one value is `(v, v)`.") - )] - InvertedRange, /// The value is not one scalar an equality term is defined for: a /// null, a boolean, a container, or a value of a logical type vitaminc /// gives no equality domain. Equality derives over one scalar, in the @@ -235,16 +232,6 @@ pub enum TermError { help("An equality term is defined for one integer, float, text, bytes, date, timestamp, decimal or uuid value, not for a boolean, a null or a container.") )] NoEqualityTerm, - /// No order term (ORE or OPE) is defined for a boolean: with two values, - /// an order term would tell anyone holding the column which rows are - /// `true`. Refused on the typed path as the dynamic path refuses it - /// (`dynamic::admits`). - #[error("no order term is defined for a boolean")] - #[diagnostic( - code(stack_encrypt::no_order_term), - help("A boolean column cannot be ordered without revealing which rows are true. Store it with no order index, and with no equality index either: a boolean has no equality term.") - )] - NoOrderTerm, /// The value cannot be normalized for its term: text with a code point /// unassigned in Unicode 16 (an equality or match term), or a value not /// valid for its logical type. The reason is vitaminc's, and names no @@ -267,9 +254,7 @@ impl crate::ErrorPayload for TermError { | Self::Ore(_) | Self::InvalidOptions(_) | Self::EmptyTermText - | Self::InvertedRange | Self::NoEqualityTerm - | Self::NoOrderTerm | Self::Normalize(_) => serde_json::Map::new(), } } @@ -875,9 +860,9 @@ impl Eq for MatchQuery {} /// term the index would send for that value alone, which is how a query /// domain that carries a range serialises it. /// -/// It comes only from a query chain: a range is never stored, and the -/// chain refuses one whose low bound is above its high bound -/// ([`TermError::InvertedRange`]) before either term is derived. +/// It comes only from a query chain: a range is never stored. Both ends +/// are inclusive, and a range whose low bound is above its high bound is +/// not refused: as SQL's `BETWEEN`, it matches nothing. #[derive(Clone, Debug, PartialEq, Eq)] pub struct RangeQuery { lo: T, @@ -1104,18 +1089,129 @@ where // ORE / OPE // ============================================================================= -/// An order-revealing (CLLW ORE) index term for a source value of type `T`. -/// Wraps the CLLW ciphertext ([`CllwOreEncrypt::Output`]); comparisons order -/// like the plaintexts. +/// A plaintext type an order term ([`OreTerm`], [`OpeTerm`]) is derived +/// over, and the `vitaminc-ore` order target its values are encoded as. +/// This crate is the layer above the order schemes: it names each type's +/// target, and cllw-ore encodes the value as that target and encrypts it. +/// The encoding (integer width, the canonical float, text's fold) is the +/// scheme's, through `vitaminc-ore`, and never this crate's. The value goes +/// to the scheme as a `vitaminc-aead` [`Encrypt`] source, by reference, so +/// the engine keeps the one copy it was handed and wipes it +/// ([`Zeroizing`]) when the term is derived or refused. +/// +/// | Type | Target | +/// | --- | --- | +/// | `u16` to `u128`, `i16` to `i128` | the integer at its own width | +/// | `f32`, `f64` | `F64`: an `f32` widens to it exactly, `-0.0` ties `0.0`, every NaN is one value after `+Infinity` | +/// | `String` | `Text`: Unicode 16 NFC with case and accents folded | +/// | `Vec` | `Bytes`, as they are | +/// +/// A borrowed text or byte string is not a source: an order term takes +/// its value by ownership (the visitor carries it), so pass a `String` or +/// a `Vec`. +/// +/// A `bool`, a `u8` and an `i8` have no target. An order term over a +/// domain that small tells anyone holding the column too much: with two +/// values it says which rows are `true`, and with 256 it puts every row in +/// one of 256 ranks, which a few known rows resolve to the values. So +/// `OreTerm`, `OreTerm` and `OreTerm` do not compile (nor +/// the `OpeTerm`s), as the dynamic path refuses the same scalars +/// (`dynamic::admits`); each keeps its equality term. `vitaminc-ore` still +/// offers the one-byte targets; their removal is tracked at +/// . +/// +/// ```compile_fail +/// use stack_encrypt::sem::OreTerm; +/// +/// let _: Option> = None; // `bool` is not `Orderable` +/// ``` +/// +/// ```compile_fail +/// use stack_encrypt::sem::OpeTerm; +/// +/// let _: Option> = None; // `u8` is not `Orderable` +/// ``` +/// +/// Sealed: the target is part of the stored format, so a type cannot pick +/// its own. +/// +/// This table is a stand-in. `vitaminc-ore` leaves the target to whoever +/// opens the column, so the type's own target is written here; once +/// `vitaminc-ore` names it itself (tracked at +/// ), this trait and +/// the private `encrypt_in` it rests on go, and the bound becomes +/// vitaminc's own `Encrypt` with `Zeroize`. +pub trait Orderable: sealed::Sealed + Zeroize + Send + 'static { + /// The target this type's values are encoded as. + const TARGET: OrderTarget; +} + +mod sealed { + use vitaminc_ore::{OrderColumn, OrderError, OrderScheme}; + + /// Hands `&self` to the column as the `vitaminc-aead` `Encrypt` source + /// it is (`&String`, `&i32`, ...), so the owner keeps the one copy and + /// wipes it. Not public, and one body for every type: it is here, and + /// not a `&Self: Encrypt` bound on `Orderable`, because such a bound is + /// not implied at the trait's use sites and would have to be repeated on + /// each. + pub trait Sealed { + fn encrypt_in( + &self, + column: &OrderColumn<'_, S>, + ) -> Result>; + } +} + +macro_rules! orderable { + ($($ty:ty => $target:ident),* $(,)?) => {$( + impl sealed::Sealed for $ty { + fn encrypt_in( + &self, + column: &OrderColumn<'_, S>, + ) -> Result> { + column.encrypt(self) + } + } + impl Orderable for $ty { + const TARGET: OrderTarget = OrderTarget::$target; + } + )*}; +} +orderable!( + u16 => U16, u32 => U32, u64 => U64, u128 => U128, + i16 => I16, i32 => I32, i64 => I64, i128 => I128, + f32 => F64, f64 => F64, + String => Text, Vec => Bytes, +); + +/// Why cllw-ore produced no order term, as a [`TermError`]: the value's +/// encoding refused it, or the scheme failed. `cllw_ore::Error` carries +/// nothing, so a scheme failure is that one value whichever way it +/// arrives. A scalar of an [`Orderable`] type is never a container and +/// never unsupported by a CLLW scheme, so those refusals cannot occur; +/// they read as a scheme failure rather than a panic. +fn order_error(error: OrderError) -> TermError { + match error { + OrderError::Term(error) => TermError::Normalize(error), + _ => TermError::Ore(cllw_ore::Error), + } +} + +/// An order-revealing (CLLW ORE) index term for a source value of type `T`: +/// cllw-ore's encryption of the value's `vitaminc-ore` order encoding at +/// `T`'s [`Orderable`] target. Comparisons order like the plaintexts, within +/// one index. /// /// The type parameter is the *source* type: `OreTerm` in a record type -/// declares "this field is the ORE term of a `u64`". -pub struct OreTerm(T::Output); +/// declares "this field is the ORE term of a `u64`". Every source type's +/// term is the same ciphertext type, [`OreCllw8VariableV1`]. +pub struct OreTerm(OreCllw8VariableV1, PhantomData T>); /// An order-preserving (CLLW OPE) index term for a source value of type `T` -/// (see [`OreTerm`]). The wrapped ciphertext compares with plain +/// (see [`OreTerm`]): [`OpeCllw8VariableV1`], which compares with plain /// lexicographic byte order. -pub struct OpeTerm(T::Output); +pub struct OpeTerm(OpeCllw8VariableV1, PhantomData T>); /// Index terms are one-way: decryption passes over them. `Decryptable` and /// `DecryptField` say so, which is how a derived record finds its ciphertext @@ -1135,24 +1231,24 @@ macro_rules! index_term { index_term!(EqualityTerm); index_term!(MatchTerms, O: MatchConfig); -index_term!(OreTerm, T: CllwOreEncrypt); -index_term!(OpeTerm, T: CllwOpeEncrypt); +index_term!(OreTerm, T: Orderable); +index_term!(OpeTerm, T: Orderable); macro_rules! term_wrapper { - ($name:ident, $bound:ident) => { - impl $name { + ($name:ident, $inner:ty) => { + impl $name { /// Wrap an already-generated CLLW ciphertext. - pub fn new(output: T::Output) -> Self { - Self(output) + pub fn new(output: $inner) -> Self { + Self(output, PhantomData) } /// The wrapped CLLW ciphertext. - pub fn inner(&self) -> &T::Output { + pub fn inner(&self) -> &$inner { &self.0 } /// Unwrap into the CLLW ciphertext. - pub fn into_inner(self) -> T::Output { + pub fn into_inner(self) -> $inner { self.0 } @@ -1161,32 +1257,12 @@ macro_rules! term_wrapper { /// — for terms persisted server-side. Structural only (length /// checks); the caller asserts the bytes were generated for this /// source type `T` and under the same context. - pub fn from_bytes(bytes: &[u8]) -> Result - where - for<'a> T::Output: TryFrom<&'a [u8]>, - { - T::Output::try_from(bytes) - .map(Self) + pub fn from_bytes(bytes: &[u8]) -> Result { + <$inner>::try_from(bytes) + .map(Self::new) .map_err(|_| TermBytesError::MalformedCllwCiphertext(bytes.len())) } - } - - /// The inherent `from_bytes` as a std conversion — the same decoder. - impl TryFrom<&[u8]> for $name - where - for<'a> T::Output: TryFrom<&'a [u8]>, - { - type Error = TermBytesError; - - fn try_from(bytes: &[u8]) -> Result { - Self::from_bytes(bytes) - } - } - impl $name - where - T::Output: AsRef<[u8]>, - { /// The frozen byte encoding: the raw CLLW ciphertext bytes, /// unframed — the same *shape* the EQL layer hex-encodes, but /// not comparable with rows it wrote (see the @@ -1201,59 +1277,53 @@ macro_rules! term_wrapper { } } + /// The inherent `from_bytes` as a std conversion — the same decoder. + impl TryFrom<&[u8]> for $name { + type Error = TermBytesError; + + fn try_from(bytes: &[u8]) -> Result { + Self::from_bytes(bytes) + } + } + /// The frozen byte encoding — the same bytes as the inherent /// `as_bytes`. - impl AsRef<[u8]> for $name - where - T::Output: AsRef<[u8]>, - { + impl AsRef<[u8]> for $name { fn as_ref(&self) -> &[u8] { self.0.as_ref() } } - impl fmt::Debug for $name - where - T::Output: fmt::Debug, - { + impl fmt::Debug for $name { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_tuple(stringify!($name)).field(&self.0).finish() } } - impl Clone for $name - where - T::Output: Clone, - { + impl Clone for $name { fn clone(&self) -> Self { - Self(self.0.clone()) + Self::new(self.0.clone()) } } - impl PartialEq for $name - where - T::Output: PartialEq, - { + impl PartialEq for $name { fn eq(&self, other: &Self) -> bool { self.0 == other.0 } } - impl Eq for $name where T::Output: Eq {} + impl Eq for $name {} - impl PartialOrd for $name - where - T::Output: PartialOrd, - { + impl PartialOrd for $name { fn partial_cmp(&self, other: &Self) -> Option { - self.0.partial_cmp(&other.0) + Some(self.cmp(other)) } } - impl Ord for $name - where - T::Output: Ord, - { + /// The scheme's comparison of two terms of one index. Terms of + /// different indexes compare as noise: keep an index's terms + /// together. + impl Ord for $name { fn cmp(&self, other: &Self) -> std::cmp::Ordering { self.0.cmp(&other.0) } @@ -1261,16 +1331,20 @@ macro_rules! term_wrapper { }; } -term_wrapper!(OreTerm, CllwOreEncrypt); -term_wrapper!(OpeTerm, CllwOpeEncrypt); +term_wrapper!(OreTerm, OreCllw8VariableV1); +term_wrapper!(OpeTerm, OpeCllw8VariableV1); /// A [`PrfVisitor`] that carries the plaintext in and hands the CLLW ORE -/// ciphertext out. The PRF block becomes the CLLW [`Key`](cllw_ore::Key) -/// *inside* `visit_block` and dies there: the block arrives by value and is -/// copied into the `ZeroizeOnDrop` key (`[u8; 32]` is `Copy`, so `Key::from` -/// wipes its copy and this visitor wipes the one it still holds), the value -/// is encrypted, and only the ciphertext leaves. No key is ever returned to -/// the caller. +/// ciphertext out. The PRF block keys cllw-ore's vitaminc ORE scheme +/// *inside* `visit_block` and dies there: the block arrives by value, is +/// copied into the scheme's key (wiped when the scheme drops) and wiped +/// here, the value is encrypted, and only the ciphertext leaves. No key is +/// ever returned to the caller. +/// +/// The column is opened with the value's target and the empty context: +/// the key is already this index's own, derived under the index's +/// descriptor, so there is nothing left for a context to separate. The +/// column's salt still binds the scheme and the target. /// /// This is the shape a 2-party PRF needs: the caller supplies a PRF input /// (the context) and receives a term, and where the key comes from — or @@ -1279,42 +1353,47 @@ term_wrapper!(OpeTerm, CllwOpeEncrypt); /// changes this visitor, not its callers. /// /// The plaintext is owned (`T: 'static`) because the visitor outlives the -/// call under an asynchronous backend; `Send` for the same reason. CLLW +/// call under an asynchronous backend; `Send` for the same reason. Scheme /// failures surface through the visitor's `Value` rather than /// [`PrfVisitorError`] so they keep their own error type. -struct OreVisitor(T); +/// +/// The plaintext is held in a [`Zeroizing`], so it is wiped when the +/// visitor drops: after `visit_block`, or unvisited if the PRF fails first. +/// `encrypt_in` reads it by reference and cllw-ore copies nothing out of it +/// unwiped, so that drop is the last place its bytes live. +struct OreVisitor(Zeroizing); impl PrfVisitor<[u8; 32], P> for OreVisitor where - T: CllwOreEncrypt + Send + 'static, - T::Output: Send + 'static, + T: Orderable, P: Send + 'static, { - type Value = Result; + type Value = Result; fn visit_block(self, mut block: [u8; 32]) -> Result { - let key = cllw_ore::Key::from(block); + let scheme = Ore::new(Protected::new(block)); block.zeroize(); - Ok(self.0.encrypt(&key)) + let column = scheme.column(T::TARGET, ()); + Ok(sealed::Sealed::encrypt_in(&*self.0, &column).map_err(order_error)) } } /// The OPE twin of [`OreVisitor`]: same key handling, produces a CLLW OPE /// ciphertext (byte order is plaintext order). -struct OpeVisitor(T); +struct OpeVisitor(Zeroizing); impl PrfVisitor<[u8; 32], P> for OpeVisitor where - T: CllwOpeEncrypt + Send + 'static, - T::Output: Send + 'static, + T: Orderable, P: Send + 'static, { - type Value = Result; + type Value = Result; fn visit_block(self, mut block: [u8; 32]) -> Result { - let key = cllw_ore::Key::from(block); + let scheme = Ope::new(Protected::new(block)); block.zeroize(); - Ok(self.0.encrypt_ope(&key)) + let column = scheme.column(T::TARGET, ()); + Ok(sealed::Sealed::encrypt_in(&*self.0, &column).map_err(order_error)) } } @@ -1325,12 +1404,13 @@ where /// PRF — never the plaintext, which rides in the visitor and is encrypted /// there. Deterministic, so write-time and query-time terms agree; under a /// 2-party PRF backend this derivation is a visible ZeroKMS event. -fn ore(prf: &HmacSha256Prf, value: T, context: Context<'_>) -> Result, TermError> -where - T: CllwOreEncrypt + Send + 'static, - T::Output: Send + 'static, -{ - refuse_bool::()?; +fn ore( + prf: &HmacSha256Prf, + value: T, + context: Context<'_>, +) -> Result, TermError> { + // Wrapped before anything can fail, so a refused value is wiped too. + let value = Zeroizing::new(value); let context_bytes = context.as_bytes(); context_bytes .prf_visit_with_context( @@ -1340,28 +1420,18 @@ where ) .into_result() .map_err(TermError::from_prf)? - .map(OreTerm) - .map_err(TermError::Ore) -} - -/// cllw-ore orders a `bool`, and its two values would split a column into -/// its `true` and `false` rows, so no order term is derived for one, on the -/// typed path as on the dynamic one. -fn refuse_bool() -> Result<(), TermError> { - if std::any::TypeId::of::() == std::any::TypeId::of::() { - return Err(TermError::NoOrderTerm); - } - Ok(()) + .map(OreTerm::new) } /// Derive an OPE term — as [`ore`], under the OPE domain so the two schemes /// never share a key. -fn ope(prf: &HmacSha256Prf, value: T, context: Context<'_>) -> Result, TermError> -where - T: CllwOpeEncrypt + Send + 'static, - T::Output: Send + 'static, -{ - refuse_bool::()?; +fn ope( + prf: &HmacSha256Prf, + value: T, + context: Context<'_>, +) -> Result, TermError> { + // Wrapped before anything can fail, so a refused value is wiped too. + let value = Zeroizing::new(value); let context_bytes = context.as_bytes(); context_bytes .prf_visit_with_context( @@ -1371,17 +1441,15 @@ where ) .into_result() .map_err(TermError::from_prf)? - .map(OpeTerm) - .map_err(TermError::Ore) + .map(OpeTerm::new) } -/// An ORE term of any [`CllwOreEncrypt`] source. Under the local HMAC +/// An ORE term of any [`Orderable`] source. Under the local HMAC /// backend, derived during the synchronous build — the returned [`Pending`] /// carries no requests. impl<'c, S, K, T> Term> for OreTerm where - S: CllwOreEncrypt + Send + 'static, - S::Output: Send + 'static, + S: Orderable, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. @@ -1400,13 +1468,12 @@ where } } -/// An OPE term of any [`CllwOpeEncrypt`] source. Under the local HMAC +/// An OPE term of any [`Orderable`] source. Under the local HMAC /// backend, derived during the synchronous build — the returned [`Pending`] /// carries no requests. impl<'c, S, K, T> Term> for OpeTerm where - S: CllwOpeEncrypt + Send + 'static, - S::Output: Send + 'static, + S: Orderable, T: IntoPrfContext<'c>, { // Derived locally: no data key, no descriptor. @@ -1566,20 +1633,16 @@ impl KeysetCipher<'_, K> { /// derives the per-descriptor CLLW key from the PRF block and encrypts /// under it in one step. The key never leaves the visitor. /// - /// Supported inputs: `u16`/`u32`/`u64`/`u128`, `&'static str`, `String`, - /// `Vec` (via [`CllwOreEncrypt`]). The value must be owned - /// (`'static`) because the visitor carries it; pass a `String` for - /// borrowed text. Returns the raw CLLW ciphertext; the target-directed - /// path wraps the same bytes in [`OreTerm`]. - pub fn ore_term<'c, T>( + /// Supported inputs: every [`Orderable`] type, each encoded as its + /// target. The value must be owned because the visitor carries it and + /// wipes it; pass a `String` for borrowed text. Returns the raw CLLW + /// ciphertext; the target-directed path wraps the same bytes in + /// [`OreTerm`]. + pub fn ore_term<'c, T: Orderable>( &self, value: T, descriptor: NonEmpty>, - ) -> Pending<'_, T::Output, K> - where - T: CllwOreEncrypt + Send + 'static, - T::Output: Send + 'static, - { + ) -> Pending<'_, OreCllw8VariableV1, K> { let term = ore(self.prf(), value, descriptor.into_prf_context()).map(OreTerm::into_inner); Pending::ready(self, term.map_err(Error::from)) } @@ -1588,15 +1651,11 @@ impl KeysetCipher<'_, K> { /// plain lexicographic byte order, no custom comparator required. /// Encrypt-only — pair with the record ciphertext for round-trips. Key /// handling and input bounds as for [`ore_term`](Self::ore_term). - pub fn ope_term<'c, T>( + pub fn ope_term<'c, T: Orderable>( &self, value: T, descriptor: NonEmpty>, - ) -> Pending<'_, T::Output, K> - where - T: CllwOpeEncrypt + Send + 'static, - T::Output: Send + 'static, - { + ) -> Pending<'_, OpeCllw8VariableV1, K> { let term = ope(self.prf(), value, descriptor.into_prf_context()).map(OpeTerm::into_inner); Pending::ready(self, term.map_err(Error::from)) } diff --git a/packages/stack-encrypt/src/target/index.rs b/packages/stack-encrypt/src/target/index.rs index f44fdbb54..08e77c888 100644 --- a/packages/stack-encrypt/src/target/index.rs +++ b/packages/stack-encrypt/src/target/index.rs @@ -233,8 +233,7 @@ impl, O: MatchConfig + 'static> Index for Match { } impl Index for Ore where - S: cllw_ore::CllwOreEncrypt + Send + 'static, - S::Output: Send + 'static, + S: crate::sem::Orderable, { type Term = OreTerm; fn spec(&self) -> IndexSpec { @@ -251,8 +250,7 @@ where } impl Index for Ope where - S: cllw_ore::CllwOpeEncrypt + Send + 'static, - S::Output: Send + 'static, + S: crate::sem::Orderable, { type Term = OpeTerm; fn spec(&self) -> IndexSpec { @@ -563,16 +561,14 @@ impl, O: MatchConfig + 'static> TermSet for MatchTerms { } impl TermSet for OreTerm where - S: cllw_ore::CllwOreEncrypt + Send + 'static, - S::Output: Send + 'static, + S: crate::sem::Orderable, { type Indexes = Ore; const INDEXES: Ore = Ore; } impl TermSet for OpeTerm where - S: cllw_ore::CllwOpeEncrypt + Send + 'static, - S::Output: Send + 'static, + S: crate::sem::Orderable, { type Indexes = Ope; const INDEXES: Ope = Ope; diff --git a/packages/stack-encrypt/src/target/operations.rs b/packages/stack-encrypt/src/target/operations.rs index 72f003a85..cbe026b1e 100644 --- a/packages/stack-encrypt/src/target/operations.rs +++ b/packages/stack-encrypt/src/target/operations.rs @@ -546,16 +546,15 @@ term_operation!( ); term_operation!( /// The order-revealing term of `S` under the context the tree hands it. - /// The bounds are the leaf's own: they say which `S` the CLLW ORE scheme - /// can order. + /// The bound is the leaf's own: it says which `S` has an order target. ore, crate::sem::OreTerm, consume, [], - [S: cllw_ore::CllwOreEncrypt + Send + 'static, S::Output: Send + 'static] + [S: crate::sem::Orderable] ); term_operation!( /// The order-preserving term of `S` under the context the tree hands it, /// with the same bounds as [`ore`]. ope, crate::sem::OpeTerm, consume, [], - [S: cllw_ore::CllwOpeEncrypt + Send + 'static, S::Output: Send + 'static] + [S: crate::sem::Orderable] ); impl Decryption { @@ -922,8 +921,7 @@ impl, O: crate::sem::MatchConfig + 'static> EncryptFrom } impl EncryptFrom for crate::sem::OreTerm where - S: cllw_ore::CllwOreEncrypt + Clone + Send + 'static, - S::Output: Send + 'static, + S: crate::sem::Orderable + Clone, { type Context = CallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> @@ -938,8 +936,7 @@ where } impl EncryptFrom for crate::sem::OpeTerm where - S: cllw_ore::CllwOpeEncrypt + Clone + Send + 'static, - S::Output: Send + 'static, + S: crate::sem::Orderable + Clone, { type Context = CallerContext; fn encryption<'s, K: 'static>() -> Encryption<'s, S, Self, K, Self::Context> diff --git a/packages/stack-encrypt/src/target/source.rs b/packages/stack-encrypt/src/target/source.rs index 30c11d92d..09318e1ff 100644 --- a/packages/stack-encrypt/src/target/source.rs +++ b/packages/stack-encrypt/src/target/source.rs @@ -29,7 +29,7 @@ mod sealed { /// How a description receives its plaintext: the `M` parameter of /// [`Encryption`](super::Encryption). /// -/// Vitamin C's `Encrypt`, `PrfValue` and `CllwOreEncrypt` consume the value +/// Vitamin C's `Encrypt` and `PrfValue`, and the order terms, consume the value /// they are given, so an operation needs the plaintext by value. A /// description can be handed it two ways, and because the mode is a type /// parameter the difference is settled at compile time: diff --git a/packages/stack-encrypt/src/target/transcode.rs b/packages/stack-encrypt/src/target/transcode.rs index 62a9008c2..64552fe40 100644 --- a/packages/stack-encrypt/src/target/transcode.rs +++ b/packages/stack-encrypt/src/target/transcode.rs @@ -109,7 +109,7 @@ pub trait Visitor: Sized { /// # Errors /// /// Refuses with [`Error::UnsupportedShape`] unless overridden. - fn ore(self, _: OreTerm) -> Result { + fn ore(self, _: OreTerm) -> Result { Err(Error::UnsupportedShape) } /// An order-preserving term. One-way, and not authenticated. @@ -117,7 +117,7 @@ pub trait Visitor: Sized { /// # Errors /// /// Refuses with [`Error::UnsupportedShape`] unless overridden. - fn ope(self, _: OpeTerm) -> Result { + fn ope(self, _: OpeTerm) -> Result { Err(Error::UnsupportedShape) } } @@ -193,12 +193,12 @@ impl Reader for MatchTerms { visitor.matching(self) } } -impl Reader for OreTerm { +impl Reader for OreTerm { fn read(self, visitor: V) -> Result { visitor.ore(self) } } -impl Reader for OpeTerm { +impl Reader for OpeTerm { fn read(self, visitor: V) -> Result { visitor.ope(self) } diff --git a/packages/stack-encrypt/tests/derive.rs b/packages/stack-encrypt/tests/derive.rs index 5c4de8c96..37fcd0e01 100644 --- a/packages/stack-encrypt/tests/derive.rs +++ b/packages/stack-encrypt/tests/derive.rs @@ -8,8 +8,8 @@ mod common; use std::sync::atomic::Ordering as AtomicOrdering; -use cllw_ore::CllwOreEncrypt; use common::{counting_cipher, recording_cipher, stack_cipher}; +use stack_encrypt::sem::Orderable; use stack_encrypt::sem::{EqualityTerm, MatchConfig, MatchOptions, MatchTerms, OreTerm, TermError}; use stack_encrypt::target::{AeadContext, DecryptFrom, EncryptInto, Encrypted}; use stack_encrypt::{ @@ -105,7 +105,7 @@ struct BorrowedSource<'__source, '__source_> { /// by its source. A generic record's one-ciphertext check runs when the /// record is first used rather than where it is defined. #[derive(EncryptFrom, DecryptInto)] -struct Tagged { +struct Tagged { c: StackCipherText, ob: OreTerm, } diff --git a/packages/stack-encrypt/tests/fixtures/README.md b/packages/stack-encrypt/tests/fixtures/README.md index 94dac8de0..aec4ab1d3 100644 --- a/packages/stack-encrypt/tests/fixtures/README.md +++ b/packages/stack-encrypt/tests/fixtures/README.md @@ -199,9 +199,10 @@ runs; the frames and the terms must not. Do not edit the file by hand. Rows the `next` build stored, which every later build must still read. The fixtures above prove that two authors of one record agree *today*, and are regenerated when the encoding moves. This one is the opposite: it is written -once and **never regenerated or edited**. A change that alters the bytes of a -stored row (the leaf encoding, the AAD, a term derivation, the stored tree's -shape) fails `tests/stored_rows.rs` instead of being regenerated away. +once and **never regenerated**, and edited only as the exception below allows. +A change that alters the bytes of a stored row (the leaf encoding, the AAD, a +term derivation, the stored tree's shape) fails `tests/stored_rows.rs` instead +of being regenerated away. `tests/stored_rows.rs` checks every case two ways: @@ -212,15 +213,50 @@ shape) fails `tests/stored_rows.rs` instead of being regenerated away. order, the same terms and the same passthroughs. Nothing reads `STACK_ENCRYPT_UPDATE_FIXTURES` for this file. The one test that -writes it, `write_stored_rows_v1`, is `#[ignore]`d and refuses to run when the -file exists. The rows depend on `stack_kms::DeterministicSource` and the seed -below; both are part of the frozen contract. +creates it, `write_stored_rows_v1`, is `#[ignore]`d and refuses to run when the +file exists. The check rewrites it only under +`STACK_ENCRYPT_REFREEZE_ORDER_TERMS=1`, for the exception below. The rows +depend on `stack_kms::DeterministicSource` and the seed below; both are part +of the frozen contract. **If this test fails, the change broke stored data.** Do not regenerate the file. Either fix the change, or, for a deliberate change to the stored format, add `stored_rows_v2.json` (and a `write_stored_rows_v2`) beside this one, keep v1, and say in the CHANGELOG how v1 rows migrate. +**Order terms are the one exception, until stack-encrypt 1.0.** +Until then a deliberate change to how an ORE or OPE term is derived may +re-freeze the `ore` and `ope` passthroughs that moved, and nothing else: +the leaves, the AAD, every other term and the shape stay as written. Run + +```bash +STACK_ENCRYPT_REFREEZE_ORDER_TERMS=1 mise x --env test -- \ + cargo nextest run -p stack-encrypt --all-features --test stored_rows +``` + +It checks that every row opens as it did and that the resealed tree matches +the stored one in everything but its order terms' bytes, and only then +copies the new order terms over the old. Any other difference fails it and +nothing is written. + +The change must also say in the CHANGELOG's `Breaking` section that its +order terms changed bytes, which released versions wrote the old ones, and +that a column holding them is to be encrypted again. That matters more than +the word "exception" suggests. An order term is never opened, only +compared, so a stale one is not refused: every `<`, `BETWEEN` and +`ORDER BY` over the column silently misses or misplaces rows, where a leaf +that no longer opens fails loudly. stack-encrypt 0.1.0 and 0.2.0 and the Go +module's v0.1.0-alpha.1 already shipped order terms. The exception rests on +those being pre-1.0 and pre-release, not on nobody holding one. + +The exception has been used once beyond re-freezing: when `u8` and `i8` +lost their order term (a 256-value domain ranks every row, the leak a +`bool`'s order term is refused for), the `every_kind` case dropped the +`ore` terms of its `i8` and `u8` fields and its `i8_ope` and `u8_ope` +twin fields, which held only an OPE term. Its plan and source lost those +entries with them; every leaf, equality term and other order term is as it +was written. + ## Schema ```json diff --git a/packages/stack-encrypt/tests/fixtures/query_operators.json b/packages/stack-encrypt/tests/fixtures/query_operators.json index 26c80d601..3520eee25 100644 --- a/packages/stack-encrypt/tests/fixtures/query_operators.json +++ b/packages/stack-encrypt/tests/fixtures/query_operators.json @@ -75,7 +75,7 @@ "fidelity": "exact", "index": "ore", "operator": "lt", - "term": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a606d6d336" + "term": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d12f636d8b78" } }, { @@ -87,7 +87,7 @@ "fidelity": "exact", "index": "ore", "operator": "lte", - "term": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a606d6d336" + "term": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d12f636d8b78" } }, { @@ -99,7 +99,7 @@ "fidelity": "exact", "index": "ore", "operator": "gt", - "term": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a1256421f2eba6" + "term": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d060dbfcceb5" } }, { @@ -111,7 +111,7 @@ "fidelity": "exact", "index": "ore", "operator": "gte", - "term": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a1256421f2eba6" + "term": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d060dbfcceb5" } }, { @@ -127,8 +127,8 @@ "index": "ore", "operator": "between", "term": [ - "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a1256421f2eba6", - "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a20bc4002878be" + "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d060dbfcceb5", + "758821c792265d942f89fa1dad016fb71b326f756e62950367e6377200eec06b" ] } }, diff --git a/packages/stack-encrypt/tests/fixtures/record_lowering.json b/packages/stack-encrypt/tests/fixtures/record_lowering.json index 868b695f7..1cfa33472 100644 --- a/packages/stack-encrypt/tests/fixtures/record_lowering.json +++ b/packages/stack-encrypt/tests/fixtures/record_lowering.json @@ -47,7 +47,7 @@ "passthrough": "tenants/acme" }, "text": { - "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d7002bc0ceebce867d0c4942a2cf544f1195a02327f01117b05892d35d19348b1018e2dc2", + "c": "0100000000000000000000000000000000cf1a45d1517a08c45d6ae1c989ae99de2000da620d1dbdbba9372d1439b6d44f7cc4169e9a255387aa180d6a95fb17926d7002feb70f92eddc6cf5ca7057e55c63c0ad86f563b5fc6db163691f66a21e23a7b7e089", "eq": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" } }, @@ -59,7 +59,7 @@ "passthrough": "tenants/acme" }, "text": { - "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f807027e9874c89aa2b6dc1f7a073c6c41e7679b8380880c5daa0aff707d91b85142f688c2", + "c": "010000000000000000000000000000000048a99626f697c02001f07da2edb19f4220009868b833b6220fa0d13b612af03af1f38fee082c7555b6e7fbf8e13ccb70f80702b4c4d486351fc9e2b013a683cbc7307baba454f2c09d1d30201f2eb501294f2dab73", "eq": "d07ccccb55205dbb5c69c80bd88c907496a18bbed9317f39d96a83f6fd308a85" } } @@ -125,12 +125,12 @@ "records": { "lowering": { "age": { - "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac0228dd18e6432418924ee25f51a3859d9a22895f62ee52705e57ea1491ef1d88a5bf", + "c": "01000000000000000000000000000000004d5626148f77c4c6d9da0ddbdf7663a2200021162641133485216f389886cbd7c6e9b25afa0f5f166f985b23384e81ffcfac022c3ca025899006b277f5b901e4ce12fcd2ab92e68ed47a10c23aa44cdfa9f6956b", "eq": "cc854fd5c7e3f4bb28cb15cae47d866b65b7012c06df43b4b9a6c51adff4710a", - "ore": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a605cbf3f1" + "ore": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d12f62824807" }, "email": { - "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee028903df249ec7db270d3e5f25a72060fd12bc5174ed5776c9477df43aa7dc6421105f6090f8fd54e0287081c1", + "c": "0100000000000000000000000000000000b2afa77357a61488146c0edaa0246f65200043956c4f4bba857028c28165dfce9c63d904b738bbb039b6e11c805112d4a1ee025a27c14c007f12f51176221ffb32f484fb3d257fc1be1e63670d59473975e0d4fc268a016f4857baa18c26f9", "eq": "0cf708c07acaaa169e84e4adf7081f0433d87760bd7e9c63bd7a58c000b8d33b", "match": "030005000900100012001a001c0027002a002e003000340042004b0068006d007100750083009200a800b200b400b700ba00c200c500dc00e500ea00f200f300f900fe00" }, @@ -138,17 +138,17 @@ "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd90678496025763fabba65839aab92f515ccb053a4bfbea672c516be6a6c51f97e00cd0071ab8e7b337fd8055" + "c": "0100000000000000000000000000000000723f7a7f14b07197168aa13bc52010b3200072a73194b45af604cc6201b5a57554b05cc7ed6ad22b9102cbcb71bd9067849602fe8ea66badb24fd24bbb309e46dd4a291e1abd7f42fd162c71a169b3becfd443ed3d69b4757a97" } }, "typed_chain": { "age": { - "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad502f71ddc78cf13e463d4af42f9f837778bf5a0793bb6794a2f3fa406a8d3aa20e772", + "c": "010000000000000000000000000000000092e427c5e176b60261add6ce80fd02df20008d231453c97fdec97a0b6559ac8f509129d20163f262d40ce5d77cf1ebe0cad502498779411650f97518dc5eeacafb98acf3d9b84a7d2f1c1fdaa21d7782d7b02632", "eq": "cc854fd5c7e3f4bb28cb15cae47d866b65b7012c06df43b4b9a6c51adff4710a", - "ore": "7ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a605cbf3f1" + "ore": "758821c792265d942f89fa1dad016fb71b326f756e62950367e5d12f62824807" }, "email": { - "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482902053217eb2c4844e6700106487ec76d10b0052c05089848a1e18a003a8fc94a4b0b9b90db118ddf73eba753d2", + "c": "0100000000000000000000000000000000cf92191950409f9fe82a2b11888ffc1d2000ca4d5bbafbff919af4d3a95c7cc046d632fd4c28785b238f4b6f7ea3aa08482902bfae280fe03edea613bc16e6632ffa9364c5a5fdc25b6015c16700e5c1108c69cbe8116a5367e1751992751b", "eq": "0cf708c07acaaa169e84e4adf7081f0433d87760bd7e9c63bd7a58c000b8d33b", "match": "030005000900100012001a001c0027002a002e003000340042004b0068006d007100750083009200a800b200b400b700ba00c200c500dc00e500ea00f200f300f900fe00" }, @@ -156,7 +156,7 @@ "passthrough": 42 }, "notes": { - "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f1102bfdd18635fde4d7d775297d5e47c5b9a3ade28f51212c0bc5a2b64925f0225c006685f8db07cee" + "c": "0100000000000000000000000000000000562a41e032316ff2d1264a9e9f5c3b842000de33c8d1f2caaab8b90f691161a57eeb1328f7f1c9b5be76dec83e8a53765f1102b84037a30ef214de44076878d7c5965eaa2f555e2337bc5a0e1cf1e3e92a4cdafa23089183493c" } } } diff --git a/packages/stack-encrypt/tests/fixtures/stored_rows_v1.json b/packages/stack-encrypt/tests/fixtures/stored_rows_v1.json index 738747435..75c77df16 100644 --- a/packages/stack-encrypt/tests/fixtures/stored_rows_v1.json +++ b/packages/stack-encrypt/tests/fixtures/stored_rows_v1.json @@ -1,5 +1,5 @@ { - "_comment": "FROZEN. Records the next build stored, under a deterministic key source. Every later build must open each one to `opened` and seal its `source` to the same shape, terms and passthroughs. Read by tests/stored_rows.rs; the schema is in README.md beside this file. Never regenerate or edit this file: a change to the stored format adds stored_rows_v2.json beside it.", + "_comment": "FROZEN. Records the next build stored, under a deterministic key source. Every later build must open each one to `opened` and seal its `source` to the same shape, terms and passthroughs. Read by tests/stored_rows.rs; the schema is in README.md beside this file. Never regenerate this file, and edit it only as README.md allows for order terms: a change to the stored format adds stored_rows_v2.json beside it.", "cases": [ { "about": "one field per value kind with every index it admits, composites, an untyped field, a renamed field and a passthrough", @@ -252,18 +252,7 @@ ], "outputs": [ "c", - "eq", - "ore" - ], - "type": "int8" - }, - "i8_ope": { - "context": [ - "kinds", - "i8_ope" - ], - "outputs": [ - "ope" + "eq" ], "type": "int8" }, @@ -412,23 +401,12 @@ ], "outputs": [ "c", - "eq", - "ore" - ], - "type": "uint8" - }, - "u8_ope": { - "context": [ - "kinds", - "u8_ope" - ], - "outputs": [ - "ope" + "eq" ], "type": "uint8" } }, - "source": "f12600000004000000666c6167030200000069380cf80600000069385f6f70650cf8030000006931360ed4fe070000006931365f6f70650ed4fe030000006933320490eefeff070000006933325f6f70650490eefeff0300000069363405000efad5feffffff070000006936345f6f706505000efad5feffffff0400000069313238100000000000000000000000000000008008000000693132385f6f706510000000000000000000000000000000800200000075380dc80600000075385f6f70650dc8030000007531360f60ea070000007531365f6f70650f60ea030000007533320600286bee070000007533325f6f70650600286bee0300000075363407ffffffffffffffff070000007536345f6f706507ffffffffffffffff040000007531323811ffffffffffffffffffffffffffffffff08000000753132385f6f706511ffffffffffffffffffffffffffffffff030000006633320800000080070000006633325f6f7065080000008003000000663634090000000000000240070000006636345f6f706509000000000000024006000000616d6f756e7414000004800061bc000000000000000000030000006461791281460b00020000006174130bc9d26a0000000015cd5b0705000000656d61696c0a17000000416c6963652e536d697468406578616d706c652e636f6d09000000656d61696c5f6f70650a17000000416c6963652e536d697468406578616d706c652e636f6d0300000062696f0a21000000456e6372797074656420736561726368206f7665722073686f7274206e6f74657304000000626c6f620b040000000001feff08000000626c6f625f6f70650b040000000001feff0400000074616773f0030000000a01000000610401000000f000000000050000007072656673f103000000040000006461726b03040000006e6f6e650005000000656d707479f100000000050000006c6f6f73650a1400000061206669656c642077697468206e6f2074797065040000006d61696c0a0f000000626f62406578616d706c652e636f6d020000006964062a000000", + "source": "f12400000004000000666c6167030200000069380cf8030000006931360ed4fe070000006931365f6f70650ed4fe030000006933320490eefeff070000006933325f6f70650490eefeff0300000069363405000efad5feffffff070000006936345f6f706505000efad5feffffff0400000069313238100000000000000000000000000000008008000000693132385f6f706510000000000000000000000000000000800200000075380dc8030000007531360f60ea070000007531365f6f70650f60ea030000007533320600286bee070000007533325f6f70650600286bee0300000075363407ffffffffffffffff070000007536345f6f706507ffffffffffffffff040000007531323811ffffffffffffffffffffffffffffffff08000000753132385f6f706511ffffffffffffffffffffffffffffffff030000006633320800000080070000006633325f6f7065080000008003000000663634090000000000000240070000006636345f6f706509000000000000024006000000616d6f756e7414000004800061bc000000000000000000030000006461791281460b00020000006174130bc9d26a0000000015cd5b0705000000656d61696c0a17000000416c6963652e536d697468406578616d706c652e636f6d09000000656d61696c5f6f70650a17000000416c6963652e536d697468406578616d706c652e636f6d0300000062696f0a21000000456e6372797074656420736561726368206f7665722073686f7274206e6f74657304000000626c6f620b040000000001feff08000000626c6f625f6f70650b040000000001feff0400000074616773f0030000000a01000000610401000000f000000000050000007072656673f103000000040000006461726b03040000006e6f6e650005000000656d707479f100000000050000006c6f6f73650a1400000061206669656c642077697468206e6f2074797065040000006d61696c0a0f000000626f62406578616d706c652e636f6d020000006964062a000000", "stored": { "map": [ [ @@ -507,7 +485,7 @@ [ "ore", { - "passthrough": "0b2000000051f9fb996306027466334c237bd7a8c6ab5b04421a932ada6f2ef79378f345af" + "passthrough": "0b280000008bb663d8b4c006682148c9277bc5e57d411fba769c6414c115d538247d2949371e0c5aad377dc8a7" } ] ] @@ -520,7 +498,7 @@ [ "ope", { - "passthrough": "0b21000000009b7d398476273c0e0fcc3b2d512061e96e7336f5b6bceaa921834689dbafbd38" + "passthrough": "0b290000000060d402259cb39497228b893f5e0a87a0c7800b2a0fa773b681dea30ac939d570cd945da2ec1f7c44" } ] ] @@ -570,7 +548,7 @@ [ "ore", { - "passthrough": "0bb800000052268c1cf18a81131055a110a94f753161ea37a0db2ab876588e9a70eb85232215f764da1f0d69a68fa309b6086a8a19df3dcf5b9fdc82b8fc3627da70b772ac248d3fa4cae4259842ac1c6665b3ddb7a007859bb9c17f7016e85d1c120f7a5f71fde502a94368f42f2382772534e4418a0b9cdbed2ce46cb9003a5b55196641ff15d4a64c3c84bc7d7694da9219dd48f56a342486e7653972022d55dc5768cb29aa45d0662c4b5eb00836a0eab19383d3d06df9faa2ceb6" + "passthrough": "0bc0000000e4596b725e0c386758533cd6e735152740b9af9bd6e96019ac18a724e079479d43caad7e83ce14f737ca246ae6ba5ad0c0a2e5f7f0a2101c1831bc6aee4ccc2a22292816e36cc8938536d59d689be229b0d0210e0354900ba200b04f93545a06098d2edb7531e590da3dc5cd393261d54f60574a127e198bfc752099b1cfd5f4fe44666963b4e5b3e0c8e7623c8eae478d05c0513e5554be89ca7f1248fd52e23e23974da8052807e49f4782d6d42e0d5690c37d1242ba62fdc4200434be1593" } ] ] @@ -583,7 +561,7 @@ [ "ope", { - "passthrough": "0bb9000000003010e850c93e355bec0b11db704144bf0bafc8789dcf590803109d6dcaa33515aae01c477bd7f5a48d84c73b27dcf448471be3692b2cc4c5c3baa2c2ac4e29ad83c5f7b7a1ab2d40e24ad699eb65b515178be45038d03f4e1fb3e49668cbb19fbbe7a2d1e1239e82483d13a17f9656b8c191b6de1b0a6470011d857ac3cc143a697a4a936c18cb1df7c4b5cc0bffdca00bdeed00d9cbda36b57011adef25c5a672293f4b0d76c33dc5a58161bf66da2b5a7ff913434c7ecb" + "passthrough": "0bc100000000849552987a983ba140e5c8fa0eac802885238b8fcd80f3e55e95f286f24ba7b93c67dfc7501c82b3bad61452b7a8e3b1adbeb99526f990ea8f8e0570666ba91ce7be424ecd14712dd2f96518e26605f14440805a241dd14b28497fb3ed6cf0bfabab60848d2871bdb87377deae6a313c0f6485ce91f103c7f309044f8cf03a3a8aca48dce15500126d1dd2de74674f6e11fc02cf46075ef6c6f09d8b797ade427d3bbd5807e3e066dfee5a3eff31b6698c5a6e6640d70987838d373c39ce7df3" } ] ] @@ -608,7 +586,7 @@ [ "ore", { - "passthrough": "0b20000000b235cf31425d3b531548ece8605764ff9315dd9038109483ed3d5e377e9827e6" + "passthrough": "0b400000003a83168a330a190579e7a0dce23066fc5bf762b1b10a0fd4b5add6be7f76c09f027a39deaaf81c35050751ef258433a5e79bb0835ffa705b1660cf0439ae4813" } ] ] @@ -621,7 +599,7 @@ [ "ope", { - "passthrough": "0b21000000008500cca79cd991989af723ba7dba6c1aea29c50460f7ed8bece3f6b801ef86d7" + "passthrough": "0b41000000010713d30a44fad510839aa637489c19b56062ffb692d964261640079e31e321728559526c51454b6d827518b8b47c64f9875b6864e62edc62e328d0e0598f1f10" } ] ] @@ -646,7 +624,7 @@ [ "ore", { - "passthrough": "0b40000000ffc6166b247d1ea90e9e90e544f1cf683e777e2aa56d93153bd0c5122c833f7043b6d3267699feab54ed164a3c8b9da70dde8c7d8464f66060d401c8cee442aa" + "passthrough": "0b40000000bdcc3110154be791c3148934d9a7b2bb1082581099dcd46dd44719e6db2b192cb455bec8ee6c2632e0e1b1b322fe419ba72a4c34aec7a543fd93e67fd553f763" } ] ] @@ -659,7 +637,7 @@ [ "ope", { - "passthrough": "0b4100000001370b769ffb76173b05045d1ddd8d634f23142e2023dc56ff3c40883f637c1dd5807b53233e10555b5bb4d1a27552a78e4c616381933da99825b21877b83ed908" + "passthrough": "0b4100000000c8228ef43865b325619e4992b56db2acd73e6305a23db7b01c1087c865c6a921ff4b8439def81446aae4e1852ed4e38bbe194701b5d2924ac50c9407f3159a52" } ] ] @@ -697,7 +675,7 @@ [ "ore", { - "passthrough": "0b8000000006567865f72c0a46370aae0a756c736b2a18979c0232b917740f91056a0cc7ba48547974e002366e68c872563c72da66f02e8c1cc643b47fc44fa0eb2a53105a22a29323d35ff1e1fef291a091a405b879ab48135d24096f5727a04201587346de23f7f810dadc0da135e0ab779a0dab82c75d23a37431932e7e6f921b84e88f" + "passthrough": "0b80000000515b9aa9f37d24e2f041da7e424739cc232b81f85e6c6ef07829cea2434ff599f0d83ba6cad9cd71909d8b6d5bbe3bfcb61a234edac0b41ff728e98e9ab633f1d6ccecf4aa5ab288e74eaddd2b899c8952a025f7097117654c3d0eadd7278f5bd770689692b810f1c0de8d2f7ad9def60dc124d78f5360ebd2aee7a028aa4c0d" } ] ] @@ -710,7 +688,7 @@ [ "ope", { - "passthrough": "0b810000000008a30551ee81277ce529822e21f1e24777e1b362bd29ffd76140c5f14e2ff01497d8d6bf0ab77ed4546f4abf5c206e8853b2b07944d9d7fb4ed56853cf62b278cb2670fef07afba0b1ba0e3f446003c550f8f68ad8582f6687ccdf371ce64fff2164ef4d6b3386fc81782002e0568e943cf4269bd11c85cbdc55ac14db4cbd69" + "passthrough": "0b81000000007f3e0dbb3cb4371fee47438dd52f3f96c5395a75a8c6f97500af1feae03fc587960db5137bc84733c3e26c6cece4572b5068ffbd237b7959edd8b1a7ca5d317ff1869f73b29e157eeb4a0e12cc20f148c2f0f9fd00c127fe6e392f047f0f4ae84d5f03c29340cce05cd83e9baa7b5e78d4e0933ab9c943ffe49d62d5e6ba8727" } ] ] @@ -735,7 +713,7 @@ [ "ore", { - "passthrough": "0b10000000c2212cd5c1ede4ea7b7503b780bf43dd" + "passthrough": "0b10000000be1fd669d8a2cae089e62262c8f163f8" } ] ] @@ -748,7 +726,7 @@ [ "ope", { - "passthrough": "0b11000000009a7731a91f28ae2a8f95b4c8181f9634" + "passthrough": "0b11000000003f039dde79c7dd4ccffd191d5544c2ed" } ] ] @@ -773,7 +751,7 @@ [ "ore", { - "passthrough": "0b200000007a584353e40c5682545609a28ac4e0b45cda041d779c3903a596507e3362efb8" + "passthrough": "0b20000000f366ce2845fadc799d60dd73190baaa1e79444a558aeb2454490483c76cdfc01" } ] ] @@ -786,7 +764,7 @@ [ "ope", { - "passthrough": "0b2100000000afc44ce1e29dbfdd9247012b3dd70cd3ec24cae7ad00748d204385d7193e781f" + "passthrough": "0b2100000000c61f2fc35629dcad1b5b22bd86c54a908409b2921d7362d2fe21c2a93c604916" } ] ] @@ -811,7 +789,7 @@ [ "ore", { - "passthrough": "0b40000000f6761e66f13b75de472d8d3db373accdf369ee8bb2858c776a06cff11941fd287b0ba85d05056ae39de8d8b2999234fc8e30f52b1205f9519d0c4d3b1df34cec" + "passthrough": "0b4000000076f1db359db6b05234937ef37330e9b839c9b5653d16602b7871b1277981065ade1de6ee1ce10dd832f0affcd3b92ddaf75e1db9c619ee8cea18cd89425113a4" } ] ] @@ -824,7 +802,7 @@ [ "ope", { - "passthrough": "0b41000000005749011aaebebaf2fdb75120462782071e00dd1428bb8ef390eb0ba3bb74c2b921d52e9d1540481a01530d8a4616f1baf72a849478d616436d7a92d424ca4397" + "passthrough": "0b41000000006e4cb9aaf3e0ed8761ffd0b74ccf91aeabfccabc8f5fc8b4deb29d757dcaba7c063006eb92a5b572dc86349f270ab3e1d3f08bd4372d8660f37ea5725d7a159e" } ] ] @@ -845,25 +823,6 @@ { "passthrough": "0b200000009f80423babd798ac93d1f90db4c766dfbe9259da381a6e88fbdb441e42331603" } - ], - [ - "ore", - { - "passthrough": "0b08000000b873d3e0bb8417cf" - } - ] - ] - } - ], - [ - "i8_ope", - { - "map": [ - [ - "ope", - { - "passthrough": "0b09000000002875f23ff30bdb45" - } ] ] } @@ -987,7 +946,7 @@ [ "ore", { - "passthrough": "0b80000000fc84ea45fe67d35e163986a55138fb0eba1680a970048f1c2ce94d3a3ed122452a820d8c653b456015c7b9efb7194aa607539491a300b995e7c06b111e44272f58142355ab7e408aefb616150e31137f8c5d52e7ee45e6d3af2943e17f25a4fe26d97e68769175ab0cf25c53148c1de3e1ee7b4ba94f293303c56878dc9f1e6b" + "passthrough": "0b800000008e3c2cd1cb101f207b6ff8e0c736294fd32c7c9d8a27532cb8629d31d923c9b04e43c711c3e6a7a25758e6a2e957743d407c29a0944421a3d9e259d0b180a019c008e9f339a6db877ba8245ea8f3964db0f4f866e8f19cc0d6351e772aebe54d2ab6fe33efd6f8a0655edc64f1d1c6263294bbb39e27fda896aa1c09273afa30" } ] ] @@ -1000,7 +959,7 @@ [ "ope", { - "passthrough": "0b8100000000a47693ef64592b45a3d873a81cc890dae5fe677e64eeff21ef4d0f1a9a50cd2c027cd8c5b8a74f7686808f775f20dfeccb4710109ac06fdf334563173c66f5290fb7f157422456f2c9c27bcbf6a60072c39094bedad89a6edc441215c04c4fe17d2dd2c61fcbe20f8de1a264d3cfca038c15bb0b51e68b9ea37a0882a265b3b9" + "passthrough": "0b8100000000dc8f1d8a4159d905191e10403e38ba67be0dd69bc3f15471400e72115d4224f61674727ab8bac56ad6e99f309ba95f8886fa085f81e5647f2aeb5a5d2142fa087fee560c7d8df14bf85699eb34236cd8b2e6fc83f2ed7b6dd17f05978ad09bdeb6ccf1efc1afb975ccd49fa52b00b844bee0341b115f03b8d427cfe9399320b8" } ] ] @@ -1025,7 +984,7 @@ [ "ore", { - "passthrough": "0b10000000ff08042d8844226efea5c4bbaa6181eb" + "passthrough": "0b10000000a082c2300d1f28ce56a8d1388100342a" } ] ] @@ -1038,7 +997,7 @@ [ "ope", { - "passthrough": "0b11000000011c864badb10f850d5dc52b90865ea428" + "passthrough": "0b1100000000fd45295f100d24e193e7c3b4a3afe6a4" } ] ] @@ -1063,7 +1022,7 @@ [ "ore", { - "passthrough": "0b200000008b9d5a7b634254ea905f0f5cb2f2294c497cc3d29a53ffe146447b50dcaa073a" + "passthrough": "0b20000000c3d8d9e15a3c67b2e7b050406e424c86c4427ddc55cd7e94beccf06d4e1aa3af" } ] ] @@ -1076,7 +1035,7 @@ [ "ope", { - "passthrough": "0b210000000093929397b533ba2c392e0a8148bf340d71b1fd4fb38516d07706f253b24764ad" + "passthrough": "0b21000000013910babdb9df995bf6cd8b8c3809ff96c20ce7272170cbf4a2e6f192df48ae94" } ] ] @@ -1101,7 +1060,7 @@ [ "ore", { - "passthrough": "0b40000000965ad9f9dddb3827c7435a3f4864a9a8af171aaebc85abd88c524786bac7740b2665c97dbb91ddee07007c14e784ad8b8738426f6df362f960631071b7329fde" + "passthrough": "0b4000000024581d4b396284a673cc04e73d565077e3f6a56e9c178e95fc41a8665e910c0a1ece9482a38b82beee4d17d8b82639cd68390dc14fc03900f57f47023ebcdb36" } ] ] @@ -1114,7 +1073,7 @@ [ "ope", { - "passthrough": "0b410000000126a7992cb1a73bf01d5581e2c4dcab8b9a52b537b5a82edd4652fa22ad4af98a7499db8056abbd07774010275c29e41c06cae08045a12624380df4a1c55cbb58" + "passthrough": "0b4100000000c65d9da62cb7b3a4841db8ff0fa6304ab8117e40683d26f02557379f122be04b674dafce8618496ee2fe39aa3b5d3ad12f1d8c2230d100078aa11defc16e0867" } ] ] @@ -1135,25 +1094,6 @@ { "passthrough": "0b2000000079e2262e4913dcaecfb93743265ace2f57b8223c63d20e52d300c8a30ae80996" } - ], - [ - "ore", - { - "passthrough": "0b0800000001adb777c7357e19" - } - ] - ] - } - ], - [ - "u8_ope", - { - "map": [ - [ - "ope", - { - "passthrough": "0b0900000000f5af8a700c827c3e" - } ] ] } @@ -1295,7 +1235,7 @@ [ "ore", { - "passthrough": "0b20000000e0df5ac54c0e89fa91eb3d81c61449eb26650aba19cd9f1d9c021b03cf221bf0" + "passthrough": "0b200000003b66bacffee6b5c0633c4623c3845f7344545bb4c7d02ecaa5bcd9b5f87eca41" } ] ] @@ -1394,7 +1334,7 @@ [ "ore", { - "passthrough": "0b20000000ac88c6c7cbda6e2bea0cd07c71e5da98fb23b248d67d1c62cbe0f691515d251e" + "passthrough": "0b2000000031a055cf0ff7e82d73fc9711b26536ea18f8ea4be2328cd0c3b17c94b3d39c9d" } ] ] @@ -1481,7 +1421,7 @@ [ "ore", { - "passthrough": "0b200000001ba30ad18bffc4dce5aec368d8a30e1b4434a4bb00c6e420489ed61821cdbfa1" + "passthrough": "0b2000000056a202f641a9a81c5f9c179ae587fa6fb846342ba97fe605bc3bb4d55068e23f" } ] ] @@ -1577,7 +1517,7 @@ [ "ore", { - "passthrough": "0b200000007ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a12564220fa118" + "passthrough": "0b20000000758821c792265d942f89fa1dad016fb71b326f756e62950367e5d060dcbc3be9" } ] ] @@ -1631,7 +1571,7 @@ [ "ore", { - "passthrough": "0b200000007ca54e16d7944c0a9d8577d87f3864fdf79e99fea5956ba956a126a76c4152a1" + "passthrough": "0b20000000758821c792265d942f89fa1dad016fb71b326f756e62950367e5d130a8115eff" } ] ] diff --git a/packages/stack-encrypt/tests/fixtures/value_kinds.json b/packages/stack-encrypt/tests/fixtures/value_kinds.json index 3ff12c537..fc98e70b6 100644 --- a/packages/stack-encrypt/tests/fixtures/value_kinds.json +++ b/packages/stack-encrypt/tests/fixtures/value_kinds.json @@ -70,8 +70,7 @@ ], "outputs": [ "c", - "eq", - "ore" + "eq" ], "type": "int8" }, @@ -106,8 +105,7 @@ ], "outputs": [ "c", - "eq", - "ore" + "eq" ], "type": "uint8" } @@ -130,46 +128,44 @@ ], "record": { "amount": { - "c": "01000000000000000000000000000000007a4428d606d70760a6ab09137b7998952000aa5c98df7b5db708dec28195dbfa196953ce941a9404e7c315e14856139917c7029249c4c84bb9f1be5e99fff5bc199c340d0ebf56f8ae0ecf6394499f4d38494f8180fe0ed37ea53d50de4aab18", + "c": "01000000000000000000000000000000007a4428d606d70760a6ab09137b7998952000aa5c98df7b5db708dec28195dbfa196953ce941a9404e7c315e14856139917c702fa91de3a39604b362861966bda65a4e16e141bc7046d59627ff4f9e9c24114cca0afcf08713a2d350e1c06ebf6", "eq": "93a9395ae5d9581921a3fc2cdd9e0f3cc731d37e8f39f516f3dc3e3a6f0e8514" }, "at": { - "c": "01000000000000000000000000000000003df5051c44f189118ff062fc589189362000c144eccd9336ad2098131bc1ccebd36780eafd132f5d9a7900964b441fbedd240282c8968473ccbb1b6246cb787cde5551260d4f231028319ed4605d103e55f4224b4ad1741612075748", + "c": "01000000000000000000000000000000003df5051c44f189118ff062fc589189362000c144eccd9336ad2098131bc1ccebd36780eafd132f5d9a7900964b441fbedd240238762eca5b766e057a875482b286a420c4df7d043545869c76ca56f829638f723e412b58915cb8a63e", "eq": "448bc4271a535ec475e50404c800746fb71cbac46567396bc0873546e5498f4a" }, "date": { - "c": "010000000000000000000000000000000081a52593eddb4ccba009332c41f7173d200048bbb91aa1c0c890d54de6fcd6b404e91d6a8272390ab807c78820543d49ef860210b75bdc7c68980614ed2aa7d3d36c1a6d9c2a075ce309cc58801391318ddc4ce1", + "c": "010000000000000000000000000000000081a52593eddb4ccba009332c41f7173d200048bbb91aa1c0c890d54de6fcd6b404e91d6a8272390ab807c78820543d49ef86025f781af6120f255debe98eaf4c53b4d5915d3702e8b97562b5e3f10ee2fea810ff", "eq": "03226ad4425267b31f8316c513d7155cfc2fd33bbf8992d14ea26cfc64311481" }, "i128": { - "c": "0100000000000000000000000000000000f5bdd6ce34bc69330093fa760c6f6d8120001a9f80b14abd281d94e5139ba66a8744314abdde415fa182d4ad961c4dbf1dc0023029b1f06c2403c58d5b7d1f6f4c296baf4121c271fdc53db86b5fbaa4f81a713d081c46dae6848160e6621764", + "c": "0100000000000000000000000000000000f5bdd6ce34bc69330093fa760c6f6d8120001a9f80b14abd281d94e5139ba66a8744314abdde415fa182d4ad961c4dbf1dc002a391e960daff92e0968960819cd41c2f8ef67887f8395e3d0aae1e968d74bf8af5c7e092e7461e332b019c8eed", "eq": "19fb32f017fe269cb2703fe732acd8c1673e49174e5492ea8c372c487d08ce2f", - "ore": "783dd7032ee6444ff6f81cb78e4fa0933129b9ebfbb2dd85da80b1b97ceebfe9a33856f15922af174171bb344d433548eee69d2a4eff29468ada896662860f75cde27d55eabe18cba31b8fe353621dae1863c334694457e545c89761e79a6b668d51ed29455402bcf7c552cdf2da0d68a477e0f0fa702ad07175c6a2e0ea9105" + "ore": "eed82f673c37aac97130e682bffa8fedda8f50a6adbaaf22b995fe84050870ce82de1dc185d9280cab61088b382a6d297ace288376e87b222ba9580bc9235643c35333131a01da66205fc770bbfa5fd84171034d75c4952231c1652cd0b8545c80a55907f2813cf4330528bca3cca7efce20ebe69eec29ec5bf6439c236d69c8" }, "i16": { - "c": "0100000000000000000000000000000000b1739f40d4486c2af05aac240fae06e72000a9313873aaecad5b7a40ed810a0dd870304d8fb8d4ea88949606dce6384dbb66024260f0ca7dfe4e060246508a8aeb2a7149abf78353638b1d9ddc45e0847f9a", + "c": "0100000000000000000000000000000000b1739f40d4486c2af05aac240fae06e72000a9313873aaecad5b7a40ed810a0dd870304d8fb8d4ea88949606dce6384dbb660293761ddd2d762e68068488cec8f049f106623550927d770552f9a2c3610508", "eq": "b902e228c35b328eef12f0b8df397d48ee63b81e944e3dfb0855b6006426e012", - "ore": "e189d1334cff1530c141420fa0b33e72" + "ore": "83bcefa8b71d264f84ac4f346c9427ae" }, "i8": { - "c": "01000000000000000000000000000000005151a6cfdd1a51bf3a1ea0e85de41428200008a7fb000a5365b240cb064def02dedb57434897a756ae6216259ae26419f45d024ca84dd9619251c9cd068d104083a2b263a4c03dd3733d48476292e080f9", - "eq": "15e37adf2fa39067953df8988ef98fb5ca354599495b23230133d525e62e6768", - "ore": "1b6736b6df86ea70" + "c": "01000000000000000000000000000000005151a6cfdd1a51bf3a1ea0e85de41428200008a7fb000a5365b240cb064def02dedb57434897a756ae6216259ae26419f45d02da466dd320e4afd377fc28edda0282feb6be40f896dbd6334bcb881925d0", + "eq": "15e37adf2fa39067953df8988ef98fb5ca354599495b23230133d525e62e6768" }, "u128": { - "c": "010000000000000000000000000000000095b0c11aba5068ed6e8b76bcf85967a92000b74f8acb3cdefa94b9fdea95725157aad875dfdb1f567c293638e84dc7872c42024d404151d9ca6ff6ffc96245f666720772db73084409bfd1944c18e47b8c0fe7c1d5a5e97025724c6051eb2792", + "c": "010000000000000000000000000000000095b0c11aba5068ed6e8b76bcf85967a92000b74f8acb3cdefa94b9fdea95725157aad875dfdb1f567c293638e84dc7872c42022d2c6fa96ddcaa15ec1b96f7e23807d47595491c0bb036791048ec4eb99fe41a7c7958b707ed9c284896af5e82", "eq": "761458346368c5f42c19a00ec9d8849021dfb28d6d6768c75d4290fef986bb9f", - "ope": "012b2caf3024863459aebebd33b7e06cf4d8086a9e518be0d2786a31ba395ed49df105bf41bfe8e174640afb950744f5183cdbd3ac76faa1e5044ae71b4bf1422b8adaa0d7f4bac919b177e85988dc6519df554315eedbf3b49f7bf9151921d8ee6b1e5231137425a47f392a841157ed1b30c2031e5c47426274f3f43551d77d72" + "ope": "00deb76379b2d69b53e21b4468419eec6f1fd3a6338232d3f8acfb94779c95e9751d084cc759f92744b2389e1fc60b93ab3566bf061a00a9dd103329c5ad003628129935ab04d1b586ec506e986f4529f2b33f339b22aa8355971814735f9ebd6d48965546739e193cdfd5b0c6d0f550049f552698b39bbea76fc03527d775a28e" }, "u16": { - "c": "0100000000000000000000000000000000c40545f440c7f3e3f81f3fa4bbdc13152000c1aa04095f495b7cf8e1022d2e73a5d653616c157249914dc212e5c2c9eed16402c6eab4016f01dfdb0b4a034198115172c9dedd1b0e361a7a1140ec1b92e3c2", + "c": "0100000000000000000000000000000000c40545f440c7f3e3f81f3fa4bbdc13152000c1aa04095f495b7cf8e1022d2e73a5d653616c157249914dc212e5c2c9eed16402aed0aa5806118a7ab9278e6e621f621b8daf665c3f97cc112a6a97c8d234e1", "eq": "f9ec131d5dcf5739ac3a928108e1b50fc51e9f87bcd92f9e6e052b19b5f6b86d", - "ope": "013a9c12f564527ffacb40938ae8606cf5" + "ope": "008dd5395c0f5a63bc69d2c01d8ea32e3a" }, "u8": { - "c": "0100000000000000000000000000000000bf3b588776242c390761ddd7097e0ae22000fd2f9eb1bb2712deff3805ba2af81cb31ea5c1fc8ef8e0f058c256e3b170e5d3025acd5d70a0b3dced2f0dcbf999e156203d4eb1b33e2a8f8328a2b68d8260", - "eq": "ce2278b04c84191451e85ffcbe67f1e7c8585f86583cdbcef68d2dd466fa7b0e", - "ore": "43e194aefba0b27c" + "c": "0100000000000000000000000000000000bf3b588776242c390761ddd7097e0ae22000fd2f9eb1bb2712deff3805ba2af81cb31ea5c1fc8ef8e0f058c256e3b170e5d302af00af685f24c50ef4e7df040a92e4aa5d5093567923e759dce513e57116", + "eq": "ce2278b04c84191451e85ffcbe67f1e7c8585f86583cdbcef68d2dd466fa7b0e" } }, "values": [ diff --git a/packages/stack-encrypt/tests/frozen_bytes.rs b/packages/stack-encrypt/tests/frozen_bytes.rs index 12efc2303..12c629c1e 100644 --- a/packages/stack-encrypt/tests/frozen_bytes.rs +++ b/packages/stack-encrypt/tests/frozen_bytes.rs @@ -470,7 +470,7 @@ async fn ore_term_encoding_is_the_raw_cllw_bytes() { // differ (see the `sem` module docs). assert_eq!( hex(term.as_bytes()), - "1ae5f8558dc2d7dddd6c5b714e9d285586a1b8390d9140421e78906cba1bd651" + "b8291f846d8028ad4e3a67ef95e541ca3c6d1a2b145f49af3135876ae75c5d7b" ); assert_eq!(term.to_bytes(), term.as_bytes()); assert_eq!(term.as_ref(), term.as_bytes()); @@ -496,7 +496,7 @@ async fn ope_term_encoding_is_the_raw_cllw_bytes() { assert_eq!( hex(term.as_bytes()), - "00837615a1ea2fdcbebf7efe34cf4d2ee432c7eeff84fbd72e1bf05efa2338033c" + "00d7a25b801c909f3cb9d177315ba4da7802b9e62eba8c07aab2ad9d64e1a0cfa7" ); assert_eq!( OpeTerm::::from_bytes(term.as_bytes()).expect("decode ope term"), @@ -510,7 +510,10 @@ async fn ope_term_encoding_is_the_raw_cllw_bytes() { #[test] fn ore_term_from_bytes_rejects_wrong_length() { - // u32 → OreCllw8V1<32>: exactly 32 bytes. + // Every order term is cllw-ore's variable-length ciphertext: ORE is + // one byte per plaintext bit (a multiple of 8, a u32's 32 here), OPE + // that plus a leading carry byte. The length checks are structural: + // the type parameter names the source, and nothing in the bytes does. assert_eq!( OreTerm::::from_bytes(&[0u8; 31]), Err(TermBytesError::MalformedCllwCiphertext(31)) @@ -519,7 +522,6 @@ fn ore_term_from_bytes_rejects_wrong_length() { OreTerm::::from_bytes(&[0u8; 33]), Err(TermBytesError::MalformedCllwCiphertext(33)) ); - // u32 → OpeCllw8V1<33>: exactly 33 bytes. assert_eq!( OpeTerm::::from_bytes(&[0u8; 32]), Err(TermBytesError::MalformedCllwCiphertext(32)) @@ -528,9 +530,9 @@ fn ore_term_from_bytes_rejects_wrong_length() { #[tokio::test] async fn variable_length_ore_and_ope_terms_decode() { - // String sources produce variable-length CLLW ciphertexts (8 bytes per - // plaintext byte; OPE adds a leading carry byte) — their decode path is - // the length-validating TryFrom in cllw-ore. + // Text is encoded behind cllw-ore's one marker byte, so `alice` is six + // plaintext bytes: 48 ORE bytes, and 49 for OPE with its leading carry + // byte. The decode path is the length-validating TryFrom in cllw-ore. let cipher = cipher().await; let keyset = cipher.default_keyset(); let ore: OreTerm = "alice" @@ -538,14 +540,14 @@ async fn variable_length_ore_and_ope_terms_decode() { .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .expect("ore term"); - assert_eq!(ore.as_bytes().len(), 5 * 8); + assert_eq!(ore.as_bytes().len(), 6 * 8); assert_eq!( OreTerm::::from_bytes(ore.as_bytes()).expect("decode"), ore ); assert_eq!( OreTerm::::from_bytes(&ore.as_bytes()[1..]), - Err(TermBytesError::MalformedCllwCiphertext(5 * 8 - 1)) + Err(TermBytesError::MalformedCllwCiphertext(6 * 8 - 1)) ); let ope: OpeTerm = "alice" @@ -553,13 +555,13 @@ async fn variable_length_ore_and_ope_terms_decode() { .encrypt_into_with_context(&keyset, nonempty!("users/name")) .await .expect("ope term"); - assert_eq!(ope.as_bytes().len(), 5 * 8 + 1); + assert_eq!(ope.as_bytes().len(), 6 * 8 + 1); assert_eq!( OpeTerm::::from_bytes(ope.as_bytes()).expect("decode"), ope ); assert_eq!( OpeTerm::::from_bytes(&ope.as_bytes()[1..]), - Err(TermBytesError::MalformedCllwCiphertext(5 * 8)) + Err(TermBytesError::MalformedCllwCiphertext(6 * 8)) ); } diff --git a/packages/stack-encrypt/tests/heap_hygiene.rs b/packages/stack-encrypt/tests/heap_hygiene.rs index afa77c8db..0908d3b25 100644 --- a/packages/stack-encrypt/tests/heap_hygiene.rs +++ b/packages/stack-encrypt/tests/heap_hygiene.rs @@ -27,6 +27,7 @@ use std::sync::Mutex; use common::frames::{frame, object}; use stack_encrypt::dynamic::{query_term, record, verify, NoTargets, OpSpec, Scope, ValueFrame}; +use stack_encrypt::nonempty; use vitaminc_aead::plaintext::{Physical, Raw}; use vitaminc_aead::Frame; @@ -274,6 +275,39 @@ fn a_query_term_leaves_no_plaintext_on_the_heap() { } } +/// An order term takes its plaintext by value, so the engine owns the +/// `String` or `Vec` it is handed (a clone of the caller's, on the +/// target path) and must wipe it. Both paths hand a bare one; the engine +/// wraps it in `Zeroizing` on receipt, before anything can fail. +#[test] +fn an_order_term_of_owned_text_or_bytes_leaves_no_plaintext_on_the_heap() { + let rt = runtime(); + let cipher = rt.block_on(common::stack_cipher()); + let keyset = cipher.default_keyset(); + // Each plaintext is built before its watch: building one can free a + // partly written copy, which is the test's own residue, not the term's. + let text = marked("order"); + watched("ore_term (String)", || { + rt.block_on(async { keyset.ore_term(text, nonempty!("users/surname")).await }) + .unwrap(); + }); + let text = marked("order"); + watched("ope_term (String)", || { + rt.block_on(async { keyset.ope_term(text, nonempty!("users/surname")).await }) + .unwrap(); + }); + let bytes = marked("order").into_bytes(); + watched("ore_term (Vec)", || { + rt.block_on(async { keyset.ore_term(bytes, nonempty!("users/blob")).await }) + .unwrap(); + }); + let bytes = marked("order").into_bytes(); + watched("ope_term (Vec)", || { + rt.block_on(async { keyset.ope_term(bytes, nonempty!("users/blob")).await }) + .unwrap(); + }); +} + #[test] fn verify_leaves_no_plaintext_on_the_heap() { let plan = plan(); diff --git a/packages/stack-encrypt/tests/index.rs b/packages/stack-encrypt/tests/index.rs index 199ac6346..2b240598b 100644 --- a/packages/stack-encrypt/tests/index.rs +++ b/packages/stack-encrypt/tests/index.rs @@ -753,26 +753,18 @@ mod given_encrypted_terms_as_a_target { } } -/// A boolean has no order term on the typed path, as on the dynamic one -/// (`dynamic::admits`): cllw-ore would order a `bool`, and its two values -/// would split a column into its `true` and `false` rows. Nor has it an -/// equality term. Both are refused when the term is derived, before any -/// key is minted, through the index, the typed term and a plan alike. +/// A boolean has no equality term, on the typed path as on the dynamic one +/// (`dynamic::admits`): refused when the term is derived, before any key +/// is minted. (It has no order term either, which is a compile error: +/// `bool` is not `sem::Orderable`, so `OreTerm`, `OpeTerm` and +/// a plan `.with::(Ore)` do not build.) #[tokio::test] -async fn a_boolean_has_no_order_or_equality_term() { +async fn a_boolean_has_no_equality_term() { use stack_encrypt::sem::TermError; - use stack_encrypt::{EncryptInto, Plan}; + use stack_encrypt::EncryptInto; let (cipher, generates, _) = counting_cipher().await; let keyset = cipher.default_keyset(); - let ore_term: Result, _> = true - .encrypt_into_with_context(&keyset, nonempty!("users/flag")) - .await; - assert!(matches!(ore_term, Err(Error::Term(TermError::NoOrderTerm)))); - let ope_term: Result, _> = false - .encrypt_into_with_context(&keyset, nonempty!("users/flag")) - .await; - assert!(matches!(ope_term, Err(Error::Term(TermError::NoOrderTerm)))); let equality_term: Result = true .encrypt_into_with_context(&keyset, nonempty!("users/flag")) .await; @@ -780,29 +772,6 @@ async fn a_boolean_has_no_order_or_equality_term() { equality_term, Err(Error::Term(TermError::NoEqualityTerm)) )); - - for index in [IndexSpec::Ore, IndexSpec::Ope] { - let refused = match index { - IndexSpec::Ore => { - let plan = Plan::context("users/flag") - .with::(Ore) - .build() - .unwrap(); - cipher.encrypt(&true).using(&plan).await.map(|_| ()) - } - _ => { - let plan = Plan::context("users/flag") - .with::(Ope) - .build() - .unwrap(); - cipher.encrypt(&true).using(&plan).await.map(|_| ()) - } - }; - assert!( - matches!(refused, Err(Error::Term(TermError::NoOrderTerm))), - "{index:?}: {refused:?}" - ); - } assert_eq!( generates.load(Ordering::SeqCst), 0, diff --git a/packages/stack-encrypt/tests/op.rs b/packages/stack-encrypt/tests/op.rs index f6c6fd8b7..569054205 100644 --- a/packages/stack-encrypt/tests/op.rs +++ b/packages/stack-encrypt/tests/op.rs @@ -1107,48 +1107,28 @@ async fn a_between_query_is_one_term_carrying_both_bounds() { assert!(typed_range.lo() < typed_range.hi()); } -/// A range whose low bound is above its high bound holds nothing, so it is -/// refused when the query is awaited, before either bound's term is -/// derived and before any keyset is loaded. +/// A range whose low bound is above its high bound is not refused, as +/// SQL's `BETWEEN` is not: both bounds' terms are derived like any other +/// range's, in the order given, and the range matches nothing. #[tokio::test] -async fn an_inverted_range_is_refused_before_any_term_or_keyset() { - let (cipher, loads) = loads_counting_cipher().await; +async fn an_inverted_range_is_a_query_like_any_other() { + let cipher = stack_cipher().await; let plan = Plan::context("users/age") .with::(Ore) .build() .unwrap(); - let refused = cipher + let inverted = cipher .query(op::Between, &(65u32, 18)) .on(&plan) - .keyset("tenant") - .await; - assert!(matches!( - refused, - Err(Error::Term(TermError::InvertedRange)) - )); - let refused = stack_encrypt::all(( - cipher.query(op::Lt, &40u32).on(&plan), - cipher.query(op::Between, &(2u32, 1)).on(&plan), - )) - .await; - assert!(matches!( - refused, - Err(Error::Term(TermError::InvertedRange)) - )); - assert_eq!( - loads.load(std::sync::atomic::Ordering::SeqCst), - 0, - "refused before the named keyset is loaded" - ); - assert_eq!( - TermError::InvertedRange.to_string(), - "the range's low bound is above its high bound" - ); - assert!(cipher + .await + .unwrap(); + assert!(inverted.lo() > inverted.hi(), "the bounds as given"); + let one = cipher .query(op::Between, &(18u32, 18)) .on(&plan) .await - .is_ok()); + .unwrap(); + assert_eq!(one.lo(), one.hi()); } /// A field of a fields plan answers the orderings over its declared index, @@ -1265,3 +1245,50 @@ async fn a_field_answers_the_orderings_through_its_declared_index() { } ); } + +/// Text orders by its order key, ADR-0002's fold: the orderings are +/// answered over text, a query term of `Café` is the term of `cafe`, `alice` +/// orders before `Zoe` and `élan` between `eclair` and `emu`. +#[tokio::test] +async fn text_orders_by_its_folded_key() { + let cipher = stack_cipher().await; + let plan = Plan::context("users/surname") + .with::((Ore, Ope)) + .build() + .unwrap(); + let lt = |text: &str| cipher.query(op::Lt, text).on(&plan); + let ope = |text: &str| cipher.query(op::Lt, text).via(Ope).on(&plan); + let cafe: OreTerm = lt("cafe").await.unwrap(); + for text in ["Caf\u{e9}", "CAF\u{c9}", "cafe\u{301}"] { + assert_eq!(lt(text).await.unwrap(), cafe, "{text:?}"); + } + + // OPE terms compare as bytes, so the order is visible here directly. + let order = ["alice", "eclair", "\u{c9}lan", "emu", "Zoe"]; + let mut terms = Vec::new(); + for text in order { + let term: OpeTerm = ope(text).await.unwrap(); + terms.push(term.as_bytes().to_vec()); + } + assert!(terms.windows(2).all(|pair| pair[0] < pair[1]), "{order:?}"); + // And ORE terms compare through their own comparison. + let alice: OreTerm = lt("alice").await.unwrap(); + let zoe: OreTerm = lt("Zoe").await.unwrap(); + assert!(alice < zoe); + + let between = |lo: &str, hi: &str| { + cipher + .query(op::Between, &(lo.to_owned(), hi.to_owned())) + .on(&plan) + }; + let tie = between("A", "a").await.unwrap(); + assert_eq!(tie.lo(), tie.hi(), "one folded value"); + assert!(between("a", "B").await.is_ok()); + assert!( + matches!( + between("a\u{378}", "b").await, + Err(Error::Term(TermError::Normalize(_))) + ), + "a bound with no fold" + ); +} diff --git a/packages/stack-encrypt/tests/sem_terms.rs b/packages/stack-encrypt/tests/sem_terms.rs index e22d62d98..41e435f2e 100644 --- a/packages/stack-encrypt/tests/sem_terms.rs +++ b/packages/stack-encrypt/tests/sem_terms.rs @@ -375,11 +375,11 @@ async fn string_ore_terms_preserve_lexicographic_order() { let cipher = generator().await; let gen = cipher.default_keyset(); let apple = gen - .ore_term("apple", nonempty!("users/name")) + .ore_term("apple".to_string(), nonempty!("users/name")) .await .unwrap(); let banana = gen - .ore_term("banana", nonempty!("users/name")) + .ore_term("banana".to_string(), nonempty!("users/name")) .await .unwrap(); assert_eq!(apple.cmp(&banana), Ordering::Less); @@ -407,28 +407,3 @@ async fn ore_and_ope_keys_are_domain_separated() { let ope = gen.ope_term(42u64, nonempty!("users/age")).await.unwrap(); assert_ne!(ore.as_ref(), ope.as_ref()); } - -#[tokio::test] -async fn owned_and_borrowed_text_yield_identical_ore_and_ope_terms() { - let cipher = generator().await; - let gen = cipher.default_keyset(); - let borrowed = gen - .ore_term("apple", nonempty!("users/name")) - .await - .unwrap(); - let owned = gen - .ore_term(String::from("apple"), nonempty!("users/name")) - .await - .unwrap(); - assert_eq!(borrowed.as_ref(), owned.as_ref()); - - let borrowed = gen - .ope_term("apple", nonempty!("users/name")) - .await - .unwrap(); - let owned = gen - .ope_term(String::from("apple"), nonempty!("users/name")) - .await - .unwrap(); - assert_eq!(borrowed.as_ref(), owned.as_ref()); -} diff --git a/packages/stack-encrypt/tests/stored_rows.rs b/packages/stack-encrypt/tests/stored_rows.rs index 817728773..bc5aba38a 100644 --- a/packages/stack-encrypt/tests/stored_rows.rs +++ b/packages/stack-encrypt/tests/stored_rows.rs @@ -14,6 +14,11 @@ //! //! A deliberate change to the stored format adds `stored_rows_v2.json` //! beside this one. It never edits v1, and it says how v1 rows migrate. +//! Order terms are the one exception, for as long as the README says: a +//! deliberate change to how one is derived re-freezes only the `ore` and +//! `ope` terms that moved, by running the check with +//! `STACK_ENCRYPT_REFREEZE_ORDER_TERMS=1`. That run asserts nothing else +//! moved before it rewrites anything. //! //! The schema and the rules are in `tests/fixtures/README.md`. #![cfg(feature = "dynamic")] @@ -144,6 +149,71 @@ fn skeleton(json: &Json) -> Json { } } +/// Whether a stored map entry is an order term: `["ore", {"passthrough": ..}]` +/// or the same under `"ope"`. +fn is_order_term(pair: &Json) -> bool { + matches!(pair[0].as_str(), Some("ore" | "ope")) + && pair[1] + .as_object() + .is_some_and(|node| node.contains_key("passthrough")) +} + +/// [`skeleton`] with every order term's bytes masked: what must stay +/// exactly as written when order terms are re-frozen. +fn without_order_terms(json: &Json) -> Json { + let object = json.as_object().expect("a node"); + let (kind, value) = object.iter().next().expect("one entry"); + match kind.as_str() { + "leaf" | "empty_seq" | "empty_map" => json!(kind), + "seq" => json!({ + "seq": value.as_array().expect("a list").iter().map(without_order_terms).collect::>() + }), + "map" => json!({ + "map": value + .as_array() + .expect("a list") + .iter() + .map(|pair| { + if is_order_term(pair) { + json!([pair[0], "order term"]) + } else { + json!([pair[0], without_order_terms(&pair[1])]) + } + }) + .collect::>() + }), + _ => json.clone(), + } +} + +/// Copy every order term of `resealed` over the one in the same place in +/// `stored`. The two have the same shape, which the caller has asserted. +fn refreeze_order_terms(stored: &mut Json, resealed: &Json) { + let Some(object) = stored.as_object_mut() else { + return; + }; + let (kind, value) = object.iter_mut().next().expect("one entry"); + let key = kind.clone(); + let theirs = &resealed[key.as_str()]; + match key.as_str() { + "seq" => { + for (index, node) in value.as_array_mut().expect("a list").iter_mut().enumerate() { + refreeze_order_terms(node, &theirs[index]); + } + } + "map" => { + for (index, pair) in value.as_array_mut().expect("a list").iter_mut().enumerate() { + if is_order_term(pair) { + pair[1] = theirs[index][1].clone(); + } else { + refreeze_order_terms(&mut pair[1], &theirs[index][1]); + } + } + } + _ => {} + } +} + // ---- a plan as JSON --------------------------------------------------------- /// A plan written as JSON, as frame bytes: what a binding sends. @@ -194,10 +264,17 @@ fn expected_of(case: &Json) -> Option