From ab8ca589fac1be02bb893917556823cc8f77270d Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Fri, 2 Oct 2026 14:28:24 -0700 Subject: [PATCH] fix(go): Context copies byte-slice parts NewContext and Context.With stored a []byte part as the caller's slice, the same aliasing ExtendContext had one layer up: a buffer reused after the context was built changed what the context bound. One helper, ownPart, now copies a byte part for both the Context constructors and the option, so there is a single definition of what a part the SDK holds looks like. A test mutates the source buffers after NewContext and With and checks the context still holds the original bytes. --- languages/golang/stackencrypt/context.go | 18 +++++++++++++-- languages/golang/stackencrypt/record.go | 6 +---- languages/golang/stackencrypt/unit_test.go | 26 ++++++++++++++++++++++ 3 files changed, 43 insertions(+), 7 deletions(-) diff --git a/languages/golang/stackencrypt/context.go b/languages/golang/stackencrypt/context.go index 4b8c6e750..49c968de1 100644 --- a/languages/golang/stackencrypt/context.go +++ b/languages/golang/stackencrypt/context.go @@ -1,6 +1,7 @@ package stackencrypt import ( + "bytes" "errors" "fmt" ) @@ -20,6 +21,9 @@ import ( // left, so NewContext("users/age").With(uint64(7)) is the context a row // sealed with encrypt_into_with_context(row, 7u64) binds for that field. // A one-element list is not the bare part, and this type cannot spell one. +// +// A Context owns its parts: a byte-slice part is copied in, so a caller's +// buffer reused once the Context is built does not change it. type Context struct { node any } @@ -41,7 +45,7 @@ func NewContext(part any) (Context, error) { if err := checkRootNonEmpty(part); err != nil { return Context{}, err } - return Context{node: part}, nil + return Context{node: ownPart(part)}, nil } // MustContext is [NewContext] for a part known to be valid; it panics @@ -63,7 +67,17 @@ func (c Context) With(part any) (Context, error) { if err := checkPart(part); err != nil { return Context{}, err } - return Context{node: []any{c.node, part}}, nil + return Context{node: []any{c.node, ownPart(part)}}, nil +} + +// ownPart is part as a context stores it: a byte slice is copied, so +// neither a Context nor an option that extends one ([ExtendContext]) +// aliases a caller's buffer. Every other part type is a value. +func ownPart(part any) any { + if b, ok := part.([]byte); ok { + return bytes.Clone(b) + } + return part } // value renders the context in the guest's grammar: a scalar or nested diff --git a/languages/golang/stackencrypt/record.go b/languages/golang/stackencrypt/record.go index 18e417188..2f95f1847 100644 --- a/languages/golang/stackencrypt/record.go +++ b/languages/golang/stackencrypt/record.go @@ -1,7 +1,6 @@ package stackencrypt import ( - "bytes" "context" "errors" "fmt" @@ -158,10 +157,7 @@ func (e contextExtension) applyTerm(o *termOptions) { e.appendTo(&o.extension) } func ExtendContext(parts ...any) Option { owned := make([]any, len(parts)) for i, part := range parts { - if b, ok := part.([]byte); ok { - part = bytes.Clone(b) - } - owned[i] = part + owned[i] = ownPart(part) } return contextExtension{parts: owned} } diff --git a/languages/golang/stackencrypt/unit_test.go b/languages/golang/stackencrypt/unit_test.go index 395d3c3fe..5b3a206fd 100644 --- a/languages/golang/stackencrypt/unit_test.go +++ b/languages/golang/stackencrypt/unit_test.go @@ -128,6 +128,32 @@ func TestSelectorsSpellEveryVariant(t *testing.T) { } } +// A Context owns its parts, as an option does: NewContext and With copy a +// byte-slice part in, so a caller's buffer reused once the context is +// built does not change it. +func TestContextOwnsItsByteParts(t *testing.T) { + root, ext := []byte("users/email"), []byte("eu") + c, err := NewContext(root) + if err != nil { + t.Fatal(err) + } + if c, err = c.With(ext); err != nil { + t.Fatal(err) + } + copy(root, "users/phone") + copy(ext, "us") + parts, ok := c.value().([]any) + if !ok || len(parts) != 2 { + t.Fatalf("context value is %#v, want a two-part list", c.value()) + } + if got := string(parts[0].([]byte)); got != "users/email" { + t.Errorf("root part is %q after the caller's buffer changed, want \"users/email\"", got) + } + if got := string(parts[1].([]byte)); got != "eu" { + t.Errorf("extension part is %q after the caller's buffer changed, want \"eu\"", got) + } +} + func TestContextNestsToTheLeft(t *testing.T) { c := MustContext("users/age") if got := c.value(); got != "users/age" {