Skip to content

Commit 337e882

Browse files
committed
test(golang,eql): seal the smallint EQL v4 types from Go, and store Go's rows in PostgreSQL
A Tally test model, under Profile's table so its query shares v4_queries.json without touching Profile's frozen rows, holds an int16 Smallint, an int16 SmallintEq and an int32 SmallintEq. The rows seal to their EQL types and open back; an int32 outside the smallint range, above or below, is refused with ErrEncoding naming the field on the codec write, the single-field Encrypt and the Eq query; and Count.Eq derives the Rust fixture's query byte for byte. TestV4SmallintRowsMatchTheCommittedFixture checks v4_smallint_rows.json, which the Rust suite opens at 16 bits and inserts into eql_v4 columns, where = with the fixture's query finds the rows holding its value. The file is rewritten only when something other than `c` moves, so on its own it carries the ciphertexts of the last regeneration, not today's. EQL_V4_SMALLINT_ROWS_OUT and EQL_V4_SMALLINT_ROWS hand today's rows from Go to Rust, as EQL_V4_ROWS_OUT and EQL_V4_ROWS do for v4_rows.json, and the Go workflow's hand-off step now passes both pairs. So a guest change that moves only a smallint ciphertext, which Go's own round trip cannot see because the equality term is the same at every width, fails Rust's 16-bit open in the pull request that makes it. CI runs only the two open tests on the handed-off rows; PostgreSQL stores and queries the committed rows. The step's cargo filter is now --exact, so it names only the tests that read what it hands them.
1 parent d5184c2 commit 337e882

7 files changed

Lines changed: 692 additions & 8 deletions

File tree

