Skip to content

Commit 9dd4a74

Browse files
committed
fix(go): a plan field's context is a label, refused at construction
Codex, on PR #1093: the lowering refuses every plan field whose context is not a label of at least two plain segments, but NewPlan and PlanFromTags still accepted a one-part Context (MustContext("c"), the context= tag), a one-segment label, bytes or an integer, and Context.With on a field, so such a plan built fine and every record call then failed with ErrEncoding. The mistake belongs at the earliest stage (Go principle 1), so the Go side now refuses at plan construction what the lowering refuses. Context.fieldLabel is the rule, one place: a planned field's Context must be a flat list of plain text parts, two or more, and is read as that Label. A one-part context, an extended context and a part that is not text are refused with the field named and the accepted form in the message; a flat list built with NewContext("users").With("age") is the label it spells, as the lowering reads it. NewPlan applies it to every field; PlanFromTags refuses the context= tag outright, naming label= as the tag to use, after the option loop so a repeated or doubled option is still reported as what the author wrote. plan.Custom took one arbitrary text part and bound it with NewContext; it now parses its argument as a Label, so a Custom target binds a label of two or more plain segments and a one-segment or unplain one is refused at Build. plantest reads a Custom column's context the same way. The golden files do not change: a Custom context was always rendered as the text it was written as, which is a label's String() too. Two of the lowering's rules stay with the record call, documented on NewPlan: every field of a plan must sit under one table, and no two fields may bind one label. NewPlan cannot hold them without refusing policies the plan package and its golden tests pin (a Custom target beside a table's EQL columns; several fields under one Custom context), and that package is replaced by the next PR in the stack. Docs follow: Context, NewContext, MustContext, FieldPlan.Context, NewPlan, the stash tag table, Label's naming table and plan.Custom now say which contexts a planned field binds and which a probe takes. Tests cover each refusal; the tests that used the one-part form are rewritten to labels. Claude-Session: https://claude.ai/code/session_01V3WFXwax4J3uecpFEJ6yHc
1 parent f480f2f commit 9dd4a74

8 files changed

Lines changed: 221 additions & 69 deletions

File tree

