diff --git a/docs/plans/2026-10-04-plan-builder.md b/docs/plans/2026-10-04-plan-builder.md index 0d1d8d44e..88dd628fb 100644 --- a/docs/plans/2026-10-04-plan-builder.md +++ b/docs/plans/2026-10-04-plan-builder.md @@ -1122,9 +1122,10 @@ uses the guest. kinds, not new names. A wire-format addition, so it goes in the first engine PR. This is the one gap in the plan approach itself that the language check found. #1069 added the type to the grammar as an optional - key, so a field with none is still dispatched on each value's own tag; - that is transitional, until Go fills the type (#1046) and the key becomes - required on indexed fields. + key; the Go SDK fills it on every field from the Go type (#1094), and + since #1082 the key is required on every field with a term output: a plan + whose indexed field has none is refused when it is built. A field that + only seals, or only carries its value through, may still leave it out. 2. **The guest's synchronous transport import, on the edge path only.** `transport_send` is synchronous from the guest's point of view and the ABI relies on it ("`block_on` never parks"). A wazero host function may block a diff --git a/languages/golang/encrypt/checker.go b/languages/golang/encrypt/checker.go index 7c0a0ee4d..7cd2cc5a3 100644 --- a/languages/golang/encrypt/checker.go +++ b/languages/golang/encrypt/checker.go @@ -46,6 +46,12 @@ func (k *Checker) Check(ctx context.Context, plan *record.Plan) error { if err := plan.Validate(); err != nil { return fmt.Errorf("%w: %v", ErrEncoding, err) } + return k.engineCheck(ctx, plan) +} + +// engineCheck asks se_plan_check alone, with no host rule first: the half +// of Check that reaches the engine. +func (k *Checker) engineCheck(ctx context.Context, plan *record.Plan) error { encoded, err := vcffi.Marshal(plan.Wire()) if err != nil { return fmt.Errorf("%w: %v", ErrEncoding, err) diff --git a/languages/golang/encrypt/gensupport/declaration.go b/languages/golang/encrypt/gensupport/declaration.go index 8bb574a13..339044827 100644 --- a/languages/golang/encrypt/gensupport/declaration.go +++ b/languages/golang/encrypt/gensupport/declaration.go @@ -15,7 +15,8 @@ import ( // which a Rust record opens when its field is a Value. Every sealed field has a // scalar kind: stashgen refuses a struct, slice or map outside an opaque // struct, and an opaque struct seals as Bytes (one JSON document). Untyped -// names a field with no declared type and is not what generated code writes. +// names a field with no declared type; generated code never writes it, and +// the engine refuses a plan whose indexed field has no type. type Kind string // The kinds. int8, int16 and int32 are Int32; int and int64 are Int64; diff --git a/languages/golang/encrypt/guest/src/status.rs b/languages/golang/encrypt/guest/src/status.rs index 35babc7f7..4ae92acab 100644 --- a/languages/golang/encrypt/guest/src/status.rs +++ b/languages/golang/encrypt/guest/src/status.rs @@ -99,8 +99,8 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { /// A dynamic-path error as a status code. /// /// The split the library draws is the one the ABI needs: `Context`, `Term`, -/// `Plan`, `Source` and `Record` are each a statement about the caller's -/// input, decided before any key is minted or retrieved, so they are +/// `Plan`, `UntypedIndex`, `Source` and `Record` are each a statement about +/// the caller's input, decided before any key is minted or retrieved, so they are /// [`STATUS_ENCODING`] — named one by one, because that verdict is the /// host's to act on and must be given deliberately. `Cipher` defers to /// [`status_for_error`]; `Internal` is the library's own invariant failing @@ -116,9 +116,12 @@ pub fn status_for_error(error: &stack_encrypt::Error) -> u32 { pub fn status_for_dynamic(error: &stack_encrypt::dynamic::Error) -> u32 { use stack_encrypt::dynamic::Error; match error { - Error::Context | Error::Term { .. } | Error::Plan | Error::Source | Error::Record => { - STATUS_ENCODING - } + Error::Context + | Error::Term { .. } + | Error::Plan + | Error::UntypedIndex { .. } + | Error::Source + | Error::Record => STATUS_ENCODING, Error::Cipher(e) => status_for_error(e), // A target refusal is a statement about the plan, the label or the // value (an unknown or unproducible type, an extended plan, a value @@ -442,6 +445,12 @@ mod tests { }, ), ("a bad plan", Error::Plan), + ( + "an indexed field with no type", + Error::UntypedIndex { + field: "age".to_string(), + }, + ), ("a bad source", Error::Source), ("a bad record", Error::Record), ] { diff --git a/languages/golang/encrypt/guest/tests/native_ops.rs b/languages/golang/encrypt/guest/tests/native_ops.rs index 30d8f3162..5a2f86639 100644 --- a/languages/golang/encrypt/guest/tests/native_ops.rs +++ b/languages/golang/encrypt/guest/tests/native_ops.rs @@ -163,6 +163,7 @@ fn plan_under(ctx: impl Fn(&str) -> FfiValue) -> Vec { obj(vec![ ("context", ctx("age")), ("outputs", FfiValue::Array(vec![s("c"), s("eq"), s("ore")])), + ("type", s("uint32")), ]), ), ( @@ -170,6 +171,7 @@ fn plan_under(ctx: impl Fn(&str) -> FfiValue) -> Vec { obj(vec![ ("context", ctx("name")), ("outputs", FfiValue::Array(vec![s("c"), s("match")])), + ("type", s("string")), ]), ), ])) @@ -1296,6 +1298,17 @@ fn plan_check_answers_without_a_cipher() { )])); assert_eq!(ops::plan_check(&bad_index), Err(STATUS_ENCODING)); + // an indexed field with no declared type: every term derives from one + // declared kind, so the plan is refused before any value arrives. + let untyped_index = encode(obj(vec![( + "age", + obj(vec![ + ("context", label("age")), + ("outputs", FfiValue::Array(vec![s("c"), s("eq")])), + ]), + )])); + assert_eq!(ops::plan_check(&untyped_index), Err(STATUS_ENCODING)); + // a one-segment context is not a label a fields plan can seal under. let one_segment = encode(obj(vec![( "age", diff --git a/languages/golang/encrypt/guest_test.go b/languages/golang/encrypt/guest_test.go index a82caae59..6d9b1153a 100644 --- a/languages/golang/encrypt/guest_test.go +++ b/languages/golang/encrypt/guest_test.go @@ -732,6 +732,44 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { } } +// An indexed field with no declared kind is refused by the Checker, naming +// the field: the engine derives every term from one declared kind, and the +// generator never emits such a declaration, so only a plan built by hand +// reaches this. A sealed-only untyped field passes. +func TestCheckerRefusesAnIndexedUntypedField(t *testing.T) { + guestOrSkip(t) + ctx := context.Background() + checker, err := NewChecker(ctx) + if err != nil { + t.Fatal(err) + } + defer checker.Close() + for name, outputs := range map[string][]record.Output{ + "equality": {record.Ciphertext, record.Equality}, + "match": {record.Ciphertext, record.Match}, + "ore": {record.Ciphertext, record.Ore}, + "ope": {record.Ope}, + } { + p := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "age", Kind: record.Untyped, Outputs: outputs}}} + // Through Check, the Go rule refuses first and names the field. + err := checker.Check(ctx, p) + if !errors.Is(err, ErrEncoding) || !strings.Contains(err.Error(), `field "age"`) { + t.Errorf("%s: err = %v, want ErrEncoding naming the field", name, err) + } + // Past it, the engine refuses the same plan (Error::UntypedIndex in + // stack-encrypt) and the guest reports it as the caller's input. The + // ABI carries a status word and no message, so the field's name is + // the Go rule's to give; this asserts the two rules agree. + if err := checker.engineCheck(ctx, p); !errors.Is(err, ErrEncoding) { + t.Errorf("%s past Validate: err = %v, want ErrEncoding from the engine", name, err) + } + } + sealed := &record.Plan{Context: []string{"users"}, Fields: []record.Field{{Name: "notes", Kind: record.Untyped, Outputs: []record.Output{record.Ciphertext}}}} + if err := checker.Check(ctx, sealed); err != nil { + t.Errorf("a sealed-only untyped field: %v", err) + } +} + // se_plan_check answers with no cipher: a plan the engine runs passes, a // plan it refuses is ErrEncoding, never ErrState. func TestPlanCheckAnswersWithoutACipher(t *testing.T) { diff --git a/languages/golang/internal/record/record.go b/languages/golang/internal/record/record.go index 30bdb73d9..0123bb3dc 100644 --- a/languages/golang/internal/record/record.go +++ b/languages/golang/internal/record/record.go @@ -197,8 +197,8 @@ func CheckPart(part any) error { // Validate checks what the host can check before the engine sees the plan: // a context, plain segments, at least one field, names and identities once, -// outputs once and at least one, known kinds, and extension parts the codec -// carries. The engine's own rules — which index a kind admits, what the +// outputs once and at least one, known kinds, a kind on every indexed field, +// and extension parts the codec carries. The engine's own rules — which index a kind admits, what the // builder refuses — are the engine's, asked through se_plan_check. func (p *Plan) Validate() error { switch { @@ -279,6 +279,11 @@ func (p *Plan) Validate() error { return fmt.Errorf("record: field %q: output %q is asked for twice", f.Name, o) } seen[o] = true + // The engine refuses this too; it is named here, with the field, + // so a declaration is corrected where it was written. + if o.IsTerm() && f.Kind == Untyped { + return fmt.Errorf("record: field %q: an indexed field declares its type, so every value's %s term derives from one kind; declare the field's kind", f.Name, o) + } } } return nil diff --git a/languages/golang/internal/record/record_test.go b/languages/golang/internal/record/record_test.go index e12441418..0feadcdc5 100644 --- a/languages/golang/internal/record/record_test.go +++ b/languages/golang/internal/record/record_test.go @@ -136,15 +136,32 @@ func TestValidateRefusals(t *testing.T) { "unknown kind": one(Field{Name: "a", Kind: "integer", Outputs: []Output{Ciphertext}}), "no output": one(Field{Name: "a"}), "unknown output": one(Field{Name: "a", Outputs: []Output{"json"}}), - "output twice": one(Field{Name: "a", Outputs: []Output{Equality, Equality}}), + "output twice": one(Field{Name: "a", Kind: Uint32, Outputs: []Output{Equality, Equality}}), "bad extension part": {Context: []string{"users"}, Extension: []any{1.5}, Fields: []Field{{Name: "a", Outputs: []Output{Ciphertext}}}}, "name is not a label": one(Field{Name: "b64:x", Outputs: []Output{Ciphertext}}), + "indexed and untyped": one(Field{Name: "a", Outputs: []Output{Ciphertext, Equality}}), + "index alone untyped": one(Field{Name: "a", Outputs: []Output{Ope}}), } for name, p := range cases { if err := p.Validate(); err == nil { t.Errorf("%s: accepted", name) } } + // A sealed-only field needs no kind; an indexed one is refused with the + // field and the term named. + if err := one(Field{Name: "notes", Outputs: []Output{Ciphertext}}).Validate(); err != nil { + t.Errorf("a sealed-only untyped field: %v", err) + } + err := one(Field{Name: "age", Outputs: []Output{Ciphertext, Ore}}).Validate() + if err == nil || !strings.Contains(err.Error(), `field "age"`) || !strings.Contains(err.Error(), "ore term") { + t.Errorf("an indexed untyped field: err = %v, want the field and the term named", err) + } + // A repeated output is refused for the repetition: the field is typed, + // so the untyped rule cannot be what refuses it. + err = one(Field{Name: "a", Kind: Uint32, Outputs: []Output{Equality, Equality}}).Validate() + if err == nil || !strings.Contains(err.Error(), "asked for twice") { + t.Errorf("an output asked for twice: err = %v, want the repeated output named", err) + } } func TestParseContext(t *testing.T) { diff --git a/packages/stack-encrypt/CHANGELOG.md b/packages/stack-encrypt/CHANGELOG.md index adfd8d35f..a1f86c002 100644 --- a/packages/stack-encrypt/CHANGELOG.md +++ b/packages/stack-encrypt/CHANGELOG.md @@ -32,6 +32,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking +- **A data plan field with a term output must declare its `"type"`.** A + plan whose indexed field (`"eq"`, `"match"`, `"ore"`, `"ope"`) has no + `"type"` is refused when it is built (`Error::UntypedIndex`, naming the + field), by `record::plan`, + `record::plan_with`, `Plan::new` / `Plan::new_with` and + `Plan::with_context_field` alike: the field's + terms derive from the one declared kind, with every value checked against + it, never from whatever tag each value arrived with. The type stays + optional on a field whose only output is `"c"` or `"passthrough"`, and on + a target field, whose kind is the EQL type's own. The Go SDK already + fills `"type"` on every field from the Go type, and `record.Plan.Validate` + refuses an indexed `Untyped` field naming it, so generated code is + unaffected; a plan written by hand without types must add them. Declaring + a type changes no stored byte, so a row stored as the declared kind opens + as before. A row stored as another kind fails to open with + `PlanError::FieldType`, and fails any batch that holds it; its terms do + not match a query of the declared kind either. Re-encrypt such rows as + the declared kind: they are still readable, because a plan that gives the + field only `"c"` and no `"type"` remains valid and opens a row of any + kind, whatever indexes it was sealed with. Open the rows under that plan, + convert each value to the declared kind, and encrypt it again under the + typed plan. + - **A target description carries a source mode.** `Encryption` gains a last type parameter, `M: SourceMode = Borrowed`, saying how it is handed its plaintext. Code that names `Encryption<'s, S, T, K, Ctx>` still @@ -75,8 +98,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Every data plan field seals the tagged `FfiValue` leaf, whatever its `"type"`**, as every field did before; the type admits indexes and checks kinds and changes no bytes, so a row written without a type opens under a - plan that declares one, and a binding that starts sending `"type"` - re-encrypts nothing. A Rust `u32` or `String` field under the same label + plan that declares the kind it was sealed as, and a binding that starts + sending `"type"` re-encrypts nothing stored as that kind. A Rust `u32` or `String` field under the same label derives the same terms as the data field but a different leaf, and the two leaves cannot be told apart by inspection (a bare string that begins with U+000A is a valid tagged string), so the lowering does not choose an diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted deleted file mode 100644 index 4c60b0f33..000000000 Binary files a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-accepted and /dev/null differ diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused b/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused deleted file mode 100644 index f7788462d..000000000 Binary files a/packages/stack-encrypt/fuzz/corpus/check_record/valid-record-passthrough-under-c-refused and /dev/null differ diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-typed-indexed-passthrough-under-c-refused b/packages/stack-encrypt/fuzz/corpus/check_record/valid-typed-indexed-passthrough-under-c-refused new file mode 100644 index 000000000..ae5d149b6 Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/check_record/valid-typed-indexed-passthrough-under-c-refused differ diff --git a/packages/stack-encrypt/fuzz/corpus/check_record/valid-typed-indexed-record-accepted b/packages/stack-encrypt/fuzz/corpus/check_record/valid-typed-indexed-record-accepted new file mode 100644 index 000000000..f828dcd50 Binary files /dev/null and b/packages/stack-encrypt/fuzz/corpus/check_record/valid-typed-indexed-record-accepted differ diff --git a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs index ebe55eb06..39e2a375d 100644 --- a/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs +++ b/packages/stack-encrypt/fuzz/fuzz_targets/check_record.rs @@ -33,7 +33,7 @@ use arbitrary::Arbitrary; use libfuzzer_sys::fuzz_target; use stack_encrypt::dynamic::record::{check_record, plan_with, Plan}; use stack_encrypt::dynamic::{ - FfiValue, TargetDescriptor, TargetError, TargetResolver, ValueKind, + FfiValue, Output, TargetDescriptor, TargetError, TargetResolver, ValueKind, }; use stack_encrypt::{KeysetCipher, Label, Pending, SealedValue, StackCipher, StackCipherText}; use uuid::Uuid; @@ -166,12 +166,51 @@ enum SpecEntry { Context(Ctx), Outputs(Vec), Target(Name), + /// A `"type"`: required on an indexed field, so a plan with terms parses + /// only when one of these names a kind that admits them. + Type(Kind), ContextWrongShape(u32), OutputsWrongShape(u32), TargetWrongShape(u32), Unknown(Ctx), } +/// A `"type"` value: the scalar kinds, an object, and a name that is not +/// one. `Bool` is the kind that admits `"ore"` / `"ope"` but not `"eq"`, so +/// a plan can be refused for one index and accepted for another. +#[derive(Arbitrary, Debug, Clone, Copy)] +enum Kind { + Bool, + UInt32, + UInt64, + Int32, + Int64, + Float32, + String, + Bytes, + Float64, + Object, + NotAKind, +} + +impl Kind { + fn as_str(self) -> &'static str { + match self { + Kind::Bool => "bool", + Kind::UInt32 => "uint32", + Kind::UInt64 => "uint64", + Kind::Int32 => "int32", + Kind::Int64 => "int64", + Kind::Float32 => "float32", + Kind::String => "string", + Kind::Bytes => "bytes", + Kind::Float64 => "float64", + Kind::Object => "object", + Kind::NotAKind => "integer", + } + } +} + impl SpecEntry { fn into_entry(self) -> (String, FfiValue) { match self { @@ -185,10 +224,10 @@ impl SpecEntry { .collect(), ), ), - SpecEntry::Target(name) => ( - "target".to_string(), - FfiValue::String(name.as_str().into()), - ), + SpecEntry::Target(name) => { + ("target".to_string(), FfiValue::String(name.as_str().into())) + } + SpecEntry::Type(kind) => ("type".to_string(), FfiValue::String(kind.as_str().into())), SpecEntry::ContextWrongShape(v) => ("context".to_string(), FfiValue::UInt32(v)), SpecEntry::OutputsWrongShape(v) => ("outputs".to_string(), FfiValue::UInt32(v)), SpecEntry::TargetWrongShape(v) => ("target".to_string(), FfiValue::UInt32(v)), @@ -364,6 +403,23 @@ fuzz_target!(|case: Case| { } return; }; + // One rule of the parsed plan is asserted here rather than left to the + // model: every field with a term output declares its type. The parser + // ends in `Plan::new_with`, which enforces it, so a plan that reaches + // this point with an untyped indexed field means a parse path was added + // that does not. A target field's kind is the type's own and it has no + // outputs of its own, so the rule reads as written over `outputs()`. + for field in plan.fields() { + let indexed = field + .outputs() + .iter() + .any(|output| matches!(output, Output::Term(_))); + assert!( + !indexed || field.target().is_some() || field.field_type().is_some(), + "a parsed plan has an indexed field with no type: {:?}", + field.name() + ); + } let expected = model_accepts(&record, &plan); let actual = check_record(record.into_ciphertext(), &plan, None).is_ok(); if trace { diff --git a/packages/stack-encrypt/src/dynamic/kind.rs b/packages/stack-encrypt/src/dynamic/kind.rs index e3cc30869..9ed9e4d55 100644 --- a/packages/stack-encrypt/src/dynamic/kind.rs +++ b/packages/stack-encrypt/src/dynamic/kind.rs @@ -33,8 +33,10 @@ //! types of its own can rely on the declaration for what it gets back (an //! integer, not a float; bytes, not a string). //! -//! A field with no `"type"` is dispatched on each value's own tag. That is -//! transitional; see [`record::plan`](super::record::plan()). +//! Every field with a term output declares its kind; a plan whose indexed +//! field has none is refused when it is built +//! ([`Error::UntypedIndex`], naming the field). A field that only seals, or +//! only carries its value through, may leave it out: no term derives from it. use vitaminc_aead_value::{FfiValue, ValueKind}; use super::Error; diff --git a/packages/stack-encrypt/src/dynamic/mod.rs b/packages/stack-encrypt/src/dynamic/mod.rs index c8b0cf09c..3130feeea 100644 --- a/packages/stack-encrypt/src/dynamic/mod.rs +++ b/packages/stack-encrypt/src/dynamic/mod.rs @@ -59,9 +59,9 @@ //! vitaminc's [`ValueKind`], re-exported here, whose names vitaminc freezes //! beside its tag table. This crate adds only what a kind means to an index //! ([`admits`]) and to a query value ([`read`]); it decides nothing about -//! the bytes ([`record`]). A field without `"type"` is dispatched on each -//! value's own tag; that is transitional, and [`record::plan`] says until -//! when. +//! the bytes ([`record`]). Every field with a term output declares one; +//! a plan whose indexed field has none is refused when it is built +//! ([`Error::UntypedIndex`], naming the field). //! //! For the same reason the enums that spell them — [`Output`], //! [`IndexSpec`] and [`ValueKind`] — are *not* `#[non_exhaustive]`, against this workspace's @@ -185,6 +185,19 @@ pub enum Error { #[error("record plan is malformed")] Plan, + /// A record plan field has a term output (`"eq"`, `"match"`, `"ore"`, + /// `"ope"`) and declares no `"type"`. The field's terms derive from the + /// one declared kind, never from whatever tag each value arrived with, + /// so the plan is refused when it is built, before any value arrives. + /// Its own variant rather than a cause of [`Plan`](Error::Plan) because + /// it names the field: it is the refusal a plan written before types + /// were required meets first, and the one a caller fixes field by field. + #[error("record plan field {field:?} has a term output and no declared type")] + UntypedIndex { + /// The field's name — its key in the plan. + field: String, + }, + /// A record source does not fit its plan: not an object (or an array of /// them), a field the plan does not name, a plan field the source does /// not carry or carries twice, or a passthrough or a repeated map key diff --git a/packages/stack-encrypt/src/dynamic/record.rs b/packages/stack-encrypt/src/dynamic/record.rs index 87d1a8eed..d8bb78eb7 100644 --- a/packages/stack-encrypt/src/dynamic/record.rs +++ b/packages/stack-encrypt/src/dynamic/record.rs @@ -92,10 +92,16 @@ //! type decides which indexes the field admits ([`admits`]), which values it //! seals and which it opens to (checked by kind, both ways), and nothing //! about the bytes: declaring a type on a field written without one changes -//! no leaf, so a binding that starts sending `"type"` (#1082) re-encrypts -//! nothing. A field's terms dispatch on each value's own variant to the -//! typed term operation, so they are the bytes a Rust `u32` or `String` -//! field derives under the same label. +//! no leaf. A field with any index declares its type, and a plan whose +//! indexed field has none is refused when it is built +//! ([`Error::UntypedIndex`], naming the field): the term then derives +//! from the one declared kind, every value checked against it, never from +//! whatever tag each value arrived with (`34` sent once as a float and once +//! as an integer would otherwise store two terms under one field). A field +//! that only seals, or only carries its value through, may leave the type +//! out. A field's terms dispatch on the value's variant — the declared kind, +//! once checked — to the typed term operation, so they are the bytes a Rust +//! `u32` or `String` field derives under the same label. //! //! The ciphertext is where a data plan and a Rust chain over bare types part: //! a Rust `u32` field seals four bare bytes and a `String` field its bare @@ -228,12 +234,15 @@ enum Verb { /// One field of a record plan: what to call it, what label to seal it /// under, what to produce for it, and, optionally, what type its values are. /// -/// A field with a declared type (a [`ValueKind`]) admits only the indexes -/// that kind is defined for ([`admits`], checked when the plan is built), -/// seals only values of that kind and opens only to one (checked per value), -/// so the engine verifies what a binding hands it rather than trusting the -/// binding's tagging. A field with no declared type is dispatched on each -/// value's own type; see the [module docs](self#what-a-fields-type-decides). +/// A field may declare the type of its values (a [`ValueKind`]), and must +/// when it has a term output: [`Plan::new_with`] refuses the plan otherwise +/// ([`Error::UntypedIndex`]). A typed field admits only the indexes that +/// kind is defined for ([`admits`], checked when the plan is built), seals +/// only values of that kind and opens only to one (checked per value), so +/// the engine verifies what a binding hands it rather than trusting the +/// binding's tagging. A field that only seals, or only carries its value +/// through, may leave the type out; see the +/// [module docs](self#what-a-fields-type-decides-and-what-it-does-not). #[derive(Clone, Debug)] pub struct FieldPlan { name: String, @@ -548,7 +557,10 @@ impl Plan { /// labels sit under different contexts or carry different extensions, /// or does not build as a fields plan: two sealed or indexed fields /// keyed under one identity, for instance, whose terms would be - /// interchangeable. [`Error::Target`] if a target field names a type the + /// interchangeable. [`Error::UntypedIndex`], naming the field, if a field + /// with a term output declares no type: every constructor shares that + /// rule, so a parsed and a hand-built plan are refused alike. + /// [`Error::Target`] if a target field names a type the /// resolver does not know or cannot produce, declares a `"type"` other /// than the kind that type is produced from, or sits in an extended plan. pub fn new_with( @@ -623,7 +635,8 @@ impl Plan { /// [`Output::Passthrough`] (a context is not sealed, and indexing it /// would derive a term from a value that is not secret), declares a /// type other than [`ValueKind::String`], or any field's label has - /// more than one segment. [`Error::Target`] + /// more than one segment. [`Error::UntypedIndex`], naming the field, as + /// [`new_with`](Self::new_with) refuses. [`Error::Target`] /// ([`TargetError::ContextField`]) if a field names an EQL type as its /// target: an EQL value is stored under a table the declaration fixes, /// and a plan with a context field has none. @@ -677,7 +690,7 @@ impl Plan { } /// The rules both constructors share: fields named once, one extension, - /// and a whole the builder accepts. + /// every field with a term output typed, and a whole the builder accepts. fn build(context: Context, fields: Vec) -> Result { let Some(first) = fields.first() else { return Err(Error::Plan); @@ -690,6 +703,14 @@ impl Plan { if field.extension != extension { return Err(Error::Plan); } + // An indexed field declares its type: every value's term derives + // from the one declared kind, never from whatever tag each value + // arrived with. A target field's kind is the type's own. + if !field.is_target() && !field.indexes().is_empty() && field.field_type.is_none() { + return Err(Error::UntypedIndex { + field: field.name.clone(), + }); + } // A target field is keyed under its identity like a sealed one; // the builder checks that rule for the fields it lowers, so the // target fields are checked against every field here. @@ -884,21 +905,20 @@ pub fn plan(value: FfiValue) -> Result { /// /// [`MatchOptions::default`]: crate::sem::MatchOptions::default /// -/// `"type"` is optional, and names a [`ValueKind`] (`"int64"`, `"string"`, -/// …; see [`ValueKind::name`]): vitaminc's vocabulary, not one of this -/// crate's. Declared, it is checked against the field's outputs here -/// ([`admits`]) and against every value sealed into or opened -/// from the field, and it decides the field's leaf encoding (see the -/// [module docs](self#what-a-fields-type-decides)). -/// -/// **An indexed field without `"type"` is dispatched on each value's own -/// tag**, so for that field the engine trusts the binding to tag every value -/// the same way: a `34` sent once as a `Float64` and once as an `Int64` under -/// one `"ore"` field is accepted both times and stores two different terms. -/// This is transitional. It keeps the plans the Go binding sends today, which -/// carry no `"type"`, valid until that binding fills `"type"` from its struct -/// types; then `"type"` becomes required on every field with a term output -/// (#1082). +/// `"type"` names a [`ValueKind`] (`"int64"`, `"string"`, …; see +/// [`ValueKind::name`]): vitaminc's vocabulary, not one of this crate's. It +/// is **required on every field with a term output** (`"eq"`, `"match"`, +/// `"ore"`, `"ope"`): the field's terms derive from that one kind, and every +/// value is checked against it on the way in and on the way out, so a +/// binding is never trusted to have tagged each value alike (a `34` sent once +/// as a `Float64` and once as an `Int64` would store two terms under one +/// field). A field with term outputs and no `"type"` is refused when the plan +/// is built ([`Error::UntypedIndex`], naming the field). It is optional on a +/// field whose only output is `"c"` or +/// `"passthrough"`, and on a target field, whose kind is the EQL type's own. +/// Declared, it is checked against the field's outputs here ([`admits`]) and +/// decides nothing about the bytes (see the +/// [module docs](self#what-a-fields-type-decides-and-what-it-does-not)). /// /// `` is read by [`super::context`](super::context()) and must be /// the field's label, optionally extended; the @@ -929,6 +949,7 @@ pub fn plan(value: FfiValue) -> Result { /// FfiValue::String("eq".into()), /// ]), /// ), +/// ("type".to_string(), FfiValue::String("uint32".into())), /// ]), /// )]), &NoTargets)?; /// @@ -957,9 +978,10 @@ pub fn plan(value: FfiValue) -> Result { /// contexts or extensions, or a plan the builder refuses ([`Plan::new`]); /// with `"context_field"`, a value that is not a string, given twice, /// naming no field of the plan, or a plan [`Plan::with_context_field`] -/// refuses. [`Error::Context`] for a `"context"` that is present but is -/// not a context at all, or renders empty. [`Error::Target`] for a target -/// the resolver refuses ([`Plan::new_with`]). +/// refuses. [`Error::UntypedIndex`], naming the field, for a field with a +/// term output and no `"type"`. [`Error::Context`] for a `"context"` that +/// is present but is not a context at all, or renders empty. +/// [`Error::Target`] for a target the resolver refuses ([`Plan::new_with`]). /// /// The transport codec refuses duplicate object keys before a binding's /// value reaches here, but an [`FfiValue`] can be built with them directly @@ -1051,8 +1073,8 @@ pub fn plan_with( /// index nothing. /// /// The source is checked and converted here, with no cipher: that is -/// [`check_source`], and it is where [`Error::Source`] and [`Error::Term`] -/// come from. What comes back is the plan's [`Pending`], with every term +/// [`check_source`], and it is where [`Error::Source`] comes from. What +/// comes back is the plan's [`Pending`], with every term /// derived and every key request queued and nothing sent: one batched /// `generate_keys` for every ciphertext leaf of every row when it is /// awaited, however many rows and fields there are. Its failure is the @@ -1095,6 +1117,7 @@ pub fn plan_with( /// FfiValue::String("eq".into()), /// ]), /// ), +/// ("type".to_string(), FfiValue::String("uint32".into())), /// ]), /// )]))?; /// @@ -1113,8 +1136,11 @@ pub fn plan_with( /// /// # Errors /// -/// [`Error::Source`] if the source does not fit the plan; [`Error::Term`] -/// if a value has no term the plan asks for. Both are decided here, before +/// [`Error::Source`] if the source does not fit the plan — a row that is +/// not an object, a field the plan does not name or one it names that the +/// row lacks, or a value of another kind than its field declares, which is +/// how a value no term is defined for is refused: every indexed field +/// declares its kind and the kind check comes first. Decided here, before /// the pending exists. A failure of the pending itself is the engine's. /// /// A plan with a target field needs the resolver that built it: @@ -1997,9 +2023,9 @@ mod tests { /// `nick` indexed for match only, never sealed; `id` carried through. fn plan_value() -> FfiValue { obj(vec![ - ("age", spec(label("age"), &["c", "eq", "ore"])), + ("age", typed(label("age"), &["c", "eq", "ore"], "uint32")), ("email", spec(label("email"), &["c"])), - ("nick", spec(label("nick"), &["match"])), + ("nick", typed(label("nick"), &["match"], "string")), ("id", spec(label("id"), &["passthrough"])), ]) } @@ -2457,8 +2483,8 @@ mod tests { #[test] fn refuses_two_fields_keyed_under_one_identity() { let parsed = plan(obj(vec![ - ("mail", spec(label("email"), &["c", "eq"])), - ("mail2", spec(label("email"), &["c", "eq"])), + ("mail", typed(label("email"), &["c", "eq"], "string")), + ("mail2", typed(label("email"), &["c", "eq"], "string")), ])); assert!(matches!(parsed, Err(Error::Plan)), "{parsed:?}"); // Two passthrough fields key nothing, so they may share a label. @@ -2542,6 +2568,7 @@ mod tests { obj(vec![ ("context", label("nick")), ("outputs", FfiValue::Array(vec![s("c"), wide])), + ("type", s("string")), ]), )])) .expect("parses"); @@ -2659,6 +2686,110 @@ mod tests { } } + /// The refusal names the indexed field that has no type. A typed indexed + /// field, a sealed-only field and a passthrough field beside it are not + /// what is refused. + #[test] + fn untyped_index_names_the_field_that_has_no_type() { + let parsed = plan(obj(vec![ + ("age", typed(label("age"), &["c", "eq"], "uint32")), + ("email", spec(label("email"), &["c"])), + ("id", spec(label("id"), &["passthrough"])), + ("nick", spec(label("nick"), &["match"])), + ])); + assert!( + matches!(&parsed, Err(Error::UntypedIndex { field }) if field == "nick"), + "{parsed:?}" + ); + } + + /// An indexed field declares its type. A plan whose indexed field has + /// none is refused when it is built, for every index kind, with no key + /// request; a field that only seals or only carries its value through + /// still builds without one, seals and opens. + #[tokio::test] + async fn an_indexed_field_without_a_type_is_refused_at_build() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + fn wide() -> FfiValue { + IndexSpec::Match(crate::sem::MatchOptions { + k: 6, + ..crate::sem::MatchOptions::default() + }) + .to_value() + } + // Each case is refused for the missing type and nothing else: the + // refusal is the variant that names the field, and the same spec + // parses once a kind that admits its outputs is declared. (An + // `FfiValue` does not clone, so each case builds its outputs twice.) + type Outputs = fn() -> Vec; + let cases: [(&str, Outputs, &str); 7] = [ + ("equality", || vec![s("c"), s("eq")], "uint32"), + ("match", || vec![s("c"), s("match")], "string"), + ("match with options", || vec![s("c"), wide()], "string"), + ("ore", || vec![s("c"), s("ore")], "uint32"), + ("ope", || vec![s("c"), s("ope")], "uint32"), + ("an index alone", || vec![s("eq")], "uint32"), + ( + "several indexes", + || vec![s("c"), s("eq"), s("ore"), s("ope")], + "uint32", + ), + ]; + for (what, outputs, kind) in cases { + let parse = |ty: Option<&str>| { + let mut spec = vec![ + ("context", label("age")), + ("outputs", FfiValue::Array(outputs())), + ]; + spec.extend(ty.map(|ty| ("type", s(ty)))); + plan(obj(vec![("age", obj(spec))])) + }; + let refused = parse(None); + assert!( + matches!(&refused, Err(Error::UntypedIndex { field }) if field == "age"), + "{what} with no type: {refused:?}" + ); + assert!( + parse(Some(kind)).is_ok(), + "{what} typed {kind} is the same spec, accepted" + ); + } + // By hand alike: the rule is the plan's, not the parser's. + let field = FieldPlan::new( + "age", + context(label("age")).expect("context"), + vec![Output::Ciphertext, Output::Term(IndexSpec::Equality)], + ) + .expect("a field plan"); + assert!( + matches!(Plan::new(vec![field.clone()]), Err(Error::UntypedIndex { field }) if field == "age"), + "an indexed field plan with no type does not make a plan" + ); + let typed_field = field.with_type(ValueKind::UInt32).expect("admits equality"); + assert!(Plan::new(vec![typed_field]).is_ok()); + + let untyped = plan(obj(vec![ + ("notes", spec(label("notes"), &["c"])), + ("id", spec(label("id"), &["passthrough"])), + ])) + .expect("a sealed-only and a passthrough field need no type"); + let sealed = seal( + &keyset, + obj(vec![("notes", s("hi")), ("id", FfiValue::UInt64(7))]), + &untyped, + ) + .await; + let opened = object(open(&cipher, sealed, &untyped).await); + assert_eq!(text_of(&opened[0].1), "hi"); + assert_eq!(u64_of(&opened[1].1), 7); + assert_eq!( + generates(&cipher), + 1, + "one key request, for the one sealed field" + ); + } + mod given_a_source_that_does_not_fit_the_plan { use super::*; @@ -2743,36 +2874,22 @@ mod tests { |e| matches!(e, Error::Source), ), ( - "a container under an indexed field", + "a container under an indexed field, which declares a scalar kind", { let mut entries = object(row(1)); entries[2].1 = FfiValue::Array(vec![s("al")]); FfiValue::Object(entries) }, - |e| { - matches!( - e, - Error::Term { - kind: IndexSpec::Match(_) - } - ) - }, + |e| matches!(e, Error::Source), ), ( - "a scalar the scheme has no such term for", + "a scalar of another kind under an indexed field", { let mut entries = object(row(1)); entries[2].1 = FfiValue::UInt32(3); FfiValue::Object(entries) }, - |e| { - matches!( - e, - Error::Term { - kind: IndexSpec::Match(_) - } - ) - }, + |e| matches!(e, Error::Source), ), ]; for (label_, source, expected) in cases { @@ -2803,13 +2920,8 @@ mod tests { entries[2].1 = FfiValue::UInt32(3); let err = encrypt(&keyset, FfiValue::Object(entries), &plan).err(); assert!( - matches!( - err, - Some(Error::Term { - kind: IndexSpec::Match(_) - }) - ), - "encrypt refuses a value with no such term: {err:?}" + matches!(err, Some(Error::Source)), + "encrypt refuses a value of another kind than the indexed field declares: {err:?}" ); assert_eq!( generates(&cipher), @@ -2818,22 +2930,33 @@ mod tests { ); } + /// A float cannot reach an equality index through a plan: a field + /// declared `float64` is refused when the plan is built, and a field + /// declared an integer refuses the float value as the wrong kind, + /// before any key request. There is no untyped indexed field for a + /// float to slip through. #[tokio::test] - async fn a_float_asked_for_equality_is_refused_as_that_kind() { + async fn a_float_asked_for_equality_is_refused_at_build_or_as_the_wrong_kind() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let plan = - plan(obj(vec![("score", spec(label("score"), &["c", "eq"]))])).expect("plan"); + let as_float = plan(obj(vec![( + "score", + typed(label("score"), &["c", "eq"], "float64"), + )])); + assert!( + matches!(as_float, Err(Error::Plan)), + "no PRF encoding exists for a float: {as_float:?}" + ); + let as_u32 = plan(obj(vec![( + "score", + typed(label("score"), &["c", "eq"], "uint32"), + )])) + .expect("plan"); let source = obj(vec![("score", FfiValue::Float64(1.5))]); - let err = encrypt(&keyset, source, &plan).err(); + let err = encrypt(&keyset, source, &as_u32).err(); assert!( - matches!( - err, - Some(Error::Term { - kind: IndexSpec::Equality - }) - ), - "no PRF encoding exists for a float: {err:?}" + matches!(err, Some(Error::Source)), + "a float is not the declared kind: {err:?}" ); assert_eq!(generates(&cipher), 0, "refused before any key request"); } @@ -2912,8 +3035,11 @@ mod tests { async fn terms_ride_in_index_order_after_the_ciphertext() { let cipher = cipher().await; let keyset = cipher.default_keyset(); - let plan = - plan(obj(vec![("age", spec(label("age"), &["ore", "c", "eq"]))])).expect("plan"); + let plan = plan(obj(vec![( + "age", + typed(label("age"), &["ore", "c", "eq"], "uint32"), + )])) + .expect("plan"); let sealed = seal(&keyset, obj(vec![("age", FfiValue::UInt32(1))]), &plan).await; let mut fields = map(sealed); let age = map(node(&mut fields, "age")); @@ -3019,6 +3145,7 @@ mod tests { "outputs", FfiValue::Array(vec![IndexSpec::Match(options.clone()).to_value()]), ), + ("type", s("string")), ]), )])) .expect("parses"); @@ -3303,7 +3430,7 @@ mod tests { let parts = [FfiValue::UInt64(7), s("eu")]; let plan = plan(obj(vec![( "age", - spec(extended("age", &parts), &["c", "eq"]), + typed(extended("age", &parts), &["c", "eq"], "uint32"), )])) .expect("plan"); assert_eq!(plan.extension().len(), 2); @@ -3870,6 +3997,45 @@ mod tests { ); } + /// A plan with a context field holds the type rule too: an untyped + /// indexed field beside the context field is refused, parsed or + /// built by hand, and the refusal names it. + #[test] + fn an_untyped_indexed_field_beside_the_context_field_is_refused() { + let parsed = plan(obj(vec![ + ("context_field", s("tenant")), + ("tenant", spec(identity("tenant"), &["passthrough"])), + ("age", spec(identity("age"), &["c", "eq"])), + ])); + assert!( + matches!(&parsed, Err(Error::UntypedIndex { field }) if field == "age"), + "{parsed:?}" + ); + + let field = |name: &str, outputs: &[&str]| { + FieldPlan::new( + name, + context(identity(name)).expect("context"), + outputs + .iter() + .map(|o| Output::parse(o).expect("output")) + .collect(), + ) + .expect("field") + }; + let built = Plan::with_context_field( + "tenant", + vec![ + field("tenant", &["passthrough"]), + field("age", &["c", "eq"]), + ], + ); + assert!( + matches!(&built, Err(Error::UntypedIndex { field }) if field == "age"), + "{built:?}" + ); + } + /// Two tenants in one batch: each record is sealed under the context /// its own field names, in one key request, and each opens back /// under the context it stores. @@ -4477,6 +4643,76 @@ mod tests { assert_eq!(u64_of(&object(opened)[0].1), 34, "the declared type opens"); } + /// One row stored as another kind fails the whole batch, as the + /// CHANGELOG says. The other rows are not returned. + #[tokio::test] + async fn decrypt_fails_a_batch_when_one_row_opens_to_another_type() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let untyped = plan(obj(vec![("age", spec(label("age"), &["c"]))])).expect("plan"); + let rows = FfiValue::Array(vec![ + obj(vec![("age", FfiValue::UInt64(34))]), + obj(vec![("age", FfiValue::Float64(34.0))]), + ]); + let sealed = seal(&keyset, rows, &untyped).await; + let as_uint64 = + plan(obj(vec![("age", typed(label("age"), &["c"], "uint64"))])).expect("plan"); + let result = decrypt(Scope::Client(&cipher), sealed, &as_uint64, None) + .expect("the shape fits") + .await; + assert_eq!( + plan_error(result.err().expect("the batch is refused")), + PlanError::FieldType { + field: "age".into(), + expected: "uint64", + } + ); + } + + /// The migration path the CHANGELOG gives for rows stored as + /// another kind: a plan that gives the field only `"c"` and no + /// `"type"` opens a row of any kind, whatever indexes it was sealed + /// with, and the value, converted to the declared kind, seals again + /// under the typed plan and opens there. + #[tokio::test] + async fn a_ciphertext_only_untyped_plan_reads_rows_of_any_kind() { + let cipher = cipher().await; + let keyset = cipher.default_keyset(); + let as_uint32 = seal( + &keyset, + obj(vec![("age", FfiValue::UInt32(34))]), + &age_plan("uint32"), + ) + .await; + let as_int64 = seal( + &keyset, + obj(vec![("age", FfiValue::Int64(34))]), + &age_plan("int64"), + ) + .await; + let read = plan(obj(vec![("age", spec(label("age"), &["c"]))])).expect("plan"); + + let opened = object(open(&cipher, as_uint32, &read).await); + assert!( + matches!(opened[0].1, FfiValue::UInt32(34)), + "a uint32 row opens as one" + ); + let opened = object(open(&cipher, as_int64, &read).await); + let FfiValue::Int64(value) = opened[0].1 else { + panic!("an int64 row opens as one") + }; + + let converted = u32::try_from(value).expect("fits"); + let resealed = seal( + &keyset, + obj(vec![("age", FfiValue::UInt32(converted))]), + &age_plan("uint32"), + ) + .await; + let reopened = object(open(&cipher, resealed, &age_plan("uint32")).await); + assert_eq!(u32_of(&reopened[0].1), 34); + } + /// In a batch, each opened value is checked against its own field, /// not the first field's. #[tokio::test] @@ -4882,7 +5118,7 @@ mod tests { /// `age` sealed and indexed by the lowered plan, `email` a target. fn mixed_plan_value() -> FfiValue { obj(vec![ - ("age", spec(label("age"), &["c", "eq"])), + ("age", typed(label("age"), &["c", "eq"], "uint32")), ("email", target_spec(label("email"), TEXT_EQ)), ]) }