From b4f0f0a50a6714529f6549c7e186d3817c678f93 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 2 Oct 2026 14:13:03 -0700 Subject: [PATCH 1/3] feat(go): one option type for record calls and probes A tenant-scoped query probe had to be assembled by hand. ExtendContext was a RecordOption, so EncryptRecords and DecryptRecords took it and Term did not: the caller rebuilt the field's context with Context.With, re-typing what the plan already knew, and any slip (uint64(7) on write and 7 on read, or a different nesting) produced a valid term in a different domain. The query returned nothing and no error, which is the failure ADR-0004 warns about. RecordOption becomes Option, accepted by every record call, and TermOption is the subset Cipher.Term also accepts. ExtendContext returns a TermOption, so one value serves encrypt, decrypt and probe; WithPlan returns an Option only, so passing it to Term does not compile. One function, extend, applies an extension for both the plan and the probe, and a unit test pins that the two produce the same context. The live records test now checks a tenant's probe matches only that tenant's rows. --- languages/golang/stackencrypt/cipher.go | 20 +++- languages/golang/stackencrypt/client.go | 4 +- languages/golang/stackencrypt/doc.go | 7 +- languages/golang/stackencrypt/export_test.go | 2 +- languages/golang/stackencrypt/guest_test.go | 6 +- languages/golang/stackencrypt/live_test.go | 28 +++++ languages/golang/stackencrypt/record.go | 102 +++++++++++++++---- languages/golang/stackencrypt/unit_test.go | 59 ++++++++++- 8 files changed, 197 insertions(+), 31 deletions(-) diff --git a/languages/golang/stackencrypt/cipher.go b/languages/golang/stackencrypt/cipher.go index 5820a6aa8..e0926a029 100644 --- a/languages/golang/stackencrypt/cipher.go +++ b/languages/golang/stackencrypt/cipher.go @@ -86,13 +86,27 @@ func (cph *Cipher) DecryptElement(ctx context.Context, ct any, aad []byte) (any, // encoding); a string for Match; any scalar for Ore and Ope. The result is // one of EqualityTerm, MatchTerm, OreTerm or OpeTerm. // +// opts are the [TermOption]s a probe shares with the record calls. +// [ExtendContext] extends context exactly as it extends each field's own +// context in a record call, so a probe for a field written under an +// extension is the field's context plus the same option value the rows +// were written with, never a context spelled by hand. +// // Term takes a context and returns an error because it may be a ZeroKMS // round trip: term derivation is asynchronous in the Rust crate, and a // ZeroKMS backend that derives terms server-side settles the same way. -func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind) (any, error) { +func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind, opts ...TermOption) (any, error) { if context.node == nil { return nil, fmt.Errorf("stackencrypt: term context is empty") } + var o termOptions + for _, opt := range opts { + opt.applyTerm(&o) + } + context, err := extend(context, o.extension) + if err != nil { + return nil, err + } encodedValue, err := vcffi.Marshal(value) if err != nil { return nil, err @@ -105,12 +119,12 @@ func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind Te return nil, err } defer wipe(encodedContext) - opts, err := vcffi.Marshal(options(cph.keyset)) + encodedOpts, err := vcffi.Marshal(options(cph.keyset)) if err != nil { return nil, err } out, err := cph.client.call(ctx, func(inst *instance) ([]byte, error) { - return inst.call(ctx, inst.term, buf(encodedValue), buf(encodedContext), scalar(uint64(kind)), buf(opts)) + return inst.call(ctx, inst.term, buf(encodedValue), buf(encodedContext), scalar(uint64(kind)), buf(encodedOpts)) }) if err != nil { return nil, err diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 8482cb573..79b88be43 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -398,13 +398,13 @@ func (c *Client) DecryptElement(ctx context.Context, ct any, aad []byte) (any, e // DecryptRecords opens records produced by Cipher.EncryptRecords under any // keyset of this client, into a slice; see Cipher.DecryptRecords. -func (c *Client) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { +func (c *Client) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...Option) error { return c.decryptRecords(ctx, anyKeyset{}, records, out, opts) } // DecryptRecord opens one record under any keyset of this client; see // Cipher.DecryptRecord. -func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { +func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...Option) error { return c.decryptRecord(ctx, anyKeyset{}, record, out, opts) } diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index 3bc5dd0af..a5ededa55 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -51,7 +51,12 @@ // for any ordinary value or batch, one more per 500 sealed leaves beyond // that. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are // byte-equal to the ones the Rust crate derives, so a probe from -// [Cipher.Term] compares against a stored term from any language. +// [Cipher.Term] compares against a stored term from any language. The +// record calls and Term share one option type ([Option]; [TermOption] is +// the part both accept): [ExtendContext] given to a record call and to the +// probe extends the field's context and the probe's identically, so a +// tenant-scoped probe is the field's own context plus the option value the +// rows were written with, never a context spelled by hand. // [Cipher.Term] takes a context and returns an error from day one: term // derivation may be a ZeroKMS round trip. // diff --git a/languages/golang/stackencrypt/export_test.go b/languages/golang/stackencrypt/export_test.go index 22b844ae5..fcd69d99c 100644 --- a/languages/golang/stackencrypt/export_test.go +++ b/languages/golang/stackencrypt/export_test.go @@ -40,7 +40,7 @@ func withZeroKMSURL(url string) ClientOption { // guest under p, each context extended by ext: what the external tests // compare byte for byte. Test-only; not part of the package's API. func GuestPlanInput(p Plan, t reflect.Type, ext ...any) ([]byte, error) { - o := applyOptions([]RecordOption{WithPlan(p), ExtendContext(ext...)}) + o := applyOptions([]Option{WithPlan(p), ExtendContext(ext...)}) bound, err := planFor(t, o) if err != nil { return nil, err diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index c876b17e9..7af0ca715 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -677,7 +677,11 @@ func TestGuestAcceptsEveryEncodingThisPackageBuilds(t *testing.T) { _, err := byID.Term(ctx, 1.5, c, Ore) return err }, - "Term ope bytes": func() error { _, err := def.Term(ctx, []byte{1}, MustContext("k"), Ope); return err }, + "Term ope bytes": func() error { _, err := def.Term(ctx, []byte{1}, MustContext("k"), Ope); return err }, + "Term ext option": func() error { + _, err := byID.Term(ctx, 1.5, MustContext("users/age"), Ore, ExtendContext(uint64(7), "eu")) + return err + }, "EncryptRecords": func() error { _, err := def.EncryptRecords(ctx, rows); return err }, "EncryptRecords ext": func() error { _, err := named.EncryptRecords(ctx, &rows, ExtendContext(uint64(7), "eu")); return err }, "EncryptRecord": func() error { _, err := byID.EncryptRecord(ctx, rows[0]); return err }, diff --git a/languages/golang/stackencrypt/live_test.go b/languages/golang/stackencrypt/live_test.go index 9a49bee12..f3e4e50d2 100644 --- a/languages/golang/stackencrypt/live_test.go +++ b/languages/golang/stackencrypt/live_test.go @@ -220,6 +220,34 @@ func TestLiveRecordsAndTerms(t *testing.T) { if err := cipher.DecryptRecords(ctx, ext, &back, ExtendContext(uint64(7))); err != nil { t.Fatalf("extended record with its extension: %v", err) } + + // A probe takes the same option, and matches only the rows written + // under it: not another tenant's, and not the unextended ones. + tenant7, tenant8 := ExtendContext(uint64(7)), ExtendContext(uint64(8)) + other, err := cipher.EncryptRecords(ctx, users, tenant8) + if err != nil { + t.Fatal(err) + } + scoped, err := cipher.Term(ctx, "bob@example.com", MustContext("users/email"), Equality, tenant7) + if err != nil { + t.Fatal(err) + } + if !scoped.(EqualityTerm).Equal(ext[1]["Email"].Equality) { + t.Error("tenant probe does not equal the term written under the same extension") + } + if scoped.(EqualityTerm).Equal(other[1]["Email"].Equality) { + t.Error("tenant probe equals another tenant's term") + } + if scoped.(EqualityTerm).Equal(records[1]["Email"].Equality) { + t.Error("tenant probe equals the unextended term") + } + unscoped, err := cipher.Term(ctx, "bob@example.com", MustContext("users/email"), Equality) + if err != nil { + t.Fatal(err) + } + if unscoped.(EqualityTerm).Equal(ext[1]["Email"].Equality) { + t.Error("an unextended probe equals a tenant's term") + } } // An explicit plan round-trips a struct that carries no tags, and a record diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index e29196535..0ffccd29e 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -79,23 +79,87 @@ type EncryptedField struct { // EncryptedRecord is one record's planned fields, by wire name. type EncryptedRecord map[string]EncryptedField -// RecordOption adjusts how a record call binds its fields. -type RecordOption func(*recordOptions) +// Option adjusts one record call: [Cipher.EncryptRecords], +// [Cipher.EncryptRecord], [Cipher.DecryptRecords], [Cipher.DecryptRecord] +// and the Client forms of the last two. An option is a value built by one +// of the functions below, and a call applies the options it is given in +// order. +// +// A [TermOption] is an Option the probe call, [Cipher.Term], accepts as +// well: what a record and the probe that matches it must agree on. An +// option that means something only on a record call, such as [WithPlan], +// is not one, so handing it to Term does not compile. +type Option interface { + applyRecord(*recordOptions) +} + +// TermOption is an [Option] that [Cipher.Term] also accepts. Every +// TermOption is an Option, so one value serves the encrypt, decrypt and +// probe calls alike, and the three cannot drift apart: +// +// tenant := stackencrypt.ExtendContext(uint64(tenantID)) +// rows, err := cipher.EncryptRecords(ctx, users, tenant) +// probe, err := cipher.Term(ctx, "bob@example.com", email, stackencrypt.Equality, tenant) +// err = cipher.DecryptRecords(ctx, rows, &back, tenant) +type TermOption interface { + Option + applyTerm(*termOptions) +} type recordOptions struct { extension []any plan Plan } +type termOptions struct { + extension []any +} + +// contextExtension is what [ExtendContext] returns. +type contextExtension struct{ parts []any } + +func (e contextExtension) applyRecord(o *recordOptions) { + o.extension = append(o.extension, e.parts...) +} + +func (e contextExtension) applyTerm(o *termOptions) { + o.extension = append(o.extension, e.parts...) +} + // ExtendContext extends every field's context by parts, in order, the way // the Rust derive extends a field's context by the caller's // (encrypt_into_with_context): a field tagged context=users/age with -// ExtendContext(uint64(7)) binds ["users/age", 7]. The same extension must -// be given to decrypt the records. -func ExtendContext(parts ...any) RecordOption { - return func(o *recordOptions) { o.extension = append(o.extension, parts...) } +// ExtendContext(uint64(7)) binds ["users/age", 7]. On [Cipher.Term] it +// extends the probe's context the same way, so a probe built under the +// extension a record was written under compares against that record's +// terms, and under any other extension, or none, against nothing. +// +// The same extension must be given to decrypt the records. A part's type +// is part of the context (an int crosses as int64, so uint64(7) and 7 are +// different contexts), which is why an extension is best held in one value +// and passed to every call rather than spelled afresh at each. +func ExtendContext(parts ...any) TermOption { + return contextExtension{parts: slices.Clone(parts)} +} + +// extend is c extended by every part of ext, in order: the one definition +// of how an extension applies, shared by the record plan and the probe so +// the two cannot disagree. +func extend(c Context, ext []any) (Context, error) { + for _, part := range ext { + var err error + if c, err = c.With(part); err != nil { + return Context{}, err + } + } + return c, nil } +// planOption is what [WithPlan] returns. +type planOption struct{ plan Plan } + +func (p planOption) applyRecord(o *recordOptions) { o.plan = p.plan } + // WithPlan encrypts or decrypts records under an explicit plan instead of // the struct's `stash` tags. // @@ -107,8 +171,8 @@ func ExtendContext(parts ...any) RecordOption { // generated struct may be decrypted into a domain struct under a plan // with the same Names and Contexts. Terms are one-way outputs, derived on // encryption and never sent to decrypt, so they need not match either. -func WithPlan(p Plan) RecordOption { - return func(o *recordOptions) { o.plan = p } +func WithPlan(p Plan) Option { + return planOption{plan: p} } // FieldPlan is one planned field of a record. @@ -355,10 +419,8 @@ func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { if err != nil { return nil, err } - for _, part := range opts.extension { - if ctx, err = ctx.With(part); err != nil { - return nil, err - } + if ctx, err = extend(ctx, opts.extension); err != nil { + return nil, err } outputs := make([]any, len(f.outputs)) for i, o := range f.outputs { @@ -372,10 +434,10 @@ func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { return out, nil } -func applyOptions(opts []RecordOption) recordOptions { +func applyOptions(opts []Option) recordOptions { var o recordOptions for _, opt := range opts { - opt(&o) + opt.applyRecord(&o) } return o } @@ -385,7 +447,7 @@ func applyOptions(opts []RecordOption) recordOptions { // fields from batched ZeroKMS key requests (one per 500 sealed fields), // terms derived under this keyset's index key. One EncryptedRecord per // row, in order. -func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordOption) ([]EncryptedRecord, error) { +func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...Option) ([]EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(rows)) if !v.IsValid() || v.Kind() != reflect.Slice { return nil, fmt.Errorf("stackencrypt: EncryptRecords takes a slice of structs, not %T", rows) @@ -418,7 +480,7 @@ func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordO // EncryptRecord seals one struct (or a pointer to one) per its `stash` // tags, or per [WithPlan]; see EncryptRecords. -func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...RecordOption) (EncryptedRecord, error) { +func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...Option) (EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(row)) if !v.IsValid() { return nil, fmt.Errorf("stackencrypt: EncryptRecord takes a struct, not %T", row) @@ -531,18 +593,18 @@ func termBytes(node any) ([]byte, error) { // left as they are: when the slice already holds one row per record, each // row keeps its other fields; otherwise it is replaced by a fresh slice. // Nothing is written unless every record decodes. -func (cph *Cipher) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { +func (cph *Cipher) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...Option) error { return cph.client.decryptRecords(ctx, cph.keyset, records, out, opts) } // DecryptRecord opens one record into out, a pointer to a struct; see // DecryptRecords. Fields the plan does not name keep their values, and // nothing is written unless every planned field decodes. -func (cph *Cipher) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { +func (cph *Cipher) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...Option) error { return cph.client.decryptRecord(ctx, cph.keyset, record, out, opts) } -func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records []EncryptedRecord, out any, opts []RecordOption) error { +func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records []EncryptedRecord, out any, opts []Option) error { ptr := reflect.ValueOf(out) if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Slice { return fmt.Errorf("stackencrypt: DecryptRecords writes into a pointer to a slice of structs, not %T", out) @@ -600,7 +662,7 @@ func commitRecord(target reflect.Value, item any, plan []fieldPlan) error { return nil } -func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record EncryptedRecord, out any, opts []RecordOption) error { +func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record EncryptedRecord, out any, opts []Option) error { ptr := reflect.ValueOf(out) if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Struct { return fmt.Errorf("stackencrypt: DecryptRecord writes into a pointer to a struct, not %T", out) diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 12c5536a8..43c4257c7 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -258,11 +258,11 @@ func TestExplicitPlanIsTheTagPlan(t *testing.T) { if a, b := encode(explicit), encode(tagged); !bytes.Equal(a, b) { t.Fatalf("guest input differs:\n%x\n%x", a, b) } - viaOption, err := planFor(typ, applyOptions([]RecordOption{WithPlan(explicit)})) + viaOption, err := planFor(typ, applyOptions([]Option{WithPlan(explicit)})) if err != nil { t.Fatal(err) } - viaTags, err := planFor(typ, applyOptions([]RecordOption{WithPlan(Plan{})})) + viaTags, err := planFor(typ, applyOptions([]Option{WithPlan(Plan{})})) if err != nil { t.Fatal(err) } @@ -276,6 +276,59 @@ func TestExplicitPlanIsTheTagPlan(t *testing.T) { } } +// A probe's context under ExtendContext is, byte for byte, the context the +// record plan sends for a field with the same own context under the same +// extension — the one place the probe and the stored term could silently +// disagree. And it differs from the unextended context and from another +// extension's, which is what makes the match tenant-specific. +func TestTermExtensionMatchesRecordFieldContext(t *testing.T) { + type row struct { + Email string `stash:"context=users/email,index=eq"` + } + ext := []any{uint64(7), "eu"} + o := applyOptions([]Option{ExtendContext(ext...)}) + bound, err := planFor(reflect.TypeOf(row{}), o) + if err != nil { + t.Fatal(err) + } + obj, err := planValue(bound, o) + if err != nil { + t.Fatal(err) + } + spec, ok := obj[0].Value.(vcvalue.Object) + if !ok || spec[0].Key != "context" { + t.Fatalf("plan field encodes as %+v", obj[0].Value) + } + fieldContext := spec[0].Value + + var to termOptions + ExtendContext(ext...).applyTerm(&to) + probe, err := extend(MustContext("users/email"), to.extension) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(probe.value(), fieldContext) { + t.Fatalf("probe context %#v, record field context %#v", probe.value(), fieldContext) + } + if reflect.DeepEqual(MustContext("users/email").value(), fieldContext) { + t.Fatal("the unextended probe context equals the extended field's") + } + other, err := extend(MustContext("users/email"), []any{uint64(8), "eu"}) + if err != nil { + t.Fatal(err) + } + if reflect.DeepEqual(other.value(), fieldContext) { + t.Fatal("another tenant's probe context equals the field's") + } + // The same option value, held once and passed to both calls, is how + // the two sides are kept in step; it applies identically through + // either interface. + var opt Option = ExtendContext(ext...) + if _, ok := opt.(TermOption); !ok { + t.Fatal("ExtendContext is not a TermOption through its Option interface") + } +} + // A plan can name only exported, direct fields of the struct it binds to, // and only fields that exist; an untagged struct binds fine under it. func TestPlanBindsByFieldName(t *testing.T) { @@ -293,7 +346,7 @@ func TestPlanBindsByFieldName(t *testing.T) { if err != nil { t.Fatal(err) } - bound, err := planFor(typ, applyOptions([]RecordOption{WithPlan(ok)})) + bound, err := planFor(typ, applyOptions([]Option{WithPlan(ok)})) if err != nil { t.Fatal(err) } From 898214479ef3b634ab64617084fee71b7e4e3689 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 2 Oct 2026 14:23:30 -0700 Subject: [PATCH 2/3] fix(go): ExtendContext copies byte-slice parts Cloning the []any only copied the slice of parts; a []byte part still pointed at the caller's buffer, so an option held across calls, which is what this API asks for, would extend by whatever that buffer held at each call. The option now owns a copy of every byte part, and a test mutates the source buffer between applications to pin it. --- languages/golang/stackencrypt/record.go | 14 +++++++++-- languages/golang/stackencrypt/unit_test.go | 28 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 0ffccd29e..84e42a20a 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -1,6 +1,7 @@ package stackencrypt import ( + "bytes" "context" "errors" "fmt" @@ -137,9 +138,18 @@ func (e contextExtension) applyTerm(o *termOptions) { // The same extension must be given to decrypt the records. A part's type // is part of the context (an int crosses as int64, so uint64(7) and 7 are // different contexts), which is why an extension is best held in one value -// and passed to every call rather than spelled afresh at each. +// and passed to every call rather than spelled afresh at each. The option +// owns its parts: a byte-slice part is copied, so a caller's buffer reused +// after the call does not change what the option extends by. func ExtendContext(parts ...any) TermOption { - return contextExtension{parts: slices.Clone(parts)} + owned := make([]any, len(parts)) + for i, part := range parts { + if b, ok := part.([]byte); ok { + part = bytes.Clone(b) + } + owned[i] = part + } + return contextExtension{parts: owned} } // extend is c extended by every part of ext, in order: the one definition diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 43c4257c7..4d4bf1ad7 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -329,6 +329,34 @@ func TestTermExtensionMatchesRecordFieldContext(t *testing.T) { } } +// An option owns its parts. A byte-slice part is copied when the option +// is built, so a caller's buffer reused between the write and the probe +// does not move the context the saved option extends by, on either side. +func TestExtendContextOwnsItsByteParts(t *testing.T) { + region := []byte("eu") + opt := ExtendContext(uint64(7), region) + first := applyOptions([]Option{opt}) + var firstProbe termOptions + opt.applyTerm(&firstProbe) + + copy(region, "us") + + second := applyOptions([]Option{opt}) + var secondProbe termOptions + opt.applyTerm(&secondProbe) + for name, ext := range map[string][]any{ + "record, before": first.extension, "record, after": second.extension, + "probe, before": firstProbe.extension, "probe, after": secondProbe.extension, + } { + if got := string(ext[1].([]byte)); got != "eu" { + t.Errorf("%s: byte part is %q after the caller's buffer changed, want \"eu\"", name, got) + } + } + if !reflect.DeepEqual(first.extension, second.extension) || !reflect.DeepEqual(firstProbe.extension, secondProbe.extension) { + t.Error("the same option applied twice gave different extensions") + } +} + // A plan can name only exported, direct fields of the struct it binds to, // and only fields that exist; an untagged struct binds fine under it. func TestPlanBindsByFieldName(t *testing.T) { From 97749aa93f26a741920e6268dbaa30a55b35c8ed Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 2 Oct 2026 15:05:09 -0700 Subject: [PATCH 3/3] refactor(go): Option is the option every call accepts The type named Option was the narrow one: it served only the record calls, while TermOption served those and Cipher.Term too. The broad name now goes to the broad type. RecordOption is what only a record call takes (WithPlan); Option is a RecordOption that Cipher.Term accepts as well (ExtendContext). Cipher.Term takes ...Option and still refuses a plan at compile time. Several ExtendContext options on one call join in order, so ExtendContext(a), ExtendContext(b) is the context ExtendContext(a, b) gives. That rule is now written in the ExtendContext doc, with the warning that follows from it: an extension given twice extends twice, and a probe built with it once matches none of those rows. applyRecord and applyTerm both call one appendTo method, so the record side and the probe side cannot combine options by different rules. Tests: TestSeveralExtensionsJoinInOrder applies two options to one record plan and one probe and checks both equal the one-option context; TestTermExtensionMatchesRecordFieldContext now also checks WithPlan is not an Option; TestGuestRefusesMalformedInputsBeforeState gains bad extension parts on Term, a record write and a record read, and TestBadExtensionPartFailsTheCall checks those calls fail because of the part, which tells a dropped error apart from a guest refusal. --- languages/golang/stackencrypt/cipher.go | 13 +-- languages/golang/stackencrypt/client.go | 4 +- languages/golang/stackencrypt/doc.go | 9 +- languages/golang/stackencrypt/export_test.go | 2 +- languages/golang/stackencrypt/guest_test.go | 44 ++++++++++ languages/golang/stackencrypt/record.go | 64 +++++++++------ languages/golang/stackencrypt/unit_test.go | 86 +++++++++++++++++--- 7 files changed, 174 insertions(+), 48 deletions(-) diff --git a/languages/golang/stackencrypt/cipher.go b/languages/golang/stackencrypt/cipher.go index e0926a029..79fbc6bad 100644 --- a/languages/golang/stackencrypt/cipher.go +++ b/languages/golang/stackencrypt/cipher.go @@ -86,16 +86,17 @@ func (cph *Cipher) DecryptElement(ctx context.Context, ct any, aad []byte) (any, // encoding); a string for Match; any scalar for Ore and Ope. The result is // one of EqualityTerm, MatchTerm, OreTerm or OpeTerm. // -// opts are the [TermOption]s a probe shares with the record calls. -// [ExtendContext] extends context exactly as it extends each field's own -// context in a record call, so a probe for a field written under an -// extension is the field's context plus the same option value the rows -// were written with, never a context spelled by hand. +// opts are [Option]s, the options a probe shares with the record calls; a +// [RecordOption] that only a record call takes, such as [WithPlan], does +// not compile here. [ExtendContext] extends context exactly as it extends +// each field's own context in a record call, so a probe for a field +// written under an extension is the field's context plus the same option +// value the rows were written with, never a context spelled by hand. // // Term takes a context and returns an error because it may be a ZeroKMS // round trip: term derivation is asynchronous in the Rust crate, and a // ZeroKMS backend that derives terms server-side settles the same way. -func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind, opts ...TermOption) (any, error) { +func (cph *Cipher) Term(ctx context.Context, value any, context Context, kind TermKind, opts ...Option) (any, error) { if context.node == nil { return nil, fmt.Errorf("stackencrypt: term context is empty") } diff --git a/languages/golang/stackencrypt/client.go b/languages/golang/stackencrypt/client.go index 79b88be43..8482cb573 100644 --- a/languages/golang/stackencrypt/client.go +++ b/languages/golang/stackencrypt/client.go @@ -398,13 +398,13 @@ func (c *Client) DecryptElement(ctx context.Context, ct any, aad []byte) (any, e // DecryptRecords opens records produced by Cipher.EncryptRecords under any // keyset of this client, into a slice; see Cipher.DecryptRecords. -func (c *Client) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...Option) error { +func (c *Client) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { return c.decryptRecords(ctx, anyKeyset{}, records, out, opts) } // DecryptRecord opens one record under any keyset of this client; see // Cipher.DecryptRecord. -func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...Option) error { +func (c *Client) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { return c.decryptRecord(ctx, anyKeyset{}, record, out, opts) } diff --git a/languages/golang/stackencrypt/doc.go b/languages/golang/stackencrypt/doc.go index a5ededa55..fa22f8ea1 100644 --- a/languages/golang/stackencrypt/doc.go +++ b/languages/golang/stackencrypt/doc.go @@ -51,10 +51,11 @@ // for any ordinary value or batch, one more per 500 sealed leaves beyond // that. Terms ([EqualityTerm], [MatchTerm], [OreTerm], [OpeTerm]) are // byte-equal to the ones the Rust crate derives, so a probe from -// [Cipher.Term] compares against a stored term from any language. The -// record calls and Term share one option type ([Option]; [TermOption] is -// the part both accept): [ExtendContext] given to a record call and to the -// probe extends the field's context and the probe's identically, so a +// [Cipher.Term] compares against a stored term from any language. An +// [Option] is the one value that serves encrypt, decrypt and probe alike; +// a [RecordOption], such as [WithPlan], is what only a record call takes. +// [ExtendContext] is an Option: given to a record call and to the probe it +// extends the field's context and the probe's identically, so a // tenant-scoped probe is the field's own context plus the option value the // rows were written with, never a context spelled by hand. // [Cipher.Term] takes a context and returns an error from day one: term diff --git a/languages/golang/stackencrypt/export_test.go b/languages/golang/stackencrypt/export_test.go index fcd69d99c..22b844ae5 100644 --- a/languages/golang/stackencrypt/export_test.go +++ b/languages/golang/stackencrypt/export_test.go @@ -40,7 +40,7 @@ func withZeroKMSURL(url string) ClientOption { // guest under p, each context extended by ext: what the external tests // compare byte for byte. Test-only; not part of the package's API. func GuestPlanInput(p Plan, t reflect.Type, ext ...any) ([]byte, error) { - o := applyOptions([]Option{WithPlan(p), ExtendContext(ext...)}) + o := applyOptions([]RecordOption{WithPlan(p), ExtendContext(ext...)}) bound, err := planFor(t, o) if err != nil { return nil, err diff --git a/languages/golang/stackencrypt/guest_test.go b/languages/golang/stackencrypt/guest_test.go index 7af0ca715..9c54e03de 100644 --- a/languages/golang/stackencrypt/guest_test.go +++ b/languages/golang/stackencrypt/guest_test.go @@ -735,6 +735,22 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { "record without c": func() error { return c.DecryptRecord(ctx, EncryptedRecord{"Age": {Equality: EqualityTerm{1}}, "Email": {Ciphertext: Sealed(fixtureLeaf)}}, new(recordRow)) }, + // ExtendContext checks nothing when it is built; Context.With + // refuses the part when a call applies it. A call that dropped + // that error would run under a context missing the extension: + // a probe that matches no rows, or rows no probe matches. + "bad ext part in a term": func() error { + _, err := def.Term(ctx, 1, MustContext("k"), Equality, ExtendContext(1.5)) + return err + }, + "bad ext part in a record write": func() error { + _, err := def.EncryptRecords(ctx, []recordRow{{Age: 1, Email: "a@b.c"}}, ExtendContext(1.5)) + return err + }, + "bad ext part in a record read": func() error { + record := EncryptedRecord{"Age": {Ciphertext: Sealed(fixtureLeaf)}, "Email": {Ciphertext: Sealed(fixtureLeaf)}} + return c.DecryptRecord(ctx, record, new(recordRow), ExtendContext(1.5)) + }, } for name, call := range calls { err := call() @@ -746,6 +762,34 @@ func TestGuestRefusesMalformedInputsBeforeState(t *testing.T) { } } +// A bad ExtendContext part fails the call for that reason, on the probe and +// on both record directions. TestGuestRefusesMalformedInputsBeforeState +// shows the call never reaches the cipher, but a call that ignored the +// error and went on with an empty context would be refused too, by the +// guest, for another reason; only the error's own words tell the two apart. +func TestBadExtensionPartFailsTheCall(t *testing.T) { + ctx := context.Background() + c := rawInstance(t) + def := c.DefaultKeyset() + bad := ExtendContext(uint64(7), 1.5) + record := EncryptedRecord{"Age": {Ciphertext: Sealed(fixtureLeaf)}, "Email": {Ciphertext: Sealed(fixtureLeaf)}} + for name, call := range map[string]func() error{ + "Term": func() error { + _, err := def.Term(ctx, 1, MustContext("k"), Equality, bad) + return err + }, + "EncryptRecords": func() error { + _, err := def.EncryptRecords(ctx, []recordRow{{Age: 1, Email: "a@b.c"}}, bad) + return err + }, + "DecryptRecord": func() error { return c.DecryptRecord(ctx, record, new(recordRow), bad) }, + } { + if err := call(); err == nil || !strings.Contains(err.Error(), "float64 is not a context part") { + t.Errorf("%s with a float64 extension part: %v, want the part refused", name, err) + } + } +} + func TestClosedClientIsState(t *testing.T) { ctx := context.Background() c := rawInstance(t) diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 84e42a20a..18e417188 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -80,30 +80,30 @@ type EncryptedField struct { // EncryptedRecord is one record's planned fields, by wire name. type EncryptedRecord map[string]EncryptedField -// Option adjusts one record call: [Cipher.EncryptRecords], +// RecordOption adjusts one record call: [Cipher.EncryptRecords], // [Cipher.EncryptRecord], [Cipher.DecryptRecords], [Cipher.DecryptRecord] // and the Client forms of the last two. An option is a value built by one // of the functions below, and a call applies the options it is given in // order. // -// A [TermOption] is an Option the probe call, [Cipher.Term], accepts as -// well: what a record and the probe that matches it must agree on. An -// option that means something only on a record call, such as [WithPlan], -// is not one, so handing it to Term does not compile. -type Option interface { +// A RecordOption that is not also an [Option], such as [WithPlan], means +// something only on a record call, so handing it to [Cipher.Term] does not +// compile. +type RecordOption interface { applyRecord(*recordOptions) } -// TermOption is an [Option] that [Cipher.Term] also accepts. Every -// TermOption is an Option, so one value serves the encrypt, decrypt and -// probe calls alike, and the three cannot drift apart: +// Option is a [RecordOption] that the probe call, [Cipher.Term], accepts +// as well: what a record and the probe that matches it must agree on. +// Every Option is a RecordOption, so one value serves the encrypt, decrypt +// and probe calls alike, and the three cannot drift apart: // // tenant := stackencrypt.ExtendContext(uint64(tenantID)) // rows, err := cipher.EncryptRecords(ctx, users, tenant) // probe, err := cipher.Term(ctx, "bob@example.com", email, stackencrypt.Equality, tenant) // err = cipher.DecryptRecords(ctx, rows, &back, tenant) -type TermOption interface { - Option +type Option interface { + RecordOption applyTerm(*termOptions) } @@ -119,13 +119,16 @@ type termOptions struct { // contextExtension is what [ExtendContext] returns. type contextExtension struct{ parts []any } -func (e contextExtension) applyRecord(o *recordOptions) { - o.extension = append(o.extension, e.parts...) +// appendTo adds the extension's parts after any an earlier option gave: +// the one rule for combining extensions, shared by the record calls and +// the probe so the two cannot combine them differently. +func (e contextExtension) appendTo(ext *[]any) { + *ext = append(*ext, e.parts...) } -func (e contextExtension) applyTerm(o *termOptions) { - o.extension = append(o.extension, e.parts...) -} +func (e contextExtension) applyRecord(o *recordOptions) { e.appendTo(&o.extension) } + +func (e contextExtension) applyTerm(o *termOptions) { e.appendTo(&o.extension) } // ExtendContext extends every field's context by parts, in order, the way // the Rust derive extends a field's context by the caller's @@ -141,7 +144,18 @@ func (e contextExtension) applyTerm(o *termOptions) { // and passed to every call rather than spelled afresh at each. The option // owns its parts: a byte-slice part is copied, so a caller's buffer reused // after the call does not change what the option extends by. -func ExtendContext(parts ...any) TermOption { +// +// Several ExtendContext options on one call join in order: +// ExtendContext(a), ExtendContext(b) is the same context as +// ExtendContext(a, b), on a record call and on Term alike. So each call +// must receive a given extension once. A helper that always adds the +// tenant, called by code that adds the tenant as well, writes records +// under [field, tenant, tenant], and a probe built with the tenant once +// matches none of them, with no error. +// +// A part is checked when a call applies it, not here: a part that is not +// a string, a byte slice or an integer fails the call it is given to. +func ExtendContext(parts ...any) Option { owned := make([]any, len(parts)) for i, part := range parts { if b, ok := part.([]byte); ok { @@ -181,7 +195,7 @@ func (p planOption) applyRecord(o *recordOptions) { o.plan = p.plan } // generated struct may be decrypted into a domain struct under a plan // with the same Names and Contexts. Terms are one-way outputs, derived on // encryption and never sent to decrypt, so they need not match either. -func WithPlan(p Plan) Option { +func WithPlan(p Plan) RecordOption { return planOption{plan: p} } @@ -444,7 +458,7 @@ func planValue(plan []fieldPlan, opts recordOptions) (vcvalue.Object, error) { return out, nil } -func applyOptions(opts []Option) recordOptions { +func applyOptions(opts []RecordOption) recordOptions { var o recordOptions for _, opt := range opts { opt.applyRecord(&o) @@ -457,7 +471,7 @@ func applyOptions(opts []Option) recordOptions { // fields from batched ZeroKMS key requests (one per 500 sealed fields), // terms derived under this keyset's index key. One EncryptedRecord per // row, in order. -func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...Option) ([]EncryptedRecord, error) { +func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...RecordOption) ([]EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(rows)) if !v.IsValid() || v.Kind() != reflect.Slice { return nil, fmt.Errorf("stackencrypt: EncryptRecords takes a slice of structs, not %T", rows) @@ -490,7 +504,7 @@ func (cph *Cipher) EncryptRecords(ctx context.Context, rows any, opts ...Option) // EncryptRecord seals one struct (or a pointer to one) per its `stash` // tags, or per [WithPlan]; see EncryptRecords. -func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...Option) (EncryptedRecord, error) { +func (cph *Cipher) EncryptRecord(ctx context.Context, row any, opts ...RecordOption) (EncryptedRecord, error) { v := reflect.Indirect(reflect.ValueOf(row)) if !v.IsValid() { return nil, fmt.Errorf("stackencrypt: EncryptRecord takes a struct, not %T", row) @@ -603,18 +617,18 @@ func termBytes(node any) ([]byte, error) { // left as they are: when the slice already holds one row per record, each // row keeps its other fields; otherwise it is replaced by a fresh slice. // Nothing is written unless every record decodes. -func (cph *Cipher) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...Option) error { +func (cph *Cipher) DecryptRecords(ctx context.Context, records []EncryptedRecord, out any, opts ...RecordOption) error { return cph.client.decryptRecords(ctx, cph.keyset, records, out, opts) } // DecryptRecord opens one record into out, a pointer to a struct; see // DecryptRecords. Fields the plan does not name keep their values, and // nothing is written unless every planned field decodes. -func (cph *Cipher) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...Option) error { +func (cph *Cipher) DecryptRecord(ctx context.Context, record EncryptedRecord, out any, opts ...RecordOption) error { return cph.client.decryptRecord(ctx, cph.keyset, record, out, opts) } -func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records []EncryptedRecord, out any, opts []Option) error { +func (c *Client) decryptRecords(ctx context.Context, sel KeysetSelector, records []EncryptedRecord, out any, opts []RecordOption) error { ptr := reflect.ValueOf(out) if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Slice { return fmt.Errorf("stackencrypt: DecryptRecords writes into a pointer to a slice of structs, not %T", out) @@ -672,7 +686,7 @@ func commitRecord(target reflect.Value, item any, plan []fieldPlan) error { return nil } -func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record EncryptedRecord, out any, opts []Option) error { +func (c *Client) decryptRecord(ctx context.Context, sel KeysetSelector, record EncryptedRecord, out any, opts []RecordOption) error { ptr := reflect.ValueOf(out) if ptr.Kind() != reflect.Pointer || ptr.IsNil() || ptr.Elem().Kind() != reflect.Struct { return fmt.Errorf("stackencrypt: DecryptRecord writes into a pointer to a struct, not %T", out) diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 4d4bf1ad7..395d3c3fe 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -258,11 +258,11 @@ func TestExplicitPlanIsTheTagPlan(t *testing.T) { if a, b := encode(explicit), encode(tagged); !bytes.Equal(a, b) { t.Fatalf("guest input differs:\n%x\n%x", a, b) } - viaOption, err := planFor(typ, applyOptions([]Option{WithPlan(explicit)})) + viaOption, err := planFor(typ, applyOptions([]RecordOption{WithPlan(explicit)})) if err != nil { t.Fatal(err) } - viaTags, err := planFor(typ, applyOptions([]Option{WithPlan(Plan{})})) + viaTags, err := planFor(typ, applyOptions([]RecordOption{WithPlan(Plan{})})) if err != nil { t.Fatal(err) } @@ -286,7 +286,7 @@ func TestTermExtensionMatchesRecordFieldContext(t *testing.T) { Email string `stash:"context=users/email,index=eq"` } ext := []any{uint64(7), "eu"} - o := applyOptions([]Option{ExtendContext(ext...)}) + o := applyOptions([]RecordOption{ExtendContext(ext...)}) bound, err := planFor(reflect.TypeOf(row{}), o) if err != nil { t.Fatal(err) @@ -322,10 +322,76 @@ func TestTermExtensionMatchesRecordFieldContext(t *testing.T) { } // The same option value, held once and passed to both calls, is how // the two sides are kept in step; it applies identically through - // either interface. - var opt Option = ExtendContext(ext...) - if _, ok := opt.(TermOption); !ok { - t.Fatal("ExtendContext is not a TermOption through its Option interface") + // either interface. An option that means something only on a record + // call is not an Option, so Cipher.Term cannot accept it and ignore it. + var opt RecordOption = ExtendContext(ext...) + if _, ok := opt.(Option); !ok { + t.Fatal("ExtendContext is not an Option through its RecordOption interface") + } + if _, ok := WithPlan(Plan{}).(Option); ok { + t.Fatal("WithPlan is an Option; Cipher.Term must not accept it") + } +} + +// Several ExtendContext options on one call join in order, and the record +// calls and the probe join them by the same rule: two options a and b are +// the context ExtendContext(a, b) gives, on both sides. A rule that let a +// later option replace an earlier one on one side only would put records +// and probes under different contexts with no error. +func TestSeveralExtensionsJoinInOrder(t *testing.T) { + type row struct { + Email string `stash:"context=users/email,index=eq"` + } + typ := reflect.TypeOf(row{}) + fieldContext := func(opts ...RecordOption) any { + t.Helper() + o := applyOptions(opts) + bound, err := planFor(typ, o) + if err != nil { + t.Fatal(err) + } + obj, err := planValue(bound, o) + if err != nil { + t.Fatal(err) + } + return obj[0].Value.(vcvalue.Object)[0].Value + } + probeContext := func(opts ...Option) any { + t.Helper() + var to termOptions + for _, opt := range opts { + opt.applyTerm(&to) + } + c, err := extend(MustContext("users/email"), to.extension) + if err != nil { + t.Fatal(err) + } + return c.value() + } + + tenant, region := ExtendContext(uint64(7)), ExtendContext("eu") + want := fieldContext(ExtendContext(uint64(7), "eu")) + if got := fieldContext(tenant, region); !reflect.DeepEqual(got, want) { + t.Errorf("record: two options give %#v, one option with both parts %#v", got, want) + } + if got := probeContext(tenant, region); !reflect.DeepEqual(got, want) { + t.Errorf("probe: two options give %#v, the record's one-option context %#v", got, want) + } + if got := probeContext(ExtendContext(uint64(7), "eu")); !reflect.DeepEqual(got, want) { + t.Errorf("probe: one option gives %#v, the record's %#v", got, want) + } + // Order is part of the context: the same parts the other way round are + // another context, on both sides. + if got := fieldContext(region, tenant); reflect.DeepEqual(got, want) { + t.Error("record: options in the other order give the same context") + } + if got := probeContext(region, tenant); reflect.DeepEqual(got, want) { + t.Error("probe: options in the other order give the same context") + } + // Joining is not deduplication: the same extension given twice extends + // twice, which is why a call must receive it once. + if got := fieldContext(tenant, tenant); reflect.DeepEqual(got, fieldContext(tenant)) { + t.Error("record: the same extension given twice is the single-extension context") } } @@ -335,13 +401,13 @@ func TestTermExtensionMatchesRecordFieldContext(t *testing.T) { func TestExtendContextOwnsItsByteParts(t *testing.T) { region := []byte("eu") opt := ExtendContext(uint64(7), region) - first := applyOptions([]Option{opt}) + first := applyOptions([]RecordOption{opt}) var firstProbe termOptions opt.applyTerm(&firstProbe) copy(region, "us") - second := applyOptions([]Option{opt}) + second := applyOptions([]RecordOption{opt}) var secondProbe termOptions opt.applyTerm(&secondProbe) for name, ext := range map[string][]any{ @@ -374,7 +440,7 @@ func TestPlanBindsByFieldName(t *testing.T) { if err != nil { t.Fatal(err) } - bound, err := planFor(typ, applyOptions([]Option{WithPlan(ok)})) + bound, err := planFor(typ, applyOptions([]RecordOption{WithPlan(ok)})) if err != nil { t.Fatal(err) }