‎languages/golang/stackencrypt/context.go‎

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,24 @@ import (
1616
// A Context is a part or a list of parts. A part is a string, a byte slice
1717
// or an integer (int32, int64, uint32, uint64; Go's int is sent as int64).
1818
// [NewContext] makes a one-part context — the bare part, what a Rust
19-
// `#[stash(context = "..")]` literal binds. [Context.With] extends it as
19+
// `nonempty!("..")` literal binds. [Context.With] extends it as
2020
// Rust's NonEmpty::with does: the result is the two-element list
2121
// [previous, part], nesting to the left. So NewContext("users").With("age")
2222
// is the pair a Rust `struct = .., context = "users"` derive binds its `age`
2323
// field under — and what ParseLabel("users/age") binds — rendering the ZeroKMS
2424
// descriptor users/age; extended With(uint64(7)) it is what a row sealed
25-
// with encrypt_into_with_context(row, 7u64) binds for that field.
25+
// with the chain's .extend(7u64) binds for that field.
2626
// A one-element list is not the bare part, and this type cannot spell one.
2727
//
28+
// Not every Context is a planned field's. A probe ([Cipher.Term]) takes any
29+
// Context, in whatever shape the data was sealed under. A field of a record
30+
// plan ([FieldPlan.Context]) binds a [Label] of at least two plain segments
31+
// and nothing else — the guest lowers a plan into one context per record
32+
// with one identity per field, and refuses any other shape — so [NewPlan]
33+
// and [PlanFromTags] refuse a one-part context, a one-segment label and an
34+
// extended context at construction. A record call extends every field's
35+
// label alike with [ExtendContext].
36+
//
2837
// A Context owns its parts: a byte-slice part is copied in, so a caller's
2938
// buffer reused once the Context is built does not change it.
3039
//
@@ -35,7 +44,11 @@ type Context struct {
3544
node any
3645
}
3746

38-
// NewContext makes a one-part context. The part must not be empty: a bare
47+
// NewContext makes a one-part context, for a probe ([Cipher.Term]) against
48+
// data sealed under one part, or as the base [Context.With] extends. It is
49+
// not a planned field's context: a field binds a [Label] of two or more
50+
// segments ([ParseLabel]), and [NewPlan] refuses a one-part context with the
51+
// field named. The part must not be empty: a bare
3952
// empty string or empty byte slice is an empty context, and the guest
4053
// proves every context non-empty at the boundary, so such a Context could
4154
// only ever fail — every call, with ErrEncoding. Rust refuses the same
@@ -56,8 +69,7 @@ func NewContext(part any) (Context, error) {
5669
}
5770

5871
// MustContext is [NewContext] for a part known to be valid; it panics
59-
// otherwise, an empty part included. For string literals in plans and
60-
// probes.
72+
// otherwise, an empty part included. For string literals in probes.
6173
func MustContext(part any) Context {
6274
c, err := NewContext(part)
6375
if err != nil {
@@ -91,6 +103,35 @@ func ownPart(part any) any {
91103
// lists of scalars, ready for the transport codec.
92104
func (c Context) value() any { return c.node }
93105

106+
// fieldLabel is the context a planned field may bind, as the guest's
107+
// lowering reads it: a label of at least two plain segments and nothing
108+
// else, returned as that Label. A one-part context, an extended context and
109+
// a part that is not text are refused with a reason that says what is
110+
// accepted; a flat list of plain text parts is the label it spells,
111+
// whichever constructor built it.
112+
func (c Context) fieldLabel() (Label, error) {
113+
parts, ok := c.node.([]any)
114+
if !ok {
115+
return Label{}, errors.New(`is one part, not a label; a planned field binds a label of at least two plain segments, ParseLabel("table/column").Context()`)
116+
}
117+
segments := make([]string, 0, len(parts))
118+
for _, part := range parts {
119+
s, ok := part.(string)
120+
if !ok {
121+
return Label{}, errors.New("is extended, or holds a part that is not text; a planned field binds a plain label, and a record call extends every field's label alike with ExtendContext")
122+
}
123+
segments = append(segments, s)
124+
}
125+
if len(segments) < 2 {
126+
return Label{}, errors.New("has one segment; a planned field binds a label of at least two")
127+
}
128+
l, err := NewLabel(segments...)
129+
if err != nil {
130+
return Label{}, fmt.Errorf("is not a plain label: %w", err)
131+
}
132+
return l, nil
133+
}
134+
94135
// checkRootNonEmpty refuses the bare parts that are themselves an empty
95136
// context. Integers never are, whatever their value.
96137
func checkRootNonEmpty(part any) error {

‎languages/golang/stackencrypt/label.go‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ import (
3232
// a deeper name ParseLabel("documents/v2/body") documents/v2/body
3333
// a one-part name ParseLabel("users"), the same as NewContext("users") users
3434
//
35+
// A one-part name is a probe's: a planned field ([FieldPlan.Context]) binds
36+
// a label of at least two segments, a table and a column, since the guest
37+
// seals every field of a record under one context and the field's own
38+
// identity.
39+
//
3540
// Do not build a name with With, and do not put a scope into a Label. The
3641
// renderer keeps the two apart: a name is one flat list, a scope nests. So
3742
// (users/email)/7u64 is never read as a three-segment name, and

‎languages/golang/stackencrypt/label_test.go‎

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,13 @@ func label(t testing.TB, s string) Label {
141141
return l
142142
}
143143

144-
// A struct tag names a field's own context as a label or as one part,
145-
// never both, and a plan built by hand needs a non-zero Context.
146-
func TestTagsSpellALabelOrOneContextPart(t *testing.T) {
144+
// A struct tag names a field's label, and only a label: the one-part
145+
// context= form, which the guest's lowering cannot seal a field under, is
146+
// refused with the tag to use instead, and a plan built by hand needs a
147+
// non-zero Context.
148+
func TestTagsSpellALabel(t *testing.T) {
147149
type tagged struct {
148150
Email string `stash:"label=users/email"`
149-
Notes string `stash:"context=notes/v1"`
150151
}
151152
p, err := PlanFromTags(reflect.TypeOf(tagged{}))
152153
if err != nil {
@@ -156,9 +157,19 @@ func TestTagsSpellALabelOrOneContextPart(t *testing.T) {
156157
if got := fields[0].Context.value(); !reflect.DeepEqual(got, []any{"users", "email"}) {
157158
t.Errorf("label=users/email bound %#v", got)
158159
}
159-
// context= is one part: the '/' is text, as a Rust literal's is.
160-
if got := fields[1].Context.value(); !reflect.DeepEqual(got, "notes/v1") {
161-
t.Errorf("context=notes/v1 bound %#v, want the one part", got)
160+
// context= is one part, and a planned field binds a label.
161+
_, err = PlanFromTags(reflect.TypeOf(struct {
162+
Notes string `stash:"context=notes/v1"`
163+
}{}))
164+
if err == nil || !strings.Contains(err.Error(), `context="notes/v1" names one text part`) || !strings.Contains(err.Error(), "use label=") {
165+
t.Errorf("context=notes/v1: err = %v; want the one part refused and label= named", err)
166+
}
167+
// A one-segment label is one part too.
168+
_, err = PlanFromTags(reflect.TypeOf(struct {
169+
Notes string `stash:"label=notes"`
170+
}{}))
171+
if err == nil || !strings.Contains(err.Error(), "one part, not a label") {
172+
t.Errorf("label=notes: err = %v; want the one segment refused", err)
162173
}
163174
for name, typ := range map[string]reflect.Type{
164175
"both": reflect.TypeOf(struct {

‎languages/golang/stackencrypt/plan/plan_test.go‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ func TestContextsByTarget(t *testing.T) {
239239
want := []se.FieldPlan{
240240
{Field: "Email", Name: "email", Context: label(t, "users/email").Context(), Terms: []se.TermKind{se.Equality}},
241241
// A custom target's context is its own; the pin names the record key only.
242-
{Field: "Blob", Name: "blob_v1", Context: se.MustContext("tenant-blobs/v1"), Terms: []se.TermKind{se.Ope}},
242+
{Field: "Blob", Name: "blob_v1", Context: label(t, "tenant-blobs/v1").Context(), Terms: []se.TermKind{se.Ope}},
243243
}
244244
if got := p.Fields(); !reflect.DeepEqual(got, want) {
245245
t.Fatalf("fields =\n%+v\nwant\n%+v", got, want)
@@ -275,7 +275,9 @@ func TestBuildRefusesMalformedDecisions(t *testing.T) {
275275
"nil target": {"t", plan.When(plan.Field("a"), plan.Encrypt(nil)), plan.ErrInvalid, "no target"},
276276
"column on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Column("c")), plan.ErrInvalid, "Plaintext"},
277277
"identity on plain": {"t", plan.When(plan.Field("a"), plan.Plaintext(), plan.Identity("c")), plan.ErrInvalid, "Plaintext"},
278-
"identity on custom": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("ctx")), plan.Identity("c")), plan.ErrInvalid, "context is fixed"},
278+
"identity on custom": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("t/ctx")), plan.Identity("c")), plan.ErrInvalid, "context is fixed"},
279+
"one-part custom": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("ctx"))), nil, "one part, not a label"},
280+
"unplain custom": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("t/7up"))), plan.ErrInvalid, "is not a label"},
279281
"slash in identity": {"t", plan.When(plan.Field("a"), plan.Encrypt(plan.EQL()), plan.Column("c"), plan.Identity("x/y")), plan.ErrInvalid, "contains '/'"},
280282
// Identifier.Label() refuses more than '/': every reason a segment is
281283
// not plain, named as the table or the column identity it came from.
@@ -326,8 +328,10 @@ func TestBuildRefusesMalformedDecisions(t *testing.T) {
326328
if !errors.Is(err, plan.ErrInvalid) || !strings.Contains(err.Error(), `identity "a" is already field a's`) {
327329
t.Errorf("two fields sharing an identity: err = %v", err)
328330
}
329-
// Custom targets may share a context: it is the policy's to choose.
330-
if _, err := plan.ForMessage(nil, "t", plan.When(plan.Any(plan.Field("a"), plan.Field("b")), plan.Encrypt(plan.Custom("ctx")))).Build(two); err != nil {
331+
// Custom targets may share a context: it is the policy's to choose, and
332+
// the guest, not NewPlan, refuses two fields under one label when a
333+
// record call runs.
334+
if _, err := plan.ForMessage(nil, "t", plan.When(plan.Any(plan.Field("a"), plan.Field("b")), plan.Encrypt(plan.Custom("blobs/ctx")))).Build(two); err != nil {
331335
t.Errorf("two custom fields sharing a context: %v", err)
332336
}
333337
}
@@ -527,11 +531,11 @@ func TestABadIdentifierKeepsItsLabelError(t *testing.T) {
527531
// by such a field's context.
528532
func TestACustomOnlyMessageTakesAnyTableName(t *testing.T) {
529533
facts := []plan.Fact{{Field: "a", Annotations: []plan.Annotation{{Key: "k", Values: []string{"v"}}}}}
530-
p, err := plan.ForMessage(nil, "a/b", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("ctx")))).Build(facts)
534+
p, err := plan.ForMessage(nil, "a/b", plan.When(plan.Field("a"), plan.Encrypt(plan.Custom("blobs/ctx")))).Build(facts)
531535
if err != nil {
532536
t.Fatal(err)
533537
}
534-
if got := p.Fields()[0].Context; !got.Equal(se.MustContext("ctx")) {
535-
t.Errorf("context = %v, want the custom part", got)
538+
if got := p.Fields()[0].Context; !got.Equal(label(t, "blobs/ctx").Context()) {
539+
t.Errorf("context = %v, want the custom label", got)
536540
}
537541
}

‎languages/golang/stackencrypt/plan/plantest/snapshot.go‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,11 @@ func contextText(table string, kind string, d plan.Decision, fp stackencrypt.Fie
121121
if !ok || err != nil {
122122
return "", fmt.Errorf("plantest: column %q: cannot spell the context of target %v; a target that does not bind its column identity must be plan.Custom", fp.Name, target)
123123
}
124-
if want, err = stackencrypt.NewContext(text); err != nil {
124+
var l stackencrypt.Label
125+
if l, err = stackencrypt.ParseLabel(text); err != nil {
125126
return "", fmt.Errorf("plantest: column %q: %w", fp.Name, err)
126127
}
128+
want = l.Context()
127129
}
128130
if !want.Equal(fp.Context) {
129131
return "", fmt.Errorf("plantest: column %q: the plan binds a context other than %q", fp.Name, text)
@@ -134,14 +136,11 @@ func contextText(table string, kind string, d plan.Decision, fp stackencrypt.Fie
134136
// contextOf is the context a snapshot's column names, rebuilt from its
135137
// text and target kind: what [contextText] wrote.
136138
func contextOf(c column) (stackencrypt.Context, error) {
137-
if c.kind == kindEQL {
138-
label, err := stackencrypt.ParseLabel(c.context)
139-
if err != nil {
140-
return stackencrypt.Context{}, err
141-
}
142-
return label.Context(), nil
139+
label, err := stackencrypt.ParseLabel(c.context)
140+
if err != nil {
141+
return stackencrypt.Context{}, err
143142
}
144-
return stackencrypt.NewContext(c.context)
143+
return label.Context(), nil
145144
}
146145

147146
// fact is one annotation value.

‎languages/golang/stackencrypt/plan/policy.go‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ func (t eqlTarget) String() string { return "EQL(" + termList(t.terms) + ")" }
7777
// Custom is a non-EQL target: the field binds context, whatever its
7878
// column, and derives the given terms. The context is the policy's to
7979
// choose and, like any context, must never change once data is written
80-
// under it. It is one arbitrary text part, exactly as written — what
81-
// [stackencrypt.NewContext] makes and a Rust `#[stash(context = "..")]`
82-
// literal binds — so a '/' in it is text, not a separator: "notes/v1" is
83-
// one part, rendered escaped in the ZeroKMS log, never the table/column
84-
// pair. A table and a column are an [EQL] target.
80+
// under it. It is a label of at least two plain segments, written as
81+
// [stackencrypt.ParseLabel] reads it ("notes/v1": the segments notes and
82+
// v1, rendered as written in the ZeroKMS log), since that is the one shape
83+
// of context a planned field binds; the message's table plays no part in
84+
// it. A table and a column are an [EQL] target.
8585
func Custom(context string, terms ...stackencrypt.TermKind) Target {
8686
return customTarget{context: context, terms: slices.Clone(terms)}
8787
}
@@ -93,7 +93,14 @@ type customTarget struct {
9393

9494
func (t customTarget) Terms() []stackencrypt.TermKind { return slices.Clone(t.terms) }
9595
func (t customTarget) Context(Identifier) (stackencrypt.Context, error) {
96-
return stackencrypt.NewContext(t.context)
96+
if t.context == "" {
97+
return stackencrypt.Context{}, errors.New("an empty string is an empty context")
98+
}
99+
l, err := stackencrypt.ParseLabel(t.context)
100+
if err != nil {
101+
return stackencrypt.Context{}, fmt.Errorf("context %q is not a label: %w", t.context, err)
102+
}
103+
return l.Context(), nil
97104
}
98105
func (t customTarget) String() string {
99106
return fmt.Sprintf("Custom(%q%s)", t.context, prefixed(termList(t.terms)))

0 commit comments

Comments
 (0)