‎.github/workflows/tests-golang.yml‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -248,17 +248,21 @@ jobs:
248248
echo "RLIMIT_MEMLOCK: $(ulimit -l) KiB"
249249
mise run go:test
250250
251-
# The Rust suite opens the committed v4_rows.json, whose ciphertexts the
252-
# Go SDK sealed when the file was last regenerated. This also hands it
253-
# the rows the Go SDK seals in this run, so a Go guest and a native
254-
# Rust build that stop agreeing on today's ciphertexts fail here rather
255-
# than when someone next regenerates the file. The toolchain is the
251+
# The Rust suite opens the committed v4_rows.json and
252+
# v4_smallint_rows.json, whose ciphertexts the Go SDK sealed when each
253+
# file was last regenerated. This also hands it the rows the Go SDK
254+
# seals in this run, so a Go guest and a native Rust build that stop
255+
# agreeing on today's ciphertexts fail here rather than when someone
256+
# next regenerates a file. Both filters are exact, so each test named
257+
# here is one that reads the rows handed to it. The toolchain is the
256258
# root's pin, as for the guests.
257259
- name: Rows the Go SDK seals today open in Rust
258260
run: |
259261
rows="$RUNNER_TEMP/v4_rows.json"
260-
(cd languages/golang && CGO_ENABLED=0 EQL_V4_ROWS_OUT="$rows" go test -count=1 -run TestV4RowsMatchTheCommittedFixture ./encrypt)
261-
EQL_V4_ROWS="$rows" cargo test --locked --manifest-path packages/eql/Cargo.toml -p eql-encryption-tests --test v4_types rows_the_go_sdk_sealed_open_in_rust
262+
smallint_rows="$RUNNER_TEMP/v4_smallint_rows.json"
263+
(cd languages/golang && CGO_ENABLED=0 EQL_V4_ROWS_OUT="$rows" EQL_V4_SMALLINT_ROWS_OUT="$smallint_rows" go test -count=1 -run '^(TestV4RowsMatchTheCommittedFixture|TestV4SmallintRowsMatchTheCommittedFixture)$' ./encrypt)
264+
test -s "$rows" && test -s "$smallint_rows"
265+
EQL_V4_ROWS="$rows" EQL_V4_SMALLINT_ROWS="$smallint_rows" cargo test --locked --manifest-path packages/eql/Cargo.toml -p eql-encryption-tests --test v4_types -- --exact rows_the_go_sdk_sealed_open_in_rust smallint_rows_the_go_sdk_sealed_open_in_rust
262266
263267
# What is encrypted is fixed before the program ships: every generated
264268
# file is committed, and this fails when `go generate` would change
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
package encrypt_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"math"
9+
"os"
10+
"strings"
11+
"testing"
12+
13+
"github.com/cipherstash/stack/languages/golang/encrypt"
14+
"github.com/cipherstash/stack/languages/golang/encrypt/internal/testusers"
15+
)
16+
17+
// The smallint EQL v4 types, sealed through generated code over the
18+
// deterministic guest build with the EQL types: an int16 field reaches
19+
// Smallint and SmallintEq, opens back, and its Eq query is the one
20+
// eql-bindings' own dispatch derives (v4_queries.json, key tally).
21+
func TestV4SmallintFieldsSealAndOpenBack(t *testing.T) {
22+
c := deterministicEQLClient(t)
23+
ctx := context.Background()
24+
cipher := c.DefaultKeyset()
25+
26+
tallies := []testusers.Tally{
27+
{ID: 1, Small: math.MinInt16, Count: -12, Wide: 7},
28+
{ID: 2, Small: math.MaxInt16, Count: 0, Wide: math.MinInt16},
29+
}
30+
encrypted, err := testusers.EncryptTally(ctx, cipher, tallies)
31+
if err != nil {
32+
t.Fatal(err)
33+
}
34+
for i, e := range encrypted {
35+
for column, value := range map[string][]byte{"small": e.Small, "tally": e.Count, "wide": e.Wide} {
36+
want := "c,hm,i,v"
37+
if column == "small" {
38+
want = "c,i,v"
39+
}
40+
if got := keysOf(t, column, value); got != want {
41+
t.Errorf("row %d %s: keys %s, want %s", i, column, got, want)
42+
}
43+
}
44+
}
45+
decrypted, err := testusers.DecryptTally(ctx, cipher, encrypted)
46+
if err != nil {
47+
t.Fatal(err)
48+
}
49+
for i := range tallies {
50+
if decrypted[i] != tallies[i] {
51+
t.Errorf("row %d: %+v, want %+v", i, decrypted[i], tallies[i])
52+
}
53+
}
54+
55+
// An int32 field in a smallint type seals at 16 bits: a value that does
56+
// not fit is refused, not truncated, on every path, and the error names
57+
// the field. Both bounds, as widened_test.go pins them for IntegerEq.
58+
for _, v := range []int32{math.MaxInt16 + 1, math.MinInt16 - 1} {
59+
refusals := map[string]error{}
60+
_, refusals["EncryptTally"] = testusers.EncryptTally(ctx, cipher, []testusers.Tally{{ID: 3, Wide: v}})
61+
_, refusals["Wide.Encrypt"] = testusers.TallyFields.Wide.Encrypt(ctx, cipher, v)
62+
_, refusals["Wide.Eq"] = testusers.TallyFields.Wide.Eq(ctx, cipher, v)
63+
for path, err := range refusals {
64+
if !errors.Is(err, encrypt.ErrEncoding) {
65+
t.Errorf("%s(%d): %v, want ErrEncoding", path, v, err)
66+
} else if !strings.Contains(err.Error(), "wide") {
67+
t.Errorf("%s(%d): %v, want the field named", path, v, err)
68+
}
69+
}
70+
}
71+
// The bounds themselves fit.
72+
for _, v := range []int32{math.MaxInt16, math.MinInt16} {
73+
if _, err := testusers.TallyFields.Wide.Eq(ctx, cipher, v); err != nil {
74+
t.Errorf("Wide.Eq(%d): %v", v, err)
75+
}
76+
}
77+
78+
raw, err := os.ReadFile(fixturePath("v4_queries.json"))
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
var fixture struct {
83+
Tally struct {
84+
Plaintext int16
85+
Query string
86+
}
87+
}
88+
if err := json.Unmarshal(raw, &fixture); err != nil {
89+
t.Fatal(err)
90+
}
91+
q, err := testusers.TallyFields.Count.Eq(ctx, cipher, fixture.Tally.Plaintext)
92+
if err != nil {
93+
t.Fatal(err)
94+
}
95+
if fixture.Tally.Query == "" || string(q) != fixture.Tally.Query {
96+
t.Errorf("Count.Eq = %s\nwant %s", q, fixture.Tally.Query)
97+
}
98+
}
99+
100+
// tallyRows are the rows v4_smallint_rows.json holds: both extremes of the
101+
// width, and an int32 field at the extremes too.
102+
var tallyRows = []testusers.Tally{
103+
{ID: 1, Small: math.MinInt16, Count: -12, Wide: math.MinInt16},
104+
{ID: 2, Small: math.MaxInt16, Count: 7, Wide: math.MaxInt16},
105+
{ID: 3, Small: 0, Count: -12, Wide: -1},
106+
}
107+
108+
type smallintRowsFixture struct {
109+
Comment string `json:"_comment"`
110+
Seed string `json:"seed"`
111+
Table string `json:"table"`
112+
Rows []smallintRow `json:"rows"`
113+
}
114+
115+
type smallintRow struct {
116+
ID int64 `json:"id"`
117+
Plaintext smallintPlain `json:"plaintext"`
118+
Stored map[string]json.RawMessage `json:"stored"`
119+
}
120+
121+
type smallintPlain struct {
122+
Small int16 `json:"small"`
123+
Tally int16 `json:"tally"`
124+
Wide int32 `json:"wide"`
125+
}
126+
127+
func storedTally(e testusers.EncryptedTally) map[string][]byte {
128+
return map[string][]byte{"small": e.Small, "tally": e.Count, "wide": e.Wide}
129+
}
130+
131+
// TestV4SmallintRowsMatchTheCommittedFixture checks the committed
132+
// v4_smallint_rows.json against what the generated code seals today, as
133+
// TestV4RowsMatchTheCommittedFixture does for v4_rows.json: everything but
134+
// each `c` must match, and the committed `c` values must open.
135+
// EQL_UPDATE_FIXTURES rewrites the file when something other than the
136+
// ciphertexts moved, so a change that moves only `c` leaves the committed
137+
// file as it was. EQL_V4_SMALLINT_ROWS_OUT names a file to write today's
138+
// rows to, ciphertexts and all: CI hands it to the Rust suite
139+
// (EQL_V4_SMALLINT_ROWS), so Rust opens the smallint ciphertexts the Go SDK
140+
// seals now, not only the committed ones.
141+
func TestV4SmallintRowsMatchTheCommittedFixture(t *testing.T) {
142+
c := deterministicEQLClient(t)
143+
ctx := context.Background()
144+
encrypted, err := testusers.EncryptTally(ctx, c.DefaultKeyset(), tallyRows)
145+
if err != nil {
146+
t.Fatal(err)
147+
}
148+
fixture := smallintRowsFixture{
149+
Comment: "Tally rows sealed by the Go SDK's generated code over the deterministic EQL guest with the seed below; the Rust suite opens and queries them. Regenerate with EQL_UPDATE_FIXTURES=1 go test ./encrypt -run TestV4SmallintRows, do not edit.",
150+
Seed: string(testSeed[:]),
151+
Table: "profiles",
152+
}
153+
for i, e := range encrypted {
154+
r := tallyRows[i]
155+
row := smallintRow{ID: r.ID, Plaintext: smallintPlain{r.Small, r.Count, r.Wide}, Stored: map[string]json.RawMessage{}}
156+
for column, value := range storedTally(e) {
157+
row.Stored[column] = json.RawMessage(value)
158+
}
159+
fixture.Rows = append(fixture.Rows, row)
160+
}
161+
rendered, err := json.MarshalIndent(fixture, "", " ")
162+
if err != nil {
163+
t.Fatal(err)
164+
}
165+
rendered = append(rendered, '\n')
166+
if out := os.Getenv("EQL_V4_SMALLINT_ROWS_OUT"); out != "" {
167+
if err := os.WriteFile(out, rendered, 0o644); err != nil {
168+
t.Fatal(err)
169+
}
170+
}
171+
path := fixturePath("v4_smallint_rows.json")
172+
var committed smallintRowsFixture
173+
committedBytes, err := os.ReadFile(path)
174+
if err == nil {
175+
err = json.Unmarshal(committedBytes, &committed)
176+
}
177+
same := err == nil && bytes.Equal(smallintWithoutCiphertexts(t, committed), smallintWithoutCiphertexts(t, fixture))
178+
if os.Getenv("EQL_UPDATE_FIXTURES") != "" && !same {
179+
if err := os.WriteFile(path, rendered, 0o644); err != nil {
180+
t.Fatal(err)
181+
}
182+
t.Logf("%s: rewritten", path)
183+
committed, same, err = fixture, true, nil
184+
}
185+
if err != nil {
186+
t.Fatalf("%v: run with EQL_UPDATE_FIXTURES=1 to write it", err)
187+
}
188+
if !same {
189+
t.Errorf("%s differs from what the generated code seals today beyond the ciphertexts; regenerate it with EQL_UPDATE_FIXTURES=1", path)
190+
}
191+
192+
sealed := make([]testusers.EncryptedTally, len(committed.Rows))
193+
for i, row := range committed.Rows {
194+
sealed[i] = testusers.EncryptedTally{ID: row.ID, Small: []byte(row.Stored["small"]), Count: []byte(row.Stored["tally"]), Wide: []byte(row.Stored["wide"])}
195+
}
196+
opened, err := testusers.DecryptTally(ctx, c.DefaultKeyset(), sealed)
197+
if err != nil {
198+
t.Fatal(err)
199+
}
200+
for i, row := range committed.Rows {
201+
if got := (smallintPlain{opened[i].Small, opened[i].Count, opened[i].Wide}); got != row.Plaintext {
202+
t.Errorf("row %d opens to %+v, want %+v", row.ID, got, row.Plaintext)
203+
}
204+
}
205+
}
206+
207+
func smallintWithoutCiphertexts(t *testing.T, f smallintRowsFixture) []byte {
208+
t.Helper()
209+
out := f
210+
out.Rows = nil
211+
for _, row := range f.Rows {
212+
stripped := smallintRow{ID: row.ID, Plaintext: row.Plaintext, Stored: map[string]json.RawMessage{}}
213+
for column, value := range row.Stored {
214+
var fields map[string]json.RawMessage
215+
if err := json.Unmarshal(value, &fields); err != nil {
216+
t.Fatal(err)
217+
}
218+
delete(fields, "c")
219+
b, err := json.Marshal(fields)
220+
if err != nil {
221+
t.Fatal(err)
222+
}
223+
stripped.Stored[column] = b
224+
}
225+
out.Rows = append(out.Rows, stripped)
226+
}
227+
b, err := json.Marshal(out)
228+
if err != nil {
229+
t.Fatal(err)
230+
}
231+
return b
232+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package testusers
2+
3+
//go:generate go run github.com/cipherstash/stack/languages/golang/cmd/stashgen -type Tally -name Tally
4+
5+
// Tally stores the smallint EQL v4 types, under Profile's table so its
6+
// query shares v4_queries.json (key tally). It is a model of its own
7+
// because Profile's rows are frozen in stored_v4_rows_v1.json: a new
8+
// Profile field would change what those rows store.
9+
type Tally struct {
10+
_ struct{} `stash:"context=profiles"`
11+
ID int64 `stash:"id,passthrough"`
12+
Small int16 `stash:"small,encrypt_into=Smallint"`
13+
Count int16 `stash:"tally,encrypt_into=SmallintEq"`
14+
Wide int32 `stash:"wide,encrypt_into=SmallintEq"`
15+
}

0 commit comments

Comments
 (0)