Skip to content

Commit ac0a38f

Browse files
feat(golang): an optional sealed field, declared over a pointer
A sealed field may now be declared over a pointer to one scalar (`DOB *encrypt.Date` with `stash:"dob,encrypt_into=Date"`, which #1293 reproduced being refused as a composite), and Decrypt gives nil back for a nil (#1298). A new tag part, `null=sealed` or `null=sql`, says how nil is stored, and reaches the engine as the data plan's "null": - sealed: nil is sealed as a typed null as long as a value, so the column does not show which rows are absent. stashgen picks it for a field with no index whose type has a fixed width, sealed with encrypt or into a storage-only EQL type. - sql: nothing is stored, so the field's columns hold SQL NULL and its member in the generated type is a pointer. A field with an index, or of text or bytes, has to say so: a null has no term yet, and text shows its length, so stashgen will not choose a visible NULL silently. gensupport gains Declaration.Null, Deref and GetPtr; the record model and the encrypt record path carry the mode and accept a SQLNull field left out. A stored null read into a field that is not a pointer is a Decrypt error, from the engine's kind check. -model does not map a null=sql field yet, and stashgen says so. The reproduction from #1293 is folded into cmd/stashgen's tests, with the guest-built skip the other engine test has. Closes #1293. Refs #1298. Co-authored-by: Jonathon Walker <1989282+jonathon-walker@users.noreply.github.com> Claude-Session: https://claude.ai/code/session_01XQN1QpaWVDGjHEg39KEJF3
1 parent e7d4898 commit ac0a38f

22 files changed

Lines changed: 981 additions & 48 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cipherstash/eql": patch
3+
---
4+
5+
`eql-bindings` (the Rust crate released with this package): a storage-only EQL v4 type (`Date`, `Integer`, `Text`, and the other types with no term) now also implements `EncryptFrom` and `DecryptInto` for an `Option` of its plaintext. A `None` seals as a typed null as long as a value (vitaminc 0.8), so an optional column does not show which rows are absent, and opens as `None`. A value seals exactly as before. The by-name dispatch (`encryption::targets`) reads and opens these types through the `Option`, so a null frame encrypts and a stored null decrypts to a null; a type with a term still refuses a null. eql-bindings' own `Date`, `Timestamp` and `Decimal` plaintexts declare their fixed width to vitaminc, so their `None` is padded too. The SQL bundle and the TypeScript types do not change.

‎languages/golang/CHANGELOG.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,23 @@ listed under **Breaking**. Releases are tagged `languages/golang/vX.Y.Z`.
1010

1111
## [Unreleased]
1212

13+
### Added
14+
15+
- **An optional field: a sealed field declared over a pointer (#1298).**
16+
`DOB *encrypt.Date` with `stash:"dob,encrypt_into=Date"` generates (it
17+
was refused as a composite, #1293), and `Decrypt` gives `nil` back for a
18+
`nil`. The new `null=` tag part says how `nil` is stored:
19+
`null=sealed` seals it as a null as long as a value of the field's type,
20+
so the column does not show which rows have no value; `null=sql` stores
21+
nothing, so the field's columns are SQL `NULL` and its member in the
22+
generated type is a pointer. `stashgen` picks `null=sealed` for a field
23+
with no index whose type has a fixed width (a bool, a number,
24+
`encrypt.Date`, `time.Time`, `encrypt.Decimal`, the 128-bit integers),
25+
and asks for `null=sql` to be written on any other: a null has no index
26+
term yet, and text shows its length. A stored null read into a field that
27+
is not a pointer is an error from `Decrypt`. `-model` does not map a
28+
`null=sql` field yet.
29+
1330
### Breaking
1431

1532
- **Every order term changes: re-derive stored `ore`, `ope` and

‎languages/golang/cmd/stashgen/README.md‎

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ The first part of a tag is the field's name, which is the column name in a datab
7575
| `stash:"tenant,context_field"` | the field's value is the context of every other field in the struct, in place of a `context=` tag; stored as it is |
7676
| `stash:"-"` | leave the field out |
7777
| `` _ struct{} `stash:"context=documents,opaque"` `` | seal the struct as one value |
78+
| `stash:"born,encrypt_into=DateEq,null=sql"` | on a field declared over a pointer (`*encrypt.Date`): how it stores `nil`, `null=sealed` or `null=sql`; see "Optional fields" |
7879

7980
A struct has one context: either a `_ struct{}` field with `context=`, or one field with `context_field`, not both.
8081
A `context_field` is a `string` whose value is a label such as `"tenants/acme"`, segments separated by `/`.
@@ -105,6 +106,24 @@ These words are the same as the Rust API's words for the same behaviour, with tw
105106
`opaque` seals the whole struct as one `bytes` field holding a JSON document (see "What crosses the binding").
106107
`json` names the JSON index, which the Rust API does not declare yet; this build refuses it.
107108

109+
### Optional fields
110+
111+
A sealed field declared over a pointer to one scalar (`*encrypt.Date`, `*int32`, `*string`, a pointer to a type defined over a scalar) is optional: `nil` is a value it may hold, and `Decrypt` gives it back as `nil`.
112+
`null=` says how the field stores `nil`. There are two ways, and they differ in what a reader of the database can see:
113+
114+
| `null=` | Stored for `nil` | What a reader of the column sees |
115+
|---|---|---|
116+
| `sealed` | a sealed null, as long as a sealed value of the field's type | nothing: an absent value looks like any other |
117+
| `sql` | nothing: the field's columns are `NULL`, and its member in the generated type is `nil` | which rows have no value |
118+
119+
A sealed null hides that the value is absent only where its length matches a value's and nothing beside it shows it, so it is the default for a field with no index whose type has a fixed width: a `bool`, a number, `encrypt.Date`, `time.Time`, `encrypt.Decimal`, `encrypt.Int128` and `encrypt.Uint128`, sealed with `encrypt` or into an EQL type with no index (`Date`, `Integer`, `Boolean`, ...).
120+
A field with an index, and a field of text or bytes, has to write `null=sql`: a null has no index term yet, and text shows its length, so a sealed null would hide nothing there, and `stashgen` will not choose a visible `NULL` for you.
121+
`null=sealed` on such a field stops `stashgen` with the reason.
122+
123+
A `null=sql` field's member in the generated type is a pointer (`*eql.DateEq`, `*EncryptedUserScore`), `nil` when the value was; a `null=sealed` field's is the same type as a non-optional field's, and its column is never `NULL`.
124+
A stored null read into a field that is not a pointer is an error from `Decrypt`, naming the field, never a zero value.
125+
`-model` does not map a `null=sql` field yet, and `stashgen` stops if it is asked to.
126+
108127
An embedded struct of your own adds its tagged fields to the outer struct.
109128
An embedded struct from another package takes one tag for all of its fields: `stash:",passthrough"` stores them as they are, and `stash:"-"` leaves them out.
110129
Tags for other libraries, such as `gorm`, `db` and `json`, are copied to the same field of the generated type.
@@ -250,10 +269,10 @@ Generated code sends the engine every sealed field with its value, under the dec
250269
A passthrough field stays on the host: the engine does nothing to it a program could observe, and the FFI codec cannot carry every Go type a program stores beside a ciphertext (a `driver.Valuer`, a `sql.NullTime`).
251270
An `opaque` struct crosses as one JSON document and is one column, decoded back into the exact Go types the struct declares; a field may be any type `encoding/json` carries both ways (a scalar or a type defined over one, `[]byte`, slices, maps with string or integer keys, pointers, structs whose fields are all exported, `time.Time`).
252271
A float in an opaque struct must be finite: `encoding/json` refuses NaN and the infinities, so `Encrypt` fails for the batch with `encrypt.ErrEncoding`. A sealed float field outside an opaque struct carries them.
253-
A sealed field outside an opaque struct is one scalar, or a type defined over one; a struct, slice or map field seals only inside an opaque struct.
272+
A sealed field outside an opaque struct is one scalar, a type defined over one, or a pointer to either (see "Optional fields"); a struct, slice or map field seals only inside an opaque struct.
254273
The scalars are `string`, `[]byte`, `bool`, every integer type (each sealed at its own width, `int` and `uint` as 64-bit), `encrypt.Int128` and `encrypt.Uint128`, `float32`, `float64`, `encrypt.Decimal`, `encrypt.Date` and `time.Time` (a timestamp). The wire type `stashgen` writes for each is the field's own: `int8` is `gensupport.Int8`, `time.Time` is `gensupport.Timestamp`, and so on.
255274
An order index (`ore`, `ope`) on a decimal, date or timestamp is refused: those order terms wait for new cllw-ore and ore-rs releases (#1236).
256-
The zero `encrypt.Date` is not a date: a date field must be set, and `Encrypt` refuses a zero one with `encrypt.ErrEncoding`, naming the field. A sealed field cannot be a pointer (`*encrypt.Date` is refused as a composite), so an optional date has no sealed form yet: keep it in an opaque struct, or store a sentinel date.
275+
The zero `encrypt.Date` is not a date: a date field must be set, and `Encrypt` refuses a zero one with `encrypt.ErrEncoding`, naming the field. An optional date is a `*encrypt.Date` field (see "Optional fields").
257276
A nil `[]byte` in a sealed field, or a nil slice of a type defined over `[]byte`, comes back from `Decrypt` as an empty, non-nil slice; inside an opaque struct, nil comes back as nil.
258277
A protobuf message with a `oneof` cannot be generated from a policy: protoc-gen-go puts its members in wrapper types, not in the message struct, and `Generate` refuses it.
259278

‎languages/golang/cmd/stashgen/main_test.go‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,48 @@ func TestRunFlags(t *testing.T) {
119119
}
120120
}
121121

122+
// A storage-only EQL type over a pointer (#1293): the generator takes
123+
// *encrypt.Date as it takes encrypt.Date, and stores nil as a sealed null,
124+
// as long as a date, since Date carries no index. From the reproduction in
125+
// #1293. Skips when the guest is not built, as the engine decides what an
126+
// EQL type is.
127+
func TestRunGeneratesNullableDOB(t *testing.T) {
128+
checker, err := encrypt.NewChecker(context.Background())
129+
if err != nil {
130+
t.Skip(err)
131+
}
132+
_ = checker.Close()
133+
for _, dateType := range []string{"encrypt.Date", "*encrypt.Date"} {
134+
t.Run(dateType, func(t *testing.T) {
135+
src := `package people
136+
137+
import "github.com/cipherstash/stack/languages/golang/encrypt"
138+
139+
type Person struct {
140+
_ struct{} ` + "`stash:\"context=people\"`" + `
141+
DOB ` + dateType + " `stash:\"dob,encrypt_into=Date\"`" + `
142+
}
143+
`
144+
dir := writeModule(t, map[string]string{"model.go": src})
145+
var stdout, stderr bytes.Buffer
146+
if code := run([]string{"-type", "Person"}, dir, &stdout, &stderr, stashgen.GuestEngine); code != 0 {
147+
t.Fatalf("exit %d, want 0\n%s", code, stderr.String())
148+
}
149+
written, err := os.ReadFile(filepath.Join(dir, "person_stash.go"))
150+
if err != nil {
151+
t.Fatal(err)
152+
}
153+
sealedNull := strings.Contains(string(written), `Null("dob", gensupport.NullSealed)`)
154+
if pointer := strings.HasPrefix(dateType, "*"); sealedNull != pointer {
155+
t.Fatalf("a %s field stores nil as a sealed null: %v, want %v\n%s", dateType, sealedNull, pointer, written)
156+
}
157+
if !strings.Contains(string(written), "DOB eql.Date") {
158+
t.Fatalf("a sealed null keeps the column's type, eql.Date:\n%s", written)
159+
}
160+
})
161+
}
162+
}
163+
122164
// The real engine: the embedded guest, which the command links with the EQL
123165
// types, so a struct with encrypt_into=TextEq generates, and a type the
124166
// engine cannot produce yet is refused with nothing written. Skips when the

‎languages/golang/encrypt/ciphertext.go‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ import (
1515
// vitaminc-encrypt and must never scan or marshal where one belongs.
1616
//
1717
// A sealed field is one scalar — a string, a number, a bool or a []byte, or
18-
// a type defined over one — and seals as vitaminc's tagged leaf, which a
18+
// a type defined over one, or a pointer to either, whose nil is sealed as a
19+
// null as long as a value or stored as SQL NULL — and seals as vitaminc's
20+
// tagged leaf, which a
1921
// Rust record opens when its field is a Value (not a bare u32 or String). A struct, slice or map seals only as part of an opaque struct,
2022
// which crosses as one JSON document and is one leaf; stashgen refuses it
2123
// anywhere else, because the engine would seal it as a tree of leaves and a

‎languages/golang/encrypt/gensupport/codec.go‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,10 @@ func (c *Codec[P, E]) Decrypt(ctx context.Context, d encrypt.Decrypter, encrypte
154154
switch {
155155
case f.verb == verbOmit:
156156
continue
157+
case !ok && f.null == NullSQL:
158+
// The field was nil, so nothing was stored for it; it opens
159+
// as nil.
160+
continue
157161
case !ok:
158162
return nil, fmt.Errorf("gensupport: %s: value %d: Open gave no field %q", c.g.TypeName, i, f.name)
159163
case f.verb == verbContextField:
@@ -190,10 +194,13 @@ func (c *Codec[P, E]) Decrypt(ctx context.Context, d encrypt.Decrypter, encrypte
190194
}
191195

192196
// split sorts a value's fields into what crosses the binding and what stays:
193-
// every declared field must be present and nothing else may be.
197+
// every declared field must be present and nothing else may be. A nil field
198+
// declared with NullSQL is left out of the row, since nothing is stored for
199+
// it; one declared with NullSealed crosses as nil, which the engine seals.
194200
func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error) {
195201
row := make(record.Source, len(c.plan.Fields))
196202
keep := map[string]any{}
203+
left := 0
197204
for _, f := range c.g.Declaration.fields {
198205
if f.verb == verbOmit {
199206
continue
@@ -202,6 +209,10 @@ func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error)
202209
if !ok {
203210
return nil, nil, fmt.Errorf("the generated Source gave no field %q", f.name)
204211
}
212+
if v == nil && f.null == NullSQL {
213+
left++
214+
continue
215+
}
205216
if f.crosses() {
206217
converted, err := asKind(f.kind, v)
207218
if err != nil {
@@ -212,7 +223,7 @@ func (c *Codec[P, E]) split(vals Values) (record.Source, map[string]any, error)
212223
keep[f.name] = v
213224
}
214225
}
215-
if len(vals) != len(row)+len(keep) {
226+
if len(vals) != len(row)+len(keep)+left {
216227
for name := range vals {
217228
if _, ok := row[name]; ok {
218229
continue
@@ -297,6 +308,33 @@ func Get[T any](vals Values, name string) (T, error) {
297308
return out, nil
298309
}
299310

311+
// Deref is a field declared over a pointer as Source gives it: nil for a nil
312+
// pointer, else the value it points to.
313+
func Deref[T any](p *T) any {
314+
if p == nil {
315+
return nil
316+
}
317+
return *p
318+
}
319+
320+
// GetPtr reads one opened field declared over a pointer: nil for a field
321+
// that opened as nil, which it stored as a null or as nothing, else a
322+
// pointer to the value as [Get] reads it.
323+
func GetPtr[T any](vals Values, name string) (*T, error) {
324+
v, ok := vals[name]
325+
if !ok {
326+
return nil, fmt.Errorf("gensupport: the opened value has no field %q", name)
327+
}
328+
if v == nil {
329+
return nil, nil
330+
}
331+
out, err := Get[T](vals, name)
332+
if err != nil {
333+
return nil, err
334+
}
335+
return &out, nil
336+
}
337+
300338
// Records wraps a codec with a model's conversions, for separate columns:
301339
// EncryptRows and DecryptRows return and take the model.
302340
func Records[P, E, R any](codec *Codec[P, E], to func(E) R, from func(R) E) *RecordsCodec[P, R] {

‎languages/golang/encrypt/gensupport/declaration.go‎

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,8 +76,24 @@ type field struct {
7676
verb verb
7777
indexes []encrypt.Index
7878
eqlType string
79+
null NullMode
7980
}
8081

82+
// NullMode is how a sealed field declared over a pointer (*T) stores nil:
83+
// stashgen's `null=` tag. A field with none is never nil.
84+
type NullMode string
85+
86+
const (
87+
// NullSealed seals nil like a value, as a null as long as a value of
88+
// the field's kind, so the stored record does not show which rows are
89+
// absent. Decrypt gives nil. The field derives no index, and its kind
90+
// has a fixed width.
91+
NullSealed NullMode = "sealed"
92+
// NullSQL stores nothing for nil: the field's columns hold SQL NULL,
93+
// which shows that the value is absent. Decrypt gives nil.
94+
NullSQL NullMode = "sql"
95+
)
96+
8197
// Declaration is a generated struct's declaration as data: its context and,
8298
// for each field, what happens to it. Only generated code builds one, from
8399
// the struct's stash tags, and only generated code reads it. A mistake in a
@@ -152,6 +168,34 @@ func (d Declaration) EncryptInto(name string, kind Kind, eqlType string) Declara
152168
return d.add(field{name: name, kind: kind, verb: verbEncryptInto, eqlType: eqlType})
153169
}
154170

171+
// Null says how the named field, declared over a pointer, stores nil. The
172+
// engine decides whether the field can, when the declaration is first used:
173+
// a sealed null needs a field with no index, of a kind of fixed width.
174+
func (d Declaration) Null(name string, mode NullMode) Declaration {
175+
if d.err != nil {
176+
return d
177+
}
178+
if mode != NullSealed && mode != NullSQL {
179+
d.err = fmt.Errorf("gensupport: Null(%q): unknown mode %q", name, mode)
180+
return d
181+
}
182+
for i := range d.fields {
183+
if d.fields[i].name != name {
184+
continue
185+
}
186+
if !d.fields[i].sealed() {
187+
d.err = fmt.Errorf("gensupport: Null(%q): only a sealed field stores nil", name)
188+
return d
189+
}
190+
// A copy, so the receiver's fields are not changed under it.
191+
d.fields = slices.Clone(d.fields)
192+
d.fields[i].null = mode
193+
return d
194+
}
195+
d.err = fmt.Errorf("gensupport: Null(%q): no such field", name)
196+
return d
197+
}
198+
155199
// Omit leaves the field out: it does not cross the binding and is not
156200
// stored. The declaration lists it so a reader sees the choice.
157201
func (d Declaration) Omit(name string) Declaration {
@@ -238,7 +282,7 @@ func (d Declaration) plan() (*record.Plan, error) {
238282
if !f.sealed() {
239283
continue
240284
}
241-
rf := record.Field{Name: f.name, Identity: f.identity, Kind: record.Kind(f.kind)}
285+
rf := record.Field{Name: f.name, Identity: f.identity, Kind: record.Kind(f.kind), Null: record.Null(f.null)}
242286
switch f.verb {
243287
case verbEncryptInto:
244288
// The EQL type's own plan decides the outputs; the engine

‎languages/golang/encrypt/internal/testusers/birthday_stash.go‎

Lines changed: 94 